Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes · 4 behind

One page shell serves both games: the document, the style and the stage move to src/pages/shell.ts #21

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/cs-engine:vesper/17-one-page-shell-serves-both-games-the-doc → main 1 commit fork

  • Merged. vesper/17-one-page-shell-serves-both-games-the-doc was merged into main as 06a3c81.

Review

Sign in to review this change.
5 files changed +385 −113
README.md +21 −6
@@ -76,17 +76,32 @@ the rest.
7676 the algorithm, with its backward pass checked against finite differences.
7777 `scripts/train.ts` runs them and writes `src/client/weights.ts`.
7878 - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the
79 schema and its queries, `src/result.ts` validates what a browser may claim,
80 `src/pages/game.ts` renders the page.
79 schema and its queries, `src/result.ts` validates what a browser may claim.
80 - `src/pages/shell.ts` — the document every game is served inside, and nothing
81 about any one game: `page({ title, description, heading, env, pull, commit,
82 script, ssh, terminalLead, stageHtml, sectionsHtml, footer, extraStyle })`
83 gives you the doctype, the head, the one `<style>` block, the header with its
84 environment pill and short commit, `<main>`, the terminal line and the footer,
85 and `stage({ overlays, touch })` gives you `#stage` — `#scene`, `#hud`, `#fps`
86 and the `#touch` pads built from `{ action, label }` buttons. Text parameters
87 (`title`, `heading`, `commit`, `ssh`, a label, an id) are escaped by the
88 shell; the ones that carry markup you already rendered say so in their name
89 (`stageHtml`, `sectionsHtml`, an overlay's `html`) and are written through.
90 A rule only one game needs goes in `extraStyle`, never in the shared block,
91 and CSS handed in there cannot close the block. So **a second game's page
92 writes its overlays, its touch buttons and its sections, and gets the rest**;
93 `src/pages/game.ts` is what that looks like for cs. The ids are the contract
94 with `src/client/main.ts`, which finds them by `getElementById`: a test in
95 `src/server.test.ts` pins every one against the served page.
8196 - `scripts/build.ts` bundles a table of entries — `ENTRIES`, one line per game:
8297 a name and a source file — into `static/<name>.<hash>.js`, committed and
8398 cached for a year. Each entry sweeps up only its own stale bundles, so one
8499 game's rebuild never deletes another's, and `bun test` fails if any committed
85100 bundle is stale. The server resolves the script for an entry by that exact
86 name, so **adding a second game is one line in `ENTRIES` plus a route in
87 `src/server.ts`** that renders its page with `scripts["<name>"]` in the
88 `<script type="module" src>` — no inline script, the CSP is `default-src
89 'self'`.
101 name, so **adding a second game is one line in `ENTRIES`, a
102 `src/pages/<name>.ts` that calls the shell, and a route in `src/server.ts`**
103 that renders it with `scripts["<name>"]` as the `script` — no inline script,
104 the CSP is `default-src 'self'`.
90105 - No dependencies. Nothing is installed to run this.
91106
92107 It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app
src/pages/game.ts +48 −107
@@ -1,15 +1,20 @@
11 /**
2 * The page. Server-rendered: the leaderboard and the instructions are in the
3 * first paint, and the only script is the game bundle, fetched once and cached
4 * by its content hash.
2 * The cs page: this game's content and nothing about the document. The shell in
3 * `./shell.ts` renders the doctype, the style, the header, the stage and the
4 * footer; what is left here is Counter-Strike's — its overlays, its touch
5 * buttons, its leaderboard and its prose.
6 *
7 * Server-rendered: the leaderboard and the instructions are in the first paint,
8 * and the only script is the game bundle, fetched once and cached by its
9 * content hash.
510 */
611
712 import type { Database } from "bun:sqlite";
813 import { headshotShare, ratio, recentMatches, standings } from "../db.ts";
914 import { CARD } from "../client/weights.ts";
15 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
1016
11 export const escape = (s: string): string =>
12 s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
17 export { escape };
1318
1419 /** The leaderboard block. Also what POST /api/match answers with. */
1520 export function statsHtml(db: Database): string {
@@ -36,94 +41,10 @@ ${table.length === 0
3641 <ul class="recent">${feed || `<li class="empty">nothing yet</li>`}</ul>`;
3742 }
3843
39 const STYLE = `
40 :root { color-scheme: dark; --bg:#0b0d11; --panel:#141821; --line:#232a36; --text:#e7eaf0; --dim:#9aa4b4; --t:#e2b04a; --ct:#6fa8e8; }
41 * { box-sizing: border-box; }
42 body { margin:0; background:var(--bg); color:var(--text); font:16px/1.55 system-ui,-apple-system,Segoe UI,sans-serif; }
43 header, main { max-width: 64rem; margin: 0 auto; padding: 0 1rem; }
44 header { display:flex; align-items:baseline; gap:.75rem; padding-top:1rem; flex-wrap:wrap; }
45 h1 { font-size:1.4rem; margin:0; letter-spacing:.02em; }
46 .env { font-size:.72rem; padding:.15rem .5rem; border-radius:999px; background:#1d4ed8; color:#fff; }
47 .env.production { background:#15803d; }
48 small.commit { color:var(--dim); font-size:.75rem; }
49 #stage { position:relative; width:100%; aspect-ratio:16/9; max-height:78vh; margin:1rem auto; background:#000;
50 border:1px solid var(--line); border-radius:.6rem; overflow:hidden; touch-action:none; }
51 #scene, #hud { position:absolute; inset:0; width:100%; height:100%; display:block; }
52 #hud { pointer-events:none; }
53 .overlay { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
54 gap:.75rem; background:rgba(8,10,14,.82); text-align:center; padding:1rem; overflow:auto; }
55 .overlay[hidden] { display:none; }
56 .overlay h2 { margin:0; font-size:1.3rem; }
57 .overlay p { margin:.2rem 0; color:var(--dim); max-width:34rem; }
58 .row { display:flex; gap:.5rem; flex-wrap:wrap; justify-content:center; align-items:center; }
59 label { display:flex; flex-direction:column; gap:.25rem; font-size:.8rem; color:var(--dim); text-align:left; }
60 input, select { min-height:44px; padding:.5rem .7rem; font:inherit; color:var(--text);
61 background:#0f131a; border:1px solid var(--line); border-radius:.45rem; }
62 button { min-height:44px; min-width:44px; padding:.6rem 1.1rem; font:inherit; border:1px solid var(--line);
63 border-radius:.45rem; background:#1b2230; color:var(--text); cursor:pointer; }
64 button:hover:not(:disabled) { background:#27303f; }
65 button:disabled { opacity:.42; cursor:not-allowed; }
66 button.primary { background:#2563eb; border-color:#2563eb; font-weight:600; }
67 #buy .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(11rem,1fr)); gap:.5rem; width:min(40rem,100%); }
68 #buy .hint, .overlay .hint { font-size:.78rem; }
69 #fps { position:absolute; right:.5rem; bottom:.4rem; font-size:.7rem; color:#8ea; opacity:.7; pointer-events:none; }
70 #touch { position:absolute; inset:auto 0 0 0; display:flex; justify-content:space-between; padding:.6rem; pointer-events:none; }
71 #touch[hidden] { display:none; }
72 #touch .pad { display:flex; gap:.4rem; flex-wrap:wrap; pointer-events:auto; }
73 #touch button { opacity:.72; background:#0f131acc; }
74 main section { margin:1.5rem 0; }
75 table { width:100%; border-collapse:collapse; font-size:.9rem; }
76 th, td { text-align:left; padding:.35rem .5rem; border-bottom:1px solid var(--line); }
77 th { color:var(--dim); font-weight:500; }
78 ul.recent { list-style:none; padding:0; margin:0; font-size:.86rem; }
79 ul.recent li { padding:.35rem 0; border-bottom:1px solid var(--line); display:flex; gap:.5rem; flex-wrap:wrap; }
80 ul.recent span, ul.recent time, .empty { color:var(--dim); }
81 kbd { background:#1b2230; border:1px solid var(--line); border-radius:.25rem; padding:0 .3rem; font-size:.82rem; }
82 footer { color:var(--dim); font-size:.8rem; padding:1rem; text-align:center; }
83 code { background:#141821; padding:.1rem .35rem; border-radius:.25rem; }
84 @media (max-width: 640px) { #stage { aspect-ratio:4/3; } h1 { font-size:1.2rem; } }
85 `;
44 /** What the shell needs that this page cannot know: the environment it runs in. */
45 export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh">;
8646
87 export type PageOptions = {
88 env: string;
89 pull: string | undefined;
90 commit: string;
91 script: string;
92 ssh: string | undefined;
93 };
94
95 export function gamePage(db: Database, options: PageOptions): string {
96 const where = options.env === "preview" ? `preview of pull #${options.pull ?? "?"}` : options.env;
97 return `<!doctype html>
98 <html lang="en">
99 <meta charset="utf-8">
100 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
101 <title>cs — a browser defusal shooter</title>
102 <meta name="description" content="A Counter-Strike-shaped defusal shooter that runs in the browser: WebGL2, bots, bomb rounds and an economy.">
103 <style>${STYLE}</style>
104 <header>
105 <h1>cs</h1>
106 <span class="env ${options.env === "production" ? "production" : ""}">${escape(where)}</span>
107 ${options.commit ? `<small class="commit">@ ${escape(options.commit)}</small>` : ""}
108 </header>
109 <main>
110 <div id="stage">
111 <canvas id="scene"></canvas>
112 <canvas id="hud"></canvas>
113 <div id="fps"></div>
114 <div id="touch" hidden>
115 <div class="pad">
116 <button type="button" data-action="duck">crouch</button>
117 <button type="button" data-action="jump">jump</button>
118 <button type="button" data-action="use">use</button>
119 </div>
120 <div class="pad">
121 <button type="button" data-action="reload">reload</button>
122 <button type="button" data-action="buy">buy</button>
123 <button type="button" data-action="fire">fire</button>
124 </div>
125 </div>
126 <div class="overlay" id="menu">
47 const MENU = `
12748 <h2>Defuse or detonate</h2>
12849 <p>Five against five on <b>de_forge</b>. Bomb rounds, CS movement and economy, bots that push the sites.</p>
12950 <div class="row">
@@ -136,15 +57,42 @@ export function gamePage(db: Database, options: PageOptions): string {
13657 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·
13758 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy ·
13859 <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>Esc</kbd> pause</p>
139 </div>
140 <div class="overlay" id="pause" hidden>
60 `;
61
62 const PAUSE = `
14163 <h2>Paused</h2>
14264 <div class="row"><button type="button" class="primary" id="resume">Resume</button><button type="button" id="quit">Leave match</button></div>
143 </div>
144 <div class="overlay" id="buy" hidden></div>
145 </div>
65 `;
14666
147 <section id="stats">${statsHtml(db)}</section>
67 export function gamePage(db: Database, options: PageOptions): string {
68 return page({
69 ...options,
70 title: "cs — a browser defusal shooter",
71 description:
72 "A Counter-Strike-shaped defusal shooter that runs in the browser: WebGL2, bots, bomb rounds and an economy.",
73 heading: "cs",
74 terminalLead: "The same leaderboard in a terminal:",
75 footer: "state lives in this environment's own database",
76 stageHtml: stage({
77 touch: [
78 [
79 { action: "duck", label: "crouch" },
80 { action: "jump", label: "jump" },
81 { action: "use", label: "use" },
82 ],
83 [
84 { action: "reload", label: "reload" },
85 { action: "buy", label: "buy" },
86 { action: "fire", label: "fire" },
87 ],
88 ],
89 overlays: [
90 { id: "menu", html: MENU },
91 { id: "pause", html: PAUSE, hidden: true },
92 { id: "buy", html: "", hidden: true },
93 ],
94 }),
95 sectionsHtml: ` <section id="stats">${statsHtml(db)}</section>
14896
14997 <section>
15098 <h2>What this is</h2>
@@ -164,13 +112,6 @@ export function gamePage(db: Database, options: PageOptions): string {
164112 takes to react, so there is still a ladder from fodder to uncomfortable.</p>
165113 <p>The match runs entirely in your browser; the leaderboard here is what your browser
166114 reported about it.</p>
167 <p>${options.ssh === undefined
168 ? "The app's terminal is off."
169 : `The same leaderboard in a terminal: <code>${escape(options.ssh)}</code>`}</p>
170 </section>
171 </main>
172 <footer>cs · ${escape(where)} · state lives in this environment's own database</footer>
173 <script type="module" src="${escape(options.script)}"></script>
174 </html>
175 `;
115 </section>`,
116 });
176117 }
src/pages/shell.test.ts added +133
@@ -0,0 +1,133 @@
1 /**
2 * The shell is where text becomes markup, so this reads like a hand check: the
3 * same hostile string goes in at every text parameter and the assertion is that
4 * it comes out inert, while markup handed in as markup is written through
5 * untouched.
6 */
7
8 import { expect, test } from "bun:test";
9 import { escape, page, stage, terminal, where } from "./shell.ts";
10
11 const XSS = `</script><script>alert(1)</script>`;
12
13 const options = {
14 title: "t",
15 description: "d",
16 heading: "h",
17 env: "local",
18 pull: undefined,
19 commit: "abc1234",
20 script: "/static/app.0123456789.js",
21 ssh: undefined,
22 terminalLead: "lead:",
23 stageHtml: "",
24 sectionsHtml: "",
25 footer: "f",
26 };
27
28 test("the document is a document, and the only script is the bundle", () => {
29 const html = page(options);
30 expect(html.startsWith("<!doctype html>\n<html lang=\"en\">\n")).toBe(true);
31 expect(html).toContain(`<meta charset="utf-8">`);
32 expect(html).toContain(`content="width=device-width, initial-scale=1, viewport-fit=cover"`);
33 expect(html).toContain("<title>t</title>");
34 expect(html).toContain(`<meta name="description" content="d">`);
35 expect(html).toContain("<h1>h</h1>");
36 expect(html).toContain(`<small class="commit">@ abc1234</small>`);
37 expect(html).toContain("<main>");
38 expect(html).toContain(`<script type="module" src="/static/app.0123456789.js"></script>`);
39 // One script element, and it has a src: the CSP is default-src 'self'.
40 expect(html.match(/<script/g)?.length).toBe(1);
41 expect(html.match(/<style/g)?.length).toBe(1);
42 });
43
44 test("the environment pill says which deployment this is", () => {
45 expect(where("production", undefined)).toBe("production");
46 expect(where("preview", "17")).toBe("preview of pull #17");
47 expect(where("preview", undefined)).toBe("preview of pull #?");
48 expect(page({ ...options, env: "production" })).toContain(`<span class="env production">production</span>`);
49 expect(page({ ...options, env: "preview", pull: "17" })).toContain(`preview of pull #17</span>`);
50 expect(page({ ...options, commit: "" })).not.toContain("class=\"commit\"");
51 });
52
53 test("text that carries markup comes out inert, wherever it enters", () => {
54 const html = page({
55 ...options,
56 title: XSS,
57 description: XSS,
58 heading: XSS,
59 env: XSS,
60 commit: XSS,
61 ssh: XSS,
62 footer: XSS,
63 script: XSS,
64 });
65 // The one script element is still the only one: nothing opened a second.
66 expect(html.match(/<script/g)?.length).toBe(1);
67 expect(html).not.toContain("<script>alert(1)</script>");
68 expect(html).not.toContain("</script><script>");
69 // Ten, not eight: the heading and the environment are each printed twice,
70 // once in the header and once in the footer.
71 expect(html.split("&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;").length - 1).toBe(10);
72 });
73
74 test("markup handed in as markup is written through, not escaped again", () => {
75 const stats = `<table><tr><td>Vesper &amp; co</td></tr></table>`;
76 const html = page({ ...options, sectionsHtml: `<section id="stats">${stats}</section>`, stageHtml: "<div id=\"stage\"></div>" });
77 expect(html).toContain(stats);
78 expect(html).toContain("<div id=\"stage\"></div>");
79 expect(html).not.toContain("&lt;table&gt;");
80 expect(html).not.toContain("&amp;amp;");
81 });
82
83 test("a game's own style is appended, and cannot close the block", () => {
84 const html = page({ ...options, extraStyle: "#board { color: red; }" });
85 expect(html).toContain("#board { color: red; }");
86 expect(html.match(/<style/g)?.length).toBe(1);
87 const broken = page({ ...options, extraStyle: "}</style><script>alert(1)</script>" });
88 expect(broken).not.toContain("</style><script>");
89 expect(broken).toContain("\\3c /style>");
90 expect(broken.match(/<script/g)?.length).toBe(1);
91 });
92
93 test("the stage keeps the ids the client finds it by", () => {
94 const html = stage({
95 touch: [[{ action: "duck", label: "crouch" }], [{ action: "fire", label: "fire" }]],
96 overlays: [{ id: "menu", html: "<h2>go</h2>" }, { id: "pause", html: "", hidden: true }],
97 });
98 expect(html).toContain(`<div id="stage">`);
99 expect(html).toContain(`<canvas id="scene"></canvas>`);
100 expect(html).toContain(`<canvas id="hud"></canvas>`);
101 expect(html).toContain(`<div id="fps"></div>`);
102 expect(html).toContain(`<div id="touch" hidden>`);
103 expect(html.match(/<div class="pad">/g)?.length).toBe(2);
104 expect(html).toContain(`<button type="button" data-action="duck">crouch</button>`);
105 expect(html).toContain(`<div class="overlay" id="menu"><h2>go</h2></div>`);
106 expect(html).toContain(`<div class="overlay" id="pause" hidden></div>`);
107 });
108
109 test("a touch label and an action are text, not markup", () => {
110 const html = stage({ touch: [[{ action: XSS, label: XSS }]], overlays: [{ id: XSS, html: "" }] });
111 expect(html).not.toContain("<script>");
112 const inert = "&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;";
113 // The label as text, the action inside an attribute, the id inside an
114 // attribute: three places, one escaping, no quote or angle bracket survives.
115 expect(html).toContain(`<button type="button" data-action="${inert}">${inert}</button>`);
116 expect(html).toContain(`<div class="overlay" id="${inert}">`);
117 expect(escape(`a" onclick="x`)).toBe("a&quot; onclick=&quot;x");
118 });
119
120 test("the terminal line prints FORGE_APP_SSH verbatim, or says the terminal is off", () => {
121 expect(terminal("ssh -p 2222 [email protected]", "reach it with:"))
122 .toBe("reach it with: <code>ssh -p 2222 [email protected]</code>");
123 expect(terminal(undefined, "reach it with:")).toBe("The app's terminal is off.");
124 expect(terminal(undefined, "x")).not.toContain("@");
125 expect(page({ ...options, ssh: undefined })).toContain("The app's terminal is off.");
126 expect(page({ ...options, ssh: "ssh -p 2222 cs@localhost" }))
127 .toContain("<code>ssh -p 2222 cs@localhost</code>");
128 });
129
130 test("escape covers the five characters that matter", () => {
131 expect(escape(`&<>"'`)).toBe("&amp;&lt;&gt;&quot;&#39;");
132 expect(escape("plain")).toBe("plain");
133 });
src/pages/shell.ts added +170
@@ -0,0 +1,170 @@
1 /**
2 * The shell: the document every game on this server is served inside, and
3 * nothing about any one game. It owns the doctype, the head, the one style
4 * block, the header with its environment pill, `<main>`, the footer, the
5 * terminal line and the single module script — a second game writes its stage
6 * and its sections and gets the rest.
7 *
8 * Escaping is the contract at this boundary. A parameter that carries text is
9 * escaped here (`title`, `heading`, `env`, `commit`, `ssh`, a touch label, an
10 * id); a parameter that carries markup the caller already rendered says so in
11 * its name (`stageHtml`, `sectionsHtml`, an overlay's `html`) and is written
12 * through untouched. There is no inline script: the CSP is `default-src 'self'`.
13 */
14
15 export const escape = (s: string): string =>
16 s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
17
18 // A `<style>` block ends at the first `</`, so CSS handed in from outside the
19 // shell cannot be trusted to stay inside it. `\3c ` is how CSS spells `<`:
20 // valid in a selector or a content string, inert as markup.
21 const cssSafe = (css: string): string => css.replace(/</g, "\\3c ");
22
23 const STYLE = `
24 :root { color-scheme: dark; --bg:#0b0d11; --panel:#141821; --line:#232a36; --text:#e7eaf0; --dim:#9aa4b4; --t:#e2b04a; --ct:#6fa8e8; }
25 * { box-sizing: border-box; }
26 body { margin:0; background:var(--bg); color:var(--text); font:16px/1.55 system-ui,-apple-system,Segoe UI,sans-serif; }
27 header, main { max-width: 64rem; margin: 0 auto; padding: 0 1rem; }
28 header { display:flex; align-items:baseline; gap:.75rem; padding-top:1rem; flex-wrap:wrap; }
29 h1 { font-size:1.4rem; margin:0; letter-spacing:.02em; }
30 .env { font-size:.72rem; padding:.15rem .5rem; border-radius:999px; background:#1d4ed8; color:#fff; }
31 .env.production { background:#15803d; }
32 small.commit { color:var(--dim); font-size:.75rem; }
33 #stage { position:relative; width:100%; aspect-ratio:16/9; max-height:78vh; margin:1rem auto; background:#000;
34 border:1px solid var(--line); border-radius:.6rem; overflow:hidden; touch-action:none; }
35 #scene, #hud { position:absolute; inset:0; width:100%; height:100%; display:block; }
36 #hud { pointer-events:none; }
37 .overlay { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
38 gap:.75rem; background:rgba(8,10,14,.82); text-align:center; padding:1rem; overflow:auto; }
39 .overlay[hidden] { display:none; }
40 .overlay h2 { margin:0; font-size:1.3rem; }
41 .overlay p { margin:.2rem 0; color:var(--dim); max-width:34rem; }
42 .row { display:flex; gap:.5rem; flex-wrap:wrap; justify-content:center; align-items:center; }
43 label { display:flex; flex-direction:column; gap:.25rem; font-size:.8rem; color:var(--dim); text-align:left; }
44 input, select { min-height:44px; padding:.5rem .7rem; font:inherit; color:var(--text);
45 background:#0f131a; border:1px solid var(--line); border-radius:.45rem; }
46 button { min-height:44px; min-width:44px; padding:.6rem 1.1rem; font:inherit; border:1px solid var(--line);
47 border-radius:.45rem; background:#1b2230; color:var(--text); cursor:pointer; }
48 button:hover:not(:disabled) { background:#27303f; }
49 button:disabled { opacity:.42; cursor:not-allowed; }
50 button.primary { background:#2563eb; border-color:#2563eb; font-weight:600; }
51 #buy .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(11rem,1fr)); gap:.5rem; width:min(40rem,100%); }
52 #buy .hint, .overlay .hint { font-size:.78rem; }
53 #fps { position:absolute; right:.5rem; bottom:.4rem; font-size:.7rem; color:#8ea; opacity:.7; pointer-events:none; }
54 #touch { position:absolute; inset:auto 0 0 0; display:flex; justify-content:space-between; padding:.6rem; pointer-events:none; }
55 #touch[hidden] { display:none; }
56 #touch .pad { display:flex; gap:.4rem; flex-wrap:wrap; pointer-events:auto; }
57 #touch button { opacity:.72; background:#0f131acc; }
58 main section { margin:1.5rem 0; }
59 table { width:100%; border-collapse:collapse; font-size:.9rem; }
60 th, td { text-align:left; padding:.35rem .5rem; border-bottom:1px solid var(--line); }
61 th { color:var(--dim); font-weight:500; }
62 ul.recent { list-style:none; padding:0; margin:0; font-size:.86rem; }
63 ul.recent li { padding:.35rem 0; border-bottom:1px solid var(--line); display:flex; gap:.5rem; flex-wrap:wrap; }
64 ul.recent span, ul.recent time, .empty { color:var(--dim); }
65 kbd { background:#1b2230; border:1px solid var(--line); border-radius:.25rem; padding:0 .3rem; font-size:.82rem; }
66 footer { color:var(--dim); font-size:.8rem; padding:1rem; text-align:center; }
67 code { background:#141821; padding:.1rem .35rem; border-radius:.25rem; }
68 @media (max-width: 640px) { #stage { aspect-ratio:4/3; } h1 { font-size:1.2rem; } }
69 `;
70
71 /** One touch button: the action `src/client/input.ts` looks up, and its label. */
72 export type TouchButton = { action: string; label: string };
73
74 /** One overlay over the stage — a menu, a pause screen — by the id the client finds it with. */
75 export type Overlay = { id: string; html: string; hidden?: boolean };
76
77 export type StageOptions = {
78 overlays: Overlay[];
79 /** One inner list per `.pad`, laid out from the left edge of the stage to the right. */
80 touch: TouchButton[][];
81 };
82
83 /**
84 * The stage: the two canvases the renderer draws into, the fps readout, the
85 * touch pads and the overlays. Every id in here is found by `getElementById`
86 * in `src/client/main.ts` — `scene`, `hud`, `fps`, `touch` and each overlay's —
87 * so they are the contract, not decoration.
88 */
89 export function stage({ overlays, touch }: StageOptions): string {
90 const pads = touch
91 .map(
92 (pad) => ` <div class="pad">
93 ${pad.map((b) => ` <button type="button" data-action="${escape(b.action)}">${escape(b.label)}</button>`).join("\n")}
94 </div>`,
95 )
96 .join("\n");
97 return ` <div id="stage">
98 <canvas id="scene"></canvas>
99 <canvas id="hud"></canvas>
100 <div id="fps"></div>
101 <div id="touch" hidden>
102 ${pads}
103 </div>
104 ${overlays.map((o) => ` <div class="overlay" id="${escape(o.id)}"${o.hidden === true ? " hidden" : ""}>${o.html}</div>`).join("\n")}
105 </div>`;
106 }
107
108 /**
109 * The terminal line, and it stays honest: the exact command is whatever the
110 * forge put in `FORGE_APP_SSH`, printed verbatim. When there is none the app's
111 * terminal is off and the page says so — no `you@host` is invented here.
112 */
113 export function terminal(ssh: string | undefined, lead: string): string {
114 return ssh === undefined ? "The app's terminal is off." : `${escape(lead)} <code>${escape(ssh)}</code>`;
115 }
116
117 /** Which deployment this is, in words a visitor can act on. */
118 export const where = (env: string, pull: string | undefined): string =>
119 env === "preview" ? `preview of pull #${pull ?? "?"}` : env;
120
121 export type PageOptions = {
122 /** Text. The `<title>`, and the `<meta name=description>`. */
123 title: string;
124 description: string;
125 /** Text. The `<h1>`, and the first word of the footer. */
126 heading: string;
127 env: string;
128 pull: string | undefined;
129 commit: string;
130 /** The URL of the one module script. Nothing else runs. */
131 script: string;
132 ssh: string | undefined;
133 /** The sentence in front of the ssh command, when there is one. */
134 terminalLead: string;
135 /** Markup, already rendered — usually `stage(...)`. */
136 stageHtml: string;
137 /** Markup, already rendered: the `<section>`s under the stage. */
138 sectionsHtml: string;
139 /** Text. The footer reads `<heading> · <where> · <footer>`. */
140 footer: string;
141 /** One game's own CSS, appended to the shared block so a rule for one game never reaches the other. */
142 extraStyle?: string;
143 };
144
145 /** The whole document. */
146 export function page(options: PageOptions): string {
147 const here = where(options.env, options.pull);
148 return `<!doctype html>
149 <html lang="en">
150 <meta charset="utf-8">
151 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
152 <title>${escape(options.title)}</title>
153 <meta name="description" content="${escape(options.description)}">
154 <style>${STYLE}${options.extraStyle === undefined ? "" : cssSafe(options.extraStyle)}</style>
155 <header>
156 <h1>${escape(options.heading)}</h1>
157 <span class="env ${options.env === "production" ? "production" : ""}">${escape(here)}</span>
158 ${options.commit ? `<small class="commit">@ ${escape(options.commit)}</small>` : ""}
159 </header>
160 <main>
161 ${options.stageHtml}
162
163 ${options.sectionsHtml}
164 <p class="terminal">${terminal(options.ssh, options.terminalLead)}</p>
165 </main>
166 <footer>${escape(options.heading)} · ${escape(here)} · ${escape(options.footer)}</footer>
167 <script type="module" src="${escape(options.script)}"></script>
168 </html>
169 `;
170 }
src/server.test.ts +13
@@ -34,6 +34,19 @@ test("serves the game page with the leaderboard in the first paint", async () =>
3434 expect(body).toMatch(/<script type="module" src="\/static\/app\.[0-9a-f]+\.js">/);
3535 });
3636
37 test("the page carries every id and action the client reaches for", async () => {
38 // src/client/main.ts finds these by getElementById and input.ts by
39 // [data-action]; nothing type-checks that, so the page is checked instead.
40 // Dropping one while moving the markup around is a game that does not start.
41 const body = await (await fetch(base + "/")).text();
42 for (const id of ["scene", "hud", "fps", "touch", "menu", "pause", "buy", "stats",
43 "name", "length", "side", "sens", "play", "resume", "quit"])
44 expect(body).toContain(`id="${id}"`);
45 for (const action of ["duck", "jump", "use", "reload", "buy", "fire"])
46 expect(body).toContain(`data-action="${action}"`);
47 expect(body).toContain(`<div id="stage">`);
48 });
49
3750 test("a first visit is given a session cookie that javascript cannot read", async () => {
3851 const response = await fetch(base + "/");
3952 const cookie = response.headers.get("set-cookie") ?? "";

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs