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
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/133-the-snapshot-as-a-value-saved-ts-says-wh → main 3 commits fork 3 threads · 3 unresolved
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; | |
| 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 Mutation by mutation, each one now takes a test down with it (558 pass → 557 pass / 1 fail in every case):
The The note on The rest holds as it did: bounds match One nicety for whoever wires it, not worth another round: the literal key 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. Checked at
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 The rest stands as reviewed: one module, one concept, no dependency; The half-time note on 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 | } |
Read the whole module against
settings.ts,result.ts,match.tsandworld.ts, and ran it:bun run checkis green here too (tsc clean, 555 pass / 0 fail, 57 files). The shape is right — bounds line up withcapMoney,LOSS_BONUS.lengthandresult.ts's ownMAX_ROUNDS/KILLS_PER_ROUND; thematchIdregex is literally the server's;parseSavedis total and never throws; no dependency; nothing on the server, the security floor ortty.tsis touched; and sincebundle.tsbuilds frommain.tsalone, 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.tsis committed with a raw NUL byte, so git calls it binary. At byte 5326 (line 134) the never-throws list holds a literal0x00instead of an escape: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:
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 tonullwith the cap and to aSavedwithout it.Object.hasOwn(parsed, "__proto__")check → 555 pass, 0 fail. The hostile top-level case in the test is{"__proto__":{...},"matchId":"…"}with norules,round,playersetc., soparseRulesrejects 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: takeJSON.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/clearSavedare untested. Bun has nolocalStorage(typeof localStorage === "undefined"inbun test), which is presumably whysettings.ts's pair is untested too — but the issue names the swallow behaviour explicitly. A stub onglobalThisin the test file covers all three in a dozen lines: a fake store for the write→read round trip and the clear, and one whosesetItemthrows to provewriteSaveddoes not.One thing to weigh rather than fix, since this pull's job is to pin the shape down before anything depends on it:
SavedPlayerhas noteam, and after half time the index→team layout is not reproducible fromplayerTeamalone.world.ts:1054flips every player's team in place, so a live world that startedctreadst,ct,ct,ct,ct,ct,t,t,t,tin the second half, whilecreateWorld(rules, name, "t", 5, seed)buildst,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 withother(playerTeam)whenround > halfTime(rules)and replay the swap. It is recoverable, so this is not a blocker — but oneteam: Teamper player, validated the wayplayerTeamis, 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 onSavedso the next run does not step in it.The preview at
acb6692renders the menu unchanged, which is right: nothing calls this yet.