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

forked from trevato/cs · proposes

The engine hands out a headless page: one stub document behind the door, and both games boot through it #103

Merged opened this

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

Merged

vesper/cs-engine:vesper/59-the-engine-hands-out-a-headless-page-one → main 1 commit fork

  • Merged. vesper/59-the-engine-hands-out-a-headless-page-one was merged into main as 81a1cdb.

Review

Sign in to review this change.
12 files changed +905 −425
README.md +8 −6
@@ -218,8 +218,8 @@ subtends — and the panel says which of the two you are looking at.
218218 its host: `boot(host)` is handed the document, the window, a store, a `fetch`,
219219 a source of match ids and the two factories that make a renderer and a voice,
220220 and the last lines of the file are the only place a browser global is named.
221 `main.test.ts` boots the whole frame against the stub page in
222 `stage.test-helper.ts` — elements that record what was hidden, written and
221 `main.test.ts` boots the whole frame against `fakePage` from the engine's door,
222 wrapped by `stage.test-helper.ts` — elements that record what was hidden, written and
223223 focused, a 2D context that writes down every call, a renderer that keeps the
224224 scene it was handed, a `fetch` that keeps the bodies, and a clock the test
225225 moves — `boot` hands back the running frame, which is how a test says what
@@ -301,10 +301,12 @@ subtends — and the panel says which of the two you are looking at.
301301 where arithmetic knows, so a ray down the barrel hits, a ray 5° wide of it
302302 misses, a retired plate cannot be hit, the same seed plays the same run twice
303303 and the clock at zero refuses the trigger; and `boot.test.ts` boots the entry
304 itself against the stub page in `dom.ts` — a document of plain objects and a
305 clock the test moves — so a key, a button, a thumb stick, a locked mouse, a
306 canvas that gives no context, a page with WebGL2 and a page without — the
307 drawn one against a GL recorder in `dom.ts` that writes down what it was asked
304 itself against the engine's headless page — `fakePage` from behind the door, a
305 document of plain objects and a clock the test moves, wrapped by
306 `src/range/stage.test-helper.ts` with this game's own ids — so a key, a button,
307 a thumb stick, a locked mouse, a canvas that gives no context, a page with
308 WebGL2 and a page without — the drawn one against a GL recorder in
309 `src/range/stage.test-helper.ts` that writes down what it was asked
308310 for, so the room, one draw per standing plate and the tracer are counted — and
309311 a whole run played with the mouse (turn onto the blip the fallback drew, click,
310312 watch the score move) are all checked with no browser in the room. `bundle.test.ts` then does the same to
src/client/stage.test-helper.ts +61 −233
@@ -1,17 +1,17 @@
11 /**
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.
2 * The shooter's half of a stub page. The page itself is the engine's —
3 * `fakePage` behind the door hands back the document, the window, the elements,
4 * the 2D recorder and the clock that only moves when the test says so, because
5 * both games needed exactly that and neither may import the other's copy of it.
76 *
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 scene it was handed, and `fetch` keeps the bodies it was
11 * posted. The scene is one object the frame refills, so `draws` is a count and
12 * an identity, not a history: read it in the step that produced it. The
13 * recorders have exactly the members `boot` and `hud.ts` call, so a stub that
14 * has to be maintained is smaller than the frame it checks.
7 * What is left here is what is genuinely this game's: the ids `src/pages/game.ts`
8 * renders, the `Audio` the frame plays through, the renderer that keeps the
9 * scene it was handed, the `fetch` that keeps the bodies it was posted, the
10 * store, the match ids — and the line that installs the whole thing as globals.
11 * The scene is one object the frame refills, so `draws` is a count and an
12 * identity, not a history: read it in the step that produced it. The recorders
13 * have exactly the members `boot` and `hud.ts` call, so a stub that has to be
14 * maintained is smaller than the frame it checks.
1515 *
1616 * Two tests boot on it and they reach it from opposite sides: `main.test.ts`
1717 * calls `boot(stage.host)` and hands the page straight over, while
@@ -20,145 +20,30 @@
2020 * That second way is why the scene canvas can answer `getContext("webgl2")`
2121 * with a `glRecorder`: the shipped entry builds its own renderer.
2222 *
23 * It ships in no bundle: only those two import it. `src/range/dom.ts` is
24 * the same idea for the other game — it cannot import this one, because
23 * It ships in no bundle, and neither does the engine's page under it: only
24 * those two tests import this file, and `src/engine/page.test.ts` reads both
25 * committed bundles for the page. `src/range/stage.test-helper.ts` is the same
26 * half for the other game — it cannot import this one, because
2527 * `src/independence.test.ts` forbids either game from reaching into the other,
26 * but a third game copies whichever is nearer and adds its own ids.
28 * and it no longer needs to: what the two shared is behind the door now.
2729 */
2830
31 import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakePaint, type FakeWindow } from "../engine/index.ts";
2932 import type { Audio } from "./audio.ts";
30 import type { Paint } from "./hud.ts";
3133 import type { Draws, Host, Stage } from "./main.ts";
3234 import type { Scene } from "./scene.ts";
3335
34 type Handler = (event: any) => void;
35
36 export type Fake = {
37 /** Deliver an event to everything listening for it here. */
38 fire(type: string, event?: unknown): void;
39 readonly listening: ReadonlySet<string>;
40 addEventListener(type: string, handler: Handler): void;
41 removeEventListener(type: string, handler: Handler): void;
42 };
43
44 /** Anything that can be listened to, and told what happened. */
45 export function fake(): Fake {
46 const handlers = new Map<string, Handler[]>();
47 return {
48 listening: new Set<string>(),
49 addEventListener(type, handler) {
50 const list = handlers.get(type);
51 if (list === undefined) handlers.set(type, [handler]);
52 else list.push(handler);
53 (this.listening as Set<string>).add(type);
54 },
55 removeEventListener(type, handler) {
56 const list = handlers.get(type) ?? [];
57 const at = list.indexOf(handler);
58 if (at >= 0) list.splice(at, 1);
59 },
60 fire(type, event = {}) {
61 for (const handler of [...(handlers.get(type) ?? [])]) handler({ preventDefault() {}, ...(event as object) });
62 },
63 };
64 }
65
66 export type FakeElement = Fake & {
67 id: string;
68 tag: string;
69 className: string;
70 hidden: boolean;
71 innerHTML: string;
72 textContent: string | null;
73 value: string;
74 disabled: boolean;
75 type: string;
76 dataset: { action?: string };
77 /** How many times something asked for the focus. */
78 focused: number;
79 readonly children: FakeElement[];
80 focus(): void;
81 appendChild(child: FakeElement): void;
82 querySelector(selector: string): FakeElement | null;
83 };
84
85 /**
86 * The one thing this stub knows about markup: `class="grid"` in an `innerHTML`
87 * makes a child you can find by `.grid`. That is what the buy panel does — it
88 * writes its shell, then fills the grid — and nothing here parses anything else.
89 */
90 const CLASS = /class="([a-z-]+)"/g;
91
92 export function element(options: { id?: string; tag?: string; action?: string; className?: string } = {}): FakeElement {
93 const children: FakeElement[] = [];
94 let html = "";
95 const self: FakeElement = {
96 ...fake(),
97 id: options.id ?? "",
98 tag: options.tag ?? "div",
99 className: options.className ?? "",
100 dataset: options.action === undefined ? {} : { action: options.action },
101 hidden: false,
102 textContent: "",
103 value: "",
104 disabled: false,
105 type: "",
106 focused: 0,
107 children,
108 get innerHTML() {
109 return html;
110 },
111 set innerHTML(value: string) {
112 html = value;
113 children.length = 0;
114 for (const [, name] of value.matchAll(CLASS)) children.push(element({ className: name as string }));
115 },
116 focus() {
117 self.focused += 1;
118 },
119 appendChild(child) {
120 children.push(child);
121 },
122 querySelector(selector) {
123 const hit = (e: FakeElement): boolean =>
124 selector.startsWith(".") ? e.className === selector.slice(1) : e.tag === selector;
125 for (const child of children) {
126 if (hit(child)) return child;
127 const deeper = child.querySelector(selector);
128 if (deeper !== null) return deeper;
129 }
130 return null;
131 },
132 };
133 return self;
134 }
135
136 /** Every 2D call `hud.ts` makes, written down in order. */
137 export type Recorder = Paint & { calls: [string, ...unknown[]][]; readonly texts: string[] };
138
139 export function recorder(): Recorder {
140 const calls: [string, ...unknown[]][] = [];
141 const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]);
142 return {
143 calls,
144 get texts() {
145 return calls.filter((c) => c[0] === "fillText").map((c) => String(c[1]));
146 },
147 fillStyle: "", strokeStyle: "", lineWidth: 0, font: "", textAlign: "", textBaseline: "", globalAlpha: 1,
148 clearRect: note("clearRect"), fillRect: note("fillRect"), fillText: note("fillText"),
149 beginPath: note("beginPath"), moveTo: note("moveTo"), lineTo: note("lineTo"), rect: note("rect"),
150 arc: note("arc"), fill: note("fill"), stroke: note("stroke"),
151 } as Recorder;
152 }
153
15436 /**
15537 * Every GL call the engine's renderer makes, written down in order, and every
15638 * enum it names — numbered, so a recorded call reads back. It is the same trick
157 * the `Recorder` above plays for 2D: not a fake GPU, only a list of what was
39 * the engine's `FakePaint` plays for 2D: not a fake GPU, only a list of what was
15840 * asked for.
15941 *
160 * `main.test.ts` never sees one, because `boot(host)` is handed a renderer that
161 * keeps the scene it was given. `bundle.test.ts` does: the committed bundle
42 * It stays in this game rather than behind the door because a page that knew
43 * what a renderer was would be one game's page wearing the engine's name: the
44 * engine's canvas has an empty `gl` slot, and this is what the shooter puts in
45 * it. `main.test.ts` never sees one, because `boot(host)` is handed a renderer
46 * that keeps the scene it was given. `bundle.test.ts` does: the committed bundle
16247 * builds its own renderer out of the canvas it finds, so that canvas has to
16348 * answer `getContext("webgl2")` with something.
16449 */
@@ -205,36 +90,8 @@ export function glRecorder(): GlRecorder {
20590 };
20691 }
20792
208 export type FakeCanvas = FakeElement & {
209 clientWidth: number;
210 clientHeight: number;
211 width: number;
212 height: number;
213 paint: Recorder;
214 /** The WebGL2 context this canvas hands out, or null for a browser with none. */
215 gl: GlRecorder | null;
216 /** How many times the game asked for the pointer. */
217 locks: number;
218 getContext(kind: string): Recorder | GlRecorder | null;
219 requestPointerLock(): void;
220 };
221
222 export function canvas(id: string, gl: GlRecorder | null = null): FakeCanvas {
223 return {
224 ...element({ id, tag: "canvas" }),
225 clientWidth: 800, clientHeight: 450, width: 0, height: 0,
226 paint: recorder(),
227 gl,
228 locks: 0,
229 getContext(kind) {
230 if (kind === "webgl2") return this.gl;
231 return kind === "2d" ? this.paint : null;
232 },
233 requestPointerLock() {
234 this.locks += 1;
235 },
236 };
237 }
93 /** One of this page's canvases: it always has a 2D recorder, and its GL one is this game's. */
94 export type StageCanvas = FakeCanvas & { paint: FakePaint; gl: GlRecorder | null };
23895
23996 /** One POST the frame made, as it was made. */
24097 export type Post = { url: string; method: string; headers: Record<string, string>; body: string };
@@ -242,28 +99,13 @@ export type Post = { url: string; method: string; headers: Record<string, string
24299 /** One draw call the frame made, with the scene it was given. */
243100 export type Drawn = { scene: Scene; dt: number };
244101
245 /**
246 * The document, written out rather than left to the host's type, because a test
247 * that installs this page as a global reads it back: which ids the entry asked
248 * for, and — through `readyState` — whether it boots on import or waits for
249 * `DOMContentLoaded`.
250 */
251 export type FakeDocument = Fake & {
252 readyState: string;
253 pointerLockElement: unknown;
254 getElementById(id: string): FakeElement | null;
255 querySelectorAll(selector: string): FakeElement[];
256 createElement(tag: string): FakeElement;
257 exitPointerLock(): void;
258 };
259
260102 export type FakeStage = {
261103 /** What `boot` is handed. */
262104 host: Host;
263105 document: FakeDocument;
264 window: Fake & { devicePixelRatio: number };
265 scene: FakeCanvas;
266 hud: FakeCanvas;
106 window: FakeWindow;
107 scene: StageCanvas;
108 hud: StageCanvas;
267109 by(id: string): FakeElement;
268110 /** The `[data-action]` buttons the page carries, in the page's order. */
269111 buttons: FakeElement[];
@@ -323,47 +165,27 @@ const GLOBALS = ["document", "window", "localStorage", "fetch"];
323165 * `coarse: true` for a phone.
324166 */
325167 export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}): FakeStage {
168 const page = fakePage({
169 ids: PAGE_IDS,
170 canvases: ["scene", "hud"],
171 hidden: HIDDEN,
172 actions: PAGE_ACTIONS,
173 coarse: options.coarse,
174 });
175 // Asked for with `paint` left on, so both canvases have their 2D recorder.
176 const [scene, hud] = page.canvases as [StageCanvas, StageCanvas];
326177 // The same switch on both sides of the seam: `webgl: false` is a browser with
327178 // no context to give, whether the frame asks this page's `host` for a renderer
328179 // or — as the committed bundle does — asks the canvas itself.
329 const scene = canvas("scene", options.webgl === false ? null : glRecorder());
330 const hud = canvas("hud");
331 const buttons = PAGE_ACTIONS.map((action) => element({ action }));
332 const elements = new Map<string, FakeElement>(PAGE_IDS.map((id) => [id, element({ id })]));
333 elements.set("scene", scene);
334 elements.set("hud", hud);
335 for (const id of HIDDEN) (elements.get(id) as FakeElement).hidden = true;
336 const frames: ((now: number) => void)[] = [];
180 if (options.webgl !== false) scene.gl = glRecorder();
181
337182 const posts: Post[] = [];
338183 const draws: Drawn[] = [];
339184 const sounds: string[] = [];
340185 const store = new Map<string, string>();
341186 const ids: string[] = [];
342 let clock = 1000;
343187 let uuid = 0;
344188
345 const document: FakeDocument = {
346 ...fake(),
347 // The entry boots on import unless the page is still parsing; a test that
348 // wants the other path sets this to "loading" before it installs the page.
349 readyState: "complete",
350 pointerLockElement: null as unknown,
351 getElementById: (id: string): FakeElement | null => elements.get(id) ?? null,
352 querySelectorAll: (_selector: string): FakeElement[] => buttons,
353 createElement: (tag: string): FakeElement => element({ tag }),
354 exitPointerLock(): void {
355 document.pointerLockElement = null;
356 },
357 };
358 const window = {
359 ...fake(),
360 devicePixelRatio: 1,
361 requestAnimationFrame: (callback: (now: number) => void): number => frames.push(callback),
362 cancelAnimationFrame: (): void => void frames.splice(0, frames.length),
363 matchMedia: (_query: string): { matches: boolean } => ({ matches: options.coarse === true }),
364 performance: { now: (): number => clock },
365 };
366
367189 const audio: Audio = {
368190 muted: false,
369191 resume: () => void sounds.push("resume"),
@@ -379,8 +201,8 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}):
379201
380202 const stub: FakeStage = {
381203 host: {
382 document,
383 window,
204 document: page.document,
205 window: page.window,
384206 storage: {
385207 getItem: (key) => store.get(key) ?? null,
386208 setItem: (key, value) => void store.set(key, value),
@@ -398,19 +220,21 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}):
398220 createRenderer: (_canvas: Stage) => (options.webgl === false ? null : renderer),
399221 createAudio: () => audio,
400222 },
401 document, window, scene, hud, buttons, frames, posts, draws, sounds, store, ids,
402 by: (id) => elements.get(id) as FakeElement,
403 step(ms) {
404 clock += ms;
405 const next = frames.shift();
406 next?.(clock);
407 },
223 document: page.document,
224 window: page.window,
225 scene,
226 hud,
227 buttons: page.buttons,
228 frames: page.frames,
229 posts, draws, sounds, store, ids,
230 by: page.by,
231 step: page.step,
408232 lock(on) {
409 document.pointerLockElement = on ? scene : null;
410 document.fire("pointerlockchange");
233 page.document.pointerLockElement = on ? scene : null;
234 page.document.fire("pointerlockchange");
411235 },
412236 hold(code, down) {
413 window.fire(down ? "keydown" : "keyup", { code });
237 page.window.fire(down ? "keydown" : "keyup", { code });
414238 },
415239 press(code) {
416240 stub.hold(code, true);
@@ -420,10 +244,14 @@ export function fakeStage(options: { webgl?: boolean; coarse?: boolean } = {}):
420244 install() {
421245 const globals = globalThis as Record<string, unknown>;
422246 // Saved one key at a time, and whether it was there at all: `fetch` is
423 // Bun's own and the next test file wants it back exactly as it was.
247 // Bun's own and the next test file wants it back exactly as it was. It is
248 // put back by assignment rather than by `Object.defineProperty`, which
249 // would be the same value and a permanent tax on every global lookup in
250 // the rest of the run — `src/range/stage.test-helper.ts` says what that
251 // measured.
424252 const was = GLOBALS.map((key) => ({ key, there: key in globals, value: globals[key] }));
425 globals.document = document;
426 globals.window = window;
253 globals.document = page.document;
254 globals.window = page.window;
427255 globals.localStorage = stub.host.storage;
428256 globals.fetch = (url: string, init: Parameters<Host["fetch"]>[1]) => stub.host.fetch(url, init);
429257 return () => {
src/engine/GUIDE.md +47 −6
@@ -68,11 +68,11 @@ parameter. Controls want a `Host` — `{ surface, window, document }`, where
6868 const controls = createControls(host, { keys: KEYS, requireLock: false });
6969 ```
7070
71 That host is the real browser in production and plain objects under test: the
72 shooter's are in `src/client/stage.test-helper.ts`, the range's in
73 `src/range/dom.ts`, and the example's are a few objects that keep a list of
74 handlers at the top of `example.test.ts`. Nothing in the engine knows the
75 difference, because those types are the members it reads and nothing wider.
71 That host is the real browser in production and plain objects under test — and
72 because the engine asks a game for a host, it hands one back: `fakePage()` is a
73 host made of plain objects, and every game here boots on it. Nothing in the
74 engine knows the difference, because those types are the members it reads and
75 nothing wider. The page is the last section of this guide.
7676
7777 ### 3. A bindings table
7878
@@ -351,6 +351,47 @@ animates itself: a bob, a fade or a tint is a decision, and decisions are the
351351 game's. One line a frame draws it, and `src/client/main.ts` and
352352 `src/range/main.ts` are where those two lines live.
353353
354 ### page
355
356 How you boot your entry in a test with no browser. `fakePage` hands back a
357 `FakePage`: a `FakePage.surface`, a `FakePage.window` and a `FakePage.document`
358 — which together are exactly the `Host` above — plus the frames your game asked
359 for and a clock that moves only when you say so. Nothing in it does anything;
360 everything in it records.
361
362 What you bring is `PageOptions`, which is your game and not the engine's:
363 `PageOptions.ids` is every id your page renders and your entry looks up,
364 `PageOptions.canvases` says which of those are canvases (the first one is the
365 surface), `PageOptions.hidden` is the ones the server renders already hidden,
366 and `PageOptions.actions` is one `[data-action]` element per name.
367 `paint: false` is a browser whose canvas gives no 2D context, and `coarse: true`
368 is a phone. Then `FakePage.step(ms)` runs the oldest frame that was asked for,
369 that many milliseconds after the last one — that is the whole clock, and there
370 is no timer anywhere in the file.
371
372 Everything on it reads back. A `FakeTarget` — the base of every element, of the
373 `FakeWindow` and of the `FakeDocument` — has `FakeTarget.fire` to deliver an
374 event to exactly the handlers listening for it, and `FakeTarget.listening`, the
375 set of types with a handler attached right now, which is how you check that a
376 game let go of the page when it stopped. A `FakeElement` remembers what was
377 hidden, written and focused on it. A `FakeCanvas` counts the times it was asked
378 for the pointer and hands out a `FakePaint` for a 2D context: every call, in
379 order, with its arguments — `["fillText", "held nothing", 8, 20]` — so a test of
380 what was drawn reads a list rather than a screen. It is a `Paint`, so anything
381 you draw through `overlay` draws onto one.
382
383 What it deliberately does not know is what a renderer or a sound device is.
384 `FakeCanvas.gl` is an empty slot that `getContext("webgl2")` hands back, and the
385 recorder you put in it is your game's: both games here keep one that writes down
386 every GL call the engine's renderer made. The same goes for an `AudioContext`,
387 for a `window.fetch`, and for installing any of this as a global — a page that
388 knew about those would be one game's page wearing the engine's name. Your game's
389 half lives beside your entry: `src/client/stage.test-helper.ts` and
390 `src/range/stage.test-helper.ts` are the two to copy.
391
392 It ships in no bundle. Nothing a game bundles imports it, and `page.test.ts`
393 reads both committed bundles to say so.
394
354395 ## The seam, named as pairs
355396
356397 Two games ship here, and every place they differ is a place the engine chose to
@@ -361,7 +402,7 @@ own tuning, and nothing else in this table at all.
361402 | the game supplies | the shooter | the range |
362403 | --- | --- | --- |
363404 | an entry | `src/client/main.ts` | `src/range/main.ts` |
364 | a host | a stub page in `src/client/stage.test-helper.ts` | a stub page in `src/range/dom.ts` |
405 | a host | `fakePage` plus the ids, the sound and the recorders in `src/client/stage.test-helper.ts` | `fakePage` plus the ids, the `AudioContext` and the recorder in `src/range/stage.test-helper.ts` |
365406 | a bindings table | `src/client/binding.ts` — buy, scoreboard, weapon slots | `src/range/binding.ts` — a trigger and a start, nothing else |
366407 | a tick rate | 64 Hz | 50 Hz |
367408 | mesh recipes | `src/client/models.ts` | `src/range/level.ts` — a room and a plate |
src/engine/boundary.test.ts +6 −3
@@ -237,14 +237,17 @@ test("the example imports the door and nothing else, which is what it is for", (
237237 * copy of the surface, read twice, rather than two that drift.
238238 */
239239 export const SURFACE = [
240 "Box", "Builder", "Burst", "Button", "ButtonOptions", "Camera", "Controls", "Ctor", "Frames",
240 "Box", "Builder", "Burst", "Button", "ButtonOptions", "Camera", "Controls", "Ctor",
241 "FakeCanvas", "FakeDocument", "FakeElement", "FakePage", "FakePaint", "FakeTarget", "FakeWindow",
242 "Frames",
241243 "Grid", "Hit", "Host", "Instance", "KeyEventish", "Listens", "LockOwner", "Loop", "LoopOptions",
242 "Mat4", "Mesh", "MouseEventish", "Options", "Overlay", "Paint", "Renderer", "RendererAssets",
244 "Mat4", "Mesh", "MouseEventish", "Options", "Overlay", "PageOptions", "Paint", "Renderer",
245 "RendererAssets",
243246 "Scene", "SceneCapacity", "Shader", "SoundDevice", "Stick", "Surface", "Tone",
244247 "TouchEventish", "Touchish", "Vec3", "add", "addBox", "angleDelta", "angleVectors",
245248 "attachButtons", "attenuate", "builder", "cellAt", "clamp", "colOf", "createControls",
246249 "createLoop", "createRenderer", "createScene", "createSoundDevice", "cross", "deg", "dist",
247 "dist2d", "dot", "fbm",
250 "dist2d", "dot", "fakePage", "fbm",
248251 "findPath", "finish", "hash", "inside", "length", "length2d", "lerp", "mat4", "mergeSolid",
249252 "modelMatrix", "mul", "multiply", "nextInstance", "noise", "normalize", "overlay", "paintAtlas",
250253 "perspective", "pushLine",
src/engine/index.ts +16
@@ -60,3 +60,19 @@ export type { Camera, Instance, Scene, SceneCapacity } from "./scene.ts";
6060 /** The WebGL2 draw: an atlas and named meshes in, a frame of instances out. */
6161 export { createRenderer } from "./renderer.ts";
6262 export type { Renderer, RendererAssets } from "./renderer.ts";
63
64 /**
65 * A headless page: the host the parameters above already describe, made of
66 * plain objects. A package that asks a game for a `surface`, a `window` and a
67 * `document` should hand a fake one back, because otherwise every game writes
68 * the same double — both games here did, and neither could import the other's,
69 * so the copy was the thing a third game would have copied. It records rather
70 * than does, it has no timer and touches no global, and it ships in no bundle:
71 * `page.test.ts` reads both committed bundles to say so. What belongs to one
72 * game — its ids, its sound, its renderer and its posts — stays in that game's
73 * own helper, built on this.
74 */
75 export { fakePage } from "./page.ts";
76 export type {
77 FakeCanvas, FakeDocument, FakeElement, FakePage, FakePaint, FakeTarget, FakeWindow, PageOptions,
78 } from "./page.ts";
src/engine/page.test.ts added +327
@@ -0,0 +1,327 @@
1 /**
2 * The double itself, which is the thing neither game's stub ever had. Both
3 * games boot their entry on this page and then assert about their own game, so
4 * every one of these — an event reaching exactly the handlers that are
5 * listening, a recorded call keeping its arguments, an element reading back
6 * what was written on it — was only ever checked sideways, through a game that
7 * happened to need it.
8 *
9 * Three things are pinned besides the behaviour: that the page is a host
10 * `createControls` and `attachButtons` accept, with no cast and no browser;
11 * that it is honest about being a double — no timer, no global, nothing thrown
12 * for a member a game does not use; and that it is in neither committed bundle,
13 * read out of the files in static/ the way `bundle.test.ts` reads them.
14 */
15
16 import { expect, test } from "bun:test";
17 import { readFileSync, readdirSync } from "node:fs";
18 import { join, resolve } from "node:path";
19 import { attachButtons, createControls } from "./controls.ts";
20 import { overlay } from "./overlay.ts";
21 import { HEADLESS, fakePage, type FakePage } from "./page.ts";
22
23 const ENGINE = import.meta.dir;
24 const ROOT = resolve(ENGINE, "..", "..");
25
26 /** A page shaped like a small game's: two canvases, a note, and two buttons. */
27 const demo = (): FakePage =>
28 fakePage({
29 ids: ["scene", "hud", "note", "fps"],
30 canvases: ["scene", "hud"],
31 hidden: ["note"],
32 actions: ["jump", "fire"],
33 });
34
35 // --- events ----------------------------------------------------------------
36
37 test("an event reaches every listener on that target, and nothing else", () => {
38 const page = fakePage();
39 const seen: string[] = [];
40 page.window.addEventListener("keydown", (event) => seen.push(`one:${event.code}`));
41 page.window.addEventListener("keydown", (event) => seen.push(`two:${event.code}`));
42 page.window.addEventListener("keyup", () => seen.push("up"));
43 page.document.addEventListener("keydown", () => seen.push("document"));
44
45 page.window.fire("keydown", { code: "KeyW" });
46 expect(seen).toEqual(["one:KeyW", "two:KeyW"]);
47
48 // A type nobody listens for is delivered to nobody, and is not an exception.
49 page.window.fire("pointerdown");
50 expect(seen).toEqual(["one:KeyW", "two:KeyW"]);
51 });
52
53 test("a removed handler hears nothing, and the ones beside it still do", () => {
54 const page = fakePage();
55 const seen: string[] = [];
56 const first = (): number => seen.push("first");
57 const second = (): number => seen.push("second");
58 page.window.addEventListener("keydown", first);
59 page.window.addEventListener("keydown", second);
60
61 page.window.removeEventListener("keydown", first);
62 page.window.fire("keydown");
63 expect(seen).toEqual(["second"]);
64
65 page.window.removeEventListener("keydown", second);
66 page.window.fire("keydown");
67 expect(seen).toEqual(["second"]);
68
69 // Removing one that was never added is nothing, not a throw.
70 expect(() => page.window.removeEventListener("keyup", first)).not.toThrow();
71 });
72
73 test("every event carries a preventDefault, and the fields the test gave it", () => {
74 const page = fakePage();
75 const events: Record<string, unknown>[] = [];
76 page.surface.addEventListener("mousedown", (event) => events.push(event));
77 page.surface.fire("mousedown", { button: 2, movementX: 4 });
78 expect(typeof events[0]?.preventDefault).toBe("function");
79 expect(events[0]?.button).toBe(2);
80 expect(events[0]?.movementX).toBe(4);
81 // And an event nobody described is still an event with a preventDefault on it.
82 page.surface.fire("contextmenu");
83 expect(typeof events[0]?.preventDefault).toBe("function");
84 });
85
86 test("`listening` is exactly the types with a handler attached", () => {
87 const page = fakePage();
88 expect([...page.window.listening]).toEqual([]);
89
90 const first = (): void => {};
91 const second = (): void => {};
92 page.window.addEventListener("keydown", first);
93 page.window.addEventListener("keydown", second);
94 page.window.addEventListener("blur", first);
95 expect([...page.window.listening].sort()).toEqual(["blur", "keydown"]);
96 // The two targets are two: what is listening on one is not listening on the other.
97 expect(page.document.listening.size).toBe(0);
98
99 // One of two handlers off leaves the type; the last one takes it away.
100 page.window.removeEventListener("keydown", first);
101 expect(page.window.listening.has("keydown")).toBe(true);
102 page.window.removeEventListener("keydown", second);
103 expect([...page.window.listening]).toEqual(["blur"]);
104 });
105
106 // --- the host the engine's own parameters describe -------------------------
107
108 test("the page is what createControls asks for: a surface, a window, a document", () => {
109 const page = demo();
110 const controls = createControls(page, { keys: { KeyW: "forward" }, mouse: { 0: "fire" } });
111 try {
112 page.window.fire("keydown", { code: "KeyW" });
113 expect([...controls.held]).toEqual(["forward"]);
114 page.window.fire("keyup", { code: "KeyW" });
115 expect([...controls.held]).toEqual([]);
116
117 // The surface is the first canvas, which is what the pointer locks to.
118 expect(page.surface).toBe(page.canvases[0] as (typeof page.canvases)[number]);
119 expect(page.surface.id).toBe("scene");
120 page.document.pointerLockElement = page.surface;
121 page.document.fire("pointerlockchange");
122 expect(controls.locked).toBe(true);
123 page.window.fire("mousemove", { movementX: 10, movementY: 0 });
124 expect(controls.takeLook()[0]).toBeCloseTo(10 * 2.2 * 0.022, 6);
125 } finally {
126 controls.detach();
127 }
128 // Detaching leaves the page as it was found, which this page can be asked.
129 expect(page.window.listening.size).toBe(0);
130 expect(page.document.listening.size).toBe(0);
131 expect(page.surface.listening.size).toBe(0);
132 });
133
134 test("a page with no canvas still has a surface, and it belongs to no document", () => {
135 const page = fakePage();
136 expect(page.canvases).toEqual([]);
137 expect(page.surface.id).toBe("");
138 expect(page.surface.clientWidth).toBe(800);
139 expect(page.document.getElementById("")).toBeNull();
140 // It is a host all the same: the controls attach to it and let go of it.
141 const controls = createControls(page, { keys: {} });
142 expect(page.surface.listening.size).toBeGreaterThan(0);
143 controls.detach();
144 expect(page.surface.listening.size).toBe(0);
145 });
146
147 test("attachButtons finds the page's buttons and a press is the same action as a key", () => {
148 const page = demo();
149 const controls = createControls(page, { keys: { KeyF: "fire" } });
150 try {
151 attachButtons(page.document.querySelectorAll("[data-action]"), controls);
152 expect(page.buttons.map((b) => b.dataset.action)).toEqual(["jump", "fire"]);
153 page.buttons[1]?.fire("pointerdown");
154 expect([...controls.held]).toEqual(["fire"]);
155 page.buttons[1]?.fire("pointerup");
156 expect([...controls.held]).toEqual([]);
157 } finally {
158 controls.detach();
159 }
160 });
161
162 // --- elements --------------------------------------------------------------
163
164 test("an element reads back what was hidden, written and focused on it", () => {
165 const page = demo();
166 const note = page.by("note");
167 // The page is served with this one hidden, which is what `hidden` is for.
168 expect(note.hidden).toBe(true);
169 expect(page.by("fps").hidden).toBe(false);
170
171 note.hidden = false;
172 expect(note.hidden).toBe(false);
173 note.textContent = "30 fps · 33.3 ms";
174 expect(note.textContent).toBe("30 fps · 33.3 ms");
175 expect(note.focused).toBe(0);
176 note.focus();
177 note.focus();
178 expect(note.focused).toBe(2);
179 // An id the page was never given is nothing, not a throw.
180 expect(page.document.getElementById("nobody")).toBeNull();
181 });
182
183 test("innerHTML remembers the markup and hands back the classes it named", () => {
184 const page = demo();
185 const note = page.by("note");
186 note.innerHTML = `<div class="panel"><button class="buy">AK-47</button></div>`;
187 expect(note.innerHTML).toContain("AK-47");
188 expect(note.children.length).toBe(2);
189 expect(note.querySelector(".buy")?.className).toBe("buy");
190 expect(note.querySelector(".nothing")).toBeNull();
191 // Writing it again is a new set of children, not more of the old ones.
192 note.innerHTML = `<p class="panel">empty</p>`;
193 expect(note.children.length).toBe(1);
194
195 const made = page.document.createElement("button");
196 made.textContent = "play";
197 note.appendChild(made);
198 expect(note.children.length).toBe(2);
199 expect(note.querySelector("button")?.textContent).toBe("play");
200 });
201
202 // --- the 2D recorder -------------------------------------------------------
203
204 test("a recorded 2D call keeps its name and its arguments, in the order they came", () => {
205 const page = demo();
206 const paint = page.surface.paint;
207 if (paint === null) throw new Error("this page was asked for a 2D context");
208 paint.fillStyle = "#123456";
209 paint.fillRect(1, 2, 3, 4);
210 paint.fillText("held nothing", 8, 20);
211 paint.beginPath();
212 paint.arc(10, 20, 30, 0, Math.PI);
213 paint.stroke();
214
215 expect(paint.calls).toEqual([
216 ["fillRect", 1, 2, 3, 4],
217 ["fillText", "held nothing", 8, 20],
218 ["beginPath"],
219 ["arc", 10, 20, 30, 0, Math.PI],
220 ["stroke"],
221 ]);
222 // A style is a value it keeps, not a call it records.
223 expect(paint.fillStyle).toBe("#123456");
224 expect(paint.texts).toEqual(["held nothing"]);
225 });
226
227 test("the recorder is a Paint, so the engine's overlay draws onto one", () => {
228 const page = demo();
229 const paint = page.canvases[1]?.paint;
230 if (paint === null || paint === undefined) throw new Error("the hud was asked for a 2D context");
231 const draw = overlay(paint, 800, 450);
232 paint.clearRect(0, 0, 800, 450);
233 draw.text("tick 1", 8, 20);
234 draw.crosshair(400, 225, 4, 10, "#fff");
235 expect(paint.texts).toEqual(["tick 1"]);
236 expect(paint.calls[0]).toEqual(["clearRect", 0, 0, 800, 450]);
237 expect(paint.calls.some((call) => call[0] === "stroke")).toBe(true);
238 });
239
240 test("a browser with no 2D context hands back null rather than a recorder", () => {
241 const page = fakePage({ ids: ["scene"], canvases: ["scene"], paint: false });
242 expect(page.surface.paint).toBeNull();
243 expect(page.surface.getContext("2d")).toBeNull();
244 // And WebGL2 is a slot the game fills: this file does not know what one is.
245 expect(page.surface.getContext("webgl2")).toBeNull();
246 const recorder = { calls: [] as string[] };
247 page.surface.gl = recorder;
248 expect(page.surface.getContext("webgl2")).toBe(recorder);
249 expect(page.surface.getContext("bitmaprenderer")).toBeNull();
250 });
251
252 test("the canvas counts the times it was asked for the pointer", () => {
253 const page = demo();
254 expect(page.surface.locks).toBe(0);
255 page.surface.requestPointerLock();
256 page.surface.requestPointerLock();
257 expect(page.surface.locks).toBe(2);
258 page.document.pointerLockElement = page.surface;
259 page.document.exitPointerLock();
260 expect(page.document.pointerLockElement).toBeNull();
261 });
262
263 // --- the clock -------------------------------------------------------------
264
265 test("a frame waits in the queue until the test runs it, and the clock is the test's", () => {
266 const page = demo();
267 const seen: number[] = [];
268 const start = page.now();
269 page.window.requestAnimationFrame((now) => seen.push(now));
270 page.window.requestAnimationFrame((now) => seen.push(now));
271 expect(page.frames.length).toBe(2);
272 expect(seen).toEqual([]);
273 expect(page.window.performance.now()).toBe(start);
274
275 page.step(16);
276 expect(seen).toEqual([start + 16]);
277 expect(page.frames.length).toBe(1);
278 expect(page.now()).toBe(start + 16);
279
280 // The oldest frame first, and a step with nothing queued only moves the clock.
281 page.step(16);
282 page.step(16);
283 expect(seen).toEqual([start + 16, start + 32]);
284 expect(page.now()).toBe(start + 48);
285
286 // Cancelling drops what was queued.
287 page.window.requestAnimationFrame((now) => seen.push(now));
288 page.window.cancelAnimationFrame();
289 expect(page.frames.length).toBe(0);
290 });
291
292 test("a phone is a page that says so, and nothing else about it changes", () => {
293 expect(fakePage().window.matchMedia("(pointer: coarse)").matches).toBe(false);
294 expect(fakePage({ coarse: true }).window.matchMedia("(pointer: coarse)").matches).toBe(true);
295 expect(fakePage().window.devicePixelRatio).toBe(1);
296 expect(fakePage().document.readyState).toBe("complete");
297 });
298
299 // --- honest about being a double -------------------------------------------
300
301 test("the module has no timer of its own and touches no global", () => {
302 const source = readFileSync(join(ENGINE, "page.ts"), "utf8");
303 // The comments come out first: this file is allowed to talk about them.
304 const code = source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
305 for (const forbidden of ["globalThis", "setTimeout", "setInterval", "Date.now", "window."])
306 expect(code).not.toContain(forbidden);
307 // Installing a page as the globals an entry reads is the game's own business:
308 // its helper does it, over the objects this one hands back.
309 expect(code).toContain("export function fakePage");
310 });
311
312 test("neither committed bundle carries the double", () => {
313 const dir = join(ROOT, "static");
314 const bundles = readdirSync(dir).filter((file) => file.endsWith(".js"));
315 // Both games' entries, so this is not a test that passes on an empty directory.
316 expect(bundles.length).toBeGreaterThanOrEqual(2);
317 for (const file of bundles) {
318 const code = readFileSync(join(dir, file), "utf8");
319 // The reader is alive: these files are what it thinks they are.
320 expect(code.length).toBeGreaterThan(1000);
321 expect(code).toContain("webgl2");
322 // And no byte of this module is in them — the sentence it carries, and the
323 // one pattern only it has.
324 expect(code).not.toContain(HEADLESS);
325 expect(code).not.toContain(`class="([a-z-]+)"`);
326 }
327 });
src/engine/page.ts added +344
@@ -0,0 +1,344 @@
1 /**
2 * A page made of plain objects: the handful of browser shapes a game's entry
3 * and this package's own `createControls` actually touch, and a clock that only
4 * moves when the test says so. A game asks this package for a host — a
5 * `surface`, a `window`, a `document` — so this package hands one back, and an
6 * entry can be booted and stepped in a process with no browser in it.
7 *
8 * It is behind the door for the same reason `Listens` is: both games needed it,
9 * and neither may import the other's, so the copy they shared was the one thing
10 * a third game would copy out of whichever directory was nearer. It ships in no
11 * bundle — nothing a game bundles imports it, and `page.test.ts` reads the
12 * committed bundles to say so.
13 *
14 * Everything here records rather than does. An element remembers what was
15 * hidden, written and focused; a canvas's 2D context writes down every call it
16 * was asked for, in order, with its arguments; a target remembers what is
17 * listening on it and delivers an event to exactly those handlers. It is honest
18 * about being a double: it has no timer, it touches no global, it throws for
19 * nothing, and it knows what neither a renderer nor an `AudioContext` is —
20 * `canvas.gl` is a slot a game fills with its own recorder, because what a
21 * WebGL2 context means is the game's business and not this file's.
22 *
23 * What it does not have is what belongs to one game: its ids, its buttons' names,
24 * its sounds, its posts. Those arrive as `PageOptions` or stay in the game's own
25 * helper — `src/client/stage.test-helper.ts` and `src/range/stage.test-helper.ts`
26 * are each the game's half, built on this one.
27 */
28
29 import type { Paint } from "./overlay.ts";
30
31 /**
32 * A sentence that appears in this file and in no other. A minifier renames
33 * every identifier it can reach but keeps the bytes of a string, so
34 * `page.test.ts` reads the committed bundles for this one: a double that ever
35 * ends up in a file a browser downloads fails there.
36 */
37 export const HEADLESS = "src/engine/page.ts: a double of a browser, in no bundle";
38
39 type Handler = (event: any) => void;
40
41 /** Anything that can be listened to, and told what happened. */
42 export type FakeTarget = {
43 /** Deliver an event to everything listening for `type` here, and to nothing else. */
44 fire(type: string, event?: unknown): void;
45 /**
46 * The event types with at least one handler attached right now. It is live:
47 * the last `removeEventListener` for a type takes it back out, so a page that
48 * was never booted has an empty one and a detached game leaves one behind.
49 */
50 readonly listening: ReadonlySet<string>;
51 addEventListener(type: string, handler: Handler, options?: { passive?: boolean }): void;
52 removeEventListener(type: string, handler: Handler): void;
53 };
54
55 function target(): FakeTarget {
56 const handlers = new Map<string, Handler[]>();
57 const listening = new Set<string>();
58 return {
59 listening,
60 addEventListener(type, handler) {
61 const list = handlers.get(type);
62 if (list === undefined) handlers.set(type, [handler]);
63 else list.push(handler);
64 listening.add(type);
65 },
66 removeEventListener(type, handler) {
67 const list = handlers.get(type) ?? [];
68 const at = list.indexOf(handler);
69 if (at >= 0) list.splice(at, 1);
70 if (list.length === 0) listening.delete(type);
71 },
72 // A copy, because a handler may detach itself while this is delivering.
73 // Every event carries a `preventDefault`, which is the one member the
74 // engine's own listeners call on one they were given.
75 fire(type, event = {}) {
76 for (const handler of [...(handlers.get(type) ?? [])])
77 handler({ preventDefault() {}, ...(event as object) });
78 },
79 };
80 }
81
82 export type FakeElement = FakeTarget & {
83 id: string;
84 tag: string;
85 className: string;
86 hidden: boolean;
87 innerHTML: string;
88 textContent: string | null;
89 value: string;
90 disabled: boolean;
91 type: string;
92 dataset: { action?: string };
93 /** How many times something asked for the focus. */
94 focused: number;
95 readonly children: FakeElement[];
96 focus(): void;
97 appendChild(child: FakeElement): void;
98 querySelector(selector: string): FakeElement | null;
99 };
100
101 /**
102 * The one thing this page knows about markup: `class="grid"` in an `innerHTML`
103 * makes a child you can find by `.grid`. That is what a panel built from a
104 * string does — write its shell, then fill the grid it just wrote — and nothing
105 * here parses anything else.
106 */
107 const CLASS = /class="([a-z-]+)"/g;
108
109 function element(options: { id?: string; tag?: string; action?: string; className?: string } = {}): FakeElement {
110 const children: FakeElement[] = [];
111 let html = "";
112 const self: FakeElement = {
113 ...target(),
114 id: options.id ?? "",
115 tag: options.tag ?? "div",
116 className: options.className ?? "",
117 dataset: options.action === undefined ? {} : { action: options.action },
118 hidden: false,
119 textContent: "",
120 value: "",
121 disabled: false,
122 type: "",
123 focused: 0,
124 children,
125 get innerHTML() {
126 return html;
127 },
128 set innerHTML(value: string) {
129 html = value;
130 children.length = 0;
131 for (const [, name] of value.matchAll(CLASS)) children.push(element({ className: name as string }));
132 },
133 focus() {
134 self.focused += 1;
135 },
136 appendChild(child) {
137 children.push(child);
138 },
139 querySelector(selector) {
140 const hit = (e: FakeElement): boolean =>
141 selector.startsWith(".") ? e.className === selector.slice(1) : e.tag === selector;
142 for (const child of children) {
143 if (hit(child)) return child;
144 const deeper = child.querySelector(selector);
145 if (deeper !== null) return deeper;
146 }
147 return null;
148 },
149 };
150 return self;
151 }
152
153 /** The members of `Paint` a recorder answers as the plain strings a game sets. */
154 type Styles = "fillStyle" | "strokeStyle" | "font" | "textAlign" | "textBaseline";
155
156 /**
157 * Every 2D call, written down in order with its arguments: `["fillText", "hi",
158 * 8, 20]`. It is a `Paint` — the engine's own 2D surface — so anything that
159 * draws through `overlay` draws onto one of these, and a test reads a list
160 * instead of a screen. The styles are strings rather than the `unknown` a real
161 * context's are, so a game with a narrower surface of its own takes one too.
162 */
163 export type FakePaint = Omit<Paint, Styles> &
164 Record<Styles, string> & {
165 /** Every call, oldest first. */
166 readonly calls: [string, ...unknown[]][];
167 /** The first argument of every `fillText`, which is the drawn words. */
168 readonly texts: string[];
169 };
170
171 function paintRecorder(): FakePaint {
172 const calls: [string, ...unknown[]][] = [];
173 const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]);
174 return {
175 calls,
176 get texts() {
177 return calls.filter((c) => c[0] === "fillText").map((c) => String(c[1]));
178 },
179 fillStyle: "", strokeStyle: "", lineWidth: 0, font: "", textAlign: "", textBaseline: "", globalAlpha: 1,
180 clearRect: note("clearRect"), fillRect: note("fillRect"), fillText: note("fillText"),
181 beginPath: note("beginPath"), moveTo: note("moveTo"), lineTo: note("lineTo"), rect: note("rect"),
182 arc: note("arc"), fill: note("fill"), stroke: note("stroke"),
183 };
184 }
185
186 export type FakeCanvas = FakeElement & {
187 clientWidth: number;
188 clientHeight: number;
189 width: number;
190 height: number;
191 /** The 2D recorder this canvas hands out, or null for a browser with none. */
192 paint: FakePaint | null;
193 /**
194 * What `getContext("webgl2")` answers with. It starts null — a browser with
195 * no WebGL2, which is a browser a game has to keep working on — and a game
196 * puts its own recorder here, because this file does not know what a GL
197 * context is and should not.
198 */
199 gl: unknown;
200 /** How many times something asked for the pointer. */
201 locks: number;
202 getContext(kind: string): unknown;
203 requestPointerLock(): void;
204 };
205
206 function canvasElement(id: string, paint: boolean): FakeCanvas {
207 return {
208 ...element({ id, tag: "canvas" }),
209 clientWidth: 800, clientHeight: 450, width: 0, height: 0,
210 paint: paint ? paintRecorder() : null,
211 gl: null,
212 locks: 0,
213 getContext(kind) {
214 if (kind === "2d") return this.paint;
215 return kind === "webgl2" ? this.gl : null;
216 },
217 requestPointerLock() {
218 this.locks += 1;
219 },
220 };
221 }
222
223 export type FakeWindow = FakeTarget & {
224 devicePixelRatio: number;
225 /** Queue a frame. It runs when the test calls `step`, and never before. */
226 requestAnimationFrame(callback: (now: number) => void): number;
227 /** Drop every queued frame. The handle is ignored: this page runs one loop. */
228 cancelAnimationFrame(handle?: number): void;
229 /** Answers `coarse`, whatever is asked: a page is a phone or it is not. */
230 matchMedia(query: string): { matches: boolean };
231 performance: { now(): number };
232 };
233
234 export type FakeDocument = FakeTarget & {
235 /** `"complete"`, so an entry boots on import; set `"loading"` for the other path. */
236 readyState: string;
237 pointerLockElement: unknown;
238 getElementById(id: string): FakeElement | null;
239 /** The `[data-action]` buttons, whatever the selector says: there is one list. */
240 querySelectorAll(selector: string): FakeElement[];
241 createElement(tag: string): FakeElement;
242 exitPointerLock(): void;
243 };
244
245 /** What a game brings: its own ids, its own buttons, and the browser it is on. */
246 export type PageOptions = {
247 /** Every id `getElementById` answers for. Anything else answers null. */
248 ids?: readonly string[];
249 /** Which of those ids are canvases. The first one is the `surface`. */
250 canvases?: readonly string[];
251 /** Which of those ids the page is served with `hidden` already set. */
252 hidden?: readonly string[];
253 /** One `[data-action]` element per name, in this order. */
254 actions?: readonly string[];
255 /** Give the canvases a 2D recorder. False is a browser that has no 2D context. */
256 paint?: boolean;
257 /** What `matchMedia` answers. True is a phone. */
258 coarse?: boolean;
259 };
260
261 export type FakePage = {
262 /**
263 * The element the pointer locks to and the fingers land on — the first
264 * canvas, or a loose one belonging to no document when none was asked for.
265 * With `window` and `document` beside it this is `createControls`'s host.
266 */
267 surface: FakeCanvas;
268 window: FakeWindow;
269 document: FakeDocument;
270 /** The canvases, in the order `canvases` named them. */
271 canvases: FakeCanvas[];
272 /** The `[data-action]` elements, in the order `actions` named them. */
273 buttons: FakeElement[];
274 /** The element with this id. A game asks for the ids it put in `ids`. */
275 by(id: string): FakeElement;
276 /** The frames asked for but not yet run, oldest first. */
277 frames: ((now: number) => void)[];
278 /** Run the next frame, `ms` after the last one. Nothing else moves the clock. */
279 step(ms: number): void;
280 /** What `performance.now()` reads right now, in milliseconds. */
281 now(): number;
282 };
283
284 /** Where this page's clock starts. A browser's is not zero either. */
285 const START = 1000;
286
287 /**
288 * A page a game can boot on. Nothing here runs until a test says so: a frame
289 * waits in `frames` until `step`, and the clock moves by exactly what `step` was
290 * given.
291 */
292 export function fakePage(options: PageOptions = {}): FakePage {
293 const paint = options.paint ?? true;
294 const canvasIds = new Set(options.canvases ?? []);
295 const elements = new Map<string, FakeElement>(
296 (options.ids ?? []).map((id) => [id, canvasIds.has(id) ? canvasElement(id, paint) : element({ id })]),
297 );
298 for (const id of options.hidden ?? []) {
299 const hide = elements.get(id);
300 if (hide !== undefined) hide.hidden = true;
301 }
302 const canvases = [...canvasIds]
303 .map((id) => elements.get(id))
304 .filter((found): found is FakeCanvas => found !== undefined);
305 const buttons = (options.actions ?? []).map((action) => element({ action }));
306 const frames: ((now: number) => void)[] = [];
307 let clock = START;
308
309 const document: FakeDocument = {
310 ...target(),
311 readyState: "complete",
312 pointerLockElement: null as unknown,
313 getElementById: (id) => elements.get(id) ?? null,
314 querySelectorAll: (_selector) => buttons,
315 createElement: (tag) => element({ tag }),
316 exitPointerLock(): void {
317 document.pointerLockElement = null;
318 },
319 };
320
321 const window: FakeWindow = {
322 ...target(),
323 devicePixelRatio: 1,
324 requestAnimationFrame: (callback) => frames.push(callback),
325 cancelAnimationFrame: () => void frames.splice(0, frames.length),
326 matchMedia: (_query) => ({ matches: options.coarse === true }),
327 performance: { now: () => clock },
328 };
329
330 return {
331 surface: canvases[0] ?? canvasElement("", paint),
332 window,
333 document,
334 canvases,
335 buttons,
336 frames,
337 by: (id) => elements.get(id) as FakeElement,
338 step(ms) {
339 clock += ms;
340 frames.shift()?.(clock);
341 },
342 now: () => clock,
343 };
344 }
src/range/boot.test.ts +17 −11
@@ -1,13 +1,19 @@
11 /**
2 * The entry itself, booted against the stub page in `dom.ts`: the ids it looks
2 * The entry itself, booted against the engine's headless page: the ids it looks
33 * up, the listeners it attaches, the frames it asks for and what it draws and
44 * plays when one runs. Nothing here is a browser, and nothing here is timed by
55 * a real clock — the test decides when a frame happens and how long it took.
6 *
7 * The page comes from `src/engine/index.ts` through `stage.test-helper.ts`,
8 * which adds the three things that are this game's and not the engine's: the
9 * ids `src/pages/range.ts` renders, the GL recorder, and the counted
10 * `AudioContext`. The range importing the engine is the whole point of the
11 * range, so `src/independence.test.ts` has nothing to say about it.
612 */
713
814 import { expect, test } from "bun:test";
915 import { HIT, POP, TICK } from "./audio.ts";
10 import { page, type FakePage, type Voice } from "./dom.ts";
16 import { page, type RangePage, type Voice } from "./stage.test-helper.ts";
1117
1218 // The entry boots itself when it is imported, so it is imported into a page
1319 // that is still loading: then every test below boots a fresh one on purpose.
@@ -18,14 +24,14 @@ const { boot } = await import("./main.ts");
1824 undoLoading();
1925
2026 /** A booted page, and the undo that puts the globals back. */
21 function booted(options: { has2d?: boolean; webgl?: boolean } = {}): { p: FakePage; undo: () => void } {
27 function booted(options: { has2d?: boolean; webgl?: boolean } = {}): { p: RangePage; undo: () => void } {
2228 const p = page(options);
2329 const undo = p.install();
2430 boot();
2531 return { p, undo };
2632 }
2733
28 const hudTexts = (p: FakePage): string[] => p.hud.paint?.texts ?? [];
34 const hudTexts = (p: RangePage): string[] => p.hud.paint?.texts ?? [];
2935
3036 test("it boots, asks for a frame, and draws the readout the loop produced", () => {
3137 const { p, undo } = booted();
@@ -150,13 +156,13 @@ test("a canvas with no context is a sentence on the page, not an exception", ()
150156 });
151157
152158 /** The fillRects of the last frame, less the sky and the ground: the plates. */
153 function platesDrawn(p: FakePage, from: number): [number, ...unknown[]][] {
159 function platesDrawn(p: RangePage, from: number): [number, ...unknown[]][] {
154160 const calls = (p.scene.paint?.calls ?? []).slice(from);
155161 return calls.filter((c) => c[0] === "fillRect").slice(2) as [number, ...unknown[]][];
156162 }
157163
158164 /** The most recent line the panel drew starting with `prefix`: this frame's. */
159 const latest = (p: FakePage, prefix: string): string =>
165 const latest = (p: RangePage, prefix: string): string =>
160166 [...hudTexts(p)].reverse().find((line) => line.startsWith(prefix)) ?? "";
161167
162168 /** Degrees of view per unit of mouse movement: the engine's 2.2 × 0.022. */
@@ -265,15 +271,15 @@ test("the clock ends the run, the entry stops shooting and the best score stays"
265271 });
266272
267273 /** How many plates the panel said were standing in the frame just drawn. */
268 const standing = (p: FakePage): number =>
274 const standing = (p: RangePage): number =>
269275 Number(/(\d+) up/.exec([...hudTexts(p)].reverse().find((line) => line.includes(" up")) ?? "")?.[1]);
270276
271277 /** Every GL call the scene canvas was given, in order. */
272 const glCalls = (p: FakePage): [string, ...unknown[]][] =>
278 const glCalls = (p: RangePage): [string, ...unknown[]][] =>
273279 (p.scene.gl?.calls ?? []) as [string, ...unknown[]][];
274280
275281 /** What one frame asked the GL context for: the calls made after `from`. */
276 const since = (p: FakePage, from: number): [string, ...unknown[]][] => glCalls(p).slice(from);
282 const since = (p: RangePage, from: number): [string, ...unknown[]][] => glCalls(p).slice(from);
277283
278284 const named = (calls: [string, ...unknown[]][], name: string): [string, ...unknown[]][] =>
279285 calls.filter((call) => call[0] === name);
@@ -358,10 +364,10 @@ const nameOf = (voice: Voice): string => {
358364 };
359365
360366 /** Every sound the page has made, named, oldest first. */
361 const sounds = (p: FakePage): string[] => p.audio.voices.map(nameOf);
367 const sounds = (p: RangePage): string[] => p.audio.voices.map(nameOf);
362368
363369 /** The sounds made since there were `from` of them. */
364 const sincePlayed = (p: FakePage, from: number): string[] => sounds(p).slice(from);
370 const sincePlayed = (p: RangePage, from: number): string[] => sounds(p).slice(from);
365371
366372 test("booting makes no sound, and a page nobody plays opens no AudioContext", () => {
367373 const { p, undo } = booted();
src/range/bundle.test.ts +8 −6
@@ -1,12 +1,14 @@
11 /**
22 * The committed bundle, booted. `boot.test.ts` proves the range's *sources* run
3 * against the stub page in `dom.ts`; this proves the minified file in `static/`
3 * against the engine's headless page; this proves the minified file in `static/`
44 * does, which is the file a browser actually downloads. A bundling mistake — a
55 * mangled name the entry needed, a tree shake that took the boot line with it —
66 * fails here rather than in the preview.
77 *
8 * Nothing under `src/range/` is imported except `dom.ts`, which ships in no
9 * bundle: the thing under test is the artifact, found on disk by name. Finding
8 * Nothing under `src/range/` is imported except `stage.test-helper.ts`, which
9 * ships in no bundle — and neither does the engine's page under it, which
10 * `src/engine/page.test.ts` checks by reading this very file off disk. The thing
11 * under test is the artifact, found on disk by name. Finding
1012 * it and importing it afresh is `scripts/bundle.test-helper.ts`, shared with the
1113 * shooter's `src/client/bundle.test.ts` — the same file, the same trick, one
1214 * copy of it.
@@ -14,7 +16,7 @@
1416
1517 import { afterAll, expect, test } from "bun:test";
1618 import { discardBundleCopies, importBundle } from "../../scripts/bundle.test-helper.ts";
17 import { page, type FakePage } from "./dom.ts";
19 import { page, type RangePage } from "./stage.test-helper.ts";
1820
1921 afterAll(discardBundleCopies);
2022
@@ -28,7 +30,7 @@ const HAD_DOCUMENT = "document" in globalThis;
2830 */
2931 async function booted(
3032 options: { has2d?: boolean; webgl?: boolean } = {},
31 ): Promise<{ p: FakePage; undo: () => void }> {
33 ): Promise<{ p: RangePage; undo: () => void }> {
3234 const p = page(options);
3335 const undo = p.install();
3436 try {
@@ -40,7 +42,7 @@ async function booted(
4042 return { p, undo };
4143 }
4244
43 const hudTexts = (p: FakePage): string[] => p.hud.paint?.texts ?? [];
45 const hudTexts = (p: RangePage): string[] => p.hud.paint?.texts ?? [];
4446
4547 test("the committed bundle boots the page and draws what the loop produced", async () => {
4648 const { p, undo } = await booted();
src/range/dom.ts → src/range/stage.test-helper.ts renamed +63 −152
@@ -1,86 +1,34 @@
11 /**
2 * A document made of plain objects: the handful of DOM shapes `main.ts` and the
3 * engine's controls actually touch, and nothing else. It exists so the entry
4 * can be booted and stepped in a test with no browser — the same trick
5 * `src/engine/controls.ts` plays with its `Listens` parameter, one level up.
6 *
7 * Two recorders hang off a canvas here: the 2D one the readout's panel draws
8 * onto, and — when a test asks for `webgl` — a WebGL2 one that writes down every
9 * call the engine's renderer makes, so the drawn game is checked by reading a
10 * list rather than a screen.
2 * The range's half of a stub page. The page itself is the engine's —
3 * `fakePage` behind the door hands back the document, the window, the elements,
4 * the 2D recorder and the clock, because both games needed exactly that and
5 * neither may import the other's copy of it. What is left here is what is
6 * genuinely this game's: the ids `src/pages/range.ts` renders, a recorder for
7 * every GL call the engine's renderer makes, a counter for the `AudioContext`
8 * the engine's sound device opens, and the line that installs all of it as the
9 * globals `main.ts` reads for itself.
1110 *
1211 * It ships in no bundle: only `boot.test.ts` and `bundle.test.ts` import it.
1312 */
1413
15 import type { Paint } from "./readout.ts";
14 import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts";
1615
17 type Handler = (event: any) => void;
16 /** Every id `src/pages/range.ts` puts on the page that the entry looks up. */
17 export const RANGE_IDS = ["scene", "hud", "note", "touch", "look", "fps"];
1818
19 export type Fake = {
20 /** Deliver an event to everything listening for it here. */
21 fire(type: string, event?: unknown): void;
22 readonly listening: ReadonlySet<string>;
23 addEventListener(type: string, handler: Handler): void;
24 removeEventListener(type: string, handler: Handler): void;
25 };
26
27 /** Anything that can be listened to, and told what happened. */
28 export function fake(): Fake {
29 const handlers = new Map<string, Handler[]>();
30 return {
31 listening: new Set<string>(),
32 addEventListener(type, handler) {
33 const list = handlers.get(type);
34 if (list === undefined) handlers.set(type, [handler]);
35 else list.push(handler);
36 (this.listening as Set<string>).add(type);
37 },
38 removeEventListener(type, handler) {
39 const list = handlers.get(type) ?? [];
40 const at = list.indexOf(handler);
41 if (at >= 0) list.splice(at, 1);
42 },
43 fire(type, event = {}) {
44 for (const handler of [...(handlers.get(type) ?? [])]) handler({ preventDefault() {}, ...(event as object) });
45 },
46 };
47 }
48
49 export type FakeElement = Fake & {
50 id: string;
51 hidden: boolean;
52 innerHTML: string;
53 textContent: string;
54 dataset: { action?: string };
55 };
56
57 export function element(id: string, action?: string): FakeElement {
58 return { ...fake(), id, hidden: false, innerHTML: "", textContent: "", dataset: action === undefined ? {} : { action } };
59 }
60
61 /** Every 2D call `readout.ts` makes, written down in order. */
62 export type Recorder = Paint & { calls: [string, ...unknown[]][]; texts: string[] };
63
64 export function recorder(): Recorder {
65 const calls: [string, ...unknown[]][] = [];
66 const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]);
67 return {
68 calls,
69 get texts() {
70 return calls.filter((c) => c[0] === "fillText").map((c) => String(c[1]));
71 },
72 fillStyle: "", strokeStyle: "", lineWidth: 0, font: "", textBaseline: "",
73 clearRect: note("clearRect"), fillRect: note("fillRect"), fillText: note("fillText"),
74 beginPath: note("beginPath"), moveTo: note("moveTo"), lineTo: note("lineTo"), stroke: note("stroke"),
75 } as Recorder;
76 }
19 /** The `[data-action]` buttons the shell renders for this game. */
20 export const RANGE_ACTIONS = ["duck", "jump", "fire"];
7721
7822 /**
7923 * Every GL call the engine's renderer makes, written down in order, and every
8024 * enum it names — numbered, so a recorded call reads back. It is the same trick
81 * the `Recorder` above plays for 2D and `renderer.test.ts` plays inside the
25 * the engine's `FakePaint` plays for 2D and `renderer.test.ts` plays inside the
8226 * engine: not a fake GPU, only a list of what was asked for. A test reads it to
8327 * see that the room and the plates were drawn; nothing here checks a pixel.
28 *
29 * It stays here rather than behind the door because a page that knew what a
30 * renderer was would be this game's page wearing the engine's name: the engine's
31 * canvas has an empty `gl` slot, and this is what the range puts in it.
8432 */
8533 export type GlRecorder = Record<string, unknown> & { calls: [string, ...unknown[]][] };
8634
@@ -205,50 +153,9 @@ export function audioCounter(): FakeAudio {
205153 };
206154 }
207155
208 export type FakeCanvas = FakeElement & {
209 clientWidth: number;
210 clientHeight: number;
211 width: number;
212 height: number;
213 paint: Recorder | null;
214 gl: GlRecorder | null;
215 locks: number;
216 getContext(kind: string): Recorder | GlRecorder | null;
217 requestPointerLock(): void;
218 };
219
220 /**
221 * A canvas whose 2D context is a recorder — or nothing at all when `has2d` is
222 * false — and whose WebGL2 context is a GL recorder only when `webgl` is asked
223 * for. It is off by default because most of these tests are about the game and
224 * not about the draw, and because a browser with no WebGL2 is a real browser
225 * this page has to keep working on.
226 */
227 export function canvas(id: string, has2d = true, webgl = false): FakeCanvas {
228 return {
229 ...element(id),
230 clientWidth: 800, clientHeight: 450, width: 0, height: 0,
231 paint: has2d ? recorder() : null,
232 gl: webgl ? glRecorder() : null,
233 locks: 0,
234 getContext(kind) {
235 if (kind === "webgl2") return this.gl;
236 return kind === "2d" ? this.paint : null;
237 },
238 requestPointerLock() {
239 this.locks += 1;
240 },
241 };
242 }
243
244 export type FakePage = {
245 document: Fake & {
246 readyState: string;
247 pointerLockElement: unknown;
248 getElementById(id: string): FakeElement | null;
249 querySelectorAll(selector: string): FakeElement[];
250 };
251 window: Fake & { devicePixelRatio: number };
156 export type RangePage = {
157 document: FakeDocument;
158 window: FakeWindow;
252159 scene: FakeCanvas;
253160 hud: FakeCanvas;
254161 /** The `AudioContext` this page would give the engine's sound device. */
@@ -263,55 +170,59 @@ export type FakePage = {
263170 install(): () => void;
264171 };
265172
173 /** The globals the last lines of `main.ts` and the engine's two edges name. */
174 const GLOBALS = [
175 "document", "window", "requestAnimationFrame", "cancelAnimationFrame", "matchMedia",
176 "performance", "AudioContext",
177 ];
178
266179 /**
267180 * The whole page the range's entry expects: the stage's canvases, the overlay,
268 * the touch pads and a clock that only moves when the test says so.
181 * the touch pads and a clock that only moves when the test says so. WebGL2 is
182 * off by default, because most of these tests are about the game and not about
183 * the draw, and because a browser with no WebGL2 is a real browser this page has
184 * to keep working on; `has2d: false` is a canvas that gives no context at all.
269185 */
270 export function page(options: { has2d?: boolean; webgl?: boolean } = {}): FakePage {
271 const scene = canvas("scene", options.has2d ?? true, options.webgl ?? false);
272 const hud = canvas("hud", options.has2d ?? true);
273 const buttons = [element("", "duck"), element("", "jump"), element("", "fire")];
274 const elements = new Map<string, FakeElement>([
275 ["scene", scene], ["hud", hud], ["note", element("note")], ["touch", element("touch")],
276 ["look", element("look")], ["fps", element("fps")],
277 ]);
278 const frames: ((now: number) => void)[] = [];
186 export function page(options: { has2d?: boolean; webgl?: boolean } = {}): RangePage {
187 const fake = fakePage({
188 ids: RANGE_IDS,
189 canvases: ["scene", "hud"],
190 actions: RANGE_ACTIONS,
191 paint: options.has2d ?? true,
192 });
193 const [scene, hud] = fake.canvases as [FakeCanvas, FakeCanvas];
194 if (options.webgl === true) scene.gl = glRecorder();
279195 const audio = audioCounter();
280 let clock = 1000;
281
282 const document = {
283 ...fake(),
284 readyState: "complete",
285 pointerLockElement: null as unknown,
286 getElementById: (id: string) => elements.get(id) ?? null,
287 querySelectorAll: (_selector: string) => buttons,
288 };
289 const window = { ...fake(), devicePixelRatio: 1 };
290196
291197 return {
292 document, window, scene, hud, buttons, frames, audio,
293 by: (id) => elements.get(id) as FakeElement,
294 step(ms) {
295 clock += ms;
296 const next = frames.shift();
297 next?.(clock);
298 },
198 document: fake.document,
199 window: fake.window,
200 scene,
201 hud,
202 audio,
203 buttons: fake.buttons,
204 frames: fake.frames,
205 by: fake.by,
206 step: fake.step,
299207 install() {
300208 const globals = globalThis as Record<string, unknown>;
209 // The undo is a snapshot of the global object's own enumerable properties,
210 // which is what this file has always taken. A global that is not one of
211 // those — `performance` is not — is deleted rather than put back, and that
212 // is deliberate: restoring a native global with `Object.defineProperty`
213 // costs every global lookup in every file that runs after this one, and
214 // measurably so. `bun test` takes 5 seconds with this line and 17 with the
215 // honest-looking one.
301216 const before = { ...globals };
302 const keys = [
303 "document", "window", "requestAnimationFrame", "cancelAnimationFrame", "matchMedia",
304 "performance", "AudioContext",
305 ];
306 globals.document = document;
307 globals.window = window;
217 globals.document = fake.document;
218 globals.window = fake.window;
308219 globals.AudioContext = audio.Ctor;
309 globals.requestAnimationFrame = (callback: (now: number) => void): number => frames.push(callback);
310 globals.cancelAnimationFrame = (): void => void frames.splice(0, frames.length);
311 globals.matchMedia = (): { matches: boolean } => ({ matches: false });
312 globals.performance = { now: () => clock };
220 globals.requestAnimationFrame = fake.window.requestAnimationFrame;
221 globals.cancelAnimationFrame = fake.window.cancelAnimationFrame;
222 globals.matchMedia = fake.window.matchMedia;
223 globals.performance = fake.window.performance;
313224 return () => {
314 for (const key of keys)
225 for (const key of GLOBALS)
315226 if (key in before) globals[key] = before[key];
316227 else delete globals[key];
317228 };
static/app.63bb6ab307.js → static/app.f34f5d20d9.js renamed +5 −5

Diff not shown — 139,854 bytes of diff (limit 100,000). +5 −5

View file
static/range.72363b5535.js → static/range.74f7561b23.js renamed +3 −3

Diff not shown — a line 15,449 characters wide (limit 2,000). +3 −3

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs