Split The code is public; the app answers only to the crew.
A snapshot records the map it was played on: `Saved` gains `map`, validated like every other field #163
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/160-a-snapshot-records-the-map-it-was-played → main 1 commit fork
2 files
changed +20
−1
src/client/saved.test.ts +9
| @@ -47,6 +47,7 @@ const validSaved: Saved = { | ||
| 47 | 47 | losses: { t: 1, ct: 0 }, |
| 48 | 48 | playerTeam: "ct", |
| 49 | 49 | seed: 12345, |
| 50 | map: "de_cinder", | |
| 50 | 51 | name: "scout", |
| 51 | 52 | players: Array.from({ length: 10 }, (_, i) => ({ |
| 52 | 53 | money: 4000 + i, |
| @@ -76,6 +77,7 @@ test("snapshot reads only what world.ts already exports, and parses back", () => | ||
| 76 | 77 | expect(saved.players).toHaveLength(10); |
| 77 | 78 | expect(saved.matchId).toBe(matchId); |
| 78 | 79 | expect(saved.seed).toBe(world.seed); |
| 80 | expect(saved.map).toBe("de_forge"); | |
| 79 | 81 | expect(saved.name).toBe("scout"); |
| 80 | 82 | expect(parseSaved(JSON.stringify(saved))).toEqual(saved); |
| 81 | 83 | }); |
| @@ -141,6 +143,13 @@ test("the wrong number of players is not a match", () => { | ||
| 141 | 143 | expect(parseSaved(JSON.stringify(long))).toBeNull(); |
| 142 | 144 | }); |
| 143 | 145 | |
| 146 | test("an unknown, wrongly-typed or missing map is not a match", () => { | |
| 147 | expect(parseSaved(JSON.stringify({ ...validSaved, map: "de_nonsense" }))).toBeNull(); | |
| 148 | expect(parseSaved(JSON.stringify({ ...validSaved, map: 7 }))).toBeNull(); | |
| 149 | const { map: _map, ...withoutMap } = validSaved; | |
| 150 | expect(parseSaved(JSON.stringify(withoutMap))).toBeNull(); | |
| 151 | }); | |
| 152 | ||
| 144 | 153 | test("a matchId that is not the shape the server issues is not a match", () => { |
| 145 | 154 | for (const matchId of ["", "nope", "../../etc", "1", "a".repeat(65)]) { |
| 146 | 155 | expect(parseSaved(JSON.stringify({ ...validSaved, matchId }))).toBeNull(); |
src/client/saved.ts +11 −1
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | * match — wiring this into `main.ts` and `world.ts` — is the next issue. |
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | import { MAP_NAMES, type MapName } from "./map.ts"; | |
| 17 | 18 | import type { Rules, Team } from "./match.ts"; |
| 18 | 19 | import { LOSS_BONUS } from "./match.ts"; |
| 19 | 20 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| @@ -51,6 +52,7 @@ export type Saved = { | ||
| 51 | 52 | losses: { t: number; ct: number }; |
| 52 | 53 | playerTeam: Team; |
| 53 | 54 | seed: number; |
| 55 | map: MapName; | |
| 54 | 56 | name: string; |
| 55 | 57 | players: SavedPlayer[]; |
| 56 | 58 | }; |
| @@ -95,6 +97,9 @@ export function snapshot(world: World, matchId: string): Saved { | ||
| 95 | 97 | losses: { ...world.match.losses }, |
| 96 | 98 | playerTeam: world.match.playerTeam, |
| 97 | 99 | seed: world.seed, |
| 100 | // world.map.name is a plain string in MapDef, but every MapDef comes from | |
| 101 | // MAPS, so it is always one of MAP_NAMES. | |
| 102 | map: world.map.name as MapName, | |
| 98 | 103 | name: world.me.name, |
| 99 | 104 | players: world.players.map(savePlayer), |
| 100 | 105 | }; |
| @@ -124,6 +129,8 @@ const inRange = (n: number, lo: number, hi: number): boolean => n >= lo && n <= | ||
| 124 | 129 | |
| 125 | 130 | const isWeaponId = (v: unknown): v is WeaponId => typeof v === "string" && Object.hasOwn(WEAPONS, v); |
| 126 | 131 | |
| 132 | const isMapName = (v: unknown): v is MapName => typeof v === "string" && (MAP_NAMES as readonly string[]).includes(v); | |
| 133 | ||
| 127 | 134 | const encoder = new TextEncoder(); |
| 128 | 135 | const byteLength = (s: string): number => encoder.encode(s).length; |
| 129 | 136 | |
| @@ -195,6 +202,9 @@ export function parseSaved(raw: string | null): Saved | null { | ||
| 195 | 202 | const seed = parsed.seed; |
| 196 | 203 | if (!wholeNumber(seed) || !inRange(seed, 0, 0xffffffff)) return null; |
| 197 | 204 | |
| 205 | const map = parsed.map; | |
| 206 | if (!isMapName(map)) return null; | |
| 207 | ||
| 198 | 208 | const name = parsed.name; |
| 199 | 209 | if (typeof name !== "string" || name.length < 1 || name.length > MAX_NAME_LENGTH) return null; |
| 200 | 210 | |
| @@ -207,7 +217,7 @@ export function parseSaved(raw: string | null): Saved | null { | ||
| 207 | 217 | players.push(player); |
| 208 | 218 | } |
| 209 | 219 | |
| 210 | return { rules, matchId, round, score, losses, playerTeam, seed, name, players }; | |
| 220 | return { rules, matchId, round, score, losses, playerTeam, seed, map, name, players }; | |
| 211 | 221 | } |
| 212 | 222 | |
| 213 | 223 | const KEY = "cs.saved"; |
new above
offline