Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes

The gate the leaderboard trusts is pinned: src/result.ts gets the test file it never had #171

Merged opened this

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

Merged

torque/cs-engine:torque/101-the-gate-the-leaderboard-trusts-is-pinne → main 3 commits fork

  • Merged. torque/101-the-gate-the-leaderboard-trusts-is-pinne was merged into main as d45c9c0.

Review

Sign in to review this change.
8 files changed +555 −14
README.md +12 −5
@@ -22,6 +22,7 @@ bun run scripts/build.ts # rebuild every static/<
2222 bun run scripts/train.ts --iters 1000 # retrain the bots' policy
2323 bun run scripts/train.ts --bench # how fast the arena steps
2424 bun test # the check every pull runs
25 bun run scripts/mutate-result.ts # break each rule in src/result.ts, see a test go red
2526 FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
2627 ```
2728
@@ -114,9 +115,10 @@ put an element full screen — an iPhone will not — never shows that button ra
114115 than showing one that does nothing.
115116
116117 Killed, you watch the rest of the round out of a teammate's eyes: fire moves
117 to the next player on your side and aim moves back one, the camera eases onto
118 whoever you picked instead of snapping around with their aim, and it stays on
119 them until they die or you ask for someone else. With your whole side down and
118 to the next player on your side and aim moves back one, the camera is *placed*
119 on whoever you picked — asking for someone else is a cut, never a flight across
120 the map — and from there it eases along with them instead of snapping around
121 with their aim, and it stays on them until they die or you ask for someone else. With your whole side down and
120122 the bomb still ticking it watches whoever is left alive rather than your own
121123 body. `src/client/spectate.ts` is that camera, and nothing else in the game
122124 reads it.
@@ -542,8 +544,13 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
542544 `src/range/pin.ts` had each written out in full until #110 put one copy behind
543545 the door; what stays here is the script, the fields and the 64 Hz.
544546 - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the
545 schema and its queries, `src/result.ts` validates what a browser may claim,
546 `src/who.ts` verifies the forge's signed identity headers, `src/limit.ts` is a
547 schema and its queries, `src/result.ts` validates what a browser may claim
548 — the scores add up to the rounds, a headshot is a kill, nobody dies more
549 often than there were rounds, and the clock is bounded by the rounds it is
550 spread over — with every one of those rules pinned in `src/result.test.ts`;
551 `bun run scripts/mutate-result.ts` breaks each line of it in turn and names
552 the test that catches it, so a rule nothing tests is visible rather than
553 assumed. `src/who.ts` verifies the forge's signed identity headers, `src/limit.ts` is a
547554 token bucket per caller (the clock is a parameter, so its test does not wait),
548555 and `src/bind.ts` decides the socket: `PORT` and `HOST` are the forge's, but
549556 `HOST` is also what a shell sets to the machine's name, so only an address —
scripts/mutate-result.ts added +108
@@ -0,0 +1,108 @@
1 /**
2 * A test that stays green when the rule it tests is deleted is not a test.
3 * This runs `src/result.test.ts` against a copy of `src/result.ts` with one
4 * rule broken at a time and reports which test caught it. Every mutant must
5 * die; a survivor is a rule nothing pins.
6 *
7 * bun run scripts/mutate-result.ts
8 *
9 * It is not part of `bun test`: it spawns one test run per mutant, which takes
10 * seconds rather than milliseconds. Run it when you change `src/result.ts`.
11 */
12
13 import { copyFileSync, unlinkSync, writeFileSync } from "node:fs";
14
15 const SOURCE = "src/result.ts";
16 const BACKUP = "src/result.ts.original";
17
18 /**
19 * Each mutant: a name, the text to find, and what to put in its place.
20 * `redundant` marks a line no test can kill because another rule already
21 * refuses everything it would — defence in depth rather than behaviour. Those
22 * are expected to survive, and a mutant that stops matching its note (dies
23 * when it should survive, or the reverse) fails this run.
24 */
25 const MUTANTS: { rule: string; find: string; replace: string; redundant?: string }[] = [
26 { rule: "a body must be an object", find: `if (typeof value !== "object" || value === null) return null;`, replace: "" },
27 { rule: "matchId must be a string", find: `typeof body.matchId === "string" ? body.matchId : ""`, replace: "String(body.matchId ?? \"\")" },
28 { rule: "matchId must match the regex", find: "if (!/^[0-9a-fA-F-]{8,64}$/.test(matchId)) return null;", replace: "" },
29 { rule: "a number must be a number", find: `if (typeof value !== "number" || !Number.isFinite(value)) return null;`, replace: "" },
30 { rule: "a number must be finite", find: "!Number.isFinite(value)", replace: "false" },
31 { rule: "a float is floored", find: "const n = Math.floor(value);", replace: "const n = value;" },
32 { rule: "negative zero is refused", find: "if (Object.is(n, -0)) return null;", replace: "" },
33 { rule: "a negative is refused", find: "n < 0 || n > max", replace: "n > max" },
34 { rule: "a number over its ceiling is refused", find: "n < 0 || n > max", replace: "n < 0" },
35 { rule: "the rounds ceiling", find: "const rounds = integer(body.rounds, MAX_ROUNDS);", replace: "const rounds = integer(body.rounds, Infinity);" },
36 {
37 rule: "the score ceilings", find: "const scoreMine = integer(body.scoreMine, MAX_ROUNDS);",
38 replace: "const scoreMine = integer(body.scoreMine, Infinity);",
39 redundant: "both scores are non-negative and sum to rounds, so neither can pass 30 anyway",
40 },
41 {
42 rule: "the kills ceiling", find: "const kills = integer(body.kills, MAX_ROUNDS * KILLS_PER_ROUND);",
43 replace: "const kills = integer(body.kills, Infinity);",
44 redundant: "kills > rounds * 5 refuses everything over 150 already",
45 },
46 {
47 rule: "the deaths ceiling", find: "const deaths = integer(body.deaths, MAX_ROUNDS);",
48 replace: "const deaths = integer(body.deaths, Infinity);",
49 redundant: "deaths > rounds refuses everything over 30 already",
50 },
51 {
52 rule: "the headshots ceiling", find: "const headshots = integer(body.headshots, MAX_ROUNDS * KILLS_PER_ROUND);",
53 replace: "const headshots = integer(body.headshots, Infinity);",
54 redundant: "headshots > kills refuses everything over 150 already",
55 },
56 { rule: "the damage ceiling", find: "const damage = integer(body.damage, MAX_ROUNDS * KILLS_PER_ROUND * 200);", replace: "const damage = integer(body.damage, Infinity);" },
57 { rule: "the seconds ceiling", find: "const seconds = integer(body.seconds, MAX_ROUNDS * SECONDS_PER_ROUND);", replace: "const seconds = integer(body.seconds, Infinity);" },
58 { rule: "a missing number is not a zero", find: "deaths === null || headshots === null || damage === null || seconds === null", replace: "false" },
59 { rule: "the scores are the rounds", find: "if (scoreMine + scoreTheirs !== rounds) return null;", replace: "" },
60 { rule: "headshots are kills", find: "if (headshots > kills) return null;", replace: "" },
61 { rule: "deaths are bounded by rounds", find: "if (deaths > rounds) return null;", replace: "" },
62 { rule: "kills are bounded by the enemies there were", find: "if (kills > rounds * KILLS_PER_ROUND) return null;", replace: "" },
63 { rule: "the clock is bounded by the rounds", find: "if (seconds > (rounds + 1) * SECONDS_PER_ROUND) return null;", replace: "" },
64 { rule: "finished is exactly true", find: "finished: body.finished === true,", replace: "finished: !!body.finished," },
65 { rule: "a name must be a string", find: `if (typeof value !== "string") return "anon";`, replace: `if (typeof value !== "string") return String(value);` },
66 { rule: "control characters become spaces", find: `.replace(/[\\u0000-\\u001f\\u007f]/g, " ")`, replace: "" },
67 { rule: "a name is trimmed", find: ".trim().slice(0, 24)", replace: ".slice(0, 24)" },
68 { rule: "a name is cut to 24", find: ".slice(0, 24)", replace: "" },
69 { rule: "an empty name is anon", find: `return stripped.length === 0 ? "anon" : stripped;`, replace: "return stripped;" },
70 { rule: "a win is finished", find: "result.finished && result.scoreMine > result.scoreTheirs", replace: "result.scoreMine > result.scoreTheirs" },
71 { rule: "a draw is not a win", find: "result.scoreMine > result.scoreTheirs", replace: "result.scoreMine >= result.scoreTheirs" },
72 ];
73
74 const original = await Bun.file(SOURCE).text();
75 copyFileSync(SOURCE, BACKUP);
76 let wrong = 0;
77
78 try {
79 for (const { rule, find, replace, redundant } of MUTANTS) {
80 if (!original.includes(find)) {
81 console.log(`? ${rule}: no longer in ${SOURCE} — the mutant needs rewriting`);
82 wrong++;
83 continue;
84 }
85 writeFileSync(SOURCE, original.replace(find, replace));
86 const run = Bun.spawnSync(["bun", "test", "src/result.test.ts"], { stderr: "pipe", stdout: "pipe" });
87 const log = run.stderr.toString() + run.stdout.toString();
88 const killers = [...log.matchAll(/\(fail\) (.*?)(?: \[[\d.]+m?s\])?$/gm)].map((m) => m[1]?.trim());
89 const died = run.exitCode !== 0;
90 if (died) console.log(`killed ${rule}\n by: ${killers.join("\n by: ")}`);
91 else console.log(`survived ${rule}${redundant === undefined ? "" : `\n expected: ${redundant}`}`);
92 if (died === (redundant !== undefined)) {
93 console.log(died
94 ? ` UNEXPECTED: a line called redundant is not: a test can tell the difference`
95 : ` UNEXPECTED: nothing catches this line being deleted`);
96 wrong++;
97 }
98 }
99 } finally {
100 copyFileSync(BACKUP, SOURCE);
101 unlinkSync(BACKUP);
102 }
103
104 const expected = MUTANTS.filter((m) => m.redundant !== undefined).length;
105 console.log(wrong === 0
106 ? `\nall ${MUTANTS.length - expected} mutants died; the ${expected} noted as redundant survived, as noted`
107 : `\n${wrong} of ${MUTANTS.length} mutants did not do what the list says`);
108 process.exit(wrong === 0 ? 0 : 1);
src/client/spectate.test.ts +37
@@ -156,6 +156,43 @@ test("a change of target is a cut: the camera is placed, not flown", () => {
156156 expect(cam.yaw).toBe(seat(w, 7).yaw);
157157 });
158158
159 test("the cut is the change of target, not the distance between the two bodies", () => {
160 // Spawns are 256 units apart, so the test above is also a teleport by
161 // `CUT_DISTANCE` and would pass with the cut on a change of target gone.
162 // Two teammates down the same corridor are not: this is the case that told
163 // the difference, and it was a fly-through across the room until `cycle`
164 // asked for the cut it promises.
165 const w = world();
166 onlyAlive(w, 6, 7);
167 const near = seat(w, 7);
168 const from = seat(w, 6);
169 near.pos[0] = from.pos[0] + 60;
170 near.pos[1] = from.pos[1];
171 near.pos[2] = from.pos[2];
172 expect(Math.hypot(near.pos[0] - from.pos[0], near.pos[1] - from.pos[1])).toBeLessThan(CUT_DISTANCE);
173
174 const spectator = createSpectator();
175 const cam = camera();
176 follow(w, spectator, cam, FRAME);
177 expect(cycle(w, spectator, 1)).toBe(near);
178 follow(w, spectator, cam, FRAME);
179 expect(cam.pos).toEqual(eyeOf(near));
180 });
181
182 test("cycling to the only player there is does not snap the ease already running", () => {
183 // One teammate left and the trigger pressed anyway: nothing changed, so the
184 // camera keeps closing the gap it was closing rather than jumping the rest.
185 const w = world();
186 onlyAlive(w, 6);
187 const { spectator, cam } = watching(w, 6);
188 const target = seat(w, 6);
189 target.pos[0] += 40;
190 expect(cycle(w, spectator, 1)).toBe(target);
191 follow(w, spectator, cam, FRAME);
192 expect(cam.pos[0]).toBeLessThan(target.pos[0]);
193 expect(cam.pos[0]).toBeCloseTo(target.pos[0] - 40 * Math.exp(-FRAME / POS_TAU), 8);
194 });
195
159196 test("between cuts the camera eases towards the body, and gets there", () => {
160197 const w = world();
161198 onlyAlive(w, 6);
src/client/spectate.ts +7
@@ -123,6 +123,13 @@ export function cycle(world: World, spectator: Spectator, step: number): Player
123123 const anyone = !sideStands(world);
124124 const now = watched(world, spectator);
125125 const next = around(world, seatOf(world, now.id) + step, step, anyone) ?? now;
126 // Asking for somebody else is a cut, and it has to be asked for here:
127 // committing the target below is exactly what stops `follow` from noticing
128 // the change, and then the only thing left to catch it is the distance
129 // between the two bodies — a cut across the map and a 45 ms fly-through
130 // across a room. Cycling to the only player left to watch is not a change,
131 // and does not snap the ease that is already running.
132 if (next.id !== now.id) spectator.cut = true;
126133 spectator.target = next.id;
127134 return next;
128135 }
src/result.test.ts added +384
@@ -0,0 +1,384 @@
1 /**
2 * The gate the leaderboard trusts. Every number on the board is the browser's
3 * word, so each rule in `src/result.ts` is pinned here on its own: one test per
4 * line that can refuse, written so that deleting the line turns exactly that
5 * test red. The ceilings are spelled out as literals rather than imported —
6 * a test that reads its expectations out of the module under test agrees with
7 * the module whatever the module says.
8 *
9 * No server, no database, no fetch: this is arithmetic and it runs in
10 * milliseconds.
11 */
12
13 import { expect, test } from "bun:test";
14 import { cleanName, parseResult, won, type Result } from "./result.ts";
15
16 const MAX_ROUNDS = 30;
17 const MAX_KILLS = 150; // five enemies a round, thirty rounds
18 const MAX_DAMAGE = 30_000; // 150 kills at 200 hp
19 const SECONDS_PER_ROUND = 180;
20 const MAX_SECONDS = MAX_ROUNDS * SECONDS_PER_ROUND; // 5400
21
22 /** A body a real match produces: five rounds, 3–2, nothing near a ceiling. */
23 const body = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
24 matchId: "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0",
25 name: "tester",
26 rounds: 5, scoreMine: 3, scoreTheirs: 2,
27 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true,
28 ...over,
29 });
30
31 /** Parse a body that differs from a good one in the named ways. */
32 const parse = (over: Record<string, unknown> = {}): Result | null => parseResult(body(over));
33
34 const FIELDS = [
35 "matchId", "name", "rounds", "scoreMine", "scoreTheirs",
36 "kills", "deaths", "headshots", "damage", "seconds", "finished",
37 ];
38
39 test("a whole result comes back field for field, and nothing the body invented", () => {
40 const parsed = parseResult({ ...body({ name: " spaced " }), extra: "ignored", kills: 9 });
41 expect(parsed).toEqual({
42 matchId: "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0",
43 name: "spaced",
44 rounds: 5, scoreMine: 3, scoreTheirs: 2,
45 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true,
46 });
47 expect(Object.keys(parsed ?? {}).sort()).toEqual([...FIELDS].sort());
48 });
49
50 test("what is not an object is not a result", () => {
51 for (const value of [null, undefined, "no", 12, true, Symbol("s")]) {
52 expect(parseResult(value)).toBe(null);
53 }
54 // An array is an object, so it falls to the next gate rather than this one.
55 expect(parseResult([])).toBe(null);
56 });
57
58 // --- matchId, against the regex that is the whole of its shape
59
60 test("a matchId is 8 to 64 characters of hex and dashes, and nothing else", () => {
61 expect(parse({ matchId: "a".repeat(8) })?.matchId).toBe("a".repeat(8));
62 expect(parse({ matchId: "a".repeat(64) })?.matchId).toBe("a".repeat(64));
63 expect(parse({ matchId: "a".repeat(7) })).toBe(null);
64 expect(parse({ matchId: "a".repeat(65) })).toBe(null);
65 // The id the client actually mints.
66 const uuid = crypto.randomUUID();
67 expect(parse({ matchId: uuid })?.matchId).toBe(uuid);
68 expect(parse({ matchId: uuid.toUpperCase() })?.matchId).toBe(uuid.toUpperCase());
69 // Characters outside the class, at either end and in the middle.
70 for (const id of ["zzzzzzzz", "0123456g", "g0123456", "0123 456", "0123456_", "0123456\n"]) {
71 expect(parse({ matchId: id })).toBe(null);
72 }
73 // The regex is a shape, not a UUID parser: eight dashes are a legal id, and
74 // saying so here means nobody tightens it by accident and calls it a fix.
75 expect(parse({ matchId: "--------" })?.matchId).toBe("--------");
76 });
77
78 test("a matchId that is not a string is refused rather than stringified", () => {
79 for (const id of [undefined, null, 12345678, true, ["0f1e2d3c"], { toString: () => "0f1e2d3c" }]) {
80 expect(parse({ matchId: id })).toBe(null);
81 }
82 });
83
84 // --- the number parser, at every edge it has
85
86 test("a float is floored and a negative is refused", () => {
87 expect(parse({ kills: 9.9 })?.kills).toBe(9);
88 expect(parse({ kills: 9.000001 })?.kills).toBe(9);
89 expect(parse({ seconds: 419.5 })?.seconds).toBe(419);
90 expect(parse({ kills: -1 })).toBe(null);
91 expect(parse({ kills: -0.5 })).toBe(null); // floors to -1
92 expect(parse({ damage: -1 })).toBe(null);
93 expect(parse({ seconds: -10 })).toBe(null);
94 });
95
96 test("negative zero is not a count", () => {
97 // It equals zero and signs like a negative; a counted number is never it.
98 // Letting it through would put a -0 in the database and in every digest
99 // taken of one.
100 expect(parse({ kills: -0, headshots: 0 })).toBe(null);
101 expect(parse({ damage: -0 })).toBe(null);
102 expect(parse({ seconds: -0 })).toBe(null);
103 // Plain zero, in the same places, is fine.
104 expect(parse({ kills: 0, headshots: 0 })?.kills).toBe(0);
105 expect(Object.is(parse({ damage: 0 })?.damage, 0)).toBe(true);
106 });
107
108 test("each ceiling accepts its exact maximum and refuses one more", () => {
109 // Only three of the eight ceilings can refuse anything on their own —
110 // rounds, damage and seconds. The other five sit behind a rule that is
111 // already tighter (the scores sum to rounds, headshots are kills, deaths are
112 // rounds, kills are five a round), so raising them changes no answer.
113 // `bun run scripts/mutate-result.ts` proves that rather than assuming it:
114 // those are the only lines it expects to survive being broken, and it says
115 // which rule covers each. They stay as defence in depth; the maxima below
116 // hold either way.
117 //
118 // rounds: the scores are kept equal to it so only the ceiling can refuse.
119 expect(parse({ rounds: MAX_ROUNDS, scoreMine: 15, scoreTheirs: 15 })?.rounds).toBe(MAX_ROUNDS);
120 expect(parse({ rounds: MAX_ROUNDS + 1, scoreMine: 16, scoreTheirs: 15 })).toBe(null);
121 // A single score: thirty of them is a whole match won, and thirty-one is
122 // over the ceiling whatever the other score says.
123 expect(parse({ rounds: MAX_ROUNDS, scoreMine: MAX_ROUNDS, scoreTheirs: 0, deaths: 4 })?.scoreMine)
124 .toBe(MAX_ROUNDS);
125 expect(parse({ rounds: MAX_ROUNDS, scoreMine: MAX_ROUNDS + 1, scoreTheirs: 0 })).toBe(null);
126 expect(parse({ rounds: MAX_ROUNDS, scoreMine: 0, scoreTheirs: MAX_ROUNDS + 1 })).toBe(null);
127 // kills and headshots, at the only round count that can hold them
128 const full = { rounds: MAX_ROUNDS, scoreMine: 15, scoreTheirs: 15 };
129 expect(parse({ ...full, kills: MAX_KILLS, headshots: MAX_KILLS })?.kills).toBe(MAX_KILLS);
130 expect(parse({ ...full, kills: MAX_KILLS + 1 })).toBe(null);
131 expect(parse({ ...full, kills: MAX_KILLS, headshots: MAX_KILLS + 1 })).toBe(null);
132 // deaths
133 expect(parse({ ...full, deaths: MAX_ROUNDS })?.deaths).toBe(MAX_ROUNDS);
134 expect(parse({ ...full, deaths: MAX_ROUNDS + 1 })).toBe(null);
135 // damage, which no other rule bounds
136 expect(parse({ damage: MAX_DAMAGE })?.damage).toBe(MAX_DAMAGE);
137 expect(parse({ damage: MAX_DAMAGE + 1 })).toBe(null);
138 // seconds: at thirty rounds the clock rule would allow 5580, so 5401 can
139 // only be the ceiling refusing it.
140 expect(parse({ ...full, seconds: MAX_SECONDS })?.seconds).toBe(MAX_SECONDS);
141 expect(parse({ ...full, seconds: MAX_SECONDS + 1 })).toBe(null);
142 });
143
144 test("a number that is not a finite number is refused, and nothing is coerced into one", () => {
145 const notNumbers: unknown[] = [
146 NaN, Infinity, -Infinity, "12", "", " ", "1e3", null, undefined, true, false,
147 { valueOf: () => 12 }, [12], [], 12n, Symbol("12"), new Date(0), () => 12,
148 ];
149 for (const value of notNumbers) {
150 for (const field of ["rounds", "scoreMine", "scoreTheirs", "kills", "deaths", "headshots", "damage", "seconds"]) {
151 expect(parse({ [field]: value })).toBe(null);
152 }
153 }
154 // A missing field is a missing number, not a zero.
155 const { kills: _dropped, ...without } = body();
156 expect(parseResult(without)).toBe(null);
157 });
158
159 // --- the four rules the file exists for, one test each
160
161 test("the two scores are exactly the rounds played", () => {
162 expect(parse({ rounds: 5, scoreMine: 3, scoreTheirs: 2 })?.rounds).toBe(5);
163 expect(parse({ rounds: 5, scoreMine: 4, scoreTheirs: 4 })).toBe(null);
164 expect(parse({ rounds: 5, scoreMine: 2, scoreTheirs: 2 })).toBe(null);
165 expect(parse({ rounds: 0, scoreMine: 0, scoreTheirs: 0, kills: 0, deaths: 0, headshots: 0, seconds: 30 })?.rounds)
166 .toBe(0);
167 });
168
169 test("a headshot is a kill, so there are never more of them", () => {
170 expect(parse({ kills: 9, headshots: 9 })?.headshots).toBe(9);
171 expect(parse({ kills: 9, headshots: 10 })).toBe(null);
172 expect(parse({ kills: 3, headshots: 4 })).toBe(null);
173 expect(parse({ kills: 0, headshots: 1 })).toBe(null);
174 });
175
176 test("nobody dies more often than there were rounds", () => {
177 expect(parse({ rounds: 5, deaths: 5 })?.deaths).toBe(5);
178 expect(parse({ rounds: 5, deaths: 6 })).toBe(null);
179 expect(parse({ rounds: 0, scoreMine: 0, scoreTheirs: 0, kills: 0, headshots: 0, deaths: 1 })).toBe(null);
180 });
181
182 test("five enemies a round is all there are", () => {
183 expect(parse({ rounds: 5, kills: 25, headshots: 3 })?.kills).toBe(25);
184 expect(parse({ rounds: 5, kills: 26, headshots: 3 })).toBe(null);
185 expect(parse({ rounds: 1, scoreMine: 1, scoreTheirs: 0, deaths: 1, kills: 6, headshots: 0 })).toBe(null);
186 });
187
188 test("the clock is bounded by the rounds it is spread over, plus the one being played", () => {
189 // Five rounds hold six rounds' worth of clock: the round in progress is not
190 // scored yet, so one round is always spare.
191 expect(parse({ rounds: 5, seconds: 6 * SECONDS_PER_ROUND })?.seconds).toBe(1080);
192 expect(parse({ rounds: 5, seconds: 6 * SECONDS_PER_ROUND + 1 })).toBe(null);
193 // The claim this rule exists to refuse: a three-round match with two hours
194 // on it, sitting at the top of anything sorted by time.
195 expect(parse({ rounds: 1, scoreMine: 1, scoreTheirs: 0, kills: 2, deaths: 1, headshots: 0, seconds: 7200 }))
196 .toBe(null);
197 expect(parse({ rounds: 0, scoreMine: 0, scoreTheirs: 0, kills: 0, deaths: 0, headshots: 0, seconds: 181 }))
198 .toBe(null);
199 });
200
201 test("finished is the word `true` and nothing that merely looks like it", () => {
202 expect(parse({ finished: true })?.finished).toBe(true);
203 for (const value of ["true", 1, "yes", {}, [], undefined, null, 0, false]) {
204 expect(parse({ finished: value })?.finished).toBe(false);
205 }
206 });
207
208 // --- names
209
210 test("a name is one line of printable text, trimmed, and at most 24 characters", () => {
211 expect(cleanName("vesper")).toBe("vesper");
212 expect(cleanName(" vesper ")).toBe("vesper");
213 expect(cleanName("x".repeat(40))).toBe("x".repeat(24));
214 expect(cleanName("x".repeat(24)).length).toBe(24);
215 });
216
217 test("control characters become spaces rather than disappearing or staying", () => {
218 expect(cleanName("a\u0000b")).toBe("a b");
219 expect(cleanName("one\ntwo")).toBe("one two");
220 expect(cleanName("tab\there")).toBe("tab here");
221 expect(cleanName("del\u007fete")).toBe("del ete");
222 expect(cleanName("\u001bsgr")).toBe("sgr"); // an escape at the edge is trimmed away
223 // A name made only of control characters is a name made only of spaces.
224 expect(cleanName("\u0000\u0001\u001f")).toBe("anon");
225 });
226
227 test("an empty name, a blank one, or one that is not a string is anon", () => {
228 expect(cleanName("")).toBe("anon");
229 expect(cleanName(" ")).toBe("anon");
230 expect(cleanName("\t\n")).toBe("anon");
231 for (const value of [undefined, null, 42, true, {}, [], ["a"], { toString: () => "x" }, Symbol("s")]) {
232 expect(cleanName(value)).toBe("anon");
233 }
234 // And through the parser, where a name is never a reason to refuse a match.
235 expect(parse({ name: 42 })?.name).toBe("anon");
236 expect(parse({ name: " vesper " })?.name).toBe("vesper");
237 });
238
239 test("the cut is by code unit, so an emoji at the boundary is cut in half", () => {
240 // This is what the implementation does, said out loud rather than left to be
241 // discovered: `slice(0, 24)` counts UTF-16 code units, so a surrogate pair
242 // straddling 24 loses its tail. The half that survives is a lone surrogate.
243 const cut = cleanName("x".repeat(23) + "\u{1F600}");
244 expect(cut.length).toBe(24);
245 expect(cut).toBe("x".repeat(23) + "\ud83d");
246 // It is safe downstream: JSON.stringify escapes a lone surrogate rather than
247 // emitting invalid UTF-8, and the page escapes every name it prints.
248 expect(JSON.stringify(cut)).toBe(`"${"x".repeat(23)}\\ud83d"`);
249 // An emoji that fits is whole, and one just inside the cut survives.
250 expect(cleanName("\u{1F600} vesper")).toBe("\u{1F600} vesper");
251 expect(cleanName("x".repeat(22) + "\u{1F600}")).toBe("x".repeat(22) + "\u{1F600}");
252 // A combining mark past the cut takes only itself: the base letter stays.
253 // Both "é" here are written decomposed — "e" and U+0301 combining acute —
254 // so the mark is the 25th code unit in the first and the 24th in the second.
255 expect(cleanName("y".repeat(23) + "é")).toBe("y".repeat(23) + "e");
256 expect(cleanName("y".repeat(22) + "é")).toBe("y".repeat(22) + "é");
257 // The cut happens after the trim, so a name can end in a space — the first
258 // character is never one.
259 expect(cleanName("x".repeat(23) + " y")).toBe("x".repeat(23) + " ");
260 });
261
262 // --- won
263
264 test("a win is finished and ahead, and the other three ways are not", () => {
265 const of = (finished: boolean, scoreMine: number, scoreTheirs: number): Result | null =>
266 parse({ finished, scoreMine, scoreTheirs, rounds: scoreMine + scoreTheirs, deaths: 0, kills: 0, headshots: 0 });
267 const ahead = of(true, 16, 14);
268 const behind = of(true, 14, 16);
269 expect(won(ahead as Result)).toBe(true);
270 expect(won(behind as Result)).toBe(false);
271 expect(won(of(false, 16, 14) as Result)).toBe(false);
272 expect(won(of(false, 14, 16) as Result)).toBe(false);
273 // A draw is not a win, finished or not.
274 expect(won(of(true, 15, 15) as Result)).toBe(false);
275 expect(won(of(false, 15, 15) as Result)).toBe(false);
276 });
277
278 // --- a deterministic fuzz
279 //
280 // A seeded xorshift32, twelve lines of it, so the same bodies are tried on
281 // every machine and a failure can be replayed from its seed. The engine has a
282 // generator of its own; the server does not import the engine and neither does
283 // this test.
284
285 const rng = (seed: number): (() => number) => {
286 let x = seed >>> 0 || 0x9e3779b9;
287 return () => {
288 x ^= x << 13; x >>>= 0;
289 x ^= x >>> 17;
290 x ^= x << 5; x >>>= 0;
291 return x / 0x1_0000_0000;
292 };
293 };
294
295 /**
296 * Every invariant the board is allowed to believe, checked against the result
297 * alone — not against the code that made it. Returns the first rule broken.
298 */
299 function violation(r: Result): string | null {
300 const counts: [string, number, number][] = [
301 ["rounds", r.rounds, MAX_ROUNDS],
302 ["scoreMine", r.scoreMine, MAX_ROUNDS],
303 ["scoreTheirs", r.scoreTheirs, MAX_ROUNDS],
304 ["kills", r.kills, MAX_KILLS],
305 ["deaths", r.deaths, MAX_ROUNDS],
306 ["headshots", r.headshots, MAX_KILLS],
307 ["damage", r.damage, MAX_DAMAGE],
308 ["seconds", r.seconds, MAX_SECONDS],
309 ];
310 for (const [field, value, max] of counts) {
311 if (!Number.isInteger(value)) return `${field} is not an integer: ${String(value)}`;
312 if (value < 0 || Object.is(value, -0)) return `${field} is negative: ${String(value)}`;
313 if (value > max) return `${field} is over ${max}: ${value}`;
314 }
315 if (r.scoreMine + r.scoreTheirs !== r.rounds) return "the scores are not the rounds";
316 if (r.headshots > r.kills) return "more headshots than kills";
317 if (r.deaths > r.rounds) return "more deaths than rounds";
318 if (r.kills > r.rounds * 5) return "more kills than there were enemies";
319 if (r.seconds > (r.rounds + 1) * SECONDS_PER_ROUND) return "more clock than rounds to hold it";
320 if (typeof r.matchId !== "string" || !/^[0-9a-fA-F-]{8,64}$/.test(r.matchId)) return "matchId is not an id";
321 if (typeof r.name !== "string") return "name is not a string";
322 if (r.name.length === 0 || r.name.length > 24) return `name is ${r.name.length} characters`;
323 if (/[\u0000-\u001f\u007f]/.test(r.name)) return "name carries a control character";
324 if (/^\s/.test(r.name)) return "name starts with whitespace";
325 if (typeof r.finished !== "boolean") return "finished is not a boolean";
326 if (Object.keys(r).length !== FIELDS.length) return `result has ${Object.keys(r).length} fields`;
327 return null;
328 }
329
330 test("a few thousand mutated bodies: never a throw, and never a number the board cannot believe", () => {
331 const random = rng(20260920);
332 const pick = <T,>(items: T[]): T => items[Math.floor(random() * items.length)] as T;
333 // Values a body might carry: plausible counts, edges, and things that are
334 // not numbers at all. JSON.parse can produce most of them; the ones it
335 // cannot are here because `parseResult` is a function anyone may call.
336 const values: unknown[] = [
337 0, -0, 1, -1, 2, 3, 5, 6, 15, 16, 24, 25, 26, 29, 30, 31, 149, 150, 151,
338 180, 420, 1080, 1081, 5400, 5401, 7200, 30_000, 30_001, 2 ** 31, Number.MAX_SAFE_INTEGER,
339 0.5, 4.9, -0.5, 29.999, NaN, Infinity, -Infinity,
340 "", " ", "5", "anon", " vesper ", "x".repeat(200), "a\u0000b", "\u{1F600}".repeat(20),
341 true, false, null, undefined, {}, [], [5], { valueOf: () => 5 }, 5n,
342 "0f1e2d3c", "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0", "z".repeat(16), "-".repeat(64), "a".repeat(65),
343 ];
344 const keys = [...FIELDS, "extra", "__proto__x", "toString"];
345
346 let parsed = 0;
347 for (let i = 0; i < 4000; i++) {
348 const mutant = body();
349 const edits = 1 + Math.floor(random() * 4);
350 for (let e = 0; e < edits; e++) {
351 const key = pick(keys);
352 if (random() < 0.15) delete mutant[key];
353 else mutant[key] = pick(values);
354 }
355 let result: Result | null;
356 try {
357 result = parseResult(mutant);
358 } catch (error) {
359 throw new Error(`parseResult threw on ${JSON.stringify(mutant)}: ${String(error)}`);
360 }
361 if (result === null) continue;
362 parsed++;
363 const broken = violation(result);
364 if (broken !== null) {
365 throw new Error(`${broken} — from ${JSON.stringify(mutant)} came ${JSON.stringify(result)}`);
366 }
367 }
368 // The fuzz is only worth its seconds if it gets past the gate sometimes: a
369 // mutation that refused everything would pass this test without testing it.
370 expect(parsed).toBeGreaterThan(200);
371 });
372
373 test("the bodies the client actually sends are accepted, round by round", () => {
374 // src/client/main.ts posts after every round scored, including round zero.
375 for (let round = 0; round <= MAX_ROUNDS; round++) {
376 const mine = Math.ceil(round / 2);
377 const result = parse({
378 rounds: round, scoreMine: mine, scoreTheirs: round - mine,
379 kills: round * 2, deaths: Math.floor(round / 2), headshots: round,
380 damage: round * 300, seconds: round * 115 + 12, finished: round === MAX_ROUNDS,
381 });
382 expect(violation(result as Result)).toBe(null);
383 }
384 });
src/result.ts +3
@@ -33,6 +33,9 @@ const SECONDS_PER_ROUND = 180;
3333 const integer = (value: unknown, max: number): number | null => {
3434 if (typeof value !== "number" || !Number.isFinite(value)) return null;
3535 const n = Math.floor(value);
36 // `-0 < 0` is false, so negative zero would otherwise pass as a count and be
37 // stored, sorted and digested as a number that is not quite zero.
38 if (Object.is(n, -0)) return null;
3639 return n < 0 || n > max ? null : n;
3740 };
3841
src/server.test.ts +3 −8
@@ -5,7 +5,6 @@ import { join } from "node:path";
55 import { bundle, STATIC_DIR } from "../scripts/build.ts";
66 import { GAMES } from "./games.ts";
77 import { escape } from "./pages/shell.ts";
8 import { parseResult } from "./result.ts";
98 import { sign } from "./who.ts";
109 import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts";
1110 import { EXAMPLE_ACTIONS, EXAMPLE_IDS } from "./example/stage.test-helper.ts";
@@ -447,13 +446,9 @@ test("the script is served immutable and cached for a year", async () => {
447446 expect(response.headers.get("cache-control")).toContain("immutable");
448447 });
449448
450 test("a result parses to exactly what was sent, and nothing else", () => {
451 const parsed = parseResult({ ...result({ name: " spaced " }), extra: "ignored" });
452 expect(parsed?.name).toBe("spaced");
453 expect(Object.keys(parsed ?? {})).not.toContain("extra");
454 expect(parseResult(null)).toBe(null);
455 expect(parseResult("no")).toBe(null);
456 });
449 // The parser itself is pinned rule by rule in src/result.test.ts, which is a
450 // unit test and runs in milliseconds. What is left here is the end-to-end: a
451 // POST that reaches it, and the status a refusal comes back as.
457452
458453 // Last in the file on purpose: it spends the bucket every guest on this
459454 // address shares, so any guest POST after it would be answered with a 429.
static/app.2047dd3508.js → static/app.e248e1964e.js renamed +1 −1

Diff not shown — 133,689 bytes of diff (limit 100,000). +1 −1

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs