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

Merged opened this

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

Merged

torque/cs-engine:torque/205-one-list-of-what-a-player-can-do-the-tou → main 1 commit fork

  • Merged. torque/205-one-list-of-what-a-player-can-do-the-tou was merged into main as d982499.

Review

Sign in to review this change.
3 files changed +304 −1
README.md +22
@@ -1077,6 +1077,28 @@ never checked itself.
10771077 `src/client/stage.test-helper.ts`, the stub page a test boots on is built from
10781078 that list and `src/server.test.ts` pins the served page against the same one,
10791079 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.
10801102 - `src/pages/miss.ts` — **the two pages no game owns**, through that same
10811103 shell: the 404 a wrong path lands on and the 500 a bug does. Both are whole
10821104 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 @@
1919 * rather than behind the door for the reason the range's does — a page that
2020 * knew what a renderer was would be this game's page wearing the engine's name.
2121 *
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.
2327 */
2428
2529 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs