Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
A third game, served: /example proves a game now costs one entry and one page #119
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/80-a-third-game-served-example-proves-a-gam → main 2 commits fork
18 files
changed +981
−30
README.md +74 −15
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | # cs-engine |
| 2 | 2 | |
| 3 | A browser game engine with two games on it. The first, at `/`, is **cs**: a | |
| 3 | A browser game engine with three games on it. The first, at `/`, is **cs**: a | |
| 4 | 4 | Counter-Strike-shaped defusal shooter, five against five on one map — bomb |
| 5 | 5 | rounds, a buy menu and an economy, bots that walk to a site and shoot back — |
| 6 | 6 | and the leaderboard of the matches people played. The second, at `/range`, is |
| @@ -9,8 +9,12 @@ built only out of the engine the shooter was taken apart into: a shooting | ||
| 9 | 9 | engine's boxes, a thirty second clock and a score, with the fixed step and the |
| 10 | 10 | controls under it — and it is **drawn by the engine's renderer**: its own room, |
| 11 | 11 | its own plates and its own tracer, out of an atlas and two meshes it painted |
| 12 | and built itself. The engine in | |
| 13 | `src/engine/` is the point of the repository; the shooter is what proves it. | |
| 12 | and built itself. The third, at `/example`, is the **example**: the guide's own | |
| 13 | game — one room seen from above, one plate, thirty seconds and a score, drawn | |
| 14 | in 2D through the engine's `overlay` and nothing else — served to say what | |
| 15 | building on this engine actually costs. The engine in | |
| 16 | `src/engine/` is the point of the repository; the shooter is what proves it, | |
| 17 | and the example is the price tag. | |
| 14 | 18 | |
| 15 | 19 | sh |
| 16 | 20 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here |
| @@ -108,6 +112,41 @@ you stand at the firing line and turn on the spot. A browser with no WebGL2 keep | ||
| 108 | 112 | on the 2D horizon it shipped with — a plate as a square drawn from the angle it |
| 109 | 113 | subtends — and the panel says which of the two you are looking at. |
| 110 | 114 | |
| 115 | At `/example` the smallest game on the engine runs on the game's own table: | |
| 116 | <kbd>←</kbd> and <kbd>→</kbd> turn, <kbd>WASD</kbd> walks, <kbd>Space</kbd> | |
| 117 | fires one shot a press, <kbd>Enter</kbd> — or the Start button — begins thirty | |
| 118 | seconds. You are looking at the room from above: the blue dots are where the | |
| 119 | shot goes, the yellow bar on the far wall is the plate, and a hit puts up the | |
| 120 | next one. On a touch screen the four buttons do the same. | |
| 121 | ||
| 122 | ### What a third game cost | |
| 123 | ||
| 124 | The example is the answer to "what does building on this engine take?", so it | |
| 125 | is written down as a number. The game itself was already there — | |
| 126 | `src/engine/example.ts`, the 79 lines the engine's guide is written out of, | |
| 127 | which `guide.test.ts` executes block by block — and `/example` did not copy a | |
| 128 | line of it. What it added was: | |
| 129 | ||
| 130 | | what | how much | | |
| 131 | | --------------------------------------- | ------------------------------- | | |
| 132 | | `src/example/main.ts` (the wiring) | 112 lines | | |
| 133 | | `src/example/plan.ts` (the drawing) | 86 lines | | |
| 134 | | `src/pages/example.ts` (its page) | 76 lines | | |
| 135 | | `src/games.ts` (its registry entry) | 10 lines | | |
| 136 | | `src/server.ts` (one line in `RENDER`) | 1 line | | |
| 137 | | `static/example.<hash>.js` (its bundle) | 8,754 raw / 4,229 gzipped bytes | | |
| 138 | ||
| 139 | That is 198 lines of game, an eighth of the shooter's bundle, and no new branch | |
| 140 | in the server. What it did **not** have to write: the loop and its fixed step, | |
| 141 | the bindings-table reader, the touch buttons, the pointer handling, the ray | |
| 142 | against a box, the seeded random, the vector maths, the panel, the bar, the | |
| 143 | crosshair, the `m:ss` clock, the text scaling, the headless page its tests boot | |
| 144 | on, the document it is served in, the header's nav, the cross-links on the | |
| 145 | other two pages and its line in the 404 — the last four because they are | |
| 146 | projections of `src/games.ts` and nobody edited them. The 2D is the point: no | |
| 147 | renderer, no atlas, no mesh and no sound ship in that bundle, and | |
| 148 | `src/example/bundle.test.ts` reads the committed file to prove it. | |
| 149 | ||
| 111 | 150 | ## Shape |
| 112 | 151 | |
| 113 | 152 | - `src/engine/` — the part a second game could use, and the rule is that it never |
| @@ -191,9 +230,9 @@ subtends — and the panel says which of the two you are looking at. | ||
| 191 | 230 | - `src/engine/example.ts` — the smallest game on the package, and the guide's |
| 192 | 231 | code made runnable: under eighty lines with one import, a room to stand in, a |
| 193 | 232 | plate to shoot, a bindings table, a 60 Hz tick and a `step(world, dt, held)` |
| 194 | that takes plain data and gives plain data back. It has no page, no renderer | |
| 195 | and nothing saved — `src/range/` is the real second game — and | |
| 196 | `example.test.ts` plays it headlessly: a seeded run scores the same twice, a | |
| 233 | that takes plain data and gives plain data back. It has no renderer and | |
| 234 | nothing saved, and it is documentation first: `src/example/` is what gives it | |
| 235 | a page. `example.test.ts` plays it headlessly: a seeded run scores the same twice, a | |
| 197 | 236 | shot down the target's bearing hits and one twenty degrees off misses, and a |
| 198 | 237 | second of elapsed time is sixty ticks however the frames are cut. |
| 199 | 238 | - `src/client/` — the game, in modules named for one thing each: `level` (what a |
| @@ -336,6 +375,22 @@ subtends — and the panel says which of the two you are looking at. | ||
| 336 | 375 | `pin.test.ts` — so a renderer, an atlas or a movement tuning that changes the |
| 337 | 376 | game says so, naming the second of the run where it changed. `src/pages/range.ts` is its page: its own title, prose and overlay |
| 338 | 377 | through the same shell, with no database, no endpoint and no cookie behind it. |
| 378 | - `src/example/` — the third game, at `/example`, and the wiring of one and | |
| 379 | nothing else: `main.ts` finds the canvas, hands the engine's controls the | |
| 380 | game's own bindings table plus the one key that starts a run, runs the fixed | |
| 381 | loop and calls the guide's game's `step`; `plan.ts` draws the room from above | |
| 382 | through the engine's `overlay` alone — filled rectangles for the floor, the | |
| 383 | plate and the aim, a crosshair for you, a bar for the countdown and the | |
| 384 | overlay's own `m:ss`. 198 lines between them, 2D on one canvas: no WebGL, no | |
| 385 | atlas, no sound, no cookie. The game is shared rather than copied, which is | |
| 386 | the one thing this pull had to buy: the guide's game is not a module of the | |
| 387 | package and is not behind the door, so `src/engine/boundary.test.ts` lets | |
| 388 | `src/example/` — and only `src/example/` — name `src/engine/example.ts` by | |
| 389 | path. `index.ts` is untouched and the engine's surface did not move. | |
| 390 | `boot.test.ts` boots the entry on the engine's headless page and | |
| 391 | `bundle.test.ts` boots the committed bundle and reads it for what is *not* in | |
| 392 | it (`webgl2`, `AudioContext`, `Math.tanh`): a door is only worth having if | |
| 393 | importing it costs what you use. | |
| 339 | 394 | - `src/rl/` — training, which no game module imports: `arena` is the environment |
| 340 | 395 | (a real duel on de_forge with the round and the economy taken out) and `ppo` is |
| 341 | 396 | the algorithm, with its backward pass checked against finite differences. |
| @@ -343,8 +398,8 @@ subtends — and the panel says which of the two you are looking at. | ||
| 343 | 398 | *shooter's* training and `src/independence.test.ts` counts it as the shooter: |
| 344 | 399 | it reads `src/client/` module by module because that game is the environment, |
| 345 | 400 | and for the same reason it may not read `src/range/`. |
| 346 | - `src/independence.test.ts` — **two games, one engine, no sharing, and this is | |
| 347 | the test that says so**, in both directions: a game's sources may not import | |
| 401 | - `src/independence.test.ts` — **three games, one engine, no sharing, and this | |
| 402 | is the test that says so**, in both directions: a game's sources may not import | |
| 348 | 403 | another game's — the shooter reaching into the range fails here by name |
| 349 | 404 | (`src/client/world.ts imports "../range/targets.ts"; a game may not import |
| 350 | 405 | another game`) exactly as the range reaching into the shooter does — and the |
| @@ -355,7 +410,8 @@ subtends — and the panel says which of the two you are looking at. | ||
| 355 | 410 | `src/engine/boundary.test.ts` rather than a second copy of them — a test may |
| 356 | 411 | read another test's helpers, which is the one path past the door, because a |
| 357 | 412 | `.test.ts` is in no bundle. Who owns which directory comes from the registry |
| 358 | (`dirname(entry)`), so a third game is covered by the entry that adds it. | |
| 413 | (`dirname(entry)`), so the third game was covered by the entry that added it | |
| 414 | and this file gained no rule for it. | |
| 359 | 415 | `src/server.ts` and `src/pages/` belong to no game and are held to neither |
| 360 | 416 | rule: the app is allowed to know about every game on it. |
| 361 | 417 | - `src/client/replay.ts` — the golden digest: a seeded round and a seeded duel |
| @@ -422,20 +478,23 @@ subtends — and the panel says which of the two you are looking at. | ||
| 422 | 478 | that names that page function — because a page is a function and cannot be |
| 423 | 479 | data. The build, the header's nav, the cross-links on every other page and the |
| 424 | 480 | 404 all follow on their own. No inline script; the CSP is `default-src 'self'`. |
| 425 | `/range` is that recipe followed once. | |
| 481 | `/range` is that recipe followed once and `/example` is it followed twice — | |
| 482 | the third game added one entry, one page and one line in `RENDER`, and the | |
| 483 | header, the 404 and the other two pages grew it without being touched. | |
| 426 | 484 | - `scripts/build.ts` bundles every entry into `static/<name>.<hash>.js`, |
| 427 | 485 | committed and cached for a year. Each entry sweeps |
| 428 | 486 | up only its own stale bundles, so one |
| 429 | 487 | game's rebuild never deletes another's, and `bun test` fails if any committed |
| 430 | bundle is stale — today that is `app` (the shooter) and `range`. The server | |
| 488 | bundle is stale — today that is `app` (the shooter), `range` and `example`. The server | |
| 431 | 489 | resolves the script for an entry by that exact name. |
| 432 | 490 | - **Every bundle has a ceiling.** Today, and `bun run scripts/build.ts` prints |
| 433 | 491 | these next to each file name: |
| 434 | 492 | |
| 435 | | bundle | raw | ceiling | gzipped | ceiling | | |
| 436 | | ------- | ------ | ------- | ------- | ------- | | |
| 437 | | `app` | 70,576 | 75,400 | 32,862 | 34,900 | | |
| 438 | | `range` | 26,658 | 29,400 | 11,456 | 12,700 | | |
| 493 | | bundle | raw | ceiling | gzipped | ceiling | | |
| 494 | | --------- | ------ | ------- | ------- | ------- | | |
| 495 | | `app` | 71,646 | 75,400 | 33,273 | 34,900 | | |
| 496 | | `range` | 27,268 | 29,400 | 11,674 | 12,700 | | |
| 497 | | `example` | 8,754 | 9,700 | 4,229 | 4,700 | | |
| 439 | 498 | |
| 440 | 499 | The ceilings are today's sizes plus about 10%. Gzip is measured at level 9 |
| 441 | 500 | and only at level 9, so the number does not move between a laptop and the |
scripts/build.test.ts +1
| @@ -39,6 +39,7 @@ test("the entry table is what the app ships today, each with the size it may not | ||
| 39 | 39 | expect(ENTRIES).toEqual([ |
| 40 | 40 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 75_400, gzip: 34_900 } }, |
| 41 | 41 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 29_400, gzip: 12_700 } }, |
| 42 | { name: "example", entry: "src/example/main.ts", ceiling: { raw: 9_700, gzip: 4_700 } }, | |
| 42 | 43 | ]); |
| 43 | 44 | // And it is the registry projected, not a second list beside it: a game's |
| 44 | 45 | // source and its ceiling are written down once, in `src/games.ts`. |
src/engine/GUIDE.md +8 −4
| @@ -11,8 +11,10 @@ any use, in the order you would actually write it. | ||
| 11 | 11 | The engine is not a framework. It never calls your game — your game calls it, |
| 12 | 12 | from a frame it owns, with a page it opened and numbers it chose. Two real games |
| 13 | 13 | do that here: the shooter in `src/client/` and the range in `src/range/`. The |
| 14 | example is neither — it has no page, no renderer and nothing saved — and that is | |
| 15 | why it fits in one file and a test can play it in a millisecond. | |
| 14 | example is a third, and the smallest — it has no renderer and nothing saved, | |
| 15 | which is why it fits in one file and a test can play it in a millisecond — and | |
| 16 | `src/example/` is the 198 lines that give it a page at `/example`, so the code | |
| 17 | on this page is also code you can go and click. | |
| 16 | 18 | |
| 17 | 19 | **Conventions.** A bare name in backticks — `rayBox`, `Mesh` — is the engine's, |
| 18 | 20 | exported from `src/engine/index.ts`. A game's own name carries its module: |
| @@ -51,8 +53,10 @@ Two more things and a real game is reachable: one entry in the registry in | ||
| 51 | 53 | ceiling — and a page that server-renders the markup the entry looks up by id |
| 52 | 54 | (`src/pages/range.ts`), named in the one map in `src/server.ts` that turns a |
| 53 | 55 | game's name into its page function. The build, the header's nav and the 404 |
| 54 | follow from the entry. The example has neither, on purpose: it is read and | |
| 55 | played by a test, never shipped. | |
| 56 | follow from the entry. The example's own are `src/example/main.ts` and | |
| 57 | `src/pages/example.ts`, and they are worth reading next to this file: they are | |
| 58 | the whole of what the two paragraphs above cost, and nothing in | |
| 59 | `src/engine/example.ts` changed to get them. | |
| 56 | 60 | |
| 57 | 61 | ### 2. A host |
| 58 | 62 |
src/engine/boundary.test.ts +35 −2
| @@ -92,18 +92,35 @@ export function outsideSources(dirs = OUTSIDE): string[] { | ||
| 92 | 92 | |
| 93 | 93 | /** |
| 94 | 94 | * Why this file outside the engine may not import this specifier, or null if it |
| 95 | * may. Only one path into the engine is a path: `index.ts`, the door. The one | |
| 96 | * exception is a test reading another test's helpers — `src/independence.test.ts` | |
| 95 | * may. Only one path into the engine is a path: `index.ts`, the door. There are | |
| 96 | * two exceptions, and both are about things that are not modules of this | |
| 97 | * package. | |
| 98 | * | |
| 99 | * The first is a test reading another test's helpers — `src/independence.test.ts` | |
| 97 | 100 | * holds both games apart with the walk and the regular expression in this file |
| 98 | 101 | * rather than a second copy of them — because the door is about what ships, and |
| 99 | 102 | * a `.test.ts` is in no bundle. |
| 103 | * | |
| 104 | * The second is `example.ts`, named by `src/example/`, the entry that serves it. | |
| 105 | * The example is not behind the door and is not a module of the engine — it is | |
| 106 | * a *game*, written on this package the way a game outside it is, which is why | |
| 107 | * `notAModule` above already says so and why it imports the door itself. A game | |
| 108 | * cannot be shared by being re-exported as API; the only way `/example` could | |
| 109 | * serve the guide's own code rather than a copy of it was for the entry to name | |
| 110 | * the file. So the exception is written as narrowly as it is true: this one | |
| 111 | * file, from that one directory. The engine's surface did not move for it — | |
| 112 | * `index.ts` offers exactly what it offered before. | |
| 100 | 113 | */ |
| 114 | const GUIDE_GAME = "example.ts"; | |
| 115 | const GUIDE_GAME_ENTRY = "src/example/"; | |
| 116 | ||
| 101 | 117 | export function reachesPastTheDoor(file: string, specifier: string): string | null { |
| 102 | 118 | if (!specifier.startsWith(".")) return null; |
| 103 | 119 | const target = resolve(ROOT, join(file, ".."), specifier); |
| 104 | 120 | const inside = relative(ENGINE, target); |
| 105 | 121 | if (inside === "" || inside.startsWith("..")) return null; |
| 106 | 122 | if (inside === "index.ts") return null; |
| 123 | if (inside === GUIDE_GAME && file.startsWith(GUIDE_GAME_ENTRY)) return null; | |
| 107 | 124 | if (file.endsWith(".test.ts") && inside.endsWith(".test.ts")) return null; |
| 108 | 125 | return `${file} imports "${specifier}"; the engine's door is src/engine/index.ts`; |
| 109 | 126 | } |
| @@ -195,6 +212,22 @@ test("the door rule is about the door and nothing else", () => { | ||
| 195 | 212 | expect(reachesPastTheDoor("src/independence.test.ts", "./engine/math.ts")).toMatch(/door/); |
| 196 | 213 | }); |
| 197 | 214 | |
| 215 | test("the guide's game is named by the one entry that serves it, and by nobody else", () => { | |
| 216 | // `src/example/` is the page, the canvas and the bindings table around | |
| 217 | // `example.ts`; the game itself is shared rather than copied. | |
| 218 | expect(reachesPastTheDoor("src/example/main.ts", "../engine/example.ts")).toBeNull(); | |
| 219 | expect(reachesPastTheDoor("src/example/plan.ts", "../engine/example.ts")).toBeNull(); | |
| 220 | expect(reachesPastTheDoor("src/example/boot.test.ts", "../engine/example.ts")).toBeNull(); | |
| 221 | // And nobody else may: not the other games, not the server, not a page. | |
| 222 | expect(reachesPastTheDoor("src/client/world.ts", "../engine/example.ts")).toMatch(/door/); | |
| 223 | expect(reachesPastTheDoor("src/range/main.ts", "../engine/example.ts")).toMatch(/door/); | |
| 224 | expect(reachesPastTheDoor("src/pages/example.ts", "../engine/example.ts")).toMatch(/door/); | |
| 225 | // The licence is that one file, not the rest of the engine. | |
| 226 | expect(reachesPastTheDoor("src/example/main.ts", "../engine/math.ts")).toMatch(/door/); | |
| 227 | expect(reachesPastTheDoor("src/example/main.ts", "../engine/overlay.ts")).toMatch(/door/); | |
| 228 | expect(reachesPastTheDoor("src/example/main.ts", "../engine/index.ts")).toBeNull(); | |
| 229 | }); | |
| 230 | ||
| 198 | 231 | test("every module in the engine is behind the door, and the door holds nothing else", () => { |
| 199 | 232 | const door = readFileSync(join(ENGINE, "index.ts"), "utf8"); |
| 200 | 233 | const behind = new Set(specifiersOf(door).map((s) => s.replace(/^\.\//, ""))); |
src/engine/page.test.ts +10 −5
| @@ -309,19 +309,24 @@ test("the module has no timer of its own and touches no global", () => { | ||
| 309 | 309 | expect(code).toContain("export function fakePage"); |
| 310 | 310 | }); |
| 311 | 311 | |
| 312 | test("neither committed bundle carries the double", () => { | |
| 312 | test("no committed bundle carries the double", () => { | |
| 313 | 313 | const dir = join(ROOT, "static"); |
| 314 | 314 | const bundles = readdirSync(dir).filter((file) => file.endsWith(".js")); |
| 315 | // Both games' entries, so this is not a test that passes on an empty directory. | |
| 316 | expect(bundles.length).toBeGreaterThanOrEqual(2); | |
| 315 | // Every game's entry, so this is not a test that passes on an empty directory. | |
| 316 | expect(bundles.length).toBeGreaterThanOrEqual(3); | |
| 317 | 317 | for (const file of bundles) { |
| 318 | 318 | const code = readFileSync(join(dir, file), "utf8"); |
| 319 | // The reader is alive: these files are what it thinks they are. | |
| 319 | // The reader is alive: these files are what it thinks they are. What every | |
| 320 | // one of them has is a canvas — not WebGL, which `/example` proves a game | |
| 321 | // here can do without, and which this file would otherwise be pinning on | |
| 322 | // every game that ever ships. | |
| 320 | 323 | expect(code.length).toBeGreaterThan(1000); |
| 321 | expect(code).toContain("webgl2"); | |
| 324 | expect(code).toContain("getContext"); | |
| 322 | 325 | // And no byte of this module is in them — the sentence it carries, and the |
| 323 | 326 | // one pattern only it has. |
| 324 | 327 | expect(code).not.toContain(HEADLESS); |
| 325 | 328 | expect(code).not.toContain(`class="([a-z-]+)"`); |
| 326 | 329 | } |
| 330 | // The two games that draw in three dimensions still do. | |
| 331 | expect(bundles.filter((f) => readFileSync(join(dir, f), "utf8").includes("webgl2")).length).toBe(2); | |
| 327 | 332 | }); |
src/example/boot.test.ts added +224
| @@ -0,0 +1,224 @@ | ||
| 1 | /** | |
| 2 | * The entry itself, booted against the engine's headless page: the ids it looks | |
| 3 | * up, the keys it binds, the frames it asks for and what it draws when one | |
| 4 | * runs. Nothing here is a browser and nothing here is timed by a real clock — | |
| 5 | * the test decides when a frame happens and how long it took. | |
| 6 | * | |
| 7 | * The page comes from `src/engine/index.ts` through `stage.test-helper.ts`, so | |
| 8 | * this game writes no stub document of its own. What is tested here is the | |
| 9 | * wiring and only the wiring: the rules — a seeded plate, a ray against a box, | |
| 10 | * one shot per press — are the guide's game's, and `src/engine/example.test.ts` | |
| 11 | * already runs those with no page in the room at all. | |
| 12 | */ | |
| 13 | ||
| 14 | import { expect, test } from "bun:test"; | |
| 15 | import { KEYS, TICK } from "../engine/example.ts"; | |
| 16 | import { page, type ExamplePage } from "./stage.test-helper.ts"; | |
| 17 | ||
| 18 | // The entry boots itself when it is imported, so it is imported into a page | |
| 19 | // that is still loading: then every test below boots a fresh one on purpose. | |
| 20 | const loading = page(); | |
| 21 | loading.document.readyState = "loading"; | |
| 22 | const undoLoading = loading.install(); | |
| 23 | const { BINDINGS, RUN, boot } = await import("./main.ts"); | |
| 24 | undoLoading(); | |
| 25 | ||
| 26 | /** A booted page, and the undo that puts the globals back. */ | |
| 27 | function booted(options: { has2d?: boolean; coarse?: boolean } = {}): { p: ExamplePage; undo: () => void } { | |
| 28 | const p = page(options); | |
| 29 | const undo = p.install(); | |
| 30 | boot(); | |
| 31 | return { p, undo }; | |
| 32 | } | |
| 33 | ||
| 34 | type Call = [string, ...unknown[]]; | |
| 35 | ||
| 36 | /** | |
| 37 | * One frame, and the calls it alone made: the recorder keeps every frame ever | |
| 38 | * drawn, and almost everything here is a question about one of them. | |
| 39 | */ | |
| 40 | function drawn(p: ExamplePage, ms: number): { calls: Call[]; texts: string[] } { | |
| 41 | const all = p.scene.paint?.calls ?? []; | |
| 42 | const from = all.length; | |
| 43 | p.step(ms); | |
| 44 | const calls = all.slice(from); | |
| 45 | return { calls, texts: calls.filter((c) => c[0] === "fillText").map((c) => String(c[1])) }; | |
| 46 | } | |
| 47 | ||
| 48 | /** | |
| 49 | * Where the aim dots are, as one string. They are the only 2×2 rectangles on | |
| 50 | * the canvas — the room, the plate and the countdown bar are all larger — so | |
| 51 | * this is the drawn answer to "which way am I facing, and from where". | |
| 52 | */ | |
| 53 | const aim = (calls: Call[]): string => | |
| 54 | calls.filter((c) => c[0] === "fillRect" && c[3] === 2 && c[4] === 2).map((c) => `${c[1]},${c[2]}`).join(" "); | |
| 55 | ||
| 56 | /** Where the player's crosshair was centred: the one path this page strokes. */ | |
| 57 | const marker = (calls: Call[]): [number, number] => { | |
| 58 | const move = calls.find((c) => c[0] === "moveTo") as Call | undefined; | |
| 59 | return [Number(move?.[1] ?? 0), Number(move?.[2] ?? 0)]; | |
| 60 | }; | |
| 61 | ||
| 62 | test("the table it binds is the game's own, plus the one key the page adds", () => { | |
| 63 | // The game says what a key does; the page says what starts a run, because a | |
| 64 | // run is the page's idea and not the game's. | |
| 65 | expect(BINDINGS).toEqual({ ...KEYS, Enter: "start" }); | |
| 66 | expect(KEYS.Enter).toBeUndefined(); | |
| 67 | expect(TICK).toBe(1 / 60); | |
| 68 | }); | |
| 69 | ||
| 70 | test("it boots, asks for a frame, and draws the room and the readout", () => { | |
| 71 | const { p, undo } = booted(); | |
| 72 | try { | |
| 73 | expect(p.frames.length).toBe(1); | |
| 74 | const first = drawn(p, 16); | |
| 75 | // The canvas was sized to its box at this page's 1× ratio, and drawn on. | |
| 76 | expect([p.scene.width, p.scene.height]).toEqual([800, 450]); | |
| 77 | expect(first.texts).toContain("score 0"); | |
| 78 | expect(first.texts).toContain("0:30"); | |
| 79 | expect(first.texts).toContain("press Enter, or Start, for thirty seconds"); | |
| 80 | expect(p.by("fps").textContent).toContain("ms"); | |
| 81 | // The room, the plate, the aim and the countdown are filled rectangles: | |
| 82 | // everything on this canvas came out of the engine's overlay. | |
| 83 | expect(first.calls.filter((c) => c[0] === "fillRect").length).toBeGreaterThan(4); | |
| 84 | expect(aim(first.calls).length).toBeGreaterThan(0); | |
| 85 | // A frame was asked for before this one ran, so the loop keeps going. | |
| 86 | expect(p.frames.length).toBe(1); | |
| 87 | } finally { | |
| 88 | undo(); | |
| 89 | } | |
| 90 | }); | |
| 91 | ||
| 92 | test("the second canvas the shared stage renders is never touched", () => { | |
| 93 | // One canvas, one context, no WebGL: the smallest a game here can be. | |
| 94 | const { p, undo } = booted(); | |
| 95 | try { | |
| 96 | p.step(16); | |
| 97 | expect(p.hud.paint?.calls).toEqual([]); | |
| 98 | expect(p.hud.gl).toBeNull(); | |
| 99 | expect(p.scene.locks).toBe(0); | |
| 100 | } finally { | |
| 101 | undo(); | |
| 102 | } | |
| 103 | }); | |
| 104 | ||
| 105 | test("Enter starts a run: the note gets out of the way and the clock counts down", () => { | |
| 106 | const { p, undo } = booted(); | |
| 107 | try { | |
| 108 | p.step(16); | |
| 109 | expect(p.by("note").hidden).toBe(false); | |
| 110 | p.window.fire("keydown", { code: "Enter" }); | |
| 111 | const started = drawn(p, 16); | |
| 112 | expect(p.by("note").hidden).toBe(true); | |
| 113 | expect(started.texts).toContain("arrows turn · WASD walks · Space fires"); | |
| 114 | // A second and a half of frames is a second and a half off the clock, | |
| 115 | // however the frames were cut. | |
| 116 | for (let i = 0; i < 14; i++) p.step(100); | |
| 117 | expect(drawn(p, 100).texts).toContain("0:29"); | |
| 118 | } finally { | |
| 119 | undo(); | |
| 120 | } | |
| 121 | }); | |
| 122 | ||
| 123 | test("a held key reaches the game: turning moves the aim, walking moves the player", () => { | |
| 124 | const { p, undo } = booted(); | |
| 125 | try { | |
| 126 | p.window.fire("keydown", { code: "Enter" }); | |
| 127 | const still = drawn(p, 16); | |
| 128 | p.window.fire("keydown", { code: "ArrowLeft" }); | |
| 129 | for (let i = 0; i < 4; i++) p.step(100); | |
| 130 | const turned = drawn(p, 100); | |
| 131 | expect(aim(turned.calls)).not.toBe(aim(still.calls)); | |
| 132 | // Letting go stops the turn: the next two frames aim at the same place. | |
| 133 | p.window.fire("keyup", { code: "ArrowLeft" }); | |
| 134 | p.step(100); | |
| 135 | const held = drawn(p, 100); | |
| 136 | expect(aim(drawn(p, 100).calls)).toBe(aim(held.calls)); | |
| 137 | ||
| 138 | // Walking north is up the plan, because the room starts facing north. | |
| 139 | const from = marker(held.calls); | |
| 140 | p.window.fire("keydown", { code: "KeyW" }); | |
| 141 | for (let i = 0; i < 4; i++) p.step(100); | |
| 142 | expect(marker(drawn(p, 100).calls)[1]).toBeLessThan(from[1]); | |
| 143 | } finally { | |
| 144 | undo(); | |
| 145 | } | |
| 146 | }); | |
| 147 | ||
| 148 | test("a key the game does not bind does nothing at all", () => { | |
| 149 | const { p, undo } = booted(); | |
| 150 | try { | |
| 151 | p.window.fire("keydown", { code: "Enter" }); | |
| 152 | p.step(100); | |
| 153 | const before = drawn(p, 100); | |
| 154 | p.window.fire("keydown", { code: "KeyQ" }); | |
| 155 | expect(aim(drawn(p, 100).calls)).toBe(aim(before.calls)); | |
| 156 | expect(marker(drawn(p, 100).calls)).toEqual(marker(before.calls)); | |
| 157 | } finally { | |
| 158 | undo(); | |
| 159 | } | |
| 160 | }); | |
| 161 | ||
| 162 | test("the on-screen Start button is the same action as the key", () => { | |
| 163 | const { p, undo } = booted(); | |
| 164 | try { | |
| 165 | p.step(16); | |
| 166 | // The last button the page renders is Start, in the note's own row. | |
| 167 | p.buttons[p.buttons.length - 1]?.fire("pointerdown"); | |
| 168 | expect(drawn(p, 16).texts).toContain("arrows turn · WASD walks · Space fires"); | |
| 169 | expect(p.by("note").hidden).toBe(true); | |
| 170 | } finally { | |
| 171 | undo(); | |
| 172 | } | |
| 173 | }); | |
| 174 | ||
| 175 | test("the clock ends the run and the note says what it was worth", () => { | |
| 176 | const { p, undo } = booted(); | |
| 177 | try { | |
| 178 | p.window.fire("keydown", { code: "Enter" }); | |
| 179 | p.step(16); // the run starts on the frame that reads the key, not on the key | |
| 180 | p.window.fire("keyup", { code: "Enter" }); | |
| 181 | // 250 ms is the longest frame the loop believes, and eight steps of it are | |
| 182 | // all it will run: a whole run is a few hundred of those. | |
| 183 | for (let i = 0; i < 240; i++) p.step(250); | |
| 184 | const over = drawn(p, 250); | |
| 185 | expect(over.texts).toContain("0:00"); | |
| 186 | expect(over.texts).toContain("time — press Enter for another thirty seconds"); | |
| 187 | expect(p.by("note").hidden).toBe(false); | |
| 188 | expect(p.by("note").innerHTML).toContain("Time"); | |
| 189 | expect(p.by("note").innerHTML).toContain(`in ${RUN} seconds`); | |
| 190 | // And Enter starts another thirty seconds. | |
| 191 | p.window.fire("keydown", { code: "Enter" }); | |
| 192 | expect(drawn(p, 16).texts).toContain("0:30"); | |
| 193 | expect(p.by("note").hidden).toBe(true); | |
| 194 | } finally { | |
| 195 | undo(); | |
| 196 | } | |
| 197 | }); | |
| 198 | ||
| 199 | test("the touch pads are shown to a coarse pointer and to nobody else", () => { | |
| 200 | const mouse = booted(); | |
| 201 | try { | |
| 202 | expect(mouse.p.by("touch").hidden).toBe(true); | |
| 203 | } finally { | |
| 204 | mouse.undo(); | |
| 205 | } | |
| 206 | const phone = booted({ coarse: true }); | |
| 207 | try { | |
| 208 | expect(phone.p.by("touch").hidden).toBe(false); | |
| 209 | } finally { | |
| 210 | phone.undo(); | |
| 211 | } | |
| 212 | }); | |
| 213 | ||
| 214 | test("a canvas with no context is a sentence on the page, not an exception", () => { | |
| 215 | const { p, undo } = booted({ has2d: false }); | |
| 216 | try { | |
| 217 | expect(p.by("note").innerHTML).toContain("No 2D canvas"); | |
| 218 | expect(p.by("note").hidden).toBe(false); | |
| 219 | expect(p.frames.length).toBe(0); | |
| 220 | expect(p.window.listening.size).toBe(0); | |
| 221 | } finally { | |
| 222 | undo(); | |
| 223 | } | |
| 224 | }); |
src/example/bundle.test.ts added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | /** | |
| 2 | * The committed bundle, booted. `boot.test.ts` proves this game's *sources* run | |
| 3 | * against the engine's headless page; this proves the minified file in `static/` | |
| 4 | * does, which is the file a browser actually downloads. A bundling mistake — a | |
| 5 | * mangled name the entry needed, a tree shake that took the boot line with it — | |
| 6 | * fails here rather than in the preview. | |
| 7 | * | |
| 8 | * It also reads the bytes for what is *not* in them, which is this game's whole | |
| 9 | * claim: it is 2D, so no renderer, no atlas, no sound device and no policy | |
| 10 | * network came along behind the engine's door. A door is only worth having if | |
| 11 | * importing it costs what you use, and this file is where that is measured. | |
| 12 | * | |
| 13 | * Finding the artifact and importing it afresh is `scripts/bundle.test-helper.ts`, | |
| 14 | * shared with both other games — the same file, the same trick, one copy of it. | |
| 15 | */ | |
| 16 | ||
| 17 | import { afterAll, expect, test } from "bun:test"; | |
| 18 | import { readFileSync } from "node:fs"; | |
| 19 | import { bundlePath, discardBundleCopies, importBundle } from "../../scripts/bundle.test-helper.ts"; | |
| 20 | import { page, type ExamplePage } from "./stage.test-helper.ts"; | |
| 21 | ||
| 22 | afterAll(discardBundleCopies); | |
| 23 | ||
| 24 | /** Whether this process had a `document` before any of this ran. It does not. */ | |
| 25 | const HAD_DOCUMENT = "document" in globalThis; | |
| 26 | ||
| 27 | /** | |
| 28 | * Install a stub page, import the committed bundle into it — the entry boots | |
| 29 | * itself on the way in — and hand back the page with the undo. | |
| 30 | */ | |
| 31 | async function booted(options: { has2d?: boolean } = {}): Promise<{ p: ExamplePage; undo: () => void }> { | |
| 32 | const p = page(options); | |
| 33 | const undo = p.install(); | |
| 34 | try { | |
| 35 | await importBundle("example"); | |
| 36 | } catch (error) { | |
| 37 | undo(); | |
| 38 | throw error; | |
| 39 | } | |
| 40 | return { p, undo }; | |
| 41 | } | |
| 42 | ||
| 43 | const texts = (p: ExamplePage): string[] => p.scene.paint?.texts ?? []; | |
| 44 | ||
| 45 | test("the committed bundle boots the page and draws what the loop produced", async () => { | |
| 46 | const { p, undo } = await booted(); | |
| 47 | try { | |
| 48 | // Importing it was enough: it found the page and asked for a frame. | |
| 49 | expect(p.frames.length).toBe(1); | |
| 50 | p.step(16); | |
| 51 | expect([p.scene.width, p.scene.height]).toEqual([800, 450]); | |
| 52 | // Drawn text, so minification has nothing to hide behind. | |
| 53 | expect(texts(p)).toContain("score 0"); | |
| 54 | expect(texts(p)).toContain("0:30"); | |
| 55 | expect(p.by("fps").textContent).toContain("ms"); | |
| 56 | // And the second canvas is still untouched by the file a browser runs. | |
| 57 | expect(p.hud.paint?.calls).toEqual([]); | |
| 58 | ||
| 59 | // Enter runs the guide's game: the clock moves and the help line changes. | |
| 60 | p.window.fire("keydown", { code: "Enter" }); | |
| 61 | p.step(16); | |
| 62 | expect(p.by("note").hidden).toBe(true); | |
| 63 | expect(texts(p)).toContain("arrows turn · WASD walks · Space fires"); | |
| 64 | } finally { | |
| 65 | undo(); | |
| 66 | } | |
| 67 | }); | |
| 68 | ||
| 69 | test("a bundled canvas with no context is a sentence on the page, not an exception", async () => { | |
| 70 | const { p, undo } = await booted({ has2d: false }); | |
| 71 | try { | |
| 72 | expect(p.by("note").innerHTML).toContain("No 2D canvas"); | |
| 73 | expect(p.frames.length).toBe(0); | |
| 74 | expect(p.window.listening.size).toBe(0); | |
| 75 | } finally { | |
| 76 | undo(); | |
| 77 | } | |
| 78 | }); | |
| 79 | ||
| 80 | test("the example bundles none of the engine it does not use", () => { | |
| 81 | // A tool set ships whole to a game that uses any of it, so the way to say | |
| 82 | // this game uses none of these is to read the file. Each name below is one | |
| 83 | // the minifier cannot rename and only one module of the engine has. | |
| 84 | const code = readFileSync(bundlePath("example"), "utf8"); | |
| 85 | expect(code.length).toBeGreaterThan(1000); // the reader is alive | |
| 86 | expect(code).toContain("getContext"); // and it is a game on a canvas | |
| 87 | for (const name of ["webgl2", "AudioContext", "Math.tanh", "atob", "btoa", "createImageBitmap"]) | |
| 88 | expect(code).not.toContain(name); | |
| 89 | }); | |
| 90 | ||
| 91 | test("the stub page leaves nothing of itself behind", () => { | |
| 92 | // Every boot above undid its install, so no other test inherits a document. | |
| 93 | expect("document" in globalThis).toBe(HAD_DOCUMENT); | |
| 94 | }); |
src/example/main.ts added +112
| @@ -0,0 +1,112 @@ | ||
| 1 | /** | |
| 2 | * The example's entry: what a game on this package costs once the game itself | |
| 3 | * is written. The game is not here — it is `src/engine/example.ts`, the guide's | |
| 4 | * own code, which `guide.test.ts` and `example.test.ts` already run — and this | |
| 5 | * is the wiring a browser needs around it: find the canvas, hand the engine's | |
| 6 | * controls the game's bindings table, run the engine's fixed loop, call the | |
| 7 | * game's `step` with the actions it collected, and draw through `./plan.ts`. | |
| 8 | * No rule of the game is repeated here and no other game is imported. | |
| 9 | * | |
| 10 | * A run — thirty seconds, and a clock counting them down — is the page's idea | |
| 11 | * and not the game's, so the table below is the game's plus the one key that | |
| 12 | * starts one: the game says what `KeyW` means, the page says what Enter means. | |
| 13 | * | |
| 14 | * 2D and nothing else: one canvas, one context, no WebGL, no atlas, no sound | |
| 15 | * and no persistence. `bundle.test.ts` reads the committed file to say so. | |
| 16 | */ | |
| 17 | ||
| 18 | import { KEYS, TICK, createWorld, step, type World } from "../engine/example.ts"; | |
| 19 | import { attachButtons, createControls, createLoop, type Paint } from "../engine/index.ts"; | |
| 20 | import { drawPlan, type Phase } from "./plan.ts"; | |
| 21 | ||
| 22 | /** The game's own table, and the one key the page adds to start a run with. */ | |
| 23 | export const BINDINGS: Record<string, string> = { ...KEYS, Enter: "start" }; | |
| 24 | ||
| 25 | /** Seconds a run lasts, and the actions a button press releases by itself. */ | |
| 26 | export const RUN = 30; | |
| 27 | const TAPS = ["fire", "start"]; | |
| 28 | ||
| 29 | /** A seed for the next run: the clock, which nobody has to remember or type. */ | |
| 30 | const freshSeed = (): number => (Date.now() & 0xffff) >>> 0; | |
| 31 | ||
| 32 | const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T; | |
| 33 | ||
| 34 | /** | |
| 35 | * Wire the page up and start the loop. Exported so `boot.test.ts` can run the | |
| 36 | * whole entry against the engine's headless page; in a browser the line at the | |
| 37 | * bottom of this file is what calls it. | |
| 38 | */ | |
| 39 | export function boot(): void { | |
| 40 | const scene = $<HTMLCanvasElement>("scene"); | |
| 41 | const note = $("note"); | |
| 42 | ||
| 43 | // A canvas that gives no context is a sentence on the page, never a throw: | |
| 44 | // the text, the links and the terminal line on this page all still work. | |
| 45 | const paint = scene.getContext("2d") as Paint | null; | |
| 46 | if (paint === null) { | |
| 47 | note.innerHTML = "<h2>No 2D canvas</h2><p>This browser gave the page no drawing context, so the room" + | |
| 48 | " cannot be drawn. The rest of the page still works.</p>"; | |
| 49 | return; | |
| 50 | } | |
| 51 | ||
| 52 | const controls = createControls({ surface: scene, window, document }, { keys: BINDINGS, requireLock: false }); | |
| 53 | attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: TAPS }); | |
| 54 | $("touch").hidden = !matchMedia("(pointer: coarse)").matches; | |
| 55 | const loop = createLoop({ tick: TICK }, { | |
| 56 | requestAnimationFrame: (callback) => requestAnimationFrame(callback), | |
| 57 | cancelAnimationFrame: (handle) => cancelAnimationFrame(handle), | |
| 58 | now: () => performance.now(), | |
| 59 | }); | |
| 60 | ||
| 61 | let world: World = createWorld(freshSeed()); | |
| 62 | let phase: Phase = "ready"; | |
| 63 | let clock = RUN; | |
| 64 | // Start is an edge, not a hold: a leant-on Enter is one run, not sixty. | |
| 65 | let starting = false; | |
| 66 | let frameMs = 16.7; | |
| 67 | ||
| 68 | /** Match the canvas's backing store to its box, at most twice the CSS pixels. */ | |
| 69 | const size = (): [number, number] => { | |
| 70 | const dpr = Math.min(window.devicePixelRatio || 1, 2); | |
| 71 | const width = Math.max(1, Math.round(scene.clientWidth * dpr)); | |
| 72 | const height = Math.max(1, Math.round(scene.clientHeight * dpr)); | |
| 73 | if (scene.width !== width) scene.width = width; | |
| 74 | if (scene.height !== height) scene.height = height; | |
| 75 | return [width, height]; | |
| 76 | }; | |
| 77 | ||
| 78 | loop.start((elapsed) => { | |
| 79 | frameMs += (elapsed * 1000 - frameMs) * 0.05; | |
| 80 | const steps = loop.advance(elapsed); | |
| 81 | ||
| 82 | const asked = controls.held.has("start"); | |
| 83 | if (asked && !starting) { | |
| 84 | world = createWorld(freshSeed()); | |
| 85 | phase = "live"; | |
| 86 | clock = RUN; | |
| 87 | note.hidden = true; | |
| 88 | } | |
| 89 | starting = asked; | |
| 90 | ||
| 91 | // The game moves in whole ticks, so a slow frame and a fast one play the | |
| 92 | // same thirty seconds. Between runs it does not move at all. | |
| 93 | if (phase === "live") { | |
| 94 | for (let i = 0; i < steps; i++) world = step(world, TICK, controls.held); | |
| 95 | clock -= steps * TICK; | |
| 96 | if (clock <= 0) { | |
| 97 | clock = 0; | |
| 98 | phase = "over"; | |
| 99 | note.innerHTML = `<h2>Time</h2><p>${world.score} plate${world.score === 1 ? "" : "s"} in ${RUN} seconds.` + | |
| 100 | ` Press <kbd>Enter</kbd>, or Start, to go again.</p>`; | |
| 101 | note.hidden = false; | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | const [width, height] = size(); | |
| 106 | drawPlan(paint, width, height, { world, phase, clock, fraction: clock / RUN }); | |
| 107 | $("fps").textContent = `${(1000 / Math.max(1, frameMs)).toFixed(0)} fps · ${frameMs.toFixed(1)} ms`; | |
| 108 | }); | |
| 109 | } | |
| 110 | ||
| 111 | if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); | |
| 112 | else boot(); |
src/example/plan.test.ts added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | /** | |
| 2 | * The plan, drawn into a recorder. There is no canvas here and no page: the | |
| 3 | * engine's `FakePaint` is a `Paint`, so a frame is a list of calls a test can | |
| 4 | * read — where the room went, where the plate went, which way the aim points | |
| 5 | * and what the readout said. | |
| 6 | */ | |
| 7 | ||
| 8 | import { expect, test } from "bun:test"; | |
| 9 | import { ROOM, createWorld } from "../engine/example.ts"; | |
| 10 | import { fakePage, type FakePaint } from "../engine/index.ts"; | |
| 11 | import { drawPlan, type Frame } from "./plan.ts"; | |
| 12 | ||
| 13 | const WIDTH = 800, HEIGHT = 450; | |
| 14 | ||
| 15 | /** A recorder, and one frame drawn into it. */ | |
| 16 | function drew(over: Partial<Frame> = {}): FakePaint { | |
| 17 | const paint = fakePage().surface.paint as FakePaint; | |
| 18 | const world = createWorld(7); | |
| 19 | drawPlan(paint, WIDTH, HEIGHT, { world, phase: "ready", clock: 30, fraction: 1, ...over }); | |
| 20 | return paint; | |
| 21 | } | |
| 22 | ||
| 23 | const rects = (paint: FakePaint): number[][] => | |
| 24 | paint.calls.filter((c) => c[0] === "fillRect").map((c) => c.slice(1).map(Number)); | |
| 25 | ||
| 26 | /** The aim dots: the only 2×2 rectangles on the canvas, oldest first. */ | |
| 27 | const dots = (paint: FakePaint): number[][] => rects(paint).filter((r) => r[2] === 2 && r[3] === 2); | |
| 28 | ||
| 29 | test("a frame is one background, one room, one plate, the aim and the readout", () => { | |
| 30 | const paint = drew(); | |
| 31 | const all = rects(paint); | |
| 32 | // The first rectangle covers the canvas: there is no clearRect in a frame, | |
| 33 | // because the overlay's panel is the only thing this game draws with. | |
| 34 | expect(all[0]).toEqual([0, 0, WIDTH, HEIGHT]); | |
| 35 | expect(paint.calls.some((c) => c[0] === "clearRect")).toBe(false); | |
| 36 | // The room is square on the canvas, and fits inside it. | |
| 37 | const room = all.find((r) => r[2] === r[3] && r[2] > 100) as number[]; | |
| 38 | expect(room[2]).toBeGreaterThan(100); | |
| 39 | expect(room[0]).toBeGreaterThanOrEqual(0); | |
| 40 | expect((room[0] as number) + (room[2] as number)).toBeLessThanOrEqual(WIDTH); | |
| 41 | expect((room[1] as number) + (room[3] as number)).toBeLessThanOrEqual(HEIGHT); | |
| 42 | // The readout, and the one line of help. | |
| 43 | expect(paint.texts).toEqual(["score 0", "0:30", "press Enter, or Start, for thirty seconds"]); | |
| 44 | // The player is a crosshair: four arms, one path, one stroke. | |
| 45 | expect(paint.calls.filter((c) => c[0] === "moveTo").length).toBe(4); | |
| 46 | expect(paint.calls.filter((c) => c[0] === "stroke").length).toBe(1); | |
| 47 | }); | |
| 48 | ||
| 49 | test("the aim walks out of the eye and stops at the wall", () => { | |
| 50 | const paint = drew(); | |
| 51 | const walked = dots(paint); | |
| 52 | expect(walked.length).toBeGreaterThan(2); | |
| 53 | // The world starts facing north, which is up the plan: every dot is higher | |
| 54 | // than the one before it, and none of them leaves the room. | |
| 55 | for (let i = 1; i < walked.length; i++) expect(walked[i]?.[1]).toBeLessThan(walked[i - 1]?.[1] as number); | |
| 56 | const room = rects(paint).find((r) => r[2] === r[3] && r[2] > 100) as number[]; | |
| 57 | for (const dot of walked) expect(dot[1]).toBeGreaterThanOrEqual(room[1] as number); | |
| 58 | }); | |
| 59 | ||
| 60 | test("turning turns the aim and nothing else", () => { | |
| 61 | const world = createWorld(7); | |
| 62 | const north = drew({ world }); | |
| 63 | const east = drew({ world: { ...world, yaw: 0 } }); | |
| 64 | expect(dots(east)).not.toEqual(dots(north)); | |
| 65 | // The room and the plate did not move: only the dots did. | |
| 66 | expect(rects(north).filter((r) => r[2] !== 2)).toEqual(rects(east).filter((r) => r[2] !== 2)); | |
| 67 | }); | |
| 68 | ||
| 69 | test("the plate is drawn where the game says it is, and is never thinner than a line", () => { | |
| 70 | const world = createWorld(7); | |
| 71 | const paint = drew({ world }); | |
| 72 | const plate = rects(paint).find((r) => r[2] > 2 && r[2] < 100) as number[]; | |
| 73 | expect(plate[3]).toBeGreaterThanOrEqual(2); | |
| 74 | // It is against the far wall, so it is drawn above the middle of the plan. | |
| 75 | const room = rects(paint).find((r) => r[2] === r[3] && r[2] > 100) as number[]; | |
| 76 | expect(plate[1]).toBeLessThan((room[1] as number) + (room[3] as number) / 2); | |
| 77 | expect(world.target.max[1]).toBeLessThanOrEqual(ROOM.max[1]); | |
| 78 | }); | |
| 79 | ||
| 80 | test("the clock reads the overlay's m:ss, and the last seconds are said differently", () => { | |
| 81 | expect(drew({ clock: 30 }).texts).toContain("0:30"); | |
| 82 | expect(drew({ clock: 9.2 }).texts).toContain("0:10"); | |
| 83 | expect(drew({ clock: 0 }).texts).toContain("0:00"); | |
| 84 | // The countdown bar is the fraction of the run that is left. | |
| 85 | const full = rects(drew({ clock: 30, fraction: 1 })); | |
| 86 | const half = rects(drew({ clock: 15, fraction: 0.5 })); | |
| 87 | const bar = (r: number[][]): number[] => r.filter((x) => x[3] === 3)[0] as number[]; | |
| 88 | expect(bar(half)[2]).toBeCloseTo((bar(full)[2] as number) / 2, 6); | |
| 89 | }); | |
| 90 | ||
| 91 | test("each phase says its own line and no other", () => { | |
| 92 | expect(drew({ phase: "ready" }).texts[2]).toContain("press Enter"); | |
| 93 | expect(drew({ phase: "live" }).texts[2]).toContain("arrows turn"); | |
| 94 | expect(drew({ phase: "over" }).texts[2]).toContain("time"); | |
| 95 | }); | |
| 96 | ||
| 97 | test("a narrow phone is the same frame, laid out inside it", () => { | |
| 98 | const paint = fakePage().surface.paint as FakePaint; | |
| 99 | drawPlan(paint, 360, 270, { world: createWorld(7), phase: "live", clock: 12, fraction: 0.4 }); | |
| 100 | for (const r of rects(paint)) { | |
| 101 | expect(r[0]).toBeGreaterThanOrEqual(0); | |
| 102 | expect((r[0] as number) + (r[2] as number)).toBeLessThanOrEqual(360 + 1); | |
| 103 | expect((r[1] as number) + (r[3] as number)).toBeLessThanOrEqual(270 + 1); | |
| 104 | } | |
| 105 | }); |
src/example/plan.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** | |
| 2 | * The guide's room, from above. The game in `src/engine/example.ts` has four | |
| 3 | * facts — where you stand, which way you face, where the plate is and what you | |
| 4 | * have hit — and this draws all four as a plan, through the engine's `overlay` | |
| 5 | * and nothing else: the floor, the plate and the aim are filled rectangles, the | |
| 6 | * player is a crosshair, the countdown is a bar and the clock is the overlay's | |
| 7 | * own `m:ss`. No WebGL, no atlas, no mesh and no sound. | |
| 8 | * | |
| 9 | * It takes a `Paint` and a size, so a test draws a frame into a recorder and | |
| 10 | * reads the calls back; nothing is remembered between frames. | |
| 11 | */ | |
| 12 | ||
| 13 | import { ROOM, type World } from "../engine/example.ts"; | |
| 14 | import { angleVectors, overlay, type Box, type Paint } from "../engine/index.ts"; | |
| 15 | ||
| 16 | /** Where a run is: waiting to start, running, or finished with a score. */ | |
| 17 | export type Phase = "ready" | "live" | "over"; | |
| 18 | ||
| 19 | /** Everything one frame of this game looks like, and all of it plain data. */ | |
| 20 | export type Frame = { | |
| 21 | world: World; | |
| 22 | phase: Phase; | |
| 23 | /** Seconds left on the run's clock. */ | |
| 24 | clock: number; | |
| 25 | /** How much of the run is left, 0..1 — the bar under the clock. */ | |
| 26 | fraction: number; | |
| 27 | }; | |
| 28 | ||
| 29 | /** The shell's palette, so the canvas and the page around it are one thing. */ | |
| 30 | const BACK = "#0b0d11", FLOOR = "#141821", PLATE = "#e2b04a"; | |
| 31 | const AIM = "#6fa8e8", TEXT = "#e7eaf0", DIM = "#9aa4b4", WARN = "#e2564a"; | |
| 32 | ||
| 33 | /** The last seconds a clock is read out in the colour of one running out, and | |
| 34 | * the aim: how many dots down the view, and how far apart in metres. */ | |
| 35 | const HURRY = 5, AIM_DOTS = 16, AIM_STEP = 0.4; | |
| 36 | ||
| 37 | /** The one line under the plan, per phase. It is the whole of this game's help. */ | |
| 38 | const SAY: Record<Phase, string> = { | |
| 39 | ready: "press Enter, or Start, for thirty seconds", | |
| 40 | live: "arrows turn · WASD walks · Space fires", | |
| 41 | over: "time — press Enter for another thirty seconds", | |
| 42 | }; | |
| 43 | ||
| 44 | /** One frame, drawn. The overlay is built here because the size is this call's. */ | |
| 45 | export function drawPlan(paint: Paint, width: number, height: number, frame: Frame): void { | |
| 46 | const o = overlay(paint, width, height); | |
| 47 | const { world, clock } = frame; | |
| 48 | const late = clock <= HURRY; | |
| 49 | o.panel(0, 0, width, height, BACK); | |
| 50 | ||
| 51 | // The readout: the score, the clock, and how much of the run is left. | |
| 52 | const line = o.pad + Math.round(30 * o.scale); | |
| 53 | o.text(`score ${world.score}`, o.pad, line, { font: o.font(30), fill: TEXT }); | |
| 54 | o.text(o.clock(clock), width - o.pad, line, { align: "right", font: o.font(30), fill: late ? WARN : TEXT }); | |
| 55 | o.bar(o.pad, line + Math.round(8 * o.scale), width - o.pad * 2, Math.max(2, Math.round(4 * o.scale)), | |
| 56 | frame.fraction, late ? WARN : AIM); | |
| 57 | ||
| 58 | // The plan itself: the room is square, so the biggest square that fits under | |
| 59 | // the readout is the scale everything below is drawn at. | |
| 60 | const side = Math.max(1, Math.min(width - o.pad * 2, height - line - o.pad * 3)); | |
| 61 | const metres = side / (ROOM.max[0] - ROOM.min[0]); | |
| 62 | const cx = width / 2; | |
| 63 | const cy = line + o.pad * 2 + side / 2; | |
| 64 | const sx = (x: number): number => cx + x * metres; | |
| 65 | const sy = (y: number): number => cy - y * metres; | |
| 66 | const box = ({ min, max }: Box, fill: string): void => | |
| 67 | o.panel(sx(min[0]), sy(max[1]), Math.max(2, (max[0] - min[0]) * metres), | |
| 68 | Math.max(2, (max[1] - min[1]) * metres), fill); | |
| 69 | ||
| 70 | box(ROOM, FLOOR); | |
| 71 | box(world.target, PLATE); | |
| 72 | ||
| 73 | // Where the next shot goes: the engine's own forward vector, walked out in | |
| 74 | // dots until it leaves the room. Turning is the one thing you must be able | |
| 75 | // to see, and on a plan this is what turning looks like. | |
| 76 | const dot = Math.max(2, Math.round(3 * o.scale)); | |
| 77 | const { forward } = angleVectors(world.yaw, 0); | |
| 78 | for (let i = 1; i <= AIM_DOTS; i++) { | |
| 79 | const x = world.pos[0] + forward[0] * i * AIM_STEP; | |
| 80 | const y = world.pos[1] + forward[1] * i * AIM_STEP; | |
| 81 | if (x < ROOM.min[0] || x > ROOM.max[0] || y < ROOM.min[1] || y > ROOM.max[1]) break; | |
| 82 | o.panel(sx(x) - dot / 2, sy(y) - dot / 2, dot, dot, AIM); | |
| 83 | } | |
| 84 | o.crosshair(sx(world.pos[0]), sy(world.pos[1]), 0, Math.round(8 * o.scale), TEXT); | |
| 85 | o.text(SAY[frame.phase], width / 2, height - o.pad, { align: "center", font: o.font(18), fill: DIM }); | |
| 86 | } |
src/example/stage.test-helper.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** | |
| 2 | * The example's half of a stub page, and it is a short half: the page itself is | |
| 3 | * the engine's — `fakePage` behind the door hands back the document, the | |
| 4 | * window, the elements, the 2D recorder and the clock. What is genuinely this | |
| 5 | * game's is the list of ids `src/pages/example.ts` renders, the buttons it | |
| 6 | * renders, and the line that installs all of it as the globals `main.ts` reads. | |
| 7 | * | |
| 8 | * There is no GL recorder and no audio counter here, and that is the point: the | |
| 9 | * example opens neither, so a page that offers neither is a page it still boots | |
| 10 | * on. `AudioContext` is deliberately not installed — a boot that reached for | |
| 11 | * one would throw here rather than pass. | |
| 12 | * | |
| 13 | * It ships in no bundle: only `boot.test.ts` and `bundle.test.ts` import it. | |
| 14 | */ | |
| 15 | ||
| 16 | import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts"; | |
| 17 | ||
| 18 | /** Every id `src/pages/example.ts` puts on the page that the entry looks up. */ | |
| 19 | export const EXAMPLE_IDS = ["scene", "hud", "fps", "touch", "note"]; | |
| 20 | ||
| 21 | /** The `[data-action]` buttons the page renders: the touch pads, and Start. */ | |
| 22 | export const EXAMPLE_ACTIONS = ["turnLeft", "turnRight", "forward", "fire", "start"]; | |
| 23 | ||
| 24 | /** The globals the last lines of `main.ts` and the engine's controls name. */ | |
| 25 | const GLOBALS = ["document", "window", "requestAnimationFrame", "cancelAnimationFrame", "matchMedia", "performance"]; | |
| 26 | ||
| 27 | export type ExamplePage = { | |
| 28 | document: FakeDocument; | |
| 29 | window: FakeWindow; | |
| 30 | scene: FakeCanvas; | |
| 31 | /** The second canvas the shared stage renders. This game never touches it. */ | |
| 32 | hud: FakeCanvas; | |
| 33 | by(id: string): FakeElement; | |
| 34 | buttons: FakeElement[]; | |
| 35 | /** The frames asked for but not yet run, oldest first. */ | |
| 36 | frames: ((now: number) => void)[]; | |
| 37 | /** Run the next frame, `ms` after the last one. */ | |
| 38 | step(ms: number): void; | |
| 39 | /** Install this page as the globals `main.ts` reads, and hand back the undo. */ | |
| 40 | install(): () => void; | |
| 41 | }; | |
| 42 | ||
| 43 | /** The whole page this game's entry expects. `has2d: false` gives no context. */ | |
| 44 | export function page(options: { has2d?: boolean; coarse?: boolean } = {}): ExamplePage { | |
| 45 | const fake = fakePage({ | |
| 46 | ids: EXAMPLE_IDS, | |
| 47 | canvases: ["scene", "hud"], | |
| 48 | actions: EXAMPLE_ACTIONS, | |
| 49 | paint: options.has2d ?? true, | |
| 50 | coarse: options.coarse, | |
| 51 | }); | |
| 52 | const [scene, hud] = fake.canvases as [FakeCanvas, FakeCanvas]; | |
| 53 | ||
| 54 | return { | |
| 55 | document: fake.document, | |
| 56 | window: fake.window, | |
| 57 | scene, | |
| 58 | hud, | |
| 59 | buttons: fake.buttons, | |
| 60 | frames: fake.frames, | |
| 61 | by: fake.by, | |
| 62 | step: fake.step, | |
| 63 | install() { | |
| 64 | // The undo is a snapshot of the global object's own enumerable properties, | |
| 65 | // which is what the other games' helpers take: a global that is not one — | |
| 66 | // `performance` is not — is deleted rather than restored, because putting | |
| 67 | // a native global back with `defineProperty` costs every lookup after it. | |
| 68 | const globals = globalThis as Record<string, unknown>; | |
| 69 | const before = { ...globals }; | |
| 70 | globals.document = fake.document; | |
| 71 | globals.window = fake.window; | |
| 72 | globals.requestAnimationFrame = fake.window.requestAnimationFrame; | |
| 73 | globals.cancelAnimationFrame = fake.window.cancelAnimationFrame; | |
| 74 | globals.matchMedia = fake.window.matchMedia; | |
| 75 | globals.performance = fake.window.performance; | |
| 76 | return () => { | |
| 77 | for (const key of GLOBALS) | |
| 78 | if (key in before) globals[key] = before[key]; | |
| 79 | else delete globals[key]; | |
| 80 | }; | |
| 81 | }, | |
| 82 | }; | |
| 83 | } |
src/games.ts +16
| @@ -87,6 +87,13 @@ export type Game = { | ||
| 87 | 87 | * the app's bundle and in neither line of the range's. Measured 2026-09-18 after |
| 88 | 88 | * #106: app 71,646 raw / 33,273 gzipped, range 27,268 raw / 11,674 gzipped. |
| 89 | 89 | * Both fit, so neither ceiling moved. |
| 90 | * #80 added a third entry, `example`, and its ceiling is the first number in | |
| 91 | * this file nobody had to argue about: the guide's game with a page around it | |
| 92 | * builds to 8,754 raw / 4,229 gzipped, so its ceiling is 9,700 / 4,700, today's | |
| 93 | * size plus about 10% like the other two. It is an eighth of the shooter | |
| 94 | * because it is 2D: no renderer, no atlas, no sound and no net ship in it, and | |
| 95 | * `src/example/bundle.test.ts` reads the file to say so. Neither other bundle | |
| 96 | * moved a byte for it. | |
| 90 | 97 | */ |
| 91 | 98 | export const GAMES: readonly Game[] = [ |
| 92 | 99 | { |
| @@ -107,4 +114,13 @@ export const GAMES: readonly Game[] = [ | ||
| 107 | 114 | blurb: |
| 108 | 115 | "thirty seconds on the clock, pop-up plates down a lane, a shot traced in three dimensions and a score — every line of it out of the engine and none of it out of the shooter.", |
| 109 | 116 | }, |
| 117 | { | |
| 118 | name: "example", | |
| 119 | href: "/example", | |
| 120 | label: "example", | |
| 121 | entry: "src/example/main.ts", | |
| 122 | ceiling: { raw: 9_700, gzip: 4_700 }, | |
| 123 | blurb: | |
| 124 | "the guide's own game, served: one room, one plate, thirty seconds and a score, drawn in 2D through the engine's overlay and nothing else — the price tag on a third game.", | |
| 125 | }, | |
| 110 | 126 | ]; |
src/independence.test.ts +13 −1
| @@ -91,13 +91,18 @@ export function importsAnotherGame(file: string, specifier: string): string | nu | ||
| 91 | 91 | return `${file} imports "${specifier}"; a game may not import another game`; |
| 92 | 92 | } |
| 93 | 93 | |
| 94 | test("both games have the sources this test is about, and the app has none", () => { | |
| 94 | test("every game has the sources this test is about, and the app has none", () => { | |
| 95 | 95 | const files = outsideSources(); |
| 96 | 96 | const of = (game: string): string[] => files.filter((f) => gameOf(f) === game); |
| 97 | 97 | expect(of("app")).toContain("src/client/world.ts"); |
| 98 | 98 | expect(of("app")).toContain("src/rl/arena.ts"); |
| 99 | 99 | expect(of("range")).toContain("src/range/targets.ts"); |
| 100 | 100 | expect(of("range")).toContain("src/range/main.ts"); |
| 101 | // The third game was covered by its registry entry and nothing else: this | |
| 102 | // file gained no rule for it, which is what a third game was meant to prove. | |
| 103 | expect(of("example")).toContain("src/example/main.ts"); | |
| 104 | expect(of("example")).toContain("src/example/plan.ts"); | |
| 105 | expect(GAMES.map((game) => game.name)).toContain("example"); | |
| 101 | 106 | // The app is not a game: it is what the games are served by. |
| 102 | 107 | expect(gameOf("src/server.ts")).toBeNull(); |
| 103 | 108 | expect(gameOf("src/pages/shell.ts")).toBeNull(); |
| @@ -132,6 +137,13 @@ test("the rule is refused both ways round, and names who did it", () => { | ||
| 132 | 137 | `src/rl/arena.ts imports "../range/targets.ts"; a game may not import another game`, |
| 133 | 138 | ); |
| 134 | 139 | expect(importsAnotherGame("src/range/pin.ts", "../rl/arena.ts")).toMatch(/another game/); |
| 140 | // And the third game is held to it from the moment it was written down. | |
| 141 | expect(importsAnotherGame("src/example/main.ts", "../client/world.ts")).toMatch(/another game/); | |
| 142 | expect(importsAnotherGame("src/example/plan.ts", "../range/targets.ts")).toMatch(/another game/); | |
| 143 | expect(importsAnotherGame("src/range/main.ts", "../example/plan.ts")).toMatch(/another game/); | |
| 144 | // The guide's game is the engine's, not a game's, so sharing it is allowed: | |
| 145 | // `src/engine/boundary.test.ts` is where that one path is decided. | |
| 146 | expect(importsAnotherGame("src/example/main.ts", "../engine/example.ts")).toBeNull(); | |
| 135 | 147 | }); |
| 136 | 148 | |
| 137 | 149 | // A title that ends on the keyword, with the closing quote right behind it, |
src/pages/example.ts added +76
| @@ -0,0 +1,76 @@ | ||
| 1 | /** | |
| 2 | * The example's page: the guide's game given a document, and a page about what | |
| 3 | * that cost. The shell in `./shell.ts` renders everything that is not this | |
| 4 | * game's — the doctype, the style, the header, the stage and the footer — so | |
| 5 | * what is left here is one overlay, four touch buttons and the two paragraphs | |
| 6 | * a visitor is owed: what they are looking at, and what it took to put it | |
| 7 | * there. | |
| 8 | * | |
| 9 | * It is deliberately one screen. This game is a demonstration and the honest | |
| 10 | * thing to demonstrate is the price tag, not a third shooter. | |
| 11 | */ | |
| 12 | ||
| 13 | import { navFor, othersHtml } from "./games.ts"; | |
| 14 | import { page, type PageOptions as ShellOptions, stage } from "./shell.ts"; | |
| 15 | ||
| 16 | /** What the shell needs that this page cannot know: the environment it runs in. */ | |
| 17 | export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh">; | |
| 18 | ||
| 19 | const NOTE = ` | |
| 20 | <h2>The smallest game this engine can run</h2> | |
| 21 | <p>One room seen from above, one plate on the far wall, thirty seconds and a score. The arrow | |
| 22 | keys turn you, <kbd>WASD</kbd> walks, <kbd>Space</kbd> fires one shot a press, and the blue | |
| 23 | dots are where that shot goes.</p> | |
| 24 | <div class="row"><button type="button" class="primary" data-action="start">Start</button></div> | |
| 25 | `; | |
| 26 | ||
| 27 | export function examplePage(options: PageOptions): string { | |
| 28 | return page({ | |
| 29 | ...options, | |
| 30 | title: "example — what a game costs on this engine", | |
| 31 | description: | |
| 32 | "The guide's own game, served: one room, one plate, thirty seconds and a score, drawn in 2D through the engine's overlay and nothing else.", | |
| 33 | heading: "example", | |
| 34 | nav: navFor("/example"), | |
| 35 | terminalLead: "The shooter's leaderboard in a terminal:", | |
| 36 | footer: "no database, no cookie: this page keeps nothing", | |
| 37 | stageHtml: stage({ | |
| 38 | touch: [ | |
| 39 | [ | |
| 40 | { action: "turnLeft", label: "turn ←" }, | |
| 41 | { action: "turnRight", label: "turn →" }, | |
| 42 | ], | |
| 43 | [ | |
| 44 | { action: "forward", label: "walk" }, | |
| 45 | { action: "fire", label: "fire" }, | |
| 46 | ], | |
| 47 | ], | |
| 48 | overlays: [{ id: "note", html: NOTE }], | |
| 49 | }), | |
| 50 | sectionsHtml: ` <section> | |
| 51 | <h2>What this cost</h2> | |
| 52 | <p>The game on this page is not on this page. It is <code>src/engine/example.ts</code>, the | |
| 53 | seventy-nine lines the engine's guide is written out of — a room, a plate, a seeded spawn, a | |
| 54 | fixed step and one shot per press — and it was already there, already tested, before this | |
| 55 | route existed. What a third game cost was the wiring around it: | |
| 56 | <code>src/example/main.ts</code> finds the canvas, hands the engine's controls the game's own | |
| 57 | bindings table, runs the engine's fixed loop and calls the game's <code>step</code>; | |
| 58 | <code>src/example/plan.ts</code> draws the result. That is two files and about two hundred | |
| 59 | lines, one registry entry in <code>src/games.ts</code>, this page, and one line in the map | |
| 60 | from a game's name to its page. No branch was added to the server, and nothing in the header, | |
| 61 | the 404 or the other two pages was edited: they read the registry, so this game appeared in | |
| 62 | all of them the moment it was written down.</p> | |
| 63 | <p>What it did not have to write is the longer list: no loop, no fixed step, no bindings table | |
| 64 | reader, no touch buttons, no pointer handling, no ray against a box, no seeded random, no | |
| 65 | vector maths, no panel, no bar, no clock, no text scaling — all of that is the package, and | |
| 66 | this game reaches for it through one door, <code>src/engine/index.ts</code>. It is 2D on one | |
| 67 | canvas: no WebGL, no atlas, no mesh, no sound and no database, and the door costs what you | |
| 68 | use — the script this page just downloaded is 8,754 bytes, 4,229 of them over the wire, | |
| 69 | against the shooter's 71,646. Every rectangle, the crosshair, the countdown bar and the | |
| 70 | <code>m:ss</code> clock come out of the engine's <code>overlay</code>, and nothing else draws | |
| 71 | here.</p> | |
| 72 | <p>Nothing on this page is stored: no endpoint, no table and no cookie. The leaderboard, the | |
| 73 | database and the terminal belong to the games next door: ${othersHtml("/example")}</p> | |
| 74 | </section>`, | |
| 75 | }); | |
| 76 | } |
src/pages/shell.test.ts +11 −3
| @@ -126,9 +126,17 @@ test("a nav entry is text, wherever it enters", () => { | ||
| 126 | 126 | }); |
| 127 | 127 | |
| 128 | 128 | test("the list of games lives once, and a page marks its own", () => { |
| 129 | expect(GAMES.map((g) => g.href)).toEqual(["/", "/range"]); | |
| 130 | expect(navFor("/")).toEqual([{ href: "/", label: "cs", current: true }, { href: "/range", label: "range" }]); | |
| 131 | expect(navFor("/range")).toEqual([{ href: "/", label: "cs" }, { href: "/range", label: "range", current: true }]); | |
| 129 | expect(GAMES.map((g) => g.href)).toEqual(["/", "/range", "/example"]); | |
| 130 | const others = [{ href: "/range", label: "range" }, { href: "/example", label: "example" }]; | |
| 131 | expect(navFor("/")).toEqual([{ href: "/", label: "cs", current: true }, ...others]); | |
| 132 | expect(navFor("/range")).toEqual([ | |
| 133 | { href: "/", label: "cs" }, { href: "/range", label: "range", current: true }, | |
| 134 | { href: "/example", label: "example" }, | |
| 135 | ]); | |
| 136 | expect(navFor("/example")).toEqual([ | |
| 137 | { href: "/", label: "cs" }, { href: "/range", label: "range" }, | |
| 138 | { href: "/example", label: "example", current: true }, | |
| 139 | ]); | |
| 132 | 140 | // A path that is no game marks nothing, and still lists them all. |
| 133 | 141 | expect(navFor("/nope").filter((e) => e.current === true)).toEqual([]); |
| 134 | 142 | expect(navFor("/nope")).toHaveLength(GAMES.length); |
src/server.test.ts +29
| @@ -8,6 +8,7 @@ import { escape } from "./pages/shell.ts"; | ||
| 8 | 8 | import { parseResult } from "./result.ts"; |
| 9 | 9 | import { sign } from "./who.ts"; |
| 10 | 10 | import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts"; |
| 11 | import { EXAMPLE_ACTIONS, EXAMPLE_IDS } from "./example/stage.test-helper.ts"; | |
| 11 | 12 | |
| 12 | 13 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); |
| 13 | 14 | process.env.PORT = "0"; |
| @@ -85,6 +86,34 @@ test("the range page carries every id and action its entry reaches for", async ( | ||
| 85 | 86 | for (const id of ["menu", "pause", "buy", "stats", "play"]) expect(body).not.toContain(`id="${id}"`); |
| 86 | 87 | }); |
| 87 | 88 | |
| 89 | test("serves the example on its own route, with its own page and its own bundle", async () => { | |
| 90 | const response = await fetch(base + "/example"); | |
| 91 | expect(response.status).toBe(200); | |
| 92 | const body = await response.text(); | |
| 93 | expect(body).toContain("<title>example — what a game costs on this engine</title>"); | |
| 94 | expect(body).toContain("<h1>example</h1>"); | |
| 95 | expect(body).toMatch(/<script type="module" src="\/static\/example\.[0-9a-f]+\.js">/); | |
| 96 | expect(body.match(/<script/g)?.length).toBe(1); | |
| 97 | // Neither other game's bundle, and neither other game's page. | |
| 98 | expect(body).not.toMatch(/src="\/static\/(app|range)\./); | |
| 99 | expect(body).not.toContain("Leaderboard"); | |
| 100 | expect(body).not.toContain("de_forge"); | |
| 101 | // No session for a page that stores nothing. | |
| 102 | expect(response.headers.get("set-cookie")).toBeNull(); | |
| 103 | }); | |
| 104 | ||
| 105 | test("the example page carries every id and action its entry reaches for", async () => { | |
| 106 | // src/example/main.ts finds these by getElementById and hands [data-action] | |
| 107 | // to the engine's controls; nothing type-checks that, so the page is checked | |
| 108 | // — against the same list the stub page in src/example/stage.test-helper.ts | |
| 109 | // is built from, so the two cannot drift. | |
| 110 | const body = await (await fetch(base + "/example")).text(); | |
| 111 | for (const id of EXAMPLE_IDS) expect(body).toContain(`id="${id}"`); | |
| 112 | for (const action of EXAMPLE_ACTIONS) expect(body).toContain(`data-action="${action}"`); | |
| 113 | // The other games' ids are not on this page: a shared shell is not a shared game. | |
| 114 | for (const id of ["menu", "pause", "buy", "stats", "play", "look"]) expect(body).not.toContain(`id="${id}"`); | |
| 115 | }); | |
| 116 | ||
| 88 | 117 | test("the two pages link to each other", async () => { |
| 89 | 118 | expect(await (await fetch(base + "/")).text()).toContain(`href="/range"`); |
| 90 | 119 | expect(await (await fetch(base + "/range")).text()).toContain(`href="/"`); |
src/server.ts +3
| @@ -26,6 +26,7 @@ import { openDatabase, type Player, saveMatch } from "./db.ts"; | ||
| 26 | 26 | import { GAMES } from "./games.ts"; |
| 27 | 27 | import { createLimiter } from "./limit.ts"; |
| 28 | 28 | import { listHtml } from "./pages/games.ts"; |
| 29 | import { examplePage } from "./pages/example.ts"; | |
| 29 | 30 | import { gamePage, statsHtml } from "./pages/game.ts"; |
| 30 | 31 | import { rangePage } from "./pages/range.ts"; |
| 31 | 32 | import { cleanName, parseResult } from "./result.ts"; |
| @@ -204,6 +205,8 @@ const RENDER: Record<string, Render> = { | ||
| 204 | 205 | // The range: its own page and its own bundle, and no session — it stores |
| 205 | 206 | // nothing, so it has nothing to name a browser for. |
| 206 | 207 | range: (_request, script) => html(rangePage({ env, pull, commit, script, ssh })), |
| 208 | // The example: the guide's game with a page around it, and no session either. | |
| 209 | example: (_request, script) => html(examplePage({ env, pull, commit, script, ssh })), | |
| 207 | 210 | }; |
| 208 | 211 | |
| 209 | 212 | // The read routes, resolved once at start-up: the path each game answers on, |
static/example.95c913d802.js added +1
Diff not shown — a line 8,748 characters wide (limit 2,000). +1
View file
new above
offline