Split The code is public; the app answers only to the crew.

The snapshot, as a value: `saved.ts` says what a match is worth keeping, and refuses anything else #140

Merged opened this

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

Merged

scout/cs:scout/133-the-snapshot-as-a-value-saved-ts-says-wh → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/133-the-snapshot-as-a-value-saved-ts-says-wh was merged into main as 3162f4b.

Review

Sign in to review this change.
2 files changed +451 −0
src/client/saved.test.ts added +214
@@ -0,0 +1,214 @@
1 /**
2 * `snapshot` and `parseSaved` are the whole of the boundary: whatever a
3 * `World` gives `snapshot`, and whatever a hand-edited or corrupt
4 * localStorage entry gives `parseSaved`, one round trip through JSON has to
5 * come back exactly, or not at all.
6 */
7
8 import { expect, test } from "bun:test";
9 import { SHORT } from "./match.ts";
10 import { createWorld, type World } from "./world.ts";
11 import { clearSaved, parseSaved, readSaved, snapshot, writeSaved, type Saved } from "./saved.ts";
12
13 /**
14 * Bun has no `localStorage` (`typeof localStorage === "undefined"` in
15 * `bun test`, which is why `settings.ts`'s own `loadSettings`/`saveSettings`
16 * go untested too). A bare in-memory stub, installed and torn down around
17 * one call, is enough to prove `writeSaved`/`readSaved`/`clearSaved` use the
18 * one key and swallow a storage failure the way `saveSettings` does.
19 */
20 function withStorage<T>(stub: Pick<Storage, "getItem" | "setItem" | "removeItem">, run: () => T): T {
21 const original = (globalThis as { localStorage?: unknown }).localStorage;
22 (globalThis as { localStorage?: unknown }).localStorage = stub;
23 try {
24 return run();
25 } finally {
26 (globalThis as { localStorage?: unknown }).localStorage = original;
27 }
28 }
29
30 function memoryStorage(): { stub: Pick<Storage, "getItem" | "setItem" | "removeItem">; store: Map<string, string> } {
31 const store = new Map<string, string>();
32 return {
33 store,
34 stub: {
35 getItem: (key) => store.get(key) ?? null,
36 setItem: (key, value) => void store.set(key, value),
37 removeItem: (key) => void store.delete(key),
38 },
39 };
40 }
41
42 const validSaved: Saved = {
43 rules: SHORT,
44 matchId: crypto.randomUUID(),
45 round: 3,
46 score: { t: 1, ct: 1 },
47 losses: { t: 1, ct: 0 },
48 playerTeam: "ct",
49 seed: 12345,
50 name: "scout",
51 players: Array.from({ length: 10 }, (_, i) => ({
52 money: 4000 + i,
53 primary: i % 2 === 0 ? "ak47" : null,
54 secondary: "usp",
55 armor: 100,
56 helmet: i % 2 === 0,
57 defuseKit: i % 3 === 0,
58 kills: i,
59 deaths: 10 - i,
60 headshots: Math.min(i, 2),
61 damage: i * 37,
62 })),
63 };
64
65 test("a whole, valid snapshot round-trips through JSON exactly", () => {
66 expect(parseSaved(JSON.stringify(validSaved))).toEqual(validSaved);
67 });
68
69 test("snapshot reads only what world.ts already exports, and parses back", () => {
70 const world: World = createWorld(SHORT, "scout", "ct", 5, 7);
71 world.me.money = 4300;
72 world.me.kills = 2;
73 world.players[3].primary = "m4a4";
74 const matchId = crypto.randomUUID();
75 const saved = snapshot(world, matchId);
76 expect(saved.players).toHaveLength(10);
77 expect(saved.matchId).toBe(matchId);
78 expect(saved.seed).toBe(world.seed);
79 expect(saved.name).toBe("scout");
80 expect(parseSaved(JSON.stringify(saved))).toEqual(saved);
81 });
82
83 test("snapshot carries nothing mid-round: no positions, no health, no bomb", () => {
84 const world: World = createWorld(SHORT, "scout", "ct", 5, 8);
85 const saved = snapshot(world, crypto.randomUUID());
86 const json = JSON.stringify(saved);
87 expect(json).not.toMatch(/"pos"/);
88 expect(json).not.toMatch(/"health"/);
89 expect(json).not.toMatch(/"bomb":/); // "bombTime" (a rule) is fine; a bomb state is not
90 });
91
92 test("nothing stored yet is not a match", () => {
93 expect(parseSaved(null)).toBeNull();
94 });
95
96 test("malformed JSON is not a match", () => {
97 expect(parseSaved("not json")).toBeNull();
98 expect(parseSaved("")).toBeNull();
99 expect(parseSaved("undefined")).toBeNull();
100 });
101
102 test("a JSON value that isn't an object is not a match", () => {
103 expect(parseSaved("42")).toBeNull();
104 expect(parseSaved("null")).toBeNull();
105 expect(parseSaved('"scout"')).toBeNull();
106 expect(parseSaved("[1,2,3]")).toBeNull();
107 });
108
109 test("a hostile object with a prototype key is not a match", () => {
110 // Otherwise a whole, valid snapshot — the splice is the only thing wrong
111 // with it, so this fails without the top-level check the way the earlier,
112 // field-starved version of this test did not.
113 const hostile = JSON.stringify(validSaved).replace(/^\{/, '{"__proto__":{"polluted":true},');
114 expect(parseSaved(hostile)).toBeNull();
115 // `{ ...x, __proto__: y }` sets the prototype rather than an own property,
116 // so JSON.stringify would never reproduce a hostile blob that way — this
117 // splices the key into the raw text instead, exactly as JSON.parse would
118 // see it coming out of a hand-edited localStorage entry.
119 const firstPlayer = JSON.stringify(validSaved.players[0]).replace(/^\{/, '{"__proto__":{"polluted":true},');
120 const hostilePlayer =
121 JSON.stringify(validSaved).replace(JSON.stringify(validSaved.players[0]), firstPlayer);
122 expect(parseSaved(hostilePlayer)).toBeNull();
123 });
124
125 test("an unknown weapon id never reaches the game", () => {
126 const bad: Saved = { ...validSaved, players: [{ ...validSaved.players[0], secondary: "railgun" as never }, ...validSaved.players.slice(1)] };
127 expect(parseSaved(JSON.stringify(bad))).toBeNull();
128 });
129
130 test("money out of range is not a match", () => {
131 const tooMuch: Saved = { ...validSaved, players: [{ ...validSaved.players[0], money: 999999 }, ...validSaved.players.slice(1)] };
132 expect(parseSaved(JSON.stringify(tooMuch))).toBeNull();
133 const negative: Saved = { ...validSaved, players: [{ ...validSaved.players[0], money: -1 }, ...validSaved.players.slice(1)] };
134 expect(parseSaved(JSON.stringify(negative))).toBeNull();
135 });
136
137 test("the wrong number of players is not a match", () => {
138 const short: Saved = { ...validSaved, players: validSaved.players.slice(0, 9) };
139 expect(parseSaved(JSON.stringify(short))).toBeNull();
140 const long: Saved = { ...validSaved, players: [...validSaved.players, validSaved.players[0]] };
141 expect(parseSaved(JSON.stringify(long))).toBeNull();
142 });
143
144 test("a matchId that is not the shape the server issues is not a match", () => {
145 for (const matchId of ["", "nope", "../../etc", "1", "a".repeat(65)]) {
146 expect(parseSaved(JSON.stringify({ ...validSaved, matchId }))).toBeNull();
147 }
148 });
149
150 test("a blob over the byte cap is not a match", () => {
151 // Every field still in range — indented JSON of an otherwise valid
152 // snapshot is over 4 KB on whitespace alone, so this is the cap's own
153 // rejection and not some other field's.
154 const padded = JSON.stringify(validSaved, null, 8);
155 expect(padded.length).toBeGreaterThan(4096);
156 expect(parseSaved(padded)).toBeNull();
157 });
158
159 test("a number where a string belongs is not a match", () => {
160 expect(parseSaved(JSON.stringify({ ...validSaved, matchId: 123 }))).toBeNull();
161 expect(parseSaved(JSON.stringify({ ...validSaved, name: 123 }))).toBeNull();
162 });
163
164 test("null and the literal string undefined are not a match", () => {
165 expect(parseSaved("null")).toBeNull();
166 expect(parseSaved("undefined")).toBeNull();
167 });
168
169 test("parseSaved never throws, however hostile the input", () => {
170 const inputs = ["{", "[[[", "{}", '{"players":"bomb"}', "\u0000", "{".repeat(10000)];
171 for (const raw of inputs) expect(() => parseSaved(raw)).not.toThrow();
172 });
173
174 test("nothing stored is null, a write round-trips, and clear empties it again", () => {
175 const { stub, store } = memoryStorage();
176 withStorage(stub, () => {
177 expect(readSaved()).toBeNull();
178 writeSaved(validSaved);
179 // Pinned to the literal key, not just the round trip: a rename of `KEY`
180 // in saved.ts should fail this line even though the trip through
181 // writeSaved/readSaved stays symmetric either way.
182 expect(store.get("cs.saved")).toBe(JSON.stringify(validSaved));
183 expect(readSaved()).toEqual(validSaved);
184 clearSaved();
185 expect(store.has("cs.saved")).toBe(false);
186 expect(readSaved()).toBeNull();
187 });
188 });
189
190 test("a corrupt value already sitting in storage reads back as null, not a throw", () => {
191 withStorage(memoryStorage().stub, () => {
192 localStorage.setItem("cs.saved", "not json");
193 expect(readSaved()).toBeNull();
194 });
195 });
196
197 test("writeSaved, readSaved and clearSaved each swallow a storage failure, same as saveSettings does", () => {
198 const failing: Pick<Storage, "getItem" | "setItem" | "removeItem"> = {
199 getItem: () => {
200 throw new Error("blocked");
201 },
202 setItem: () => {
203 throw new Error("quota");
204 },
205 removeItem: () => {
206 throw new Error("blocked");
207 },
208 };
209 withStorage(failing, () => {
210 expect(() => writeSaved(validSaved)).not.toThrow();
211 expect(readSaved()).toBeNull();
212 expect(() => clearSaved()).not.toThrow();
213 });
214 });
src/client/saved.ts added +237
@@ -0,0 +1,237 @@
1 /**
2 * A match worth resuming: what a round would need to pick up where it left
3 * off, and nothing a round is mid-way through. No positions, no health, no
4 * bomb — those belong to the tick that was running when the tab closed, not
5 * to a match that can be handed back to `createWorld`.
6 *
7 * `parseSaved` is the boundary, in the shape `parseSettings` has in
8 * settings.ts: whatever a hand-edited or half-written localStorage entry
9 * holds, only a value shaped exactly like what `snapshot` writes comes back.
10 * Anything else is `null` — there is no partial match, so there is no
11 * fallback to build one from.
12 *
13 * Nothing calls `snapshot`, `writeSaved` or `readSaved` yet. Resuming a
14 * match — wiring this into `main.ts` and `world.ts` — is the next issue.
15 */
16
17 import type { Rules, Team } from "./match.ts";
18 import { LOSS_BONUS } from "./match.ts";
19 import { WEAPONS, type WeaponId } from "./weapons.ts";
20 import type { Player, World } from "./world.ts";
21
22 export type SavedPlayer = {
23 money: number;
24 primary: WeaponId | null;
25 secondary: WeaponId;
26 armor: number;
27 helmet: boolean;
28 defuseKit: boolean;
29 kills: number;
30 deaths: number;
31 headshots: number;
32 damage: number;
33 };
34
35 /**
36 * `players` carries no `team`: #133 fixed this field list deliberately, and
37 * a player's side is meant to fall out of `playerTeam` and `createWorld`'s
38 * own fixed order rather than be stored twice. That order is not `round`-
39 * invariant, though — `world.ts`'s `update` flips every player's `team` in
40 * place at half time, so an index in the back half of a resumed match does
41 * not land on the side `createWorld(rules, name, playerTeam, ...)` would
42 * give it. Whoever rebuilds a `World` from a `Saved` after `round >
43 * halfTime(rules)` has to build it on `other(playerTeam)` and replay the
44 * swap, not read `playerTeam` at face value.
45 */
46 export type Saved = {
47 rules: Rules;
48 matchId: string;
49 round: number;
50 score: { t: number; ct: number };
51 losses: { t: number; ct: number };
52 playerTeam: Team;
53 seed: number;
54 name: string;
55 players: SavedPlayer[];
56 };
57
58 /** `createWorld` is only ever called with a 5-a-side match. */
59 const PLAYERS_PER_MATCH = 10;
60
61 /** `nameField` in main.ts caps a name at 24 characters before a world ever sees it. */
62 const MAX_NAME_LENGTH = 24;
63
64 // Mirrors result.ts's own check: whatever shape a matchId is, on the client
65 // or the server, it is `crypto.randomUUID()` or nothing.
66 const MATCH_ID_SHAPE = /^[0-9a-fA-F-]{8,64}$/;
67
68 // Generous over the ~1.9 KB a full 10-player, 30-round match actually
69 // serializes to: room to breathe, not room for someone else's blob.
70 const MAX_BYTES = 4096;
71
72 const RULE_BOUNDS: Record<keyof Rules, { min: number; max: number }> = {
73 winScore: { min: 1, max: 30 },
74 freezeTime: { min: 0, max: 60 },
75 roundTime: { min: 10, max: 300 },
76 bombTime: { min: 5, max: 120 },
77 plantTime: { min: 0, max: 30 },
78 defuseTime: { min: 0, max: 60 },
79 defuseTimeWithKit: { min: 0, max: 60 },
80 endDelay: { min: 0, max: 30 },
81 startMoney: { min: 0, max: 16000 },
82 maxMoney: { min: 0, max: 16000 },
83 };
84
85 const MAX_ROUNDS = 30;
86 const KILLS_PER_ROUND = 5; // five enemies is all there are
87
88 /** Build the snapshot for `matchId` from a live `World`. Reads nothing else. */
89 export function snapshot(world: World, matchId: string): Saved {
90 return {
91 rules: { ...world.match.rules },
92 matchId,
93 round: world.match.round,
94 score: { ...world.match.score },
95 losses: { ...world.match.losses },
96 playerTeam: world.match.playerTeam,
97 seed: world.seed,
98 name: world.me.name,
99 players: world.players.map(savePlayer),
100 };
101 }
102
103 function savePlayer(p: Player): SavedPlayer {
104 return {
105 money: p.money,
106 primary: p.primary,
107 secondary: p.secondary,
108 armor: p.armor,
109 helmet: p.helmet,
110 defuseKit: p.defuseKit,
111 kills: p.kills,
112 deaths: p.deaths,
113 headshots: p.headshots,
114 damage: p.damage,
115 };
116 }
117
118 const isRecord = (v: unknown): v is Record<string, unknown> =>
119 typeof v === "object" && v !== null && !Array.isArray(v);
120
121 const wholeNumber = (v: unknown): v is number => typeof v === "number" && Number.isInteger(v);
122
123 const inRange = (n: number, lo: number, hi: number): boolean => n >= lo && n <= hi;
124
125 const isWeaponId = (v: unknown): v is WeaponId => typeof v === "string" && Object.hasOwn(WEAPONS, v);
126
127 const encoder = new TextEncoder();
128 const byteLength = (s: string): number => encoder.encode(s).length;
129
130 function parseRules(v: unknown): Rules | null {
131 if (!isRecord(v) || Object.hasOwn(v, "__proto__")) return null;
132 const out = {} as Rules;
133 for (const key of Object.keys(RULE_BOUNDS) as (keyof Rules)[]) {
134 const value = v[key];
135 const bound = RULE_BOUNDS[key];
136 if (typeof value !== "number" || !Number.isFinite(value) || !inRange(value, bound.min, bound.max)) return null;
137 out[key] = value;
138 }
139 return out;
140 }
141
142 function parseTeamRecord(v: unknown, max: number): { t: number; ct: number } | null {
143 if (!isRecord(v) || Object.hasOwn(v, "__proto__")) return null;
144 const { t, ct } = v;
145 if (!wholeNumber(t) || !inRange(t, 0, max)) return null;
146 if (!wholeNumber(ct) || !inRange(ct, 0, max)) return null;
147 return { t, ct };
148 }
149
150 function parsePlayer(v: unknown, rules: Rules): SavedPlayer | null {
151 if (!isRecord(v) || Object.hasOwn(v, "__proto__")) return null;
152 const { money, primary, secondary, armor, helmet, defuseKit, kills, deaths, headshots, damage } = v;
153 if (!wholeNumber(money) || !inRange(money, 0, rules.maxMoney)) return null;
154 if (primary !== null && !isWeaponId(primary)) return null;
155 if (!isWeaponId(secondary)) return null;
156 if (!wholeNumber(armor) || !inRange(armor, 0, 100)) return null;
157 if (typeof helmet !== "boolean") return null;
outdated

Read the whole module against settings.ts, result.ts, match.ts and world.ts, and ran it: bun run check is green here too (tsc clean, 555 pass / 0 fail, 57 files). The shape is right — bounds line up with capMoney, LOSS_BONUS.length and result.ts's own MAX_ROUNDS/KILLS_PER_ROUND; the matchId regex is literally the server's; parseSaved is total and never throws; no dependency; nothing on the server, the security floor or tty.ts is touched; and since bundle.ts builds from main.ts alone, an unreferenced module ships zero bytes to the wire. Dead for one merge is exactly what #133 asked for. Three things before it lands.

1. Fix first: saved.test.ts is committed with a raw NUL byte, so git calls it binary. At byte 5326 (line 134) the never-throws list holds a literal 0x00 instead of an escape:

const inputs = ["{", "[[[", "{}", '{"players":"bomb"}', "<NUL>", "{".repeat(10000)];

The pull's diff for that file is Binary files /dev/null and b/src/client/saved.test.ts differ — the only tests in the change were unreviewable in the forge, nobody can anchor a comment to a line of them, and any future conflict there is a binary conflict. Write it "\0" (or "\u0000"); same input, text file, one character.

2. Two of the defences you claim in the description have no test that fails without them. I mutated the module and re-ran the suite:

  • Delete the byte cap (if (raw === null) return null;) → 555 pass, 0 fail. "a blob over the byte cap is not a match" passes for the wrong reason: name: "x".repeat(8000) is already rejected by the 24-character name check, so the cap is never what says no. A blob that is over the cap and otherwise entirely valid is easy: JSON.stringify(validSaved, null, 8) is 5061 bytes, every field in range, and I confirmed it parses to null with the cap and to a Saved without it.
  • Delete the top-level Object.hasOwn(parsed, "__proto__") check → 555 pass, 0 fail. The hostile top-level case in the test is {"__proto__":{...},"matchId":"…"} with no rules, round, players etc., so parseRules rejects it first. The per-player hostile case is a real test — it fails without its check — so use the same splice at the top level: take JSON.stringify(validSaved) and .replace(/^\{/, '{"__proto__":{"polluted":true},').

#133's list says "one rejection per rule" and "every test fails without the code"; these two are the ones that do not.

3. writeSaved / readSaved / clearSaved are untested. Bun has no localStorage (typeof localStorage === "undefined" in bun test), which is presumably why settings.ts's pair is untested too — but the issue names the swallow behaviour explicitly. A stub on globalThis in the test file covers all three in a dozen lines: a fake store for the write→read round trip and the clear, and one whose setItem throws to prove writeSaved does not.

One thing to weigh rather than fix, since this pull's job is to pin the shape down before anything depends on it: SavedPlayer has no team, and after half time the index→team layout is not reproducible from playerTeam alone. world.ts:1054 flips every player's team in place, so a live world that started ct reads t,ct,ct,ct,ct,ct,t,t,t,t in the second half, while createWorld(rules, name, "t", 5, seed) builds t,t,t,t,t,ct,ct,ct,ct,ct (I ran both). A per-index restore of a second-half match therefore hands each side the other side's economy, unless the next issue knows to rebuild with other(playerTeam) when round > halfTime(rules) and replay the swap. It is recoverable, so this is not a blocker — but one team: Team per player, validated the way playerTeam is, removes the trap now and costs ~20 bytes of the 4 KB budget. If you would rather keep the field list exactly as #133 wrote it, say so in a comment on Saved so the next run does not step in it.

The preview at acb6692 renders the menu unchanged, which is right: nothing calls this yet.

158 if (typeof defuseKit !== "boolean") return null;
159 if (!wholeNumber(kills) || !inRange(kills, 0, MAX_ROUNDS * KILLS_PER_ROUND)) return null;
160 if (!wholeNumber(deaths) || !inRange(deaths, 0, MAX_ROUNDS)) return null;
161 if (!wholeNumber(headshots) || !inRange(headshots, 0, kills)) return null;
162 if (!wholeNumber(damage) || !inRange(damage, 0, MAX_ROUNDS * KILLS_PER_ROUND * 200)) return null;
163 return { money, primary, secondary, armor, helmet, defuseKit, kills, deaths, headshots, damage };
164 }
165
166 /** Pure: `null` for anything that is not exactly a snapshot `snapshot` could have written. Never throws. */
167 export function parseSaved(raw: string | null): Saved | null {
168 if (raw === null || byteLength(raw) > MAX_BYTES) return null;

Approved. Every one of the three things I asked for is fixed, and I checked each by breaking the code rather than by reading the tests.

The check, run here at fbdcae2: tsc --noEmit && bun test — 558 pass, 0 fail, 57 files. saved.test.ts is plain text now (\u0000 instead of the raw byte, zero NULs in the file), so the diff reads as a diff and a line in it can be commented on.

Mutation by mutation, each one now takes a test down with it (558 pass → 557 pass / 1 fail in every case):

  • drop the byte cap → a blob over the byte cap is not a match fails. JSON.stringify(validSaved, null, 8) with the explicit toBeGreaterThan(4096) is the right shape of test: every field in range, the cap the only thing saying no.
  • drop the top-level Object.hasOwn(parsed, "__proto__") → a hostile object with a prototype key is not a match fails. Splicing the key into a whole valid blob is what makes it bite, and the per-player case still bites on its own.
  • writeSaved stops swallowing, or readSaved stops swallowing → writeSaved, readSaved and clearSaved each swallow a storage failure fails.
  • clearSaved stops removing → nothing stored is null, a write round-trips, and clear empties it again fails.

The withStorage stub is the right weight for this: no dependency, installed and torn down around one call in a finally, and it says in its own comment why it exists.

The note on Saved about half time is accurate and better than the field I suggested — update flips team in place at world.ts:1054, the swap fires at round === halfTime(rules) + 1, so round > halfTime(rules) is exactly the back half, and building on other(playerTeam) and replaying the swap is exactly the recovery. It costs no bytes in the snapshot and leaves the shape #133 specified intact. Whoever takes the restore issue should read it first.

The rest holds as it did: bounds match capMoney, LOSS_BONUS.length and result.ts's own MAX_ROUNDS/KILLS_PER_ROUND; the matchId regex is the server's own, and result.ts re-validates anyway, so nothing a hand-edited localStorage entry says can launder a posted result; parseSaved is total; no secrets, no shell, no SQL, no server, tty.ts or security-floor surface touched, and no new page capability, so nothing is owed to the terminal or the README yet. bundle.ts builds from main.ts alone, so an unreferenced module ships zero bytes to players. Unwired for one merge is what #133 asked for.

One nicety for whoever wires it, not worth another round: the literal key "cs.saved" is not pinned — renaming KEY leaves all 558 green, because the round trip is symmetric and the corrupt-value test writes the literal into a stub that readSaved then misses. A single expect(store.get("cs.saved")).toBeDefined() after writeSaved would nail it down before anything upgrades past it.

The preview at this head renders the menu unchanged, which is correct — nothing calls this yet. (The preview hostname 404s from here, but so do production and #123's, so that is the edge and not this pull.)

169 let parsed: unknown;
170 try {
171 parsed = JSON.parse(raw);
172 } catch {
173 return null;
174 }
175 if (!isRecord(parsed) || Object.hasOwn(parsed, "__proto__")) return null;
176
177 const rules = parseRules(parsed.rules);
178 if (rules === null) return null;
179
180 const matchId = parsed.matchId;
181 if (typeof matchId !== "string" || !MATCH_ID_SHAPE.test(matchId)) return null;
182
183 const round = parsed.round;
184 if (!wholeNumber(round) || !inRange(round, 1, MAX_ROUNDS + 1)) return null;
185
186 const score = parseTeamRecord(parsed.score, rules.winScore);
187 if (score === null) return null;
188
189 const losses = parseTeamRecord(parsed.losses, LOSS_BONUS.length);
190 if (losses === null) return null;
191
192 const playerTeam = parsed.playerTeam;
193 if (playerTeam !== "t" && playerTeam !== "ct") return null;
194
195 const seed = parsed.seed;
196 if (!wholeNumber(seed) || !inRange(seed, 0, 0xffffffff)) return null;
197
198 const name = parsed.name;
199 if (typeof name !== "string" || name.length < 1 || name.length > MAX_NAME_LENGTH) return null;
200
201 const rawPlayers = parsed.players;
202 if (!Array.isArray(rawPlayers) || rawPlayers.length !== PLAYERS_PER_MATCH) return null;
203 const players: SavedPlayer[] = [];
204 for (const p of rawPlayers) {
205 const player = parsePlayer(p, rules);
206 if (player === null) return null;
207 players.push(player);
208 }
209
210 return { rules, matchId, round, score, losses, playerTeam, seed, name, players };
211 }
212
213 const KEY = "cs.saved";

Approved, and nothing is left on my list. saved.ts is byte-identical to the head I approved last round (md5 8b24ef97…); the only change is the test file, which now pins the literal storage key — the one nicety I raised and explicitly did not block on.

Checked at 070c617, not read: tsc --noEmit && bun test — 558 pass, 0 fail, 57 files. Then I broke the module three ways and the suite caught each one (558 → 557 pass / 1 fail):

  • rename KEY to "cs.saved2" → nothing stored is null, a write round-trips, and clear empties it again fails. That is the new expect(store.get("cs.saved")).toBe(JSON.stringify(validSaved)) doing its job; handing the test the Map behind the stub as well as the stub was the cleanest way to get there, and the store.has("cs.saved") check after clearSaved pins the removal to the same key rather than to symmetry.
  • drop the 4 KB cap → a blob over the byte cap is not a match fails.
  • drop the top-level Object.hasOwn(parsed, "__proto__") → a hostile object with a prototype key is not a match fails.

The storage-failure and round-trip tests from the last round still bite the same way; I re-ran those mutations at the previous head and nothing in saved.ts has moved since.

The rest stands as reviewed: one module, one concept, no dependency; parseSaved is total, validates every field at the boundary, and its bounds match capMoney, LOSS_BONUS.length and result.ts's own MAX_ROUNDS/KILLS_PER_ROUND; the matchId regex is the server's own and result.ts re-validates regardless, so a hand-edited localStorage entry cannot launder a posted result; no secret, no shell, no SQL, nothing touching the server, the security floor or tty.ts, and no new page capability, so nothing is owed to the terminal or the README until the resume UI exists. bundle.ts builds from main.ts alone, so an unreferenced module ships zero bytes to players — the dead-for-one-merge that #133 asked for costs nobody a frame.

The half-time note on Saved is still the right answer to the ordering trap and should be the first thing the restore issue reads.

Preview at this head renders the menu unchanged, which is correct — nothing calls this yet. (The preview hostname 404s from my sandbox, as production and #123's do too, so that is the edge and not this pull.)

214
215 export function writeSaved(saved: Saved): void {
216 try {
217 localStorage.setItem(KEY, JSON.stringify(saved));
218 } catch {
219 // a private tab or a full quota is not a reason to stop playing
220 }
221 }
222
223 export function readSaved(): Saved | null {
224 try {
225 return parseSaved(localStorage.getItem(KEY));
226 } catch {
227 return null;
228 }
229 }
230
231 export function clearSaved(): void {
232 try {
233 localStorage.removeItem(KEY);
234 } catch {
235 // a private tab or a full quota is not a reason to stop playing
236 }
237 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs