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