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

forked from trevato/cs · proposes

Boot becomes testable: main.ts takes its host, and a stub document drives a frame headlessly #31

Merged opened this

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

Merged

vesper/cs-engine:vesper/27-boot-becomes-testable-main-ts-takes-its → main 4 commits fork

  • Merged. vesper/27-boot-becomes-testable-main-ts-takes-its was merged into main as 52ee255.

Review

Sign in to review this change.
8 files changed +877 −117
README.md +27 −7
@@ -102,16 +102,33 @@ page shows is the loop and the controls themselves, as numbers that move.
102102 `controls` `createControls`, `attachButtons` and the event and option types
103103 they are driven with. `boundary.test.ts` fails a deep import from outside, a
104104 module that is not behind the door, and any change to that list of names that
105 is not also a change to the list written out in the test.
105 is not also a change to the list written out in the test. One exception, and
106 it is written down there too: a `.test.ts` may import another `.test.ts`, which
107 is how `src/range/independence.test.ts` reuses the source-reading helpers
108 rather than keeping a second copy of them.
106109 - `src/client/` — the game, in modules named for one thing each: `map` (de_forge
107110 itself: its areas, its crates, its cell alphabet and its spawns and sites, laid
108111 over the engine's grid), `physics`, `weapons`, `combat`, `match` (the rules),
109112 `world` (the simulation),
110113 `bots`, `observe`, `action`, `policy`, `weights`, `models`, `materials`,
111 `binding`, `renderer`, `hud`, `audio`, `main`. The last four aside, everything
112 is headless and tested; `models`, `materials` and `audio` are this game's
113 recipes for the engine's three asset tools, and `binding` is its key table and
114 the one function from a held action to an `Input`.
114 `binding`, `renderer`, `hud`, `audio`, `main`. Everything is headless and
115 tested but `renderer`, which is the one module that needs a real WebGL2
116 context; `models`, `materials` and `audio` are this game's recipes for the
117 engine's three asset tools, and `binding` is its key table and the one
118 function from a held action to an `Input`. `main` is the frame, and it takes
119 its host: `boot(host)` is handed the document, the window, a store, a `fetch`,
120 a source of match ids and the two factories that make a renderer and a voice,
121 and the last lines of the file are the only place a browser global is named.
122 `main.test.ts` boots the whole frame against the stub page in
123 `stage.test-helper.ts` — elements that record what was hidden, written and
124 focused, a 2D context that writes down every call, a renderer that keeps the
125 world it was handed, a `fetch` that keeps the bodies, and a clock the test
126 moves — so a click on **play**, the two 64 Hz steps a 1/30 s frame is worth,
127 the buy overlay suppressing fire but not movement, a lost pointer, the end of
128 a match, one POST per round scored and a browser with no WebGL2 are all
129 checked with no browser in the room. `hud` draws onto a `Paint` — the
130 seventeen members of a 2D context it uses — which is how that stub can be a
131 recorder and the page can still hand it a canvas.
115132 - `src/range/` — the second game, at `/range`, and the proof that the engine is a
116133 package: a module in here may import the engine through its door, `src/pages/shell.ts`
117134 and its own siblings, and nothing else. **Today it is the plumbing and only the
@@ -163,8 +180,11 @@ page shows is the loop and the controls themselves, as numbers that move.
163180 writes its overlays, its touch buttons and its sections, and gets the rest**;
164181 `src/pages/game.ts` is what that looks like for cs and `src/pages/range.ts`
165182 for the range. The ids are the contract
166 with `src/client/main.ts`, which finds them by `getElementById`: a test in
167 `src/server.test.ts` pins every one against the served page.
183 with `src/client/main.ts`, which finds them by `getElementById`: cs's are
184 written down once, as `PAGE_IDS` and `PAGE_ACTIONS` in
185 `src/client/stage.test-helper.ts`, the stub page a test boots on is built from
186 that list and `src/server.test.ts` pins the served page against the same one,
187 so the page the game is tested on and the page it is served on cannot drift.
168188 - `scripts/build.ts` bundles a table of entries — `ENTRIES`, one line per game:
169189 a name and a source file — into `static/<name>.<hash>.js`, committed and
170190 cached for a year. Each entry sweeps up only its own stale bundles, so one
src/client/hud.ts +29 −2
@@ -2,6 +2,12 @@
22 * The HUD, drawn on a 2D canvas over the scene: crosshair, health, ammo, the
33 * round clock, the kill feed and the scoreboard. It reads the world and draws;
44 * it never changes anything.
5 *
6 * What it draws onto is a `Paint` — the seventeen members of a 2D context it
7 * actually uses, and not a whole `CanvasRenderingContext2D`. A browser's
8 * context is one; so is a recorder in a test, which is how `main.test.ts` runs
9 * a frame of this with no canvas in the room. The same trick
10 * `src/range/readout.ts` plays with its own, smaller `Paint`.
511 */
612
713 import { clamp } from "../engine/index.ts";
@@ -9,6 +15,27 @@ import { MOVE } from "./physics.ts";
915 import { inaccuracy, WEAPONS } from "./weapons.ts";
1016 import { currentWeapon, useProgress, type World } from "./world.ts";
1117
18 /** Everything this module asks of the thing it draws on. Nothing else. */
19 export type Paint = {
20 fillStyle: string;
21 strokeStyle: string;
22 lineWidth: number;
23 font: string;
24 textAlign: string;
25 textBaseline: string;
26 globalAlpha: number;
27 clearRect(x: number, y: number, w: number, h: number): void;
28 fillRect(x: number, y: number, w: number, h: number): void;
29 fillText(text: string, x: number, y: number): void;
30 beginPath(): void;
31 moveTo(x: number, y: number): void;
32 lineTo(x: number, y: number): void;
33 rect(x: number, y: number, w: number, h: number): void;
34 arc(x: number, y: number, radius: number, start: number, end: number, counterclockwise?: boolean): void;
35 fill(rule?: string): void;
36 stroke(): void;
37 };
38
1239 const T_COLOR = "#e2b04a";
1340 const CT_COLOR = "#6fa8e8";
1441
@@ -18,7 +45,7 @@ const clock = (seconds: number): string => {
1845 };
1946
2047 export function drawHud(
21 ctx: CanvasRenderingContext2D,
48 ctx: Paint,
2249 world: World,
2350 width: number,
2451 height: number,
@@ -194,7 +221,7 @@ export function drawHud(
194221 }
195222
196223 function drawScoreboard(
197 ctx: CanvasRenderingContext2D,
224 ctx: Paint,
198225 world: World,
199226 width: number,
200227 height: number,
src/client/main.test.ts added +287
@@ -0,0 +1,287 @@
1 /**
2 * The frame itself, booted against the stub page in `stage.test-helper.ts`:
3 * the ids it looks up, the match a click starts, how many fixed steps one frame
4 * is worth, what the buy overlay does to the input, what losing the pointer
5 * means, and what it posts to the leaderboard. Nothing here is a browser and
6 * nothing is timed by a real clock — the test decides when a frame happens and
7 * how long it took.
8 *
9 * The world is not reached for behind `boot`'s back: it arrives the way the
10 * renderer gets it, in the draw call the frame makes, and a test that wants a
11 * match that is nearly over says so on that world and steps another frame.
12 */
13
14 import { expect, test } from "bun:test";
15 import { boot } from "./main.ts";
16 import { parseResult } from "../result.ts";
17 import { fakeStage, type FakeStage } from "./stage.test-helper.ts";
18
19 const TICK = 1 / 64;
20 const FRAME = 1000 / 30;
21
22 /** A booted page, and the match already started on it. */
23 function playing(options: { webgl?: boolean; coarse?: boolean } = {}): FakeStage {
24 const stage = fakeStage(options);
25 boot(stage.host);
26 stage.by("play").fire("click");
27 return stage;
28 }
29
30 /** How many 64 Hz steps the world has run, from the time it has simulated. */
31 const stepsRun = (stage: FakeStage): number => Math.round((stage.world()?.time ?? 0) / TICK);
32
33 const button = (stage: FakeStage, action: string) =>
34 stage.buttons.find((b) => b.dataset.action === action) as FakeStage["buttons"][number];
35
36 test("it boots on the page's ids, asks for a frame, and draws nothing until a match starts", () => {
37 const stage = fakeStage();
38 boot(stage.host);
39 expect(stage.frames.length).toBe(1);
40 expect(stage.by("menu").hidden).toBe(false);
41 stage.step(FRAME);
42 // The loop runs, but there is no world yet: nothing is drawn and nothing posted.
43 expect(stage.draws.length).toBe(0);
44 expect(stage.posts.length).toBe(0);
45 expect(stage.frames.length).toBe(1);
46 });
47
48 test("the name is remembered between visits, and a blank one is `you`", () => {
49 const first = fakeStage();
50 boot(first.host);
51 first.by("name").value = "Vesper";
52 first.by("play").fire("click");
53 expect(first.store.get("cs.name")).toBe("Vesper");
54
55 const again = fakeStage();
56 again.store.set("cs.name", "Vesper");
57 boot(again.host);
58 expect(again.by("name").value).toBe("Vesper");
59
60 const blank = playing();
61 expect(blank.store.get("cs.name")).toBe("you");
62 });
63
64 test("clicking play starts a match: the menu goes, the pointer is asked for, the frame draws", () => {
65 const stage = playing();
66 expect(stage.by("menu").hidden).toBe(true);
67 expect(stage.by("pause").hidden).toBe(true);
68 expect(stage.scene.locks).toBe(1);
69 expect(stage.ids.length).toBe(1);
70 expect(stage.sounds).toContain("resume");
71
72 stage.step(FRAME);
73 expect(stage.draws.length).toBe(1);
74 const drawn = stage.draws[0];
75 expect(drawn?.world.players.length).toBe(10);
76 expect(drawn?.camera.fov).toBe(90);
77 // The HUD was sized to its box and painted, and the counter says something.
78 expect([stage.hud.width, stage.hud.height]).toEqual([800, 450]);
79 expect(stage.hud.paint.calls[0]?.[0]).toBe("clearRect");
80 expect(stage.hud.paint.texts.join(" ")).toContain("$800");
81 expect(stage.by("fps").textContent).toContain("ms");
82 });
83
84 test("one frame of 1/30 s runs two 64 Hz steps and no more, and banks the rest", () => {
85 const stage = playing();
86 stage.step(FRAME);
87 expect(stepsRun(stage)).toBe(2);
88 // 1/30 is 2.13 ticks: the .13 carries, and two frames are still four steps.
89 stage.step(FRAME);
90 expect(stepsRun(stage)).toBe(4);
91 // A frame the clock slept through is clamped: a quarter second, sixteen ticks,
92 // and the loop runs at most eight of them.
93 stage.step(5000);
94 expect(stepsRun(stage)).toBe(12);
95 });
96
97 test("B opens the buy overlay: it is rebuilt, it lets the pointer go and it takes the focus", () => {
98 const stage = playing();
99 stage.lock(true);
100 stage.press("KeyB");
101 const buy = stage.by("buy");
102 expect(buy.hidden).toBe(false);
103 expect(buy.innerHTML).toContain("Buy");
104 const grid = buy.querySelector(".grid");
105 expect(grid?.children.length).toBeGreaterThan(4);
106 expect(grid?.children[0]?.focused).toBe(1);
107 expect(stage.document.pointerLockElement).toBe(null);
108
109 // Buying takes the money, beeps, and the panel is rebuilt with the new prices.
110 const world = (stage.step(FRAME), stage.world());
111 const before = world?.me.money ?? 0;
112 grid?.children[0]?.fire("click");
113 expect(world?.me.money).toBeLessThan(before);
114 expect(stage.sounds).toContain("beep");
115
116 // Escape closes it, and so does pressing B again.
117 stage.window.fire("keydown", { code: "Escape" });
118 expect(buy.hidden).toBe(true);
119 stage.press("KeyB");
120 expect(buy.hidden).toBe(false);
121 });
122
123 test("the buy overlay open suppresses fire, but not movement", () => {
124 const stage = playing();
125 stage.step(FRAME);
126 const world = stage.world();
127 if (world === null) throw new Error("the first frame drew nothing");
128 stage.press("KeyB");
129 expect(stage.by("buy").hidden).toBe(false);
130
131 // A thumb on the fire pad and a finger on W, and the round goes live under
132 // both: the freeze time is what the buy panel needs, not what this is about.
133 button(stage, "fire").fire("pointerdown");
134 stage.hold("KeyW", true);
135 world.match.phase = "live";
136 world.match.timer = 60;
137 const mag = world.me.ammo[world.me.weapon]?.mag ?? 0;
138 stage.step(FRAME);
139
140 expect(world.me.ammo[world.me.weapon]?.mag).toBe(mag);
141 expect(Math.hypot(world.me.vel[0], world.me.vel[1])).toBeGreaterThan(0);
142 // And the panel let itself out of the way as soon as buying stopped being legal.
143 expect(stage.by("buy").hidden).toBe(true);
144
145 // The same held input, one frame later, with nothing in front of it.
146 stage.step(FRAME);
147 expect(world.me.ammo[world.me.weapon]?.mag).toBe(mag - 1);
148 expect(stage.sounds.some((s) => s.startsWith("shot:"))).toBe(true);
149 });
150
151 test("losing the pointer pauses a running match, and does not while the buy panel is open", () => {
152 const stage = playing();
153 stage.lock(true);
154 stage.lock(false);
155 expect(stage.by("pause").hidden).toBe(false);
156
157 // Resume asks for the pointer back.
158 stage.by("resume").fire("click");
159 expect(stage.by("pause").hidden).toBe(true);
160 expect(stage.scene.locks).toBe(2);
161
162 // Opening the buy menu drops the lock on purpose: that is not a pause.
163 stage.lock(true);
164 stage.press("KeyB");
165 stage.lock(false);
166 expect(stage.by("buy").hidden).toBe(false);
167 expect(stage.by("pause").hidden).toBe(true);
168 });
169
170 test("leaving the match puts the menu back and stops the frame", () => {
171 const stage = playing();
172 stage.step(FRAME);
173 expect(stage.draws.length).toBe(1);
174 stage.by("quit").fire("click");
175 expect(stage.by("menu").hidden).toBe(false);
176 expect(stage.by("touch").hidden).toBe(true);
177 stage.step(FRAME);
178 expect(stage.draws.length).toBe(1);
179 });
180
181 test("match_over stops the run, shows the menu and focuses play", () => {
182 const stage = playing();
183 stage.lock(true);
184 stage.step(FRAME);
185 const world = stage.world();
186 if (world === null) throw new Error("the first frame drew nothing");
187 world.match.phase = "match_over";
188 world.match.matchWinner = "ct";
189 stage.step(FRAME);
190
191 expect(stage.by("menu").hidden).toBe(false);
192 expect(stage.by("play").focused).toBe(1);
193 expect(stage.document.pointerLockElement).toBe(null);
194 // The run stopped: the frames keep coming and nothing more is drawn.
195 const drawn = stage.draws.length;
196 stage.step(FRAME);
197 stage.step(FRAME);
198 expect(stage.draws.length).toBe(drawn);
199 });
200
201 test("a phone gets the touch pads, a mouse does not", () => {
202 expect(playing({ coarse: true }).by("touch").hidden).toBe(false);
203 expect(playing().by("touch").hidden).toBe(true);
204 });
205
206 test("one POST per round scored, in the shape the server accepts", async () => {
207 const stage = playing();
208 stage.step(FRAME);
209 expect(stage.posts.length).toBe(1);
210
211 const first = stage.posts[0];
212 expect(first?.url).toBe("/api/match");
213 expect(first?.method).toBe("POST");
214 expect(first?.headers["content-type"]).toBe("application/json");
215 const body = JSON.parse(first?.body ?? "{}");
216 expect(parseResult(body)).not.toBeNull();
217 expect(body.matchId).toBe(stage.ids[0]);
218 expect(body.rounds).toBe(0);
219 expect(body.finished).toBe(false);
220
221 // The leaderboard the server sent back is written into the page.
222 await Promise.resolve();
223 await Promise.resolve();
224 expect(stage.by("stats").innerHTML).toBe("<h2>Leaderboard</h2>");
225
226 // Three more frames and nothing new to say: still one POST.
227 stage.step(FRAME);
228 stage.step(FRAME);
229 stage.step(FRAME);
230 expect(stage.posts.length).toBe(1);
231
232 // A round goes on the board: exactly one more.
233 const world = stage.world();
234 if (world === null) throw new Error("the first frame drew nothing");
235 world.match.score.ct = 1;
236 stage.step(FRAME);
237 stage.step(FRAME);
238 expect(stage.posts.length).toBe(2);
239 const second = JSON.parse(stage.posts[1]?.body ?? "{}");
240 expect(parseResult(second)).not.toBeNull();
241 expect(second.rounds).toBe(1);
242 expect(second.finished).toBe(false);
243
244 // The last one says the match is over.
245 world.match.score.ct = 2;
246 world.match.phase = "match_over";
247 stage.step(FRAME);
248 expect(stage.posts.length).toBe(3);
249 expect(JSON.parse(stage.posts[2]?.body ?? "{}").finished).toBe(true);
250 });
251
252 test("a leaderboard that cannot be reached is not a reason to stop playing", async () => {
253 const stage = playing();
254 stage.answer = () => Promise.reject(new Error("offline"));
255 stage.step(FRAME);
256 expect(stage.posts.length).toBe(1);
257 await Promise.resolve();
258 await Promise.resolve();
259 // The page was not overwritten with anything, and the match ran on.
260 expect(stage.by("stats").innerHTML).toBe("");
261 const world = stage.world();
262 if (world === null) throw new Error("the first frame drew nothing");
263
264 // A fetch that throws where it stands is the same kind of nothing.
265 stage.answer = () => {
266 throw new Error("blocked");
267 };
268 world.match.score.t = 1;
269 stage.step(FRAME);
270 stage.step(FRAME);
271 expect(stage.posts.length).toBe(2);
272 expect(stage.draws.length).toBe(3);
273 expect(stepsRun(stage)).toBe(6);
274 expect(stage.by("menu").hidden).toBe(true);
275 });
276
277 test("no WebGL2 is a sentence on the page, not an exception", () => {
278 const stage = fakeStage({ webgl: false });
279 boot(stage.host);
280 expect(stage.by("menu").innerHTML).toContain("No WebGL2");
281 expect(stage.by("menu").hidden).toBe(false);
282 // Nothing was started: no frame was asked for and no listener attached.
283 expect(stage.frames.length).toBe(0);
284 expect(stage.window.listening.size).toBe(0);
285 expect(stage.document.listening.size).toBe(0);
286 expect(stage.draws.length).toBe(0);
287 });
src/client/main.ts +149 −44
@@ -4,12 +4,22 @@
44 * names into the `Input` the world takes, and then the scene and the HUD are
55 * drawn. The page's overlays — menu, buy, pause — are plain DOM, so they are
66 * reachable with a keyboard and big enough for a thumb.
7 *
8 * Nothing in here is a browser. `boot(host)` takes everything it reaches for —
9 * the document, the window, a store, a `fetch`, a source of ids, and the two
10 * factories that make a renderer and a voice — as one plain object, typed by
11 * the members it actually uses the way `src/engine/controls.ts` types its host.
12 * The last lines of this file are the only place a browser global is named, and
13 * `main.test.ts` boots the whole thing against a document of plain objects and
14 * a clock it moves itself.
715 */
816
9 import { createAudio } from "./audio.ts";
17 import { createAudio, type Audio } from "./audio.ts";
1018 import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts";
11 import { drawHud } from "./hud.ts";
12 import { attachButtons, clamp, createControls, createLoop } from "../engine/index.ts";
19 import { drawHud, type Paint } from "./hud.ts";
20 import {
21 attachButtons, clamp, createControls, createLoop, type Button, type Listens,
22 } from "../engine/index.ts";
1323 import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
1424 import { createRenderer, type Camera } from "./renderer.ts";
1525 import { buyable } from "./weapons.ts";
@@ -19,37 +29,114 @@ import {
1929
2030 const TICK = 1 / 64;
2131
22 const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
32 // --- the host: what a page has to be for this frame to run on it -----------
2333
24 function boot(): void {
25 const scene = $<HTMLCanvasElement>("scene");
26 const hudCanvas = $<HTMLCanvasElement>("hud");
27 const menu = $("menu");
28 const pause = $("pause");
29 const buyPanel = $("buy");
30 const touchPanel = $("touch");
31 const stats = $("stats");
32 const nameField = $<HTMLInputElement>("name");
33 const lengthField = $<HTMLSelectElement>("length");
34 const sideField = $<HTMLSelectElement>("side");
35 const sensField = $<HTMLInputElement>("sens");
34 /** One element on the page, as far as this file is concerned. */
35 export type Panel = Listens & {
36 hidden: boolean;
37 innerHTML: string;
38 textContent: string | null;
39 focus(): void;
40 /** `unknown` because a browser's own signature is wider than any subset. */
41 querySelector(selectors: string): unknown;
42 appendChild(child: unknown): void;
43 };
3644
37 const renderer = createRenderer(scene);
45 /** A field in the menu: everything a panel is, and what was typed into it. */
46 export type Field = Panel & { value: string };
47
48 /** A button this file makes rather than finds. */
49 export type Pressable = Listens & { type: string; textContent: string | null; disabled: boolean };
50
51 /** A canvas on the stage: the scene is drawn into, the HUD is painted on. */
52 export type Stage = Panel & {
53 readonly clientWidth: number;
54 readonly clientHeight: number;
55 width: number;
56 height: number;
57 getContext(kind: "2d"): Paint | null;
58 requestPointerLock(): unknown;
59 };
60
61 export type PageDocument = Listens & {
62 readonly pointerLockElement: unknown;
63 getElementById(id: string): unknown;
64 querySelectorAll(selectors: string): ArrayLike<Button>;
65 createElement(tag: "button"): Pressable;
66 exitPointerLock(): void;
67 };
68
69 /** The window: the frame clock, the pixel ratio and the media query. */
70 export type PageWindow = Listens & {
71 readonly devicePixelRatio: number;
72 requestAnimationFrame(callback: (now: number) => void): number;
73 cancelAnimationFrame(handle: number): void;
74 matchMedia(query: string): { matches: boolean };
75 readonly performance: { now(): number };
76 };
77
78 /** Where the name is remembered between visits. `localStorage` is one. */
79 export type Store = {
80 getItem(key: string): string | null;
81 setItem(key: string, value: string): void;
82 };
83
84 /** The two things this file reads off a response to POST /api/match. */
85 export type Reply = { readonly ok: boolean; text(): Promise<string> };
86
87 /** All this frame asks of a renderer: one draw call. */
88 export type Draws = { draw(world: World, camera: Camera, dt: number): void };
89
90 export type Host = {
91 document: PageDocument;
92 window: PageWindow;
93 storage: Store;
94 fetch(url: string, init: { method: string; headers: Record<string, string>; body: string }): Promise<Reply>;
95 /** A fresh match id. `crypto.randomUUID` is one; `src/result.ts` checks it. */
96 randomUUID(): string;
97 /** Null when this browser has no WebGL2: the menu says so and nothing starts. */
98 createRenderer(canvas: Stage): Draws | null;
99 createAudio(): Audio;
100 };
101
102 /**
103 * Wire the page up and start the loop. Exported so `main.test.ts` can boot the
104 * whole frame against a stub host; in a browser the line at the bottom of this
105 * file is what calls it, with the real one.
106 */
107 export function boot(host: Host): void {
108 const doc = host.document;
109 const win = host.window;
110 const by = <T extends Panel>(id: string): T => doc.getElementById(id) as T;
111
112 const scene = by<Stage>("scene");
113 const hudCanvas = by<Stage>("hud");
114 const menu = by("menu");
115 const pause = by("pause");
116 const buyPanel = by("buy");
117 const touchPanel = by("touch");
118 const stats = by("stats");
119 const nameField = by<Field>("name");
120 const lengthField = by<Field>("length");
121 const sideField = by<Field>("side");
122 const sensField = by<Field>("sens");
123
124 const renderer = host.createRenderer(scene);
38125 if (renderer === null) {
39126 menu.innerHTML = "<h2>No WebGL2</h2><p>This browser cannot run the game. Everything else on the page still works.</p>";
40127 return;
41128 }
42 const hudCtx = hudCanvas.getContext("2d") as CanvasRenderingContext2D;
43 const audio = createAudio();
129 const hudCtx = hudCanvas.getContext("2d") as Paint;
130 const audio = host.createAudio();
44131 const controls = createControls(
45 { surface: scene, window, document },
132 { surface: scene, window: win, document: doc },
46133 { keys: CS_KEYS, mouse: CS_MOUSE, sensitivity: Number(sensField.value) || 2.2 },
47134 );
48 attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: CS_TAP });
135 attachButtons(doc.querySelectorAll("[data-action]"), controls, { tap: CS_TAP });
49136 const loop = createLoop({ tick: TICK }, {
50 requestAnimationFrame: (callback) => requestAnimationFrame(callback),
51 cancelAnimationFrame: (handle) => cancelAnimationFrame(handle),
52 now: () => performance.now(),
137 requestAnimationFrame: (callback) => win.requestAnimationFrame(callback),
138 cancelAnimationFrame: (handle) => win.cancelAnimationFrame(handle),
139 now: () => win.performance.now(),
53140 });
54141
55142 let world: World | null = null;
@@ -59,7 +146,7 @@ function boot(): void {
59146 let frameAverage = 16.7;
60147 let posted = -1;
61148
62 const show = (element: HTMLElement, on: boolean): void => {
149 const show = (element: Panel, on: boolean): void => {
63150 element.hidden = !on;
64151 };
65152
@@ -76,10 +163,10 @@ function boot(): void {
76163 buyPanel.innerHTML =
77164 `<h2>Buy <small>$${me.money}</small></h2><div class="grid"></div>` +
78165 `<p class="hint">B or Escape closes. You can only buy in the freeze time.</p>`;
79 const grid = buyPanel.querySelector(".grid") as HTMLElement;
166 const grid = buyPanel.querySelector(".grid") as Panel;
80167 for (const [item, label] of items) {
81168 const price = priceOf(item, me);
82 const button = document.createElement("button");
169 const button = doc.createElement("button");
83170 button.type = "button";
84171 button.textContent = `${label} · $${price}`;
85172 button.disabled = price > me.money || !canBuy(world, me);
@@ -98,8 +185,8 @@ function boot(): void {
98185 show(buyPanel, open);
99186 if (open) {
100187 renderBuy();
101 document.exitPointerLock();
102 (buyPanel.querySelector("button") as HTMLButtonElement | null)?.focus();
188 doc.exitPointerLock();
189 (buyPanel.querySelector("button") as Panel | null)?.focus();
103190 } else if (running) {
104191 void scene.requestPointerLock();
105192 }
@@ -130,7 +217,7 @@ function boot(): void {
130217 seconds: Math.round(world.time),
131218 };
132219 try {
133 const response = await fetch("/api/match", {
220 const response = await host.fetch("/api/match", {
134221 method: "POST",
135222 headers: { "content-type": "application/json" },
136223 body: JSON.stringify(body),
@@ -149,7 +236,7 @@ function boot(): void {
149236 const team = sideField.value === "t" ? "t" : "ct";
150237 const name = nameField.value.trim().slice(0, 24) || "you";
151238 world = createWorld(rules, name, team, 5);
152 matchId = crypto.randomUUID();
239 matchId = host.randomUUID();
153240 posted = -1;
154241 loop.reset();
155242 controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12);
@@ -157,28 +244,28 @@ function boot(): void {
157244 lastSound = 0;
158245 show(menu, false);
159246 show(pause, false);
160 show(touchPanel, matchMedia("(pointer: coarse)").matches);
247 show(touchPanel, win.matchMedia("(pointer: coarse)").matches);
161248 audio.resume();
162249 void scene.requestPointerLock();
163 localStorage.setItem("cs.name", name);
250 host.storage.setItem("cs.name", name);
164251 }
165252
166 $("play").addEventListener("click", start);
167 $("resume").addEventListener("click", () => {
253 by("play").addEventListener("click", start);
254 by("resume").addEventListener("click", () => {
168255 show(pause, false);
169256 void scene.requestPointerLock();
170257 });
171 $("quit").addEventListener("click", () => {
258 by("quit").addEventListener("click", () => {
172259 running = false;
173260 world = null;
174261 show(pause, false);
175262 show(menu, true);
176263 show(touchPanel, false);
177264 });
178 window.addEventListener("keydown", (event) => {
265 win.addEventListener("keydown", (event: { code: string }) => {
179266 if (event.code === "Escape" && !buyPanel.hidden) openBuy(false);
180267 });
181 const saved = localStorage.getItem("cs.name");
268 const saved = host.storage.getItem("cs.name");
182269 if (saved !== null && nameField.value === "") nameField.value = saved;
183270
184271 // --- sound for whatever happened in the ticks we just ran
@@ -226,9 +313,9 @@ function boot(): void {
226313 }
227314 if (world.match.phase === "match_over" && running) {
228315 running = false;
229 document.exitPointerLock();
316 doc.exitPointerLock();
230317 show(menu, true);
231 $("play").focus();
318 by("play").focus();
232319 }
233320
234321 const view = spectated(world);
@@ -240,7 +327,7 @@ function boot(): void {
240327 };
241328 renderer.draw(world, camera, elapsed);
242329
243 const dpr = Math.min(window.devicePixelRatio || 1, 2);
330 const dpr = Math.min(win.devicePixelRatio || 1, 2);
244331 const w = Math.round(hudCanvas.clientWidth * dpr);
245332 const h = Math.round(hudCanvas.clientHeight * dpr);
246333 if (hudCanvas.width !== w || hudCanvas.height !== h) {
@@ -248,10 +335,28 @@ function boot(): void {
248335 hudCanvas.height = h;
249336 }
250337 drawHud(hudCtx, world, w, h, scoreboard);
251 $("fps").textContent = `${(1000 / Math.max(1, frameAverage)).toFixed(0)} fps · ${frameAverage.toFixed(1)} ms`;
338 by("fps").textContent = `${(1000 / Math.max(1, frameAverage)).toFixed(0)} fps · ${frameAverage.toFixed(1)} ms`;
252339 }
253340 loop.start(frame);
254341 }
255342
256 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
257 else boot();
343 /**
344 * The browser, and the only place in this file where one is named: the real
345 * document, window, store, network, ids and factories, handed in as a host.
346 * The cast is the seam — `createRenderer` wants a real canvas, and a `Stage` is
347 * the smaller shape this file and a test can both satisfy.
348 */
349 const browser = (): Host => ({
350 document,
351 window,
352 storage: localStorage,
353 fetch: (url, init) => fetch(url, init),
354 randomUUID: () => crypto.randomUUID(),
355 createRenderer: (canvas) => createRenderer(canvas as unknown as HTMLCanvasElement),
356 createAudio: () => createAudio(),
357 });
358
359 if (typeof document !== "undefined") {
360 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", () => boot(browser()));
361 else boot(browser());
362 }
src/client/stage.test-helper.ts added +322
@@ -0,0 +1,322 @@
1 /**
2 * A page made of plain objects: the handful of shapes `main.ts` and the
3 * engine's controls actually touch, and a clock that only moves when the test
4 * says so. It exists so the whole frame can be booted and stepped with no
5 * browser — the same trick `src/engine/controls.ts` plays with its `Listens`
6 * parameter, one game up.
7 *
8 * Everything here records rather than does: an element remembers what was
9 * hidden, written and focused, the 2D context writes down every call, the
10 * renderer keeps the world and camera it was handed, and `fetch` keeps the
11 * bodies it was posted. The recorders have exactly the members `boot` and
12 * `hud.ts` call, so a stub that has to be maintained is smaller than the frame
13 * it checks.
14 *
15 * It ships in no bundle: only `main.test.ts` imports it. `src/range/dom.ts` is
16 * the same idea for the other game — it cannot import this one, because
17 * `independence.test.ts` forbids the range from reaching into `src/client/`,
18 * but a third game copies whichever is nearer and adds its own ids.
19 */
20
21 import type { Audio } from "./audio.ts";
22 import type { Paint } from "./hud.ts";
23 import type { Draws, Host, Stage } from "./main.ts";
24 import type { Camera } from "./renderer.ts";
25 import type { World } from "./world.ts";
26
27 type Handler = (event: any) => void;
28
29 export type Fake = {
30 /** Deliver an event to everything listening for it here. */
31 fire(type: string, event?: unknown): void;
32 readonly listening: ReadonlySet<string>;
33 addEventListener(type: string, handler: Handler): void;
34 removeEventListener(type: string, handler: Handler): void;
35 };
36
37 /** Anything that can be listened to, and told what happened. */
38 export function fake(): Fake {
39 const handlers = new Map<string, Handler[]>();
40 return {
41 listening: new Set<string>(),
42 addEventListener(type, handler) {
43 const list = handlers.get(type);
44 if (list === undefined) handlers.set(type, [handler]);
45 else list.push(handler);
46 (this.listening as Set<string>).add(type);
47 },
48 removeEventListener(type, handler) {
49 const list = handlers.get(type) ?? [];
50 const at = list.indexOf(handler);
51 if (at >= 0) list.splice(at, 1);
52 },
53 fire(type, event = {}) {
54 for (const handler of [...(handlers.get(type) ?? [])]) handler({ preventDefault() {}, ...(event as object) });
55 },
56 };
57 }
58
59 export type FakeElement = Fake & {
60 id: string;
61 tag: string;
62 className: string;
63 hidden: boolean;
64 innerHTML: string;
65 textContent: string | null;
66 value: string;
67 disabled: boolean;
68 type: string;
69 dataset: { action?: string };
70 /** How many times something asked for the focus. */
71 focused: number;
72 readonly children: FakeElement[];
73 focus(): void;
74 appendChild(child: FakeElement): void;
75 querySelector(selector: string): FakeElement | null;
76 };
77
78 /**
79 * The one thing this stub knows about markup: `class="grid"` in an `innerHTML`
80 * makes a child you can find by `.grid`. That is what the buy panel does — it
81 * writes its shell, then fills the grid — and nothing here parses anything else.
82 */
83 const CLASS = /class="([a-z-]+)"/g;
84
85 export function element(options: { id?: string; tag?: string; action?: string; className?: string } = {}): FakeElement {
86 const children: FakeElement[] = [];
87 let html = "";
88 const self: FakeElement = {
89 ...fake(),
90 id: options.id ?? "",
91 tag: options.tag ?? "div",
92 className: options.className ?? "",
93 dataset: options.action === undefined ? {} : { action: options.action },
94 hidden: false,
95 textContent: "",
96 value: "",
97 disabled: false,
98 type: "",
99 focused: 0,
100 children,
101 get innerHTML() {
102 return html;
103 },
104 set innerHTML(value: string) {
105 html = value;
106 children.length = 0;
107 for (const [, name] of value.matchAll(CLASS)) children.push(element({ className: name as string }));
108 },
109 focus() {
110 self.focused += 1;
111 },
112 appendChild(child) {
113 children.push(child);
114 },
115 querySelector(selector) {
116 const hit = (e: FakeElement): boolean =>
117 selector.startsWith(".") ? e.className === selector.slice(1) : e.tag === selector;
118 for (const child of children) {
119 if (hit(child)) return child;
120 const deeper = child.querySelector(selector);
121 if (deeper !== null) return deeper;
122 }
123 return null;
124 },
125 };
126 return self;
127 }
128
129 /** Every 2D call `hud.ts` makes, written down in order. */
130 export type Recorder = Paint & { calls: [string, ...unknown[]][]; readonly texts: string[] };
131
132 export function recorder(): Recorder {
133 const calls: [string, ...unknown[]][] = [];
134 const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]);
135 return {
136 calls,
137 get texts() {
138 return calls.filter((c) => c[0] === "fillText").map((c) => String(c[1]));
139 },
140 fillStyle: "", strokeStyle: "", lineWidth: 0, font: "", textAlign: "", textBaseline: "", globalAlpha: 1,
141 clearRect: note("clearRect"), fillRect: note("fillRect"), fillText: note("fillText"),
142 beginPath: note("beginPath"), moveTo: note("moveTo"), lineTo: note("lineTo"), rect: note("rect"),
143 arc: note("arc"), fill: note("fill"), stroke: note("stroke"),
144 } as Recorder;
145 }
146
147 export type FakeCanvas = FakeElement & {
148 clientWidth: number;
149 clientHeight: number;
150 width: number;
151 height: number;
152 paint: Recorder;
153 /** How many times the game asked for the pointer. */
154 locks: number;
155 getContext(kind: string): Recorder;
156 requestPointerLock(): void;
157 };
158
159 export function canvas(id: string): FakeCanvas {
160 return {
161 ...element({ id, tag: "canvas" }),
162 clientWidth: 800, clientHeight: 450, width: 0, height: 0,
163 paint: recorder(),
164 locks: 0,
165 getContext() {
166 return this.paint;
167 },
168 requestPointerLock() {
169 this.locks += 1;
170 },
171 };
172 }
173
174 /** One POST the frame made, as it was made. */
175 export type Post = { url: string; method: string; headers: Record<string, string>; body: string };
176
177 /** One draw call the frame made, with the world and camera it was given. */
178 export type Drawn = { world: World; camera: Camera; dt: number };
179
180 export type FakeStage = {
181 /** What `boot` is handed. */
182 host: Host;
183 document: Fake & { pointerLockElement: unknown };
184 window: Fake & { devicePixelRatio: number };
185 scene: FakeCanvas;
186 hud: FakeCanvas;
187 by(id: string): FakeElement;
188 /** The `[data-action]` buttons the page carries, in the page's order. */
189 buttons: FakeElement[];
190 /** The frames asked for but not yet run, oldest first. */
191 frames: ((now: number) => void)[];
192 /** Run the next frame, `ms` after the last one. */
193 step(ms: number): void;
194 /** Pointer lock arrived or was lost — the event and the state together. */
195 lock(on: boolean): void;
196 /** A key, pressed and released on the window the way a keyboard does. */
197 press(code: string): void;
198 hold(code: string, down: boolean): void;
199 /** Every POST /api/match, in order, and what the next one is answered with. */
200 posts: Post[];
201 answer(post: Post): Promise<{ ok: boolean; text(): Promise<string> }>;
202 draws: Drawn[];
203 /** Every sound asked for, as `shot:ak47`, `hit`, `beep` or `resume`. */
204 sounds: string[];
205 store: Map<string, string>;
206 /** The match ids handed out, newest last. */
207 ids: string[];
208 /** The world the last frame drew, or null if none has been drawn. */
209 world(): World | null;
210 };
211
212 /**
213 * Every id `src/pages/game.ts` puts on the page, and nothing else. It is the
214 * contract between the page and this file, so `src/server.test.ts` checks the
215 * served page against this same list rather than a second copy of it.
216 */
217 export const PAGE_IDS = ["scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "play", "resume", "quit", "fps"];
218
219 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
220 const HIDDEN = ["pause", "buy", "touch"];
221
222 /** The `[data-action]` buttons the shell renders for this game. */
223 export const PAGE_ACTIONS = ["duck", "jump", "use", "reload", "buy", "fire"];
224
225 /**
226 * The whole page the shooter's entry expects, plus the host around it. Pass
227 * `webgl: false` for a browser that cannot give the renderer a context, and
228 * `coarse: true` for a phone.
229 */
230 export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}): FakeStage {
231 const scene = canvas("scene");
232 const hud = canvas("hud");
233 const buttons = PAGE_ACTIONS.map((action) => element({ action }));
234 const elements = new Map<string, FakeElement>(PAGE_IDS.map((id) => [id, element({ id })]));
235 elements.set("scene", scene);
236 elements.set("hud", hud);
237 for (const id of HIDDEN) (elements.get(id) as FakeElement).hidden = true;
238 const frames: ((now: number) => void)[] = [];
239 const posts: Post[] = [];
240 const draws: Drawn[] = [];
241 const sounds: string[] = [];
242 const store = new Map<string, string>();
243 const ids: string[] = [];
244 let clock = 1000;
245 let uuid = 0;
246
247 const document = {
248 ...fake(),
249 pointerLockElement: null as unknown,
250 getElementById: (id: string): FakeElement | null => elements.get(id) ?? null,
251 querySelectorAll: (_selector: string): FakeElement[] => buttons,
252 createElement: (tag: string): FakeElement => element({ tag }),
253 exitPointerLock(): void {
254 document.pointerLockElement = null;
255 },
256 };
257 const window = {
258 ...fake(),
259 devicePixelRatio: 1,
260 requestAnimationFrame: (callback: (now: number) => void): number => frames.push(callback),
261 cancelAnimationFrame: (): void => void frames.splice(0, frames.length),
262 matchMedia: (_query: string): { matches: boolean } => ({ matches: options.coarse === true }),
263 performance: { now: (): number => clock },
264 };
265
266 const audio: Audio = {
267 muted: false,
268 resume: () => void sounds.push("resume"),
269 shot: (weapon) => void sounds.push(`shot:${weapon}`),
270 hit: (headshot) => void sounds.push(headshot ? "hit:head" : "hit"),
271 beep: () => void sounds.push("beep"),
272 };
273 const renderer: Draws = {
274 draw(world, camera, dt) {
275 draws.push({ world, camera, dt });
276 },
277 };
278
279 const stub: FakeStage = {
280 host: {
281 document,
282 window,
283 storage: {
284 getItem: (key) => store.get(key) ?? null,
285 setItem: (key, value) => void store.set(key, value),
286 },
287 fetch: (url, init) => {
288 const post: Post = { url, ...init };
289 posts.push(post);
290 return stub.answer(post);
291 },
292 randomUUID: () => {
293 uuid += 1;
294 ids.push(`00000000-0000-4000-8000-${String(uuid).padStart(12, "0")}`);
295 return ids[ids.length - 1] as string;
296 },
297 createRenderer: (_canvas: Stage) => (options.webgl === false ? null : renderer),
298 createAudio: () => audio,
299 },
300 document, window, scene, hud, buttons, frames, posts, draws, sounds, store, ids,
301 by: (id) => elements.get(id) as FakeElement,
302 step(ms) {
303 clock += ms;
304 const next = frames.shift();
305 next?.(clock);
306 },
307 lock(on) {
308 document.pointerLockElement = on ? scene : null;
309 document.fire("pointerlockchange");
310 },
311 hold(code, down) {
312 window.fire(down ? "keydown" : "keyup", { code });
313 },
314 press(code) {
315 stub.hold(code, true);
316 stub.hold(code, false);
317 },
318 answer: () => Promise.resolve({ ok: true, text: () => Promise.resolve("<h2>Leaderboard</h2>") }),
319 world: () => draws[draws.length - 1]?.world ?? null,
320 };
321 return stub;
322 }
src/server.test.ts +5 −6
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { bundle, STATIC_DIR } from "../scripts/build.ts";
66 import { parseResult } from "./result.ts";
7 import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts";
78
89 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
910 process.env.PORT = "0";
@@ -37,14 +38,12 @@ test("serves the game page with the leaderboard in the first paint", async () =>
3738 test("the page carries every id and action the client reaches for", async () => {
3839 // src/client/main.ts finds these by getElementById and hands [data-action]
3940 // to the engine's controls; nothing type-checks that, so the page is
40 // checked instead.
41 // checked instead — against the same list the stub page in
42 // src/client/stage.test-helper.ts is built from, so the two cannot drift.
4143 // Dropping one while moving the markup around is a game that does not start.
4244 const body = await (await fetch(base + "/")).text();
43 for (const id of ["scene", "hud", "fps", "touch", "menu", "pause", "buy", "stats",
44 "name", "length", "side", "sens", "play", "resume", "quit"])
45 expect(body).toContain(`id="${id}"`);
46 for (const action of ["duck", "jump", "use", "reload", "buy", "fire"])
47 expect(body).toContain(`data-action="${action}"`);
45 for (const id of PAGE_IDS) expect(body).toContain(`id="${id}"`);
46 for (const action of PAGE_ACTIONS) expect(body).toContain(`data-action="${action}"`);
4847 expect(body).toContain(`<div id="stage">`);
4948 });
5049
static/app.3fa56501a8.js deleted −58

Diff not shown — a line 57,166 characters wide (limit 2,000). −58

View file
static/app.6bf398e121.js added +58

Diff not shown — a line 57,170 characters wide (limit 2,000). +58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs