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

forked from trevato/cs · proposes

The second game's plumbing lands first: /range is served, bundled and proven engine-only #29

Merged opened this

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

Merged

vesper/cs-engine:vesper/24-the-second-game-s-plumbing-lands-first-r → main 1 commit fork

  • Merged. vesper/24-the-second-game-s-plumbing-lands-first-r was merged into main as debc468.

Review

Sign in to review this change.
16 files changed +1143 −15
README.md +36 −7
@@ -3,7 +3,9 @@
33 A Counter-Strike-shaped defusal shooter that runs in the browser, and the
44 leaderboard of the matches people played. Five against five on one map:
55 bomb rounds, a buy menu and an economy, bots that walk to a site and shoot
6 back.
6 back. Next door at `/range` is a second game built only out of this one's
7 engine — so far its plumbing: the fixed step, the controls and a readout of
8 both.
79
810 ```sh
911 PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
@@ -51,6 +53,12 @@ the scoreboard, <kbd>Esc</kbd> pauses. On a touch screen the left of the view
5153 is a thumb stick, the right looks around, and the buttons along the bottom do
5254 the rest.
5355
56 At `/range` the same controls drive the second game, on its own bindings:
57 <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch,
58 <kbd>Shift</kbd> walk, <kbd>F</kbd> or the mouse fires, <kbd>R</kbd> reload,
59 <kbd>Enter</kbd> starts. Until #13 lands there is nothing to shoot: what the
60 page shows is the loop and the controls themselves, as numbers that move.
61
5462 ## Shape
5563
5664 - `src/engine/` — the part a second game could use, and the rule is that it never
@@ -75,6 +83,25 @@ the rest.
7583 is headless and tested; `models`, `materials` and `audio` are this game's
7684 recipes for the engine's three asset tools, and `binding` is its key table and
7785 the one function from a held action to an `Input`.
86 - `src/range/` — the second game, at `/range`, and the proof that the engine is a
87 package: a module in here may import `src/engine/`, `src/pages/shell.ts` and its
88 own siblings, and nothing else. **Today it is the plumbing and only the
89 plumbing**: `binding.ts` is its own key table (50 Hz, its own actions — no buy,
90 no scoreboard, no weapon slots) and the one function from held actions to an
91 intent, `readout.ts` draws a horizon and a panel of numbers with plain 2D calls,
92 and `main.ts` wires the engine's `loop` and `controls` to the shell's ids and
93 draws what they say: ticks run, milliseconds a frame, which actions are held, the
94 movement they and the thumb stick add up to, and where you are looking. No room, no targets, no physics, no WebGL — those are the
95 game, and #13 builds them on this. `independence.test.ts` reads every source in
96 here and fails on an import that leaves those three places, reusing the
97 `sources`/`specifiersOf` helpers from `src/engine/boundary.test.ts` rather than a
98 second copy of the rule; `binding.test.ts` and `readout.test.ts` pin the table,
99 the intent and the drawing headlessly, the second with a stub that records every
100 2D call; and `boot.test.ts` boots the entry itself against the stub page in
101 `dom.ts` — a document of plain objects and a clock the test moves — so a key, a
102 button, a thumb stick, a locked mouse and a canvas that gives no context are all
103 checked with no browser in the room. `src/pages/range.ts` is its page: its own title, prose and overlay
104 through the same shell, with no database, no endpoint and no cookie behind it.
78105 - `src/rl/` — training, which the game never imports: `arena` is the environment
79106 (a real duel on de_forge with the round and the economy taken out) and `ppo` is
80107 the algorithm, with its backward pass checked against finite differences.
@@ -104,18 +131,20 @@ the rest.
104131 A rule only one game needs goes in `extraStyle`, never in the shared block,
105132 and CSS handed in there cannot close the block. So **a second game's page
106133 writes its overlays, its touch buttons and its sections, and gets the rest**;
107 `src/pages/game.ts` is what that looks like for cs. The ids are the contract
134 `src/pages/game.ts` is what that looks like for cs and `src/pages/range.ts`
135 for the range. The ids are the contract
108136 with `src/client/main.ts`, which finds them by `getElementById`: a test in
109137 `src/server.test.ts` pins every one against the served page.
110138 - `scripts/build.ts` bundles a table of entries — `ENTRIES`, one line per game:
111139 a name and a source file — into `static/<name>.<hash>.js`, committed and
112140 cached for a year. Each entry sweeps up only its own stale bundles, so one
113141 game's rebuild never deletes another's, and `bun test` fails if any committed
114 bundle is stale. The server resolves the script for an entry by that exact
115 name, so **adding a second game is one line in `ENTRIES`, a
116 `src/pages/<name>.ts` that calls the shell, and a route in `src/server.ts`**
117 that renders it with `scripts["<name>"]` as the `script` — no inline script,
118 the CSP is `default-src 'self'`.
142 bundle is stale — today that is `app` (the shooter) and `range`. The server
143 resolves the script for an entry by that exact name, so **adding a second game
144 is one line in `ENTRIES`, a `src/pages/<name>.ts` that calls the shell, and a
145 route in `src/server.ts`** that renders it with `scripts["<name>"]` as the
146 `script` — no inline script, the CSP is `default-src 'self'`. `/range` is that
147 three-part recipe followed once.
119148 - No dependencies. Nothing is installed to run this.
120149
121150 It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app
scripts/build.test.ts +4 −1
@@ -21,7 +21,10 @@ function fixture(): { dir: string; entries: { name: string; entry: string }[]; s
2121 }
2222
2323 test("the entry table is what the app ships today", () => {
24 expect(ENTRIES).toEqual([{ name: "app", entry: "src/client/main.ts" }]);
24 expect(ENTRIES).toEqual([
25 { name: "app", entry: "src/client/main.ts" },
26 { name: "range", entry: "src/range/main.ts" },
27 ]);
2528 });
2629
2730 test("every entry is bundled to its own name and its own content hash", async () => {
scripts/build.ts +4 −1
@@ -15,7 +15,10 @@ export const STATIC_DIR = join(ROOT, "static");
1515 export type Entry = { name: string; entry: string };
1616
1717 /** Adding a game is one line here plus a route in `src/server.ts`. */
18 export const ENTRIES: Entry[] = [{ name: "app", entry: "src/client/main.ts" }];
18 export const ENTRIES: Entry[] = [
19 { name: "app", entry: "src/client/main.ts" },
20 { name: "range", entry: "src/range/main.ts" },
21 ];
1922
2023 // A name becomes part of a file name and part of a regular expression: keep it
2124 // plain, so neither can be surprised by it.
src/pages/game.ts +4
@@ -112,6 +112,10 @@ export function gamePage(db: Database, options: PageOptions): string {
112112 takes to react, so there is still a ladder from fodder to uncomfortable.</p>
113113 <p>The match runs entirely in your browser; the leaderboard here is what your browser
114114 reported about it.</p>
115 <p>The engine underneath — the loop, the controls, the grid, the meshes, the textures and
116 the sounds — is a package, and <a href="/range">the range</a> is a second game being built
117 out of it that shares no line with this one. Today it is that engine's loop and controls
118 drawn as a readout.</p>
115119 </section>`,
116120 });
117121 }
src/pages/range.ts added +72
@@ -0,0 +1,72 @@
1 /**
2 * The range page: this game's content and nothing about the document. The
3 * shell in `./shell.ts` renders the doctype, the style, the header, the stage
4 * and the footer; what is left here is the range's — its one overlay, its touch
5 * buttons and its prose.
6 *
7 * It says what the page is today — the engine's loop and controls, drawn as a
8 * readout — and what it is not yet, so nobody arrives expecting the game that
9 * #13 will build on it. There is no database on this page: the leaderboard and
10 * the terminal stay Counter-Strike's.
11 */
12
13 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
14
15 /** What the shell needs that this page cannot know: the environment it runs in. */
16 export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh">;
17
18 const NOTE = `
19 <h2>The range: plumbing first</h2>
20 <p>The engine's fixed step and the engine's controls, wired to a page and drawn as a
21 readout. Move and look, and watch the tick count, the frame time, the held actions and
22 the view angles follow you.</p>
23 <div class="row"><button type="button" class="primary" id="look">Capture the mouse</button></div>
24 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch ·
25 <kbd>Shift</kbd> walk · <kbd>F</kbd> or the mouse fires · <kbd>R</kbd> reload ·
26 <kbd>Enter</kbd> start · <kbd>Esc</kbd> gives the mouse back. On a touch screen the left of
27 the view is a stick, the right looks around, and the buttons do the rest.</p>
28 `;
29
30 export function rangePage(options: PageOptions): string {
31 return page({
32 ...options,
33 title: "range — the engine as a package",
34 description:
35 "A shooting range built only from this app's engine. Today it is the loop, the controls and a readout of both.",
36 heading: "range",
37 terminalLead: "The shooter's leaderboard in a terminal:",
38 footer: "no database, no cookie: this page keeps nothing",
39 stageHtml: stage({
40 touch: [
41 [
42 { action: "duck", label: "crouch" },
43 { action: "jump", label: "jump" },
44 { action: "walk", label: "walk" },
45 ],
46 [
47 { action: "reload", label: "reload" },
48 { action: "fire", label: "fire" },
49 ],
50 ],
51 overlays: [{ id: "note", html: NOTE }],
52 }),
53 sectionsHtml: ` <section>
54 <h2>What this is</h2>
55 <p>A second game's plumbing, and the proof that <code>src/engine/</code> is a package rather
56 than a folder: every line behind this page comes from the engine or from
57 <code>src/range/</code>, and a test reads the sources and fails if one of them reaches into
58 the shooter in <code>src/client/</code>. What runs right now is the engine's fixed step at
59 50 Hz — its own rate, not the shooter's 64 — the engine's controls with the range's own
60 bindings table, and a readout of what both say: ticks run, milliseconds a frame, which
61 actions are held, what movement they and the thumb stick add up to, and where you are
62 looking.</p>
63 <p>What is not here yet: the room, the pop-up targets, the shot, the score and the clock.
64 Those are the game, and they land next — with the range's own movement tuning, its own
65 atlas and its own sounds, all from the same engine. The horizon you are looking at is a
66 placeholder for that room, drawn with plain 2D calls.</p>
67 <p>Nothing on this page is stored. There is no endpoint, no table and no cookie behind it:
68 the leaderboard, the database and the terminal belong to
69 <a href="/">${escape("cs")}</a>, which is the game next door.</p>
70 </section>`,
71 });
72 }
src/range/binding.test.ts added +72
@@ -0,0 +1,72 @@
1 import { expect, test } from "bun:test";
2 import {
3 heldActions, NO_INTENT, RANGE_ACTIONS, RANGE_KEYS, RANGE_MOUSE, RANGE_TAP, readIntent,
4 } from "./binding.ts";
5
6 const NONE = { x: 0, y: 0 };
7 const intent = (actions: string[], stick = NONE) => readIntent(new Set(actions), stick);
8
9 test("the range's bindings are its own, not the shooter's", () => {
10 expect(RANGE_KEYS.KeyW).toBe("forward");
11 expect(RANGE_KEYS.KeyS).toBe("back");
12 expect(RANGE_KEYS.KeyA).toBe("left");
13 expect(RANGE_KEYS.KeyD).toBe("right");
14 expect(RANGE_KEYS.ArrowUp).toBe("forward");
15 expect(RANGE_KEYS.ArrowRight).toBe("right");
16 expect(RANGE_KEYS.Space).toBe("jump");
17 expect(RANGE_KEYS.ControlLeft).toBe("duck");
18 expect(RANGE_KEYS.ShiftLeft).toBe("walk");
19 expect(RANGE_KEYS.KeyR).toBe("reload");
20 expect(RANGE_KEYS.Enter).toBe("start");
21 // A keyboard reaches the trigger too: pointer lock is not the only way in.
22 expect(RANGE_KEYS.KeyF).toBe("fire");
23 expect(RANGE_MOUSE[0]).toBe("fire");
24 expect(RANGE_MOUSE[2]).toBeUndefined();
25 expect([...RANGE_TAP]).toEqual(["jump", "reload", "start"]);
26 // Nothing cs-shaped: no buy, no scoreboard, no weapon slots.
27 for (const code of ["KeyB", "Tab", "Digit1", "Digit2", "Digit3"])
28 expect(RANGE_KEYS[code]).toBeUndefined();
29 // Every key is bound to an action this game actually knows.
30 for (const action of Object.values(RANGE_KEYS)) expect(RANGE_ACTIONS).toContain(action);
31 for (const action of Object.values(RANGE_MOUSE)) expect(RANGE_ACTIONS).toContain(action);
32 });
33
34 test("nothing held is nothing asked for", () => {
35 expect(intent([])).toEqual(NO_INTENT);
36 });
37
38 test("the movement keys sum and opposites cancel", () => {
39 expect(intent(["forward"]).forward).toBe(1);
40 expect(intent(["back"]).forward).toBe(-1);
41 expect(intent(["forward", "back"]).forward).toBe(0);
42 expect(intent(["right"]).side).toBe(1);
43 expect(intent(["left"]).side).toBe(-1);
44 expect(intent(["left", "right"]).side).toBe(0);
45 });
46
47 test("the stick adds to the keys and the sum clamps to ±1", () => {
48 expect(intent([], { x: -0.5, y: 0.25 }).side).toBeCloseTo(-0.5, 10);
49 expect(intent([], { x: -0.5, y: 0.25 }).forward).toBeCloseTo(0.25, 10);
50 expect(intent(["forward"], { x: 0, y: 0.9 }).forward).toBe(1);
51 expect(intent(["left"], { x: -0.9, y: 0 }).side).toBe(-1);
52 });
53
54 test("every held action lands on its field", () => {
55 const held = intent(["jump", "duck", "walk", "fire", "reload", "start"]);
56 expect(held.jump).toBe(true);
57 expect(held.duck).toBe(true);
58 expect(held.walk).toBe(true);
59 expect(held.fire).toBe(true);
60 expect(held.reload).toBe(true);
61 expect(held.start).toBe(true);
62 });
63
64 test("an action the range does not know changes nothing", () => {
65 expect(intent(["buy", "scoreboard", "slot1", "nonsense"])).toEqual(NO_INTENT);
66 });
67
68 test("the readout lists the held actions this game knows, in one fixed order", () => {
69 expect(heldActions(new Set())).toEqual([]);
70 expect(heldActions(new Set(["fire", "forward", "duck"]))).toEqual(["forward", "duck", "fire"]);
71 expect(heldActions(new Set(["buy", "left"]))).toEqual(["left"]);
72 });
src/range/binding.ts added +77
@@ -0,0 +1,77 @@
1 /**
2 * The range's half of input: which keys mean what here, and what the held
3 * actions add up to this frame. The engine's controls collect names —
4 * "forward", "fire" — and know no game; this turns a name into plain data.
5 *
6 * It is deliberately not `src/client/binding.ts`: no buy, no scoreboard, no
7 * weapon slots, and a keyboard key for the trigger so a keyboard reaches
8 * everything a mouse does. Nothing in here imports the game.
9 */
10
11 import { clamp } from "../engine/math.ts";
12
13 /** `KeyboardEvent.code` to action. The range's own table, not cs's. */
14 export const RANGE_KEYS: Record<string, string> = {
15 KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right",
16 ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right",
17 Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk",
18 KeyF: "fire", KeyR: "reload", Enter: "start",
19 };
20
21 /** One weapon, one button: the left one. There is nothing to scope here. */
22 export const RANGE_MOUSE: Record<number, string> = { 0: "fire" };
23
24 /** On-screen buttons that are a tap, not a hold. */
25 export const RANGE_TAP = ["jump", "reload", "start"] as const;
26
27 /**
28 * Every action the range knows, in the order the readout prints them. An
29 * action outside this list — one bound by another game, or a typo in a
30 * `data-action` — is collected by the controls and ignored here.
31 */
32 export const RANGE_ACTIONS = [
33 "forward", "back", "left", "right", "jump", "duck", "walk", "fire", "reload", "start",
34 ] as const;
35
36 export type RangeAction = (typeof RANGE_ACTIONS)[number];
37
38 /**
39 * What this frame was asked for. Plain numbers and booleans: #13's step
40 * function takes one of these, and today's readout prints it.
41 */
42 export type Intent = {
43 /** -1 back to 1 forward, keys and stick summed and clamped. */
44 forward: number;
45 /** -1 left to 1 right. */
46 side: number;
47 jump: boolean;
48 duck: boolean;
49 walk: boolean;
50 fire: boolean;
51 reload: boolean;
52 /** Begin a run — the button and <kbd>Enter</kbd> both say it. */
53 start: boolean;
54 };
55
56 /** Nothing held, nothing pushed. */
57 export const NO_INTENT: Intent = {
58 forward: 0, side: 0, jump: false, duck: false, walk: false, fire: false, reload: false, start: false,
59 };
60
61 export function readIntent(held: ReadonlySet<string>, stick: { x: number; y: number }): Intent {
62 const down = (action: RangeAction): boolean => held.has(action);
63 return {
64 forward: clamp((down("forward") ? 1 : 0) - (down("back") ? 1 : 0) + stick.y, -1, 1),
65 side: clamp((down("right") ? 1 : 0) - (down("left") ? 1 : 0) + stick.x, -1, 1),
66 jump: down("jump"),
67 duck: down("duck"),
68 walk: down("walk"),
69 fire: down("fire"),
70 reload: down("reload"),
71 start: down("start"),
72 };
73 }
74
75 /** The held actions this game knows, in `RANGE_ACTIONS` order, for the readout. */
76 export const heldActions = (held: ReadonlySet<string>): RangeAction[] =>
77 RANGE_ACTIONS.filter((action) => held.has(action));
src/range/boot.test.ts added +149
@@ -0,0 +1,149 @@
1 /**
2 * The entry itself, booted against the stub page in `dom.ts`: the ids it looks
3 * up, the listeners it attaches, the frames it asks for and what it draws when
4 * one runs. Nothing here is a browser, and nothing here is timed by a real
5 * clock — the test decides when a frame happens and how long it took.
6 */
7
8 import { expect, test } from "bun:test";
9 import { page, type FakePage } from "./dom.ts";
10
11 // The entry boots itself when it is imported, so it is imported into a page
12 // that is still loading: then every test below boots a fresh one on purpose.
13 const loading = page();
14 loading.document.readyState = "loading";
15 const undoLoading = loading.install();
16 const { boot } = await import("./main.ts");
17 undoLoading();
18
19 /** A booted page, and the undo that puts the globals back. */
20 function booted(options: { has2d?: boolean } = {}): { p: FakePage; undo: () => void } {
21 const p = page(options);
22 const undo = p.install();
23 boot();
24 return { p, undo };
25 }
26
27 const hudTexts = (p: FakePage): string[] => p.hud.paint?.texts ?? [];
28
29 test("it boots, asks for a frame, and draws the readout the loop produced", () => {
30 const { p, undo } = booted();
31 try {
32 expect(p.frames.length).toBe(1);
33 p.step(20); // 20 ms at a 50 Hz step is exactly one tick
34 expect(hudTexts(p)).toContain("tick 1 · 50 Hz fixed step");
35 expect(hudTexts(p)).toContain("held nothing");
36 expect(hudTexts(p)).toContain("yaw 0.0° · pitch 0.0°");
37 expect(p.by("fps").textContent).toContain("ms");
38 // The canvases were sized to their boxes before anything was drawn.
39 expect([p.scene.width, p.scene.height]).toEqual([800, 450]);
40 expect(p.hud.paint?.calls[0]?.[0]).toBe("clearRect");
41 expect(p.scene.paint?.calls.some((c) => c[0] === "fillRect")).toBe(true);
42 // A frame was asked for before this one ran, so the loop keeps going.
43 expect(p.frames.length).toBe(1);
44 // A half-second frame is not caught up all at once: the loop believes a
45 // quarter of a second of it and runs at most eight steps of that.
46 p.step(500);
47 expect(hudTexts(p)).toContain("tick 9 · 50 Hz fixed step");
48 } finally {
49 undo();
50 }
51 });
52
53 test("a held key reaches the readout, and letting go clears it", () => {
54 const { p, undo } = booted();
55 try {
56 p.window.fire("keydown", { code: "KeyW" });
57 p.window.fire("keydown", { code: "KeyF" });
58 p.step(20);
59 expect(hudTexts(p)).toContain("held forward fire");
60 p.window.fire("keyup", { code: "KeyW" });
61 p.step(20);
62 expect(hudTexts(p)).toContain("held fire");
63 // A key this game does not bind is not an action.
64 p.window.fire("keydown", { code: "KeyB" });
65 p.step(20);
66 expect(hudTexts(p)).toContain("held fire");
67 } finally {
68 undo();
69 }
70 });
71
72 test("an on-screen button is the same action as the key", () => {
73 const { p, undo } = booted();
74 try {
75 p.buttons[0]?.fire("pointerdown");
76 p.step(20);
77 expect(hudTexts(p)).toContain("held duck");
78 } finally {
79 undo();
80 }
81 });
82
83 test("a phone gets there too: the left of the stage is a stick, the right looks around", () => {
84 const { p, undo } = booted();
85 try {
86 // 800 px wide, so the left 304 px are the stick and 60 px of drag is a full push.
87 p.scene.fire("touchstart", { changedTouches: [{ identifier: 1, clientX: 100, clientY: 200 }] });
88 p.scene.fire("touchmove", { changedTouches: [{ identifier: 1, clientX: 130, clientY: 140 }] });
89 p.step(20);
90 expect(hudTexts(p)).toContain("move 1.00 forward · 0.50 side");
91
92 p.scene.fire("touchend", { changedTouches: [{ identifier: 1, clientX: 130, clientY: 140 }] });
93 p.step(20);
94 expect(hudTexts(p)).toContain("move 0.00 forward · 0.00 side");
95
96 // No pointer lock on a touch screen: dragging the right of the stage turns
97 // the view anyway. 10 px × 2.2 sensitivity × 0.06° each.
98 p.scene.fire("touchstart", { changedTouches: [{ identifier: 2, clientX: 600, clientY: 200 }] });
99 p.scene.fire("touchmove", { changedTouches: [{ identifier: 2, clientX: 610, clientY: 200 }] });
100 p.step(20);
101 expect(hudTexts(p)).toContain("yaw 358.7° · pitch 0.0°");
102 } finally {
103 undo();
104 }
105 });
106
107 test("the mouse turns the view once it is locked, and not before", () => {
108 const { p, undo } = booted();
109 try {
110 p.window.fire("mousemove", { movementX: 10, movementY: 0 });
111 p.step(20);
112 expect(hudTexts(p)).toContain("yaw 0.0° · pitch 0.0°");
113
114 p.by("look").fire("click");
115 expect(p.by("note").hidden).toBe(true);
116 expect(p.scene.locks).toBe(1);
117 p.document.pointerLockElement = p.scene;
118 p.document.fire("pointerlockchange");
119 p.window.fire("mousemove", { movementX: 10, movementY: 10 });
120 p.step(20);
121 // 10 counts × 2.2 sensitivity × 0.022° each: right turns the yaw down, and
122 // down raises the pitch, which is the engine's way round.
123 expect(hudTexts(p)).toContain("yaw 359.5° · pitch 0.5°");
124 expect(hudTexts(p)).toContain("mouse captured · Esc releases it");
125
126 // Losing the lock brings the overlay back and stops the mouse turning it.
127 p.document.pointerLockElement = null;
128 p.document.fire("pointerlockchange");
129 p.window.fire("mousemove", { movementX: 100, movementY: 0 });
130 p.step(20);
131 expect(p.by("note").hidden).toBe(false);
132 expect(hudTexts(p)).toContain("yaw 359.5° · pitch 0.5°");
133 } finally {
134 undo();
135 }
136 });
137
138 test("a canvas with no context is a sentence on the page, not an exception", () => {
139 const { p, undo } = booted({ has2d: false });
140 try {
141 expect(p.by("note").innerHTML).toContain("No 2D canvas");
142 expect(p.by("note").hidden).toBe(false);
143 // Nothing was started: no frame was asked for and no listener attached.
144 expect(p.frames.length).toBe(0);
145 expect(p.window.listening.size).toBe(0);
146 } finally {
147 undo();
148 }
149 });
src/range/dom.ts added +169
@@ -0,0 +1,169 @@
1 /**
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 * It ships in no bundle: only `boot.test.ts` imports it.
8 */
9
10 import type { Paint } from "./readout.ts";
11
12 type Handler = (event: any) => void;
13
14 export type Fake = {
15 /** Deliver an event to everything listening for it here. */
16 fire(type: string, event?: unknown): void;
17 readonly listening: ReadonlySet<string>;
18 addEventListener(type: string, handler: Handler): void;
19 removeEventListener(type: string, handler: Handler): void;
20 };
21
22 /** Anything that can be listened to, and told what happened. */
23 export function fake(): Fake {
24 const handlers = new Map<string, Handler[]>();
25 return {
26 listening: new Set<string>(),
27 addEventListener(type, handler) {
28 const list = handlers.get(type);
29 if (list === undefined) handlers.set(type, [handler]);
30 else list.push(handler);
31 (this.listening as Set<string>).add(type);
32 },
33 removeEventListener(type, handler) {
34 const list = handlers.get(type) ?? [];
35 const at = list.indexOf(handler);
36 if (at >= 0) list.splice(at, 1);
37 },
38 fire(type, event = {}) {
39 for (const handler of [...(handlers.get(type) ?? [])]) handler({ preventDefault() {}, ...(event as object) });
40 },
41 };
42 }
43
44 export type FakeElement = Fake & {
45 id: string;
46 hidden: boolean;
47 innerHTML: string;
48 textContent: string;
49 dataset: { action?: string };
50 };
51
52 export function element(id: string, action?: string): FakeElement {
53 return { ...fake(), id, hidden: false, innerHTML: "", textContent: "", dataset: action === undefined ? {} : { action } };
54 }
55
56 /** Every 2D call `readout.ts` makes, written down in order. */
57 export type Recorder = Paint & { calls: [string, ...unknown[]][]; texts: string[] };
58
59 export function recorder(): Recorder {
60 const calls: [string, ...unknown[]][] = [];
61 const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]);
62 return {
63 calls,
64 get texts() {
65 return calls.filter((c) => c[0] === "fillText").map((c) => String(c[1]));
66 },
67 fillStyle: "", strokeStyle: "", lineWidth: 0, font: "", textBaseline: "",
68 clearRect: note("clearRect"), fillRect: note("fillRect"), fillText: note("fillText"),
69 beginPath: note("beginPath"), moveTo: note("moveTo"), lineTo: note("lineTo"), stroke: note("stroke"),
70 } as Recorder;
71 }
72
73 export type FakeCanvas = FakeElement & {
74 clientWidth: number;
75 clientHeight: number;
76 width: number;
77 height: number;
78 paint: Recorder | null;
79 locks: number;
80 getContext(kind: string): Recorder | null;
81 requestPointerLock(): void;
82 };
83
84 /** A canvas whose context is a recorder, or nothing at all when `has2d` is false. */
85 export function canvas(id: string, has2d = true): FakeCanvas {
86 return {
87 ...element(id),
88 clientWidth: 800, clientHeight: 450, width: 0, height: 0,
89 paint: has2d ? recorder() : null,
90 locks: 0,
91 getContext(kind) {
92 return kind === "2d" ? this.paint : null;
93 },
94 requestPointerLock() {
95 this.locks += 1;
96 },
97 };
98 }
99
100 export type FakePage = {
101 document: Fake & {
102 readyState: string;
103 pointerLockElement: unknown;
104 getElementById(id: string): FakeElement | null;
105 querySelectorAll(selector: string): FakeElement[];
106 };
107 window: Fake & { devicePixelRatio: number };
108 scene: FakeCanvas;
109 hud: FakeCanvas;
110 by(id: string): FakeElement;
111 buttons: FakeElement[];
112 /** The frames asked for but not yet run, oldest first. */
113 frames: ((now: number) => void)[];
114 /** Run the next frame, `ms` after the last one. */
115 step(ms: number): void;
116 /** Install this page as the globals `main.ts` reads, and hand back the undo. */
117 install(): () => void;
118 };
119
120 /**
121 * The whole page the range's entry expects: the stage's canvases, the overlay,
122 * the touch pads and a clock that only moves when the test says so.
123 */
124 export function page(options: { has2d?: boolean } = {}): FakePage {
125 const scene = canvas("scene", options.has2d ?? true);
126 const hud = canvas("hud", options.has2d ?? true);
127 const buttons = [element("", "duck"), element("", "jump"), element("", "fire")];
128 const elements = new Map<string, FakeElement>([
129 ["scene", scene], ["hud", hud], ["note", element("note")], ["touch", element("touch")],
130 ["look", element("look")], ["fps", element("fps")],
131 ]);
132 const frames: ((now: number) => void)[] = [];
133 let clock = 1000;
134
135 const document = {
136 ...fake(),
137 readyState: "complete",
138 pointerLockElement: null as unknown,
139 getElementById: (id: string) => elements.get(id) ?? null,
140 querySelectorAll: (_selector: string) => buttons,
141 };
142 const window = { ...fake(), devicePixelRatio: 1 };
143
144 return {
145 document, window, scene, hud, buttons, frames,
146 by: (id) => elements.get(id) as FakeElement,
147 step(ms) {
148 clock += ms;
149 const next = frames.shift();
150 next?.(clock);
151 },
152 install() {
153 const globals = globalThis as Record<string, unknown>;
154 const before = { ...globals };
155 const keys = ["document", "window", "requestAnimationFrame", "cancelAnimationFrame", "matchMedia", "performance"];
156 globals.document = document;
157 globals.window = window;
158 globals.requestAnimationFrame = (callback: (now: number) => void): number => frames.push(callback);
159 globals.cancelAnimationFrame = (): void => void frames.splice(0, frames.length);
160 globals.matchMedia = (): { matches: boolean } => ({ matches: false });
161 globals.performance = { now: () => clock };
162 return () => {
163 for (const key of keys)
164 if (key in before) globals[key] = before[key];
165 else delete globals[key];
166 };
167 },
168 };
169 }
src/range/independence.test.ts added +105
@@ -0,0 +1,105 @@
1 /**
2 * The rule that makes the range a proof rather than a second copy: a module
3 * under `src/range/` may import the engine, the page shell and its own
4 * siblings, and nothing else. Not `src/client/` — a shortcut import from the
5 * shooter would make the whole direction untrue, and it is one line nobody
6 * would notice in review — not `src/rl/`, and no package, because there is
7 * nothing to install.
8 *
9 * It reads the sources with the same two helpers `src/engine/boundary.test.ts`
10 * uses, imported rather than copied: one regular expression for imports in this
11 * repository, not two that can drift apart. The price is that the engine's four
12 * boundary tests are registered again here — they run twice and cost a
13 * millisecond, which is cheaper than a second copy of the rule.
14 */
15
16 import { expect, test } from "bun:test";
17 import { readFileSync } from "node:fs";
18 import { join, relative, resolve } from "node:path";
19 import { sources, specifiersOf } from "../engine/boundary.test.ts";
20
21 const RANGE = import.meta.dir;
22 const SRC = resolve(RANGE, "..");
23 const ENGINE = join(SRC, "engine");
24 const SHELL = join(SRC, "pages", "shell.ts");
25
26 /** `sources` names what it finds relative to the engine; these want their own names. */
27 const rangeSources = (): string[] => sources(RANGE).map((p) => relative(RANGE, resolve(ENGINE, p)));
28
29 /** Why this file under `src/range/` may not import this specifier, or null if it may. */
30 export function leavesRange(file: string, specifier: string): string | null {
31 const isTest = file.endsWith(".test.ts");
32 if (!specifier.startsWith(".")) {
33 if (isTest && (specifier === "bun:test" || specifier.startsWith("node:"))) return null;
34 return `"${specifier}" is not a path: the range installs nothing`;
35 }
36 const target = resolve(RANGE, join(file, ".."), specifier);
37 const sibling = relative(RANGE, target);
38 if (sibling !== "" && !sibling.startsWith("..")) return null;
39 const fromSrc = relative(ENGINE, target);
40 if (fromSrc !== "" && !fromSrc.startsWith("..")) return null;
41 if (target === SHELL) return null;
42 return `"${specifier}" resolves outside src/range/, src/engine/ and src/pages/shell.ts`;
43 }
44
45 test("the range has the sources this test is about", () => {
46 const files = rangeSources();
47 expect(files).toContain("main.ts");
48 expect(files).toContain("binding.ts");
49 expect(files).toContain("independence.test.ts");
50 });
51
52 test("no range module imports the shooter, the training, or a package", () => {
53 const offences: string[] = [];
54 for (const file of rangeSources()) {
55 const source = readFileSync(join(RANGE, file), "utf8");
56 for (const specifier of specifiersOf(source)) {
57 const why = leavesRange(file, specifier);
58 if (why !== null) offences.push(`${file}: ${why}`);
59 }
60 }
61 expect(offences).toEqual([]);
62 });
63
64 test("the rule catches the import it exists to catch", () => {
65 // The shortcut that would make the second game a copy of the first.
66 expect(leavesRange("main.ts", "../client/world.ts")).toMatch(/outside/);
67 expect(leavesRange("main.ts", "../client/binding.ts")).toMatch(/outside/);
68 expect(leavesRange("main.ts", "../rl/arena.ts")).toMatch(/outside/);
69 expect(leavesRange("main.ts", "../db.ts")).toMatch(/outside/);
70 expect(leavesRange("main.ts", "../pages/game.ts")).toMatch(/outside/);
71 expect(leavesRange("main.ts", "gl-matrix")).toMatch(/installs nothing/);
72 expect(leavesRange("main.ts", "node:fs")).toMatch(/installs nothing/);
73 expect(leavesRange("main.ts", "bun:test")).toMatch(/installs nothing/);
74 });
75
76 // A title that ends on the keyword, with the closing quote right behind it,
77 // reads as an import to the helper above — a false positive this test would
78 // then hold against itself. So no title here ends on one.
79 test("the engine, the shell and a sibling are what it may reach for", () => {
80 expect(leavesRange("main.ts", "../engine/loop.ts")).toBeNull();
81 expect(leavesRange("main.ts", "../engine/math.ts")).toBeNull();
82 expect(leavesRange("main.ts", "./binding.ts")).toBeNull();
83 expect(leavesRange("sub/deep.ts", "../readout.ts")).toBeNull();
84 expect(leavesRange("sub/deep.ts", "../../engine/trace.ts")).toBeNull();
85 expect(leavesRange("main.ts", "../pages/shell.ts")).toBeNull();
86 expect(leavesRange("binding.test.ts", "bun:test")).toBeNull();
87 expect(leavesRange("independence.test.ts", "node:path")).toBeNull();
88 // A test file's licence is its own: a module that ships may not reach for node.
89 expect(leavesRange("main.ts", "node:path")).toMatch(/installs nothing/);
90 });
91
92 test("a file that reached into the shooter is caught by reading it", () => {
93 // The keyword is spelled at run time so this fixture is not itself an import
94 // the test above would read out of this file and hold against it.
95 const F = "fr" + "om";
96 const source = [
97 `import { createLoop } ${F} "../engine/loop.ts";`,
98 `import { RANGE_KEYS } ${F} "./binding.ts";`,
99 `import { createWorld } ${F} "../client/world.ts";`,
100 ].join("\n");
101 const offences = specifiersOf(source)
102 .map((specifier) => leavesRange("main.ts", specifier))
103 .filter((why) => why !== null);
104 expect(offences).toEqual([`"../client/world.ts" resolves outside src/range/, src/engine/ and src/pages/shell.ts`]);
105 });
src/range/main.ts added +126
@@ -0,0 +1,126 @@
1 /**
2 * The range's entry: the plumbing of a second game, and only the plumbing.
3 * The engine's loop runs a fixed step, the engine's controls collect the held
4 * actions and the view's turn, and `readout.ts` draws what those two say on the
5 * page's two canvases. There is no room, no target, no physics and no WebGL
6 * here — #13 builds the game on top of this.
7 *
8 * Nothing outside `src/engine/` and this directory is imported, and
9 * `independence.test.ts` reads these files to make sure it stays that way.
10 */
11
12 import { RANGE_KEYS, RANGE_MOUSE, RANGE_TAP, heldActions, readIntent } from "./binding.ts";
13 import { drawHorizon, drawReadout, wrapDegrees, type Paint } from "./readout.ts";
14 import { attachButtons, createControls } from "../engine/controls.ts";
15 import { createLoop } from "../engine/loop.ts";
16 import { clamp } from "../engine/math.ts";
17
18 /** The range's own step: 50 Hz, deliberately not the shooter's 64. */
19 const TICK = 1 / 50;
20
21 const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
22
23 /**
24 * Wire the page up and start the loop. Exported so `boot.test.ts` can run the
25 * whole entry against a stubbed document and a stubbed clock; in a browser the
26 * line at the bottom of this file is what calls it.
27 */
28 export function boot(): void {
29 const scene = $<HTMLCanvasElement>("scene");
30 const hud = $<HTMLCanvasElement>("hud");
31 const note = $("note");
32 const touch = $("touch");
33
34 // A canvas that gives no context is a sentence on the page, never a throw:
35 // everything else here — the text, the links, the terminal line — still works.
36 const sceneCtx = scene.getContext("2d") as Paint | null;
37 const hudCtx = hud.getContext("2d") as Paint | null;
38 if (sceneCtx === null || hudCtx === null) {
39 note.innerHTML =
40 "<h2>No 2D canvas</h2><p>This browser gave the page no drawing context, so the readout cannot be" +
41 " drawn. The rest of the page still works.</p>";
42 return;
43 }
44
45 const controls = createControls(
46 { surface: scene, window, document },
47 { keys: RANGE_KEYS, mouse: RANGE_MOUSE },
48 );
49 attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: RANGE_TAP });
50 const loop = createLoop({ tick: TICK }, {
51 requestAnimationFrame: (callback) => requestAnimationFrame(callback),
52 cancelAnimationFrame: (handle) => cancelAnimationFrame(handle),
53 now: () => performance.now(),
54 });
55
56 let yaw = 0;
57 let pitch = 0;
58 let ticks = 0;
59 let frameMs = 16.7;
60
61 // The mouse only turns the view under pointer lock; a finger on the stage
62 // turns it either way, which is why the button is the only thing in the way.
63 // A phone has no pointer to lock, and says so by throwing or by rejecting:
64 // either way the note gets out of the way and the touch look still works.
65 $("look").addEventListener("click", () => {
66 note.hidden = true;
67 try {
68 const asked: unknown = scene.requestPointerLock?.();
69 if (asked instanceof Promise) asked.catch(() => {});
70 } catch {
71 // No pointer lock here. Nothing to do about it, and nothing to report.
72 }
73 });
74 controls.onUnlock = () => {
75 note.hidden = false;
76 };
77 touch.hidden = !matchMedia("(pointer: coarse)").matches;
78
79 /** Match a canvas's backing store to its box, at most twice the CSS pixels. */
80 const size = (canvas: HTMLCanvasElement): [number, number] => {
81 const dpr = Math.min(window.devicePixelRatio || 1, 2);
82 const width = Math.max(1, Math.round(canvas.clientWidth * dpr));
83 const height = Math.max(1, Math.round(canvas.clientHeight * dpr));
84 if (canvas.width !== width || canvas.height !== height) {
85 canvas.width = width;
86 canvas.height = height;
87 }
88 return [width, height];
89 };
90
91 function frame(elapsed: number): void {
92 frameMs += (elapsed * 1000 - frameMs) * 0.05;
93 const [dx, dy] = controls.takeLook();
94 yaw = wrapDegrees(yaw - dx);
95 pitch = clamp(pitch + dy, -89, 89);
96 ticks += loop.advance(elapsed);
97
98 // The intent #13's step function will take. Today it is drawn, not stepped,
99 // which is also how a thumb on the stick can see that it arrived.
100 const intent = readIntent(controls.held, controls.stick);
101
102 const [sw, sh] = size(scene);
103 drawHorizon(sceneCtx, { yaw, pitch }, sw, sh);
104 const [hw, hh] = size(hud);
105 drawReadout(
106 hudCtx,
107 {
108 hz: Math.round(1 / TICK),
109 ticks,
110 frameMs,
111 held: heldActions(controls.held),
112 move: { forward: intent.forward, side: intent.side },
113 yaw,
114 pitch,
115 locked: controls.locked,
116 },
117 hw,
118 hh,
119 );
120 $("fps").textContent = `${(1000 / Math.max(1, frameMs)).toFixed(0)} fps · ${frameMs.toFixed(1)} ms`;
121 }
122 loop.start(frame);
123 }
124
125 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
126 else boot();
src/range/readout.test.ts added +97
@@ -0,0 +1,97 @@
1 /**
2 * The drawing is driven by a stub that records every call, so what the page
3 * puts on the two canvases is checked headlessly — no browser, no canvas.
4 */
5
6 import { expect, test } from "bun:test";
7 import { drawHorizon, drawReadout, readoutLines, wrapDegrees, type Paint, type Readout } from "./readout.ts";
8
9 type Call = [string, ...unknown[]];
10
11 /** A `Paint` that writes down what it was asked to do. */
12 function recorder(): Paint & { calls: Call[]; texts: string[]; lines: Call[] } {
13 const calls: Call[] = [];
14 const note = (name: string) => (...args: unknown[]): void => void calls.push([name, ...args]);
15 return {
16 calls,
17 get texts() {
18 return calls.filter((c) => c[0] === "fillText").map((c) => String(c[1]));
19 },
20 get lines() {
21 return calls.filter((c) => c[0] === "moveTo" || c[0] === "lineTo");
22 },
23 fillStyle: "", strokeStyle: "", lineWidth: 0, font: "", textBaseline: "",
24 clearRect: note("clearRect"),
25 fillRect: note("fillRect"),
26 fillText: note("fillText"),
27 beginPath: note("beginPath"),
28 moveTo: note("moveTo"),
29 lineTo: note("lineTo"),
30 stroke: note("stroke"),
31 } as Paint & { calls: Call[]; texts: string[]; lines: Call[] };
32 }
33
34 const READOUT: Readout = {
35 hz: 50, ticks: 1234, frameMs: 16.66, held: ["forward", "fire"],
36 move: { forward: 1, side: -0.5 }, yaw: 45, pitch: -10, locked: true,
37 };
38
39 test("the panel prints the tick count, the frame time, the held actions and the angles", () => {
40 const lines = readoutLines(READOUT);
41 expect(lines[0]).toBe("tick 1234 · 50 Hz fixed step");
42 expect(lines[1]).toBe("frame 16.7 ms · 60 fps");
43 expect(lines[2]).toBe("held forward fire");
44 expect(lines[3]).toBe("move 1.00 forward · -0.50 side");
45 expect(lines[4]).toBe("yaw 45.0° · pitch -10.0°");
46 expect(lines[5]).toContain("Esc");
47 });
48
49 test("nothing held says so, and an unlocked pointer says how to lock it", () => {
50 const lines = readoutLines({ ...READOUT, held: [], locked: false });
51 expect(lines[2]).toBe("held nothing");
52 expect(lines[5]).toContain("click the view");
53 });
54
55 test("the yaw reads as a compass bearing however far it has been turned", () => {
56 expect(wrapDegrees(0)).toBe(0);
57 expect(wrapDegrees(-90)).toBe(270);
58 expect(wrapDegrees(725)).toBe(5);
59 expect(readoutLines({ ...READOUT, yaw: -0.5 })[4]).toBe("yaw 359.5° · pitch -10.0°");
60 });
61
62 test("a frame time of zero is not an infinite frame rate", () => {
63 expect(readoutLines({ ...READOUT, frameMs: 0 })[1]).toBe("frame 0.0 ms · 10000 fps");
64 });
65
66 test("the HUD is cleared and every line is drawn once", () => {
67 const paint = recorder();
68 drawReadout(paint, READOUT, 960, 540);
69 expect(paint.calls[0]?.[0]).toBe("clearRect");
70 expect(paint.texts).toEqual(readoutLines(READOUT));
71 // The crosshair: two strokes crossing at the centre.
72 expect(paint.lines).toContainEqual(["moveTo", 480, 270 - Math.max(6, Math.round(960 / 90))]);
73 });
74
75 test("the horizon follows the pitch and the compass follows the yaw", () => {
76 const level = recorder();
77 drawHorizon(level, { yaw: 0, pitch: 0 }, 900, 450);
78 const at = (paint: ReturnType<typeof recorder>): number =>
79 Number(paint.calls.find((c) => c[0] === "moveTo")?.[2]);
80 expect(at(level)).toBeCloseTo(225, 6);
81
82 // Pitch is positive downwards: looking down lifts the horizon up the screen.
83 const down = recorder();
84 drawHorizon(down, { yaw: 0, pitch: 20 }, 900, 450);
85 expect(at(down)).toBeLessThan(at(level));
86 const up = recorder();
87 drawHorizon(up, { yaw: 0, pitch: -20 }, 900, 450);
88 expect(at(up)).toBeGreaterThan(at(level));
89
90 // Looking at 0°, the 0 tick is dead centre and the one behind you is not drawn.
91 expect(level.texts).toContain("0");
92 expect(level.texts).not.toContain("180");
93 const turned = recorder();
94 drawHorizon(turned, { yaw: 90, pitch: 0 }, 900, 450);
95 expect(turned.texts).toContain("90");
96 expect(turned.texts).not.toContain("0");
97 });
src/range/readout.ts added +143
@@ -0,0 +1,143 @@
1 /**
2 * What the range draws today: a horizon on the scene canvas so you can see
3 * where you are looking, and a panel of numbers on the HUD canvas so you can
4 * see what the loop and the controls are doing. Plain 2D calls — there is no
5 * WebGL here, and no room, no targets and no physics either. Those are #13's.
6 *
7 * It takes the calls it makes as a parameter, typed by exactly those calls, so
8 * a stub records them in a test and a real `CanvasRenderingContext2D` fits
9 * without a cast. It reads a `Readout` and draws; it changes nothing.
10 */
11
12 import { angleDelta, clamp } from "../engine/math.ts";
13
14 /** The 2D calls this module makes, and nothing else. */
15 export type Paint = {
16 fillStyle: unknown;
17 strokeStyle: unknown;
18 lineWidth: number;
19 font: unknown;
20 textBaseline: unknown;
21 clearRect(x: number, y: number, width: number, height: number): void;
22 fillRect(x: number, y: number, width: number, height: number): void;
23 fillText(text: string, x: number, y: number): void;
24 beginPath(): void;
25 moveTo(x: number, y: number): void;
26 lineTo(x: number, y: number): void;
27 stroke(): void;
28 };
29
30 /** Where the view points. Degrees, the engine's way round: pitch is positive down. */
31 export type View = { yaw: number; pitch: number };
32
33 /** Everything the panel prints, gathered by `main.ts` once a frame. */
34 export type Readout = View & {
35 /** Seconds of simulation per tick, as the rate it is usually said at. */
36 hz: number;
37 /** Ticks the loop has run since the page booted. */
38 ticks: number;
39 /** Smoothed milliseconds a frame. */
40 frameMs: number;
41 /** The held actions this game knows, already in order. */
42 held: readonly string[];
43 /** What those actions and the thumb stick ask for: -1..1 on each axis. */
44 move: { forward: number; side: number };
45 /** Is the pointer locked to the stage? */
46 locked: boolean;
47 };
48
49 const SKY = "#101826";
50 const GROUND = "#17202c";
51 const LINE = "#2e3a4c";
52 const TEXT = "#e7eaf0";
53 const DIM = "#9aa4b4";
54
55 /** A 90° view, vertically: how many pixels one degree is worth. */
56 const perDegree = (height: number): number => height / 90;
57
58 /** Degrees into 0..360, so the compass reads like a compass. */
59 export const wrapDegrees = (angle: number): number => ((angle % 360) + 360) % 360;
60
61 /** The panel's lines, in order. Separate from the drawing so a test can read them. */
62 export function readoutLines(r: Readout): string[] {
63 return [
64 `tick ${r.ticks} · ${r.hz} Hz fixed step`,
65 `frame ${r.frameMs.toFixed(1)} ms · ${(1000 / Math.max(0.1, r.frameMs)).toFixed(0)} fps`,
66 `held ${r.held.length === 0 ? "nothing" : r.held.join(" ")}`,
67 `move ${r.move.forward.toFixed(2)} forward · ${r.move.side.toFixed(2)} side`,
68 `yaw ${wrapDegrees(r.yaw).toFixed(1)}° · pitch ${clamp(r.pitch, -89, 89).toFixed(1)}°`,
69 r.locked ? "mouse captured · Esc releases it" : "click the view, or drag it on a touch screen",
70 ];
71 }
72
73 /**
74 * The scene: sky, ground, the horizon where the pitch puts it and a compass
75 * tick every 15° of yaw. It is a placeholder for #13's room, and it is drawn
76 * from the same two angles the readout prints, so what you see and what it
77 * says cannot disagree.
78 */
79 export function drawHorizon(paint: Paint, view: View, width: number, height: number): void {
80 const px = perDegree(height);
81 // Pitch is positive downwards, so looking down lifts the horizon up the screen.
82 const horizon = clamp(height / 2 - view.pitch * px, -height, height * 2);
83 paint.fillStyle = SKY;
84 paint.fillRect(0, 0, width, Math.max(0, Math.min(height, horizon)));
85 paint.fillStyle = GROUND;
86 const top = Math.max(0, Math.min(height, horizon));
87 paint.fillRect(0, top, width, height - top);
88
89 paint.strokeStyle = LINE;
90 paint.lineWidth = 2;
91 paint.beginPath();
92 paint.moveTo(0, horizon);
93 paint.lineTo(width, horizon);
94 paint.stroke();
95
96 // One tick per 15°, only the ones that fall on screen.
97 paint.strokeStyle = DIM;
98 paint.lineWidth = 1;
99 paint.font = "12px system-ui, sans-serif";
100 paint.textBaseline = "top";
101 for (let angle = 0; angle < 360; angle += 15) {
102 const x = width / 2 + angleDelta(angle, view.yaw) * px;
103 if (x < 8 || x > width - 8) continue;
104 paint.beginPath();
105 paint.moveTo(x, horizon - 8);
106 paint.lineTo(x, horizon + 8);
107 paint.stroke();
108 paint.fillStyle = DIM;
109 paint.fillText(`${angle}`, x + 4, horizon + 10);
110 }
111 }
112
113 /** The HUD: the panel of numbers, and the crosshair the view turns around. */
114 export function drawReadout(paint: Paint, r: Readout, width: number, height: number): void {
115 paint.clearRect(0, 0, width, height);
116
117 const pad = Math.max(10, Math.round(width * 0.02));
118 const size = clamp(Math.round(width / 46), 12, 20);
119 const step = Math.round(size * 1.6);
120 const lines = readoutLines(r);
121
122 paint.fillStyle = "rgba(11,13,17,.72)";
123 paint.fillRect(pad, pad, width - pad * 2, step * lines.length + pad);
124 paint.font = `${size}px ui-monospace, SFMono-Regular, Menlo, monospace`;
125 paint.textBaseline = "top";
126 lines.forEach((line, i) => {
127 paint.fillStyle = i === 0 ? TEXT : DIM;
128 paint.fillText(line, pad * 1.6, pad * 1.4 + i * step);
129 });
130
131 // The crosshair marks the centre the angles are measured from.
132 const cx = Math.round(width / 2);
133 const cy = Math.round(height / 2);
134 const arm = Math.max(6, Math.round(width / 90));
135 paint.strokeStyle = "#8ea";
136 paint.lineWidth = 2;
137 paint.beginPath();
138 paint.moveTo(cx - arm, cy);
139 paint.lineTo(cx + arm, cy);
140 paint.moveTo(cx, cy - arm);
141 paint.lineTo(cx, cy + arm);
142 paint.stroke();
143 }
src/server.test.ts +65
@@ -48,6 +48,71 @@ test("the page carries every id and action the client reaches for", async () =>
4848 expect(body).toContain(`<div id="stage">`);
4949 });
5050
51 test("serves the range on its own route, with its own page and its own bundle", async () => {
52 const response = await fetch(base + "/range");
53 expect(response.status).toBe(200);
54 const body = await response.text();
55 expect(body).toContain("<title>range — the engine as a package</title>");
56 expect(body).toContain("<h1>range</h1>");
57 expect(body).toMatch(/<script type="module" src="\/static\/range\.[0-9a-f]+\.js">/);
58 // One script, and never the shooter's: that is the whole point of the route.
59 expect(body.match(/<script/g)?.length).toBe(1);
60 expect(body).not.toMatch(/src="\/static\/app\./);
61 // Nothing of cs's page leaked into it.
62 expect(body).not.toContain("Leaderboard");
63 expect(body).not.toContain("de_forge");
64 });
65
66 test("the range page carries every id and action its entry reaches for", async () => {
67 // src/range/main.ts finds these by getElementById and hands [data-action] to
68 // the engine's controls; nothing type-checks that, so the page is checked.
69 const body = await (await fetch(base + "/range")).text();
70 for (const id of ["scene", "hud", "fps", "touch", "note", "look"]) expect(body).toContain(`id="${id}"`);
71 for (const action of ["duck", "jump", "walk", "reload", "fire"])
72 expect(body).toContain(`data-action="${action}"`);
73 // cs's ids are not on this page: a shared shell is not a shared game.
74 for (const id of ["menu", "pause", "buy", "stats", "play"]) expect(body).not.toContain(`id="${id}"`);
75 });
76
77 test("the two pages link to each other", async () => {
78 expect(await (await fetch(base + "/")).text()).toContain(`href="/range"`);
79 expect(await (await fetch(base + "/range")).text()).toContain(`href="/"`);
80 });
81
82 test("the range stands on the same security floor as the game", async () => {
83 const [game, range] = await Promise.all([fetch(base + "/"), fetch(base + "/range")]);
84 for (const header of ["content-security-policy", "x-content-type-options", "referrer-policy",
85 "permissions-policy", "content-type"])
86 expect(range.headers.get(header)).toBe(game.headers.get(header));
87 expect(range.headers.get("content-security-policy")).toContain("default-src 'self'");
88 // No session is started for a page that stores nothing.
89 expect(range.headers.get("set-cookie")).toBeNull();
90 });
91
92 test("HEAD /range answers like GET, and an unknown path is still the same 404", async () => {
93 const head = await server.fetch(new Request(base + "/range", { method: "HEAD" }));
94 const get = await fetch(base + "/range");
95 expect(head.status).toBe(200);
96 expect(await head.text()).toBe("");
97 expect(head.headers.get("content-type")).toBe(get.headers.get("content-type"));
98 expect(head.headers.get("content-security-policy")).toBe(get.headers.get("content-security-policy"));
99
100 const missing = await fetch(base + "/range/nope");
101 expect(missing.status).toBe(404);
102 expect(await missing.text()).toContain("Not found");
103 expect(missing.headers.get("content-security-policy")).toContain("default-src 'self'");
104 });
105
106 test("the range's script is served immutable and is not the game's", async () => {
107 const page = await (await fetch(base + "/range")).text();
108 const src = /src="(\/static\/range\.[0-9a-f]+\.js)"/.exec(page)?.[1] as string;
109 const response = await fetch(base + src);
110 expect(response.status).toBe(200);
111 expect(response.headers.get("cache-control")).toContain("immutable");
112 const code = await response.text();
113 expect(code).not.toContain("de_forge");
114 });
115
51116 test("a first visit is given a session cookie that javascript cannot read", async () => {
52117 const response = await fetch(base + "/");
53118 const cookie = response.headers.get("set-cookie") ?? "";
src/server.ts +19 −6
@@ -11,6 +11,7 @@ import { join } from "node:path";
1111 import { ENTRIES, isBundleOf, STATIC_DIR } from "../scripts/build.ts";
1212 import { openDatabase, saveMatch } from "./db.ts";
1313 import { escape, gamePage, statsHtml } from "./pages/game.ts";
14 import { rangePage } from "./pages/range.ts";
1415 import { parseResult } from "./result.ts";
1516
1617 const db = openDatabase();
@@ -121,6 +122,13 @@ function findScripts(): Record<string, string> {
121122 }
122123 const scripts = findScripts();
123124 const script = scripts["app"] ?? "/static/app.js";
125 const rangeScript = scripts["range"] ?? "/static/range.js";
126
127 // HEAD is GET without the body: the headers, the status and nothing to read.
128 const withoutBodyIfHead = (request: Request, response: Response): Response =>
129 request.method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response;
130
131 const isRead = (request: Request): boolean => request.method === "GET" || request.method === "HEAD";
124132
125133 export const server = Bun.serve({
126134 port: Number(process.env.PORT ?? 8080),
@@ -147,16 +155,21 @@ export const server = Bun.serve({
147155 return html(statsHtml(db), 200, { "set-cookie": sessionCookie(session) });
148156 }
149157
150 // HEAD is GET without the body: a health check sees the app up.
151 if (url.pathname === "/" && (request.method === "GET" || request.method === "HEAD")) {
158 // The game, and a health check on HEAD sees the app up.
159 if (url.pathname === "/" && isRead(request)) {
152160 const session = readSession(request);
153161 const page = gamePage(db, { env, pull, commit, script, ssh });
154 const response = html(page, 200, session === null ? { "set-cookie": sessionCookie(newSession()) } : {});
155 return request.method === "HEAD"
156 ? new Response(null, { status: response.status, headers: response.headers })
157 : response;
162 return withoutBodyIfHead(
163 request,
164 html(page, 200, session === null ? { "set-cookie": sessionCookie(newSession()) } : {}),
165 );
158166 }
159167
168 // The second game: its own page, its own bundle, and no session — it stores
169 // nothing, so it has nothing to name a browser for.
170 if (url.pathname === "/range" && isRead(request))
171 return withoutBodyIfHead(request, html(rangePage({ env, pull, commit, script: rangeScript, ssh })));
172
160173 // Files under ./static are served as they are, cached for a year: they are
161174 // named by content (app.3f2a9c1b04.js), so a change is a new name, never a
162175 // stale cache. The path is confined to the directory, and a name that is
static/range.2543880d32.js added +1

Diff not shown — a line 7,058 characters wide (limit 2,000). +1

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs