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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/24-the-second-game-s-plumbing-lands-first-r → main 1 commit fork
16 files
changed +1143
−15
README.md +36 −7
| @@ -3,7 +3,9 @@ | ||
| 3 | 3 | A Counter-Strike-shaped defusal shooter that runs in the browser, and the |
| 4 | 4 | leaderboard of the matches people played. Five against five on one map: |
| 5 | 5 | 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. | |
| 7 | 9 | |
| 8 | 10 | sh |
| 9 | 11 | 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 | ||
| 51 | 53 | is a thumb stick, the right looks around, and the buttons along the bottom do |
| 52 | 54 | the rest. |
| 53 | 55 | |
| 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 | ||
| 54 | 62 | ## Shape |
| 55 | 63 | |
| 56 | 64 | - `src/engine/` — the part a second game could use, and the rule is that it never |
| @@ -75,6 +83,25 @@ the rest. | ||
| 75 | 83 | is headless and tested; `models`, `materials` and `audio` are this game's |
| 76 | 84 | recipes for the engine's three asset tools, and `binding` is its key table and |
| 77 | 85 | 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. | |
| 78 | 105 | - `src/rl/` — training, which the game never imports: `arena` is the environment |
| 79 | 106 | (a real duel on de_forge with the round and the economy taken out) and `ppo` is |
| 80 | 107 | the algorithm, with its backward pass checked against finite differences. |
| @@ -104,18 +131,20 @@ the rest. | ||
| 104 | 131 | A rule only one game needs goes in `extraStyle`, never in the shared block, |
| 105 | 132 | and CSS handed in there cannot close the block. So **a second game's page |
| 106 | 133 | 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 | |
| 108 | 136 | with `src/client/main.ts`, which finds them by `getElementById`: a test in |
| 109 | 137 | `src/server.test.ts` pins every one against the served page. |
| 110 | 138 | - `scripts/build.ts` bundles a table of entries — `ENTRIES`, one line per game: |
| 111 | 139 | a name and a source file — into `static/<name>.<hash>.js`, committed and |
| 112 | 140 | cached for a year. Each entry sweeps up only its own stale bundles, so one |
| 113 | 141 | 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. | |
| 119 | 148 | - No dependencies. Nothing is installed to run this. |
| 120 | 149 | |
| 121 | 150 | 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 | ||
| 21 | 21 | } |
| 22 | 22 | |
| 23 | 23 | 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 | ]); | |
| 25 | 28 | }); |
| 26 | 29 | |
| 27 | 30 | 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"); | ||
| 15 | 15 | export type Entry = { name: string; entry: string }; |
| 16 | 16 | |
| 17 | 17 | /** 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 | ]; | |
| 19 | 22 | |
| 20 | 23 | // A name becomes part of a file name and part of a regular expression: keep it |
| 21 | 24 | // 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 { | ||
| 112 | 112 | takes to react, so there is still a ladder from fodder to uncomfortable.</p> |
| 113 | 113 | <p>The match runs entirely in your browser; the leaderboard here is what your browser |
| 114 | 114 | 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> | |
| 115 | 119 | </section>`, |
| 116 | 120 | }); |
| 117 | 121 | } |
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 () => | ||
| 48 | 48 | expect(body).toContain(`<div id="stage">`); |
| 49 | 49 | }); |
| 50 | 50 | |
| 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 | ||
| 51 | 116 | test("a first visit is given a session cookie that javascript cannot read", async () => { |
| 52 | 117 | const response = await fetch(base + "/"); |
| 53 | 118 | const cookie = response.headers.get("set-cookie") ?? ""; |
src/server.ts +19 −6
| @@ -11,6 +11,7 @@ import { join } from "node:path"; | ||
| 11 | 11 | import { ENTRIES, isBundleOf, STATIC_DIR } from "../scripts/build.ts"; |
| 12 | 12 | import { openDatabase, saveMatch } from "./db.ts"; |
| 13 | 13 | import { escape, gamePage, statsHtml } from "./pages/game.ts"; |
| 14 | import { rangePage } from "./pages/range.ts"; | |
| 14 | 15 | import { parseResult } from "./result.ts"; |
| 15 | 16 | |
| 16 | 17 | const db = openDatabase(); |
| @@ -121,6 +122,13 @@ function findScripts(): Record<string, string> { | ||
| 121 | 122 | } |
| 122 | 123 | const scripts = findScripts(); |
| 123 | 124 | 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"; | |
| 124 | 132 | |
| 125 | 133 | export const server = Bun.serve({ |
| 126 | 134 | port: Number(process.env.PORT ?? 8080), |
| @@ -147,16 +155,21 @@ export const server = Bun.serve({ | ||
| 147 | 155 | return html(statsHtml(db), 200, { "set-cookie": sessionCookie(session) }); |
| 148 | 156 | } |
| 149 | 157 | |
| 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)) { | |
| 152 | 160 | const session = readSession(request); |
| 153 | 161 | 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 | ); | |
| 158 | 166 | } |
| 159 | 167 | |
| 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 | ||
| 160 | 173 | // Files under ./static are served as they are, cached for a year: they are |
| 161 | 174 | // named by content (app.3f2a9c1b04.js), so a change is a new name, never a |
| 162 | 175 | // 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
new above
offline