Public: anyone can read this; the crew can change it.
forked from trevato/cs
One list of what a player can do: the touch pads and the bindings table are held together by a test #270
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/205-one-list-of-what-a-player-can-do-the-tou → main 1 commit fork
3 files
changed +304
−1
README.md +22
| @@ -1077,6 +1077,28 @@ never checked itself. | ||
| 1077 | 1077 | `src/client/stage.test-helper.ts`, the stub page a test boots on is built from |
| 1078 | 1078 | that list and `src/server.test.ts` pins the served page against the same one, |
| 1079 | 1079 | so the page the game is tested on and the page it is served on cannot drift. |
| 1080 | - `src/pages/actions.test.ts` — **one list of what a player can do, per game**, | |
| 1081 | and the test that holds the two halves of it together. A game's actions are | |
| 1082 | written down twice — as the bindings table it hands the engine's controls | |
| 1083 | (`src/client/binding.ts`, `src/range/binding.ts`, `BINDINGS` in | |
| 1084 | `src/example/main.ts`) and as the `data-action` buttons its page renders — and | |
| 1085 | until this file existed nothing made them agree. Now every action a page | |
| 1086 | offers a thumb has to be an action that game binds, and every action a game | |
| 1087 | binds has to be reachable from that page or written down in `ELSEWHERE` with | |
| 1088 | the reason it needs no button: the analogue thumb stick that covers the | |
| 1089 | movement keys and the walk, the ⛶ button the shell renders for full screen, | |
| 1090 | Tab's hold-to-peek scoreboard against a HUD that draws the score anyway — and, | |
| 1091 | said plainly rather than hidden, the ones a phone genuinely cannot reach | |
| 1092 | today, which are cs's scope, its three weapon slots and its pause. A line | |
| 1093 | there that has stopped being true fails as loudly as a missing button. Both | |
| 1094 | sides are read from their own sources — the rendered document, scraped for | |
| 1095 | `data-action`, and `Object.values` of the tables — so there is no third copy | |
| 1096 | of the list, and a failure names the action and which side is missing it | |
| 1097 | (`app: "molotov" is bound to a key and no button reaches it — the bindings are | |
| 1098 | ahead of the page`). It covers all three games and checks itself against | |
| 1099 | `src/games.ts`, so **the fourth game has to be one entry here**. It sits with | |
| 1100 | the pages because it is the app's question, not a game's: pages belong to no | |
| 1101 | game, so reading all three costs no import anywhere else. | |
| 1080 | 1102 | - `src/pages/miss.ts` — **the two pages no game owns**, through that same |
| 1081 | 1103 | shell: the 404 a wrong path lands on and the 500 a bug does. Both are whole |
| 1082 | 1104 | documents, dark and phone-sized like every game page, with the header's nav, |
src/example/stage.test-helper.ts +5 −1
| @@ -19,7 +19,11 @@ | ||
| 19 | 19 | * rather than behind the door for the reason the range's does — a page that |
| 20 | 20 | * knew what a renderer was would be this game's page wearing the engine's name. |
| 21 | 21 | * |
| 22 | * It ships in no bundle: only `boot.test.ts` and `bundle.test.ts` import it. | |
| 22 | * It ships in no bundle, and only tests import it: `boot.test.ts`, | |
| 23 | * `bundle.test.ts`, `src/server.test.ts` for the ids and actions the served | |
| 24 | * page must carry, and `src/pages/actions.test.ts`, which installs it for one | |
| 25 | * line — importing the entry into a page that is still loading is how that test | |
| 26 | * reads `BINDINGS` without booting anything. | |
| 23 | 27 | */ |
| 24 | 28 | |
| 25 | 29 | import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts"; |
src/pages/actions.test.ts added +277
| @@ -0,0 +1,277 @@ | ||
| 1 | /** | |
| 2 | * One list of what a player can do, per game, and the test that holds it | |
| 3 | * together. | |
| 4 | * | |
| 5 | * Every game writes its actions down twice. Once as a bindings table — the | |
| 6 | * `KeyboardEvent.code` to action map each game hands the engine's controls, | |
| 7 | * plus its mouse table — and once as the `data-action` buttons its page | |
| 8 | * renders, which is the only way a thumb presses anything. Nothing but care | |
| 9 | * made the two agree: when the grenades and the flash landed, both lists gained | |
| 10 | * them. The next action that arrives with a key and no button is a feature that | |
| 11 | * silently does not exist on a phone, and the next label that outlives its | |
| 12 | * action is a button that does nothing. | |
| 13 | * | |
| 14 | * So the rule, both ways round: | |
| 15 | * | |
| 16 | * - every action a page offers a thumb is an action that game's table binds; | |
| 17 | * - every action a table binds is reachable from that page, or is written down | |
| 18 | * in `ELSEWHERE` below with the reason it needs no button — and a line there | |
| 19 | * that has stopped being true fails as loudly as a missing button. | |
| 20 | * | |
| 21 | * Both sides are read from their own sources and there is no third copy of the | |
| 22 | * list: the page side is the rendered document, scraped for `data-action`, and | |
| 23 | * the key side is `Object.values` of the tables themselves. The only action | |
| 24 | * names typed in this file are the ones in `ELSEWHERE`, which is the part that | |
| 25 | * is meant to be argued over, and the fixtures at the bottom that prove the | |
| 26 | * rule bites. | |
| 27 | * | |
| 28 | * It lives beside the pages because it is the app's question rather than a | |
| 29 | * game's: `src/independence.test.ts` refuses a game that imports another game, | |
| 30 | * and `src/pages/` is the chrome every game is served inside — `./range.ts` | |
| 31 | * already reads `src/range/targets.ts` to print the drills. No game imports | |
| 32 | * anything new for this file to exist, and a fourth game is one entry below. | |
| 33 | */ | |
| 34 | ||
| 35 | import { expect, test } from "bun:test"; | |
| 36 | import { mkdtempSync } from "node:fs"; | |
| 37 | import { tmpdir } from "node:os"; | |
| 38 | import { join } from "node:path"; | |
| 39 | import { CS_KEYS, CS_MOUSE } from "../client/binding.ts"; | |
| 40 | import type { Cost } from "../cost.ts"; | |
| 41 | import { openDatabase } from "../db.ts"; | |
| 42 | import { page as headless } from "../example/stage.test-helper.ts"; | |
| 43 | import { GAMES } from "../games.ts"; | |
| 44 | import { RANGE_KEYS, RANGE_MOUSE } from "../range/binding.ts"; | |
| 45 | import { examplePage } from "./example.ts"; | |
| 46 | import { gamePage } from "./game.ts"; | |
| 47 | import { rangePage } from "./range.ts"; | |
| 48 | ||
| 49 | // The example's table is not a module of its own: it is the game's `KEYS` plus | |
| 50 | // the two keys its entry adds, and the entry boots itself the moment it is | |
| 51 | // imported. So it is imported into a page that is still loading, exactly as | |
| 52 | // `src/example/boot.test.ts` does — nothing boots, the listener it leaves | |
| 53 | // behind is never fired, and the table is readable. | |
| 54 | const loading = headless(); | |
| 55 | loading.document.readyState = "loading"; | |
| 56 | const undoLoading = loading.install(); | |
| 57 | const { BINDINGS: EXAMPLE_KEYS } = await import("../example/main.ts"); | |
| 58 | undoLoading(); | |
| 59 | ||
| 60 | /** One reason, and the actions it covers: a line of the written-down list. */ | |
| 61 | type OutOfReach = { | |
| 62 | /** Why no button reaches these actions — how they are reached instead, or that they are not. */ | |
| 63 | why: string; | |
| 64 | actions: readonly string[]; | |
| 65 | }; | |
| 66 | ||
| 67 | /** A game as this test reads it: its page, its bindings and its exceptions. */ | |
| 68 | type Surface = { | |
| 69 | /** The name `src/games.ts` files it under, so every game is covered and named in a failure. */ | |
| 70 | game: string; | |
| 71 | /** The document a visitor is served, rendered. The page side is read out of this and nowhere else. */ | |
| 72 | html: string; | |
| 73 | /** Every action this game binds, `Object.values` of each table it hands the controls. */ | |
| 74 | bound: readonly string[]; | |
| 75 | /** The actions no button reaches, each with the reason beside it. */ | |
| 76 | elsewhere: readonly OutOfReach[]; | |
| 77 | }; | |
| 78 | ||
| 79 | /** | |
| 80 | * Every action a page offers a thumb: the `data-action` of every element in it, | |
| 81 | * deduplicated, in the order the document renders them. Pads and overlays | |
| 82 | * alike — `attachButtons` wires `[data-action]` wherever it is, so the | |
| 83 | * example's Start button is as much a way to press an action as a pad is. | |
| 84 | */ | |
| 85 | export function pageActions(html: string): string[] { | |
| 86 | const found = new Set<string>(); | |
| 87 | for (const match of html.matchAll(/ data-action="([^"]*)"/g)) { | |
| 88 | const action = match[1]; | |
| 89 | if (action !== undefined) found.add(action); | |
| 90 | } | |
| 91 | return [...found]; | |
| 92 | } | |
| 93 | ||
| 94 | /** | |
| 95 | * What the two lists disagree about: one sentence each, naming the action and | |
| 96 | * which side is missing it. Empty is a game whose page and whose table say the | |
| 97 | * same thing. | |
| 98 | */ | |
| 99 | export function disagreements(surface: Surface): string[] { | |
| 100 | const { game } = surface; | |
| 101 | const onPage = new Set(pageActions(surface.html)); | |
| 102 | const bound = new Set(surface.bound); | |
| 103 | const written = new Set<string>(); | |
| 104 | const offences: string[] = []; | |
| 105 | for (const line of surface.elsewhere) { | |
| 106 | if (line.why.trim() === "") | |
| 107 | offences.push(`${game}: ${line.actions.join(", ")} is written down as out of reach with no reason beside it`); | |
| 108 | for (const action of line.actions) { | |
| 109 | if (written.has(action)) offences.push(`${game}: "${action}" is written down as out of reach twice`); | |
| 110 | written.add(action); | |
| 111 | if (onPage.has(action)) | |
| 112 | offences.push(`${game}: "${action}" is written down as out of reach and the page has a button for it — delete the line`); | |
| 113 | else if (!bound.has(action)) | |
| 114 | offences.push(`${game}: "${action}" is written down as out of reach and nothing binds it — delete the line`); | |
| 115 | } | |
| 116 | } | |
| 117 | for (const action of [...onPage].sort()) | |
| 118 | if (!bound.has(action)) | |
| 119 | offences.push(`${game}: the page has a button for "${action}" and no table binds it — the page is ahead of the bindings`); | |
| 120 | for (const action of [...bound].sort()) | |
| 121 | if (!onPage.has(action) && !written.has(action)) | |
| 122 | offences.push(`${game}: "${action}" is bound to a key and no button reaches it — the bindings are ahead of the page`); | |
| 123 | return offences; | |
| 124 | } | |
| 125 | ||
| 126 | /** What the shell needs and this file does not care about: the environment. */ | |
| 127 | const SHELL = { | |
| 128 | env: "local", | |
| 129 | pull: undefined, | |
| 130 | commit: "abc1234", | |
| 131 | script: "/static/actions.0000000000.js", | |
| 132 | ssh: undefined, | |
| 133 | }; | |
| 134 | ||
| 135 | /** The numbers `/example` boasts are `./example.test.ts`'s business; what is read here is its buttons. */ | |
| 136 | const NO_COST: Cost = { script: { raw: 0, gzip: 0 }, shooter: { raw: 0, gzip: 0 }, game: 0, wiring: 0 }; | |
| 137 | ||
| 138 | const db = openDatabase(mkdtempSync(join(tmpdir(), "actions-"))); | |
| 139 | ||
| 140 | /** | |
| 141 | * The three games, each read from its own two sources. A fourth game is a | |
| 142 | * fourth entry here and nothing else — the rule, the messages and the check | |
| 143 | * that every game is covered are already written. | |
| 144 | */ | |
| 145 | const SURFACES: readonly Surface[] = [ | |
| 146 | { | |
| 147 | game: "app", | |
| 148 | html: gamePage(db, { ...SHELL, who: { role: "anon" } }), | |
| 149 | bound: [...Object.values(CS_KEYS), ...Object.values(CS_MOUSE)], | |
| 150 | elsewhere: [ | |
| 151 | { | |
| 152 | why: "the left of the stage is a thumb stick, and it is analogue: `wishSpeed` in " + | |
| 153 | "`src/engine/physics.ts` scales with how far it is pushed, and a stick pushed gently stays " + | |
| 154 | "under the speed a footstep is heard at — which is what walking is here, so it needs no " + | |
| 155 | "button of its own either.", | |
| 156 | actions: ["forward", "back", "left", "right", "walk"], | |
| 157 | }, | |
| 158 | { | |
| 159 | why: "the ⛶ button over the stage, which the shell renders for every game and the entry wires " + | |
| 160 | "by id rather than by `data-action`.", | |
| 161 | actions: ["fullscreen"], | |
| 162 | }, | |
| 163 | { | |
| 164 | why: "Tab is a hold-to-peek at the full board, and a thumb that is holding a button is not " + | |
| 165 | "playing. The HUD draws the round's score the whole match and the leaderboard is on the " + | |
| 166 | "page under the stage, so a phone is not missing the score.", | |
| 167 | actions: ["scoreboard"], | |
| 168 | }, | |
| 169 | { | |
| 170 | why: "a phone plays the weapon it bought: `src/client/economy.ts` equips a weapon the moment " + | |
| 171 | "it is bought, and Buy is on the pad. Three slot buttons beside the six already there is a " + | |
| 172 | "trade nobody has made.", | |
| 173 | actions: ["slot1", "slot2", "slot3"], | |
| 174 | }, | |
| 175 | { | |
| 176 | why: "the right mouse button, and the pad has no second trigger: a phone fires from the hip. " + | |
| 177 | "Dead, it steps the spectator back a player while the pad's fire steps forward, and forward " + | |
| 178 | "reaches every teammate, so spectating is whole.", | |
| 179 | actions: ["scope"], | |
| 180 | }, | |
| 181 | { | |
| 182 | why: "Esc, P, and the pointer going away all pause, and a touch screen has none of the three. " + | |
| 183 | "A pause button on the pad is the fix; this line is the record that nobody has put one there.", | |
| 184 | actions: ["pause"], | |
| 185 | }, | |
| 186 | ], | |
| 187 | }, | |
| 188 | { | |
| 189 | game: "range", | |
| 190 | html: rangePage(db, SHELL), | |
| 191 | bound: [...Object.values(RANGE_KEYS), ...Object.values(RANGE_MOUSE)], | |
| 192 | elsewhere: [ | |
| 193 | { | |
| 194 | why: "the same thumb stick, and the page says so in words above the board: on a touch screen " + | |
| 195 | "the left of the view is a stick and the right looks around.", | |
| 196 | actions: ["forward", "back", "left", "right"], | |
| 197 | }, | |
| 198 | { | |
| 199 | why: "the ⛶ button over the stage. The key is G rather than F because F is this game's trigger.", | |
| 200 | actions: ["fullscreen"], | |
| 201 | }, | |
| 202 | { | |
| 203 | why: "there is no magazine in the range: R is read into the intent and printed in the readout, " + | |
| 204 | "and nothing else consumes it, so a button for it would press nothing.", | |
| 205 | actions: ["reload"], | |
| 206 | }, | |
| 207 | ], | |
| 208 | }, | |
| 209 | { | |
| 210 | game: "example", | |
| 211 | html: examplePage({ ...SHELL, cost: NO_COST }), | |
| 212 | bound: Object.values(EXAMPLE_KEYS), | |
| 213 | elsewhere: [ | |
| 214 | { | |
| 215 | why: "the guide's game is deliberately one screen: its pads are the two things its own " + | |
| 216 | "instructions name, turning and walking forward. It reads no thumb stick at all, so " + | |
| 217 | "strafing and walking backwards are the keyboard's alone.", | |
| 218 | actions: ["back", "left", "right"], | |
| 219 | }, | |
| 220 | { why: "the ⛶ button over the stage, like the other two games.", actions: ["fullscreen"] }, | |
| 221 | ], | |
| 222 | }, | |
| 223 | ]; | |
| 224 | ||
| 225 | test("every game's page and its bindings table name the same actions", () => { | |
| 226 | expect(SURFACES.flatMap(disagreements)).toEqual([]); | |
| 227 | }); | |
| 228 | ||
| 229 | test("every game on this server is read by this test, and a fourth one will have to be", () => { | |
| 230 | expect(SURFACES.map((surface) => surface.game).sort()).toEqual(GAMES.map((game) => game.name).sort()); | |
| 231 | }); | |
| 232 | ||
| 233 | test("what a thumb can reach is read off the rendered document, pads and overlays alike", () => { | |
| 234 | const [cs, , example] = SURFACES as [Surface, Surface, Surface]; | |
| 235 | // A pad button of the shooter's, and the example's Start, which is in an | |
| 236 | // overlay and on no pad: both are `[data-action]`, so both are reachable. | |
| 237 | expect(pageActions(cs.html)).toContain("smoke"); | |
| 238 | expect(pageActions(example.html)).toContain("start"); | |
| 239 | expect(pageActions(`<p>no buttons here</p>`)).toEqual([]); | |
| 240 | expect(pageActions(`<b data-action="a"></b><b data-action="a"></b>`)).toEqual(["a"]); | |
| 241 | }); | |
| 242 | ||
| 243 | test("the rule bites both ways, and the message names the action and the side missing it", () => { | |
| 244 | expect( | |
| 245 | disagreements({ | |
| 246 | game: "fourth", | |
| 247 | html: `<button data-action="jump"></button><button data-action="nade"></button>`, | |
| 248 | bound: ["jump", "grenade", "scoreboard"], | |
| 249 | elsewhere: [], | |
| 250 | }), | |
| 251 | ).toEqual([ | |
| 252 | `fourth: the page has a button for "nade" and no table binds it — the page is ahead of the bindings`, | |
| 253 | `fourth: "grenade" is bound to a key and no button reaches it — the bindings are ahead of the page`, | |
| 254 | `fourth: "scoreboard" is bound to a key and no button reaches it — the bindings are ahead of the page`, | |
| 255 | ]); | |
| 256 | }); | |
| 257 | ||
| 258 | test("a written-down line that has stopped being true fails as loudly as a missing button", () => { | |
| 259 | expect( | |
| 260 | disagreements({ | |
| 261 | game: "fourth", | |
| 262 | html: `<button data-action="jump"></button>`, | |
| 263 | bound: ["jump", "scoreboard"], | |
| 264 | elsewhere: [ | |
| 265 | { why: "a thumb reaches it another way", actions: ["jump"] }, | |
| 266 | { why: "it was bound once", actions: ["ghost"] }, | |
| 267 | { why: " ", actions: ["scoreboard"] }, | |
| 268 | { why: "and again", actions: ["scoreboard"] }, | |
| 269 | ], | |
| 270 | }), | |
| 271 | ).toEqual([ | |
| 272 | `fourth: "jump" is written down as out of reach and the page has a button for it — delete the line`, | |
| 273 | `fourth: "ghost" is written down as out of reach and nothing binds it — delete the line`, | |
| 274 | `fourth: scoreboard is written down as out of reach with no reason beside it`, | |
| 275 | `fourth: "scoreboard" is written down as out of reach twice`, | |
| 276 | ]); | |
| 277 | }); |
new above
offline