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

forked from trevato/cs

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

Closed opened this
Closed without merging

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

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
5 files changed +506 −10
README.md +8 −2
@@ -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
@@ -531,8 +532,13 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
531532 `src/range/pin.ts` had each written out in full until #110 put one copy behind
532533 the door; what stays here is the script, the fields and the 64 Hz.
533534 - `src/server.ts` — the HTTP server and the security floor. `src/db.ts` is the
534 schema and its queries, `src/result.ts` validates what a browser may claim,
535 `src/who.ts` verifies the forge's signed identity headers, `src/limit.ts` is a
535 schema and its queries, `src/result.ts` validates what a browser may claim
536 — the scores add up to the rounds, a headshot is a kill, nobody dies more
537 often than there were rounds, and the clock is bounded by the rounds it is
538 spread over — with every one of those rules pinned in `src/result.test.ts`;
539 `bun run scripts/mutate-result.ts` breaks each line of it in turn and names
540 the test that catches it, so a rule nothing tests is visible rather than
541 assumed. `src/who.ts` verifies the forge's signed identity headers, `src/limit.ts` is a
536542 token bucket per caller (the clock is a parameter, so its test does not wait),
537543 and `src/bind.ts` decides the socket: `PORT` and `HOST` are the forge's, but
538544 `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/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.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs