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

forked from trevato/cs

Take updates from trevato/cs #286

Merged opened this in answer to

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

Merged

vesper/cs-engine:vesper/275-take-updates-from-trevato-cs → main 2 commits fork

  • Merged. vesper/275-take-updates-from-trevato-cs was merged into main as d97d85e.

Review

Sign in to review this change.
6 files changed +225 −39
.gitignore +4
@@ -4,3 +4,7 @@ static/
44 *.db
55 *.db-wal
66 *.db-shm
7 static/app.*.js
8 .checkdata/
9 .scout-tmp/
10 cookies*.txt
README.md +7 −3
@@ -28,7 +28,7 @@ bun run scripts/surface.ts --write 0.2.0 # record today's door as
2828 bun run scripts/cost.ts # what each group behind the door adds to a bundle
2929 bun run scripts/cost.ts --guide # the same, as the lines GUIDE.md carries
3030 bun test # the check every pull runs
31 bun run scripts/mutate-result.ts # break each rule in src/result.ts, see a test go red
31 bun run scripts/mutate-result.ts # break each rule in src/result.ts and src/gate.ts, see a test go red
3232 FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
3333 ```
3434
@@ -1057,9 +1057,13 @@ never checked itself.
10571057 through `src/mapname.ts`; a body that names no map at all is an older client
10581058 and played `de_forge`) — with every one of those rules pinned in
10591059 `src/result.test.ts`;
1060 `bun run scripts/mutate-result.ts` breaks each line of it in turn and names
1060 `bun run scripts/mutate-result.ts` breaks each line of that gate in turn —
1061 `src/result.ts`, and the counted number and printable name in `src/gate.ts`
1062 that every posted score goes through — and names
10611063 the test that catches it, so a rule nothing tests is visible rather than
1062 assumed. `src/matchid.ts` is the one place that says what a match id is — the shape
1064 assumed. A match's numbers only ever count up: posts arrive as it is played
1065 and a failed one is retried, so `saveMatch` raises each number rather than
1066 overwriting it, and a post that arrives late cannot undo a finished match. `src/matchid.ts` is the one place that says what a match id is — the shape
10631067 above, the `<match>--<nonce>` key one playing of a match is stored under, and
10641068 the match read back out of it — and the parser, the `?match=` query and the
10651069 page's field all ask it rather than each holding a regular expression;
scripts/mutate-result.ts +47 −30
@@ -1,40 +1,48 @@
11 /**
22 * 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.
3 * This runs `src/result.test.ts` against a copy of the gate a match comes in
4 * through, with one rule broken at a time, and reports which test caught it.
5 * Every mutant must die; a survivor is a rule nothing pins.
66 *
77 * bun run scripts/mutate-result.ts
88 *
9 * The gate is two files, not one: `src/result.ts` says what a match is, and
10 * `src/gate.ts` holds the two primitives every posted score is checked with —
11 * a counted number and a printable name — because the range posts scores too.
12 * A mutant names the file it breaks, defaulting to `src/result.ts`; both are
13 * put back afterwards whatever happens.
14 *
915 * 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`.
16 * seconds rather than milliseconds. Run it when you change either file.
1117 */
1218
1319 import { copyFileSync, unlinkSync, writeFileSync } from "node:fs";
1420
15 const SOURCE = "src/result.ts";
16 const BACKUP = "src/result.ts.original";
21 const RESULT = "src/result.ts";
22 const GATE = "src/gate.ts";
23 const backupOf = (file: string): string => `${file}.original`;
1724
1825 /**
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.
26 * Each mutant: a name, the text to find, and what to put in its place — in
27 * `file`, or in `src/result.ts` when it does not say. `redundant` marks a line
28 * no test can kill because another rule already refuses everything it would —
29 * defence in depth rather than behaviour. Those are expected to survive, and a
30 * mutant that stops matching its note (dies when it should survive, or the
31 * reverse) fails this run.
2432 */
25 const MUTANTS: { rule: string; find: string; replace: string; redundant?: string }[] = [
33 const MUTANTS: { rule: string; find: string; replace: string; file?: string; redundant?: string }[] = [
2634 { rule: "a body must be an object", find: `if (typeof value !== "object" || value === null) return null;`, replace: "" },
2735 // The id's own shape lives in `src/matchid.ts` now; what this file can still
2836 // break is asking it at all.
2937 { rule: "matchId goes through the one door", find: "if (!isMatchId(matchId)) return null;", replace: "" },
3038 { rule: "a map that is not a map is refused", find: "const map = body.map === undefined ? DEFAULT_MAP : playedOn(body.map);", replace: "const map = playedOn(body.map) ?? DEFAULT_MAP;" },
3139 { rule: "a refused map refuses the result", find: "if (map === null) return null;", replace: "" },
32 { rule: "a number must be a number", find: `if (typeof value !== "number" || !Number.isFinite(value)) return null;`, replace: "" },
33 { rule: "a number must be finite", find: "!Number.isFinite(value)", replace: "false" },
34 { rule: "a float is floored", find: "const n = Math.floor(value);", replace: "const n = value;" },
35 { rule: "negative zero is refused", find: "if (Object.is(n, -0)) return null;", replace: "" },
36 { rule: "a negative is refused", find: "n < 0 || n > max", replace: "n > max" },
37 { rule: "a number over its ceiling is refused", find: "n < 0 || n > max", replace: "n < 0" },
40 { rule: "a number must be a number", find: `if (typeof value !== "number" || !Number.isFinite(value)) return null;`, replace: "", file: GATE },
41 { rule: "a number must be finite", find: "!Number.isFinite(value)", replace: "false", file: GATE },
42 { rule: "a float is floored", find: "const n = Math.floor(value);", replace: "const n = value;", file: GATE },
43 { rule: "negative zero is refused", find: "if (Object.is(n, -0)) return null;", replace: "", file: GATE },
44 { rule: "a negative is refused", find: "n < 0 || n > max", replace: "n > max", file: GATE },
45 { rule: "a number over its ceiling is refused", find: "n < 0 || n > max", replace: "n < 0", file: GATE },
3846 { rule: "the rounds ceiling", find: "const rounds = integer(body.rounds, MAX_ROUNDS);", replace: "const rounds = integer(body.rounds, Infinity);" },
3947 {
4048 rule: "the score ceilings", find: "const scoreMine = integer(body.scoreMine, MAX_ROUNDS);",
@@ -65,27 +73,31 @@ const MUTANTS: { rule: string; find: string; replace: string; redundant?: string
6573 { rule: "kills are bounded by the enemies there were", find: "if (kills > rounds * KILLS_PER_ROUND) return null;", replace: "" },
6674 { rule: "the clock is bounded by the rounds", find: "if (seconds > (rounds + 1) * SECONDS_PER_ROUND) return null;", replace: "" },
6775 { rule: "finished is exactly true", find: "finished: body.finished === true,", replace: "finished: !!body.finished," },
68 { rule: "a name must be a string", find: `if (typeof value !== "string") return "anon";`, replace: `if (typeof value !== "string") return String(value);` },
69 { rule: "control characters become spaces", find: `.replace(/[\\u0000-\\u001f\\u007f]/g, " ")`, replace: "" },
70 { rule: "a name is trimmed", find: ".trim().slice(0, 24)", replace: ".slice(0, 24)" },
71 { rule: "a name is cut to 24", find: ".slice(0, 24)", replace: "" },
72 { rule: "an empty name is anon", find: `return stripped.length === 0 ? "anon" : stripped;`, replace: "return stripped;" },
76 { rule: "a name must be a string", find: `if (typeof value !== "string") return "anon";`, replace: `if (typeof value !== "string") return String(value);`, file: GATE },
77 { rule: "control characters become spaces", find: `.replace(/[\\u0000-\\u001f\\u007f]/g, " ")`, replace: "", file: GATE },
78 { rule: "a name is trimmed", find: ".trim().slice(0, 24)", replace: ".slice(0, 24)", file: GATE },
79 { rule: "a name is cut to 24", find: ".slice(0, 24)", replace: "", file: GATE },
80 { rule: "an empty name is anon", find: `return stripped.length === 0 ? "anon" : stripped;`, replace: "return stripped;", file: GATE },
7381 { rule: "a win is finished", find: "result.finished && result.scoreMine > result.scoreTheirs", replace: "result.scoreMine > result.scoreTheirs" },
7482 { rule: "a draw is not a win", find: "result.scoreMine > result.scoreTheirs", replace: "result.scoreMine >= result.scoreTheirs" },
7583 ];
7684
77 const original = await Bun.file(SOURCE).text();
78 copyFileSync(SOURCE, BACKUP);
85 const originals = new Map<string, string>();
86 for (const file of [RESULT, GATE]) {
87 originals.set(file, await Bun.file(file).text());
88 copyFileSync(file, backupOf(file));
89 }
7990 let wrong = 0;
8091
8192 try {
82 for (const { rule, find, replace, redundant } of MUTANTS) {
93 for (const { rule, find, replace, file = RESULT, redundant } of MUTANTS) {
94 const original = originals.get(file) as string;
8395 if (!original.includes(find)) {
84 console.log(`? ${rule}: no longer in ${SOURCE} — the mutant needs rewriting`);
96 console.log(`? ${rule}: no longer in ${file} — the mutant needs rewriting`);
8597 wrong++;
8698 continue;
8799 }
88 writeFileSync(SOURCE, original.replace(find, replace));
100 writeFileSync(file, original.replace(find, replace));
89101 const run = Bun.spawnSync(["bun", "test", "src/result.test.ts"], { stderr: "pipe", stdout: "pipe" });
90102 const log = run.stderr.toString() + run.stdout.toString();
91103 const killers = [...log.matchAll(/\(fail\) (.*?)(?: \[[\d.]+m?s\])?$/gm)].map((m) => m[1]?.trim());
@@ -98,10 +110,15 @@ try {
98110 : ` UNEXPECTED: nothing catches this line being deleted`);
99111 wrong++;
100112 }
113 // Put this mutant's file back before the next one is broken, so only ever
114 // one rule is missing at a time.
115 writeFileSync(file, original);
101116 }
102117 } finally {
103 copyFileSync(BACKUP, SOURCE);
104 unlinkSync(BACKUP);
118 for (const file of [RESULT, GATE]) {
119 copyFileSync(backupOf(file), file);
120 unlinkSync(backupOf(file));
121 }
105122 }
106123
107124 const expected = MUTANTS.filter((m) => m.redundant !== undefined).length;
src/db.test.ts +49 −2
@@ -232,8 +232,10 @@ test("which day a result counts for is read out of its id, not taken from the br
232232 // that day has begun here, and then it counts for the day it names.
233233 expect(saveMatch(db, player("u-1", "kestrel"), result({ matchId: "20260922" }), now)).toBe(true);
234234 expect(dayBoard(db, "2026-09-22")).toEqual([]);
235 expect(saveMatch(db, player("u-1", "kestrel"), result({ matchId: "20260922", kills: 3 }), now + 86_400_000)).toBe(true);
236 expect(dayBoard(db, "2026-09-22").map((r) => r.kills)).toEqual([3]);
235 // The second post's kills are above the first's because a match's numbers
236 // only ever count up — what is under test here is the day, not the kills.
237 expect(saveMatch(db, player("u-1", "kestrel"), result({ matchId: "20260922", kills: 13 }), now + 86_400_000)).toBe(true);
238 expect(dayBoard(db, "2026-09-22").map((r) => r.kills)).toEqual([13]);
237239 // And the row it went on is the same row: a re-post, not a second match.
238240 expect(recentMatches(db).filter((m) => m.id === "20260922").length).toBe(1);
239241 expect(recentMatches(db).filter((m) => m.id === "20260921")[0]?.day).toBe("2026-09-21");
@@ -266,3 +268,48 @@ test("a database written before the match of the day existed opens, and files th
266268 expect(saveMatch(db, player("u-1", "kestrel"), result({ matchId: "20260921" }), now)).toBe(true);
267269 expect(dayBoard(db, "2026-09-21").map((r) => r.name)).toEqual(["kestrel"]);
268270 });
271
272 test("a post that arrives late cannot put a match's numbers back down", () => {
273 // The game posts as it plays and `src/client/pending.ts` retries a post that
274 // did not land, so the last post to reach the database is not always the
275 // latest one the game made. Round 5's post arriving after round 12's must
276 // not undo seven rounds.
277 const db = openDatabase(dir());
278 const id = crypto.randomUUID();
279 const late = result({
280 matchId: id, rounds: 5, scoreMine: 3, scoreTheirs: 2,
281 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: false,
282 });
283 const end = result({
284 matchId: id, rounds: 12, scoreMine: 7, scoreTheirs: 5,
285 kills: 21, deaths: 9, headshots: 6, damage: 2900, seconds: 980, finished: true,
286 });
287 expect(saveMatch(db, guest("kestrel"), end)).toBe(true);
288 expect(saveMatch(db, guest("kestrel"), late)).toBe(true);
289 const row = recentMatches(db)[0]!;
290 expect(row.rounds).toBe(12);
291 expect(row.kills).toBe(21);
292 expect(row.deaths).toBe(9);
293 expect(row.headshots).toBe(6);
294 expect(row.damage).toBe(2900);
295 expect(row.seconds).toBe(980);
296 // A match that got to the end stays finished, and the score stays the pair
297 // the post that knew the most rounds sent.
298 expect(row.finished).toBe(1);
299 expect([row.score_mine, row.score_theirs]).toEqual([7, 5]);
300 expect(liveness(row, Date.now())).toBe("finished");
301 });
302
303 test("a post that knows more still moves every number up", () => {
304 // The other direction, which is the common one: the match is written as it
305 // is played, and each post raises what it raises.
306 const db = openDatabase(dir());
307 const id = crypto.randomUUID();
308 saveMatch(db, guest("kestrel"), result({ matchId: id, rounds: 5, scoreMine: 3, scoreTheirs: 2, kills: 9, finished: false }));
309 saveMatch(db, guest("kestrel"), result({ matchId: id, rounds: 6, scoreMine: 3, scoreTheirs: 3, kills: 12, finished: false }));
310 const row = recentMatches(db)[0]!;
311 expect(row.rounds).toBe(6);
312 expect(row.kills).toBe(12);
313 expect([row.score_mine, row.score_theirs]).toEqual([3, 3]);
314 expect(liveness(row, Date.now())).toBe("live");
315 });
src/db.ts +18 −4
@@ -200,6 +200,14 @@ export function liveness(row: { finished: number; updated_at: string }, now = Da
200200 * Whether it counts for a day is not asked of the browser: it is read out of
201201 * the id by `dayFiled` in `src/daily.ts` against this server's clock, which is
202202 * why `now` is here at all — a test moves it rather than the calendar.
203 *
204 * A match's numbers never go backwards. Posts for one match arrive as it is
205 * played, and `src/client/pending.ts` retries one that did not land — so the
206 * last post to reach this function is not always the latest one the game made,
207 * and an overwrite would let a retried early post undo a finished match. Every
208 * number a match keeps only counts up (a round played, a kill, a death, a
209 * headshot, damage dealt, a second on the clock), so a late arrival may raise
210 * one and never lower it, and a match that got to the end stays finished.
203211 */
204212 export function saveMatch(db: Database, player: Player, result: Result, now = Date.now()): boolean {
205213 const existing = db
@@ -213,10 +221,16 @@ export function saveMatch(db: Database, player: Player, result: Result, now = Da
213221 `INSERT INTO match (id, session, user_id, name, map, day, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, seconds)
214222 VALUES (?1, ?2, ?13, ?3, ?14, ?15, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
215223 ON CONFLICT(id) DO UPDATE SET
216 name = excluded.name, map = excluded.map, day = excluded.day, kills = excluded.kills, deaths = excluded.deaths,
217 headshots = excluded.headshots, damage = excluded.damage, rounds = excluded.rounds,
218 score_mine = excluded.score_mine, score_theirs = excluded.score_theirs,
219 finished = excluded.finished, seconds = excluded.seconds,
224 name = excluded.name, map = excluded.map, day = excluded.day,
225 kills = MAX(kills, excluded.kills), deaths = MAX(deaths, excluded.deaths),
226 headshots = MAX(headshots, excluded.headshots), damage = MAX(damage, excluded.damage),
227 rounds = MAX(rounds, excluded.rounds),
228 -- The score is a pair, not two independent numbers, so it is taken whole
229 -- from whichever post knows the most rounds rather than maxed column by
230 -- column, which could pair one post's score with another's round count.
231 score_mine = CASE WHEN excluded.rounds >= rounds THEN excluded.score_mine ELSE score_mine END,
232 score_theirs = CASE WHEN excluded.rounds >= rounds THEN excluded.score_theirs ELSE score_theirs END,
233 finished = MAX(finished, excluded.finished), seconds = MAX(seconds, excluded.seconds),
220234 updated_at = datetime('now')`,
221235 ).run(
222236 result.matchId, player.session, player.name, result.kills, result.deaths, result.headshots,
src/tree.test.ts added +100
@@ -0,0 +1,100 @@
1 /**
2 * `.scout-tmp/` held a live session cookie and four rendered pages once
3 * (issue #98): scratch a builder meant to throw away, merged by mistake.
4 * `.gitignore` keeps it from happening again; this walks the tree the way
5 * a clean checkout would look — skipping `.git` and `node_modules`, since
6 * `git ls-files` isn't available to a test — and fails the same way CI
7 * would if either shape came back: a Netscape cookie jar, or a directory
8 * named for one of the scratch patterns `.gitignore` already lists.
9 *
10 * "Ignored" and "committed" are different questions. A file that matches
11 * one of `.gitignore`'s own patterns (`data/app.db`, `static/app.*.js`)
12 * is proof the ignore is doing its job, not a violation — the app writes
13 * those on every run and every build, so flagging them makes the guard
14 * red on any machine that has used the app at all (issue #129). A
15 * directory named for one of `.gitignore`'s scratch entries is different:
16 * nothing normal creates `.scout-tmp/` or `.checkdata/`, so seeing one
17 * still means someone put it there on purpose, ignored or not — the
18 * ignore stops it from being committed, not from existing or from being
19 * force-added, so this still has to look. A cookie jar is checked
20 * regardless of either answer: ignoring a path doesn't undo a commit
21 * that already happened, so a cookie jar is a credential wherever it sits.
22 *
23 * Not every ignored directory is scratch, though, and on this repository
24 * most of them aren't: `bun install` writes `node_modules/`,
25 * `scripts/build.ts` writes the bundles into `static/` and
26 * `scripts/pack.ts` writes the engine package into `dist/`, all of them on
27 * purpose, all of them ignored because they are built rather than kept. So
28 * the scratch names are the ignored directories *minus* the ones this
29 * repository's own tools make — `MADE_ON_PURPOSE` below — which leaves the
30 * ones nothing normal creates, and keeps the rule the parent wrote: add a
31 * scratch directory to `.gitignore` and this guard starts watching for it
32 * without being told twice.
33 */
34
35 import { expect, test } from "bun:test";
36 import { closeSync, openSync, readdirSync, readFileSync, readSync } from "node:fs";
37 import { join } from "node:path";
38
39 const root = join(import.meta.dir, "..");
40 const SKIP_DIRS = new Set([".git", "node_modules"]);
41 const COOKIE_JAR_HEADER = "# Netscape HTTP Cookie File";
42
43 const gitignore = readFileSync(join(root, ".gitignore"), "utf8");
44 const lines = gitignore
45 .split("\n")
46 .map((line) => line.trim())
47 .filter((line) => line.length > 0 && !line.startsWith("#"));
48
49 /** Ignored directories a tool in this repository writes on purpose. */
50 const MADE_ON_PURPOSE = new Set(["node_modules", "dist", "static", "data"]);
51
52 const scratchDirNames = new Set(
53 lines
54 .filter((line) => line.endsWith("/"))
55 .map((line) => line.slice(0, -1))
56 .filter((name) => !MADE_ON_PURPOSE.has(name)),
57 );
58
59 /** Just enough of the file to know its first line, not the whole thing. */
60 const firstLine = (path: string): string => {
61 const fd = openSync(path, "r");
62 try {
63 const buffer = Buffer.alloc(256);
64 const read = readSync(fd, buffer, 0, buffer.length, 0);
65 return buffer.toString("utf8", 0, read).split("\n")[0] ?? "";
66 } finally {
67 closeSync(fd);
68 }
69 };
70
71 const violations: string[] = [];
72
73 const walk = (dir: string): void => {
74 for (const entry of readdirSync(dir, { withFileTypes: true })) {
75 if (SKIP_DIRS.has(entry.name)) continue;
76 const path = join(dir, entry.name);
77 const relative = path.slice(root.length + 1);
78 if (entry.isDirectory()) {
79 if (scratchDirNames.has(entry.name)) violations.push(`${relative}/ is a scratch directory .gitignore already refuses`);
80 walk(path);
81 } else if (entry.isFile()) {
82 // A file's name is not checked against .gitignore's own scratch
83 // patterns (`*.db`, `static/app.*.js`): matching one of those means
84 // the ignore is working, and running the app or building the bundle
85 // is exactly what produces them, on every machine, every time — the
86 // opposite of a violation. Its content still is: a cookie jar's
87 // header doesn't stop being a credential because its filename
88 // happens to be ignored.
89 if (firstLine(path).trim() === COOKIE_JAR_HEADER) {
90 violations.push(`${relative} is a Netscape cookie jar`);
91 }
92 }
93 }
94 };
95
96 test("the repository holds no cookie jar and no scratch directory", () => {
97 violations.length = 0;
98 walk(root);
99 expect(violations).toEqual([]);
100 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs