Split The code is public; the app answers only to the crew.
A resumed match comes back on whatever map the menu last chose: `Saved` does not record one #158
scout/cs:scout/155-a-resumed-match-comes-back-on-whatever-m → main 6 commits fork
4 files
changed +35
−8
src/client/saved.test.ts +7
| @@ -60,6 +60,7 @@ const validSaved: Saved = { | ||
| 60 | 60 | headshots: Math.min(i, 2), |
| 61 | 61 | damage: i * 37, |
| 62 | 62 | })), |
| 63 | map: "de_cinder", | |
| 63 | 64 | }; |
| 64 | 65 | |
| 65 | 66 | test("a whole, valid snapshot round-trips through JSON exactly", () => { |
| @@ -77,6 +78,7 @@ test("snapshot reads only what world.ts already exports, and parses back", () => | ||
| 77 | 78 | expect(saved.matchId).toBe(matchId); |
| 78 | 79 | expect(saved.seed).toBe(world.seed); |
| 79 | 80 | expect(saved.name).toBe("scout"); |
| 81 | expect(saved.map).toBe("de_forge"); // createWorld's own default, unset here | |
| 80 | 82 | expect(parseSaved(JSON.stringify(saved))).toEqual(saved); |
| 81 | 83 | }); |
| 82 | 84 | |
| @@ -127,6 +129,11 @@ test("an unknown weapon id never reaches the game", () => { | ||
| 127 | 129 | expect(parseSaved(JSON.stringify(bad))).toBeNull(); |
| 128 | 130 | }); |
| 129 | 131 | |
| 132 | test("an unknown map name never reaches the game", () => { | |
| 133 | expect(parseSaved(JSON.stringify({ ...validSaved, map: "de_forgery" }))).toBeNull(); | |
| 134 | expect(parseSaved(JSON.stringify({ ...validSaved, map: 7 }))).toBeNull(); | |
| 135 | }); | |
| 136 | ||
| 130 | 137 | test("money out of range is not a match", () => { |
| 131 | 138 | const tooMuch: Saved = { ...validSaved, players: [{ ...validSaved.players[0], money: 999999 }, ...validSaved.players.slice(1)] }; |
| 132 | 139 | expect(parseSaved(JSON.stringify(tooMuch))).toBeNull(); |
src/client/saved.ts +10 −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"; |
| @@ -53,6 +54,8 @@ export type Saved = { | ||
| 53 | 54 | seed: number; |
| 54 | 55 | name: string; |
| 55 | 56 | players: SavedPlayer[]; |
| 57 | /** The layout the match was played on: #153 landed a second one, so this can no longer default. */ | |
| 58 | map: MapName; | |
| 56 | 59 | }; |
| 57 | 60 | |
| 58 | 61 | /** `createWorld` is only ever called with a 5-a-side match. */ |
| @@ -97,6 +100,9 @@ export function snapshot(world: World, matchId: string): Saved { | ||
| 97 | 100 | seed: world.seed, |
| 98 | 101 | name: world.me.name, |
| 99 | 102 | players: world.players.map(savePlayer), |
| 103 | // world.map.name is always one of MAP_NAMES: it only ever comes from a | |
| 104 | // MapDef built by map.ts's own catalogue. | |
| 105 | map: world.map.name as MapName, | |
| 100 | 106 | }; |
| 101 | 107 | } |
| 102 | 108 | |
| @@ -207,7 +213,10 @@ export function parseSaved(raw: string | null): Saved | null { | ||
| 207 | 213 | players.push(player); |
| 208 | 214 | } |
| 209 | 215 | |
| 210 | return { rules, matchId, round, score, losses, playerTeam, seed, name, players }; | |
| 216 | const map = parsed.map; | |
| 217 | if (typeof map !== "string" || !(MAP_NAMES as readonly string[]).includes(map)) return null; | |
| 218 | ||
| 219 | return { rules, matchId, round, score, losses, playerTeam, seed, name, players, map: map as MapName }; | |
| 211 | 220 | } |
| 212 | 221 | |
| 213 | 222 | const KEY = "cs.saved"; |
src/client/world.test.ts +10
| @@ -682,6 +682,16 @@ test("restoreWorld(snapshot(world, id)) round-trips score, round, money and gear | ||
| 682 | 682 | expect(restored.difficulty).toBe(world.difficulty); |
| 683 | 683 | }); |
| 684 | 684 | |
| 685 | test("a match played on de_cinder resumes on de_cinder, not restoreWorld's own default", () => { | |
| 686 | const world = createWorld(SHORT, "me", "ct", 5, 901, undefined, MAPS.de_cinder); | |
| 687 | const saved = snapshot(world, crypto.randomUUID()); | |
| 688 | expect(saved.map).toBe("de_cinder"); | |
| 689 | const restored = restoreWorld(saved); | |
| 690 | expect(restored).not.toBeNull(); | |
| 691 | if (restored === null) return; | |
| 692 | expect(restored.map.name).toBe("de_cinder"); | |
| 693 | }); | |
| 694 | ||
| 685 | 695 | test("a restored round leaves bot money alone", () => { |
| 686 | 696 | const world = createWorld(SHORT, "me", "ct", 5, 401); |
| 687 | 697 | world.match.round = 2; |
src/client/world.ts +8 −7
| @@ -14,7 +14,7 @@ import { | ||
| 14 | 14 | blastDamage, GRENADE, step as stepGrenade, thrown, type Grenade, |
| 15 | 15 | } from "./grenades.ts"; |
| 16 | 16 | import { pose, remember, rememberPos, type Pose } from "./interpolate.ts"; |
| 17 | import { DE_FORGE, siteAt, type MapDef } from "./map.ts"; | |
| 17 | import { DE_FORGE, MAPS, siteAt, type MapDef } from "./map.ts"; | |
| 18 | 18 | export { DEFAULT_MAP, MAP_NAMES, mapFor } from "./map.ts"; |
| 19 | 19 | import { angleVectors, angleDelta, clamp, dist, dist2d, normalize, rng, sub, type Vec3 } from "./math.ts"; |
| 20 | 20 | import * as rules from "./match.ts"; |
| @@ -329,17 +329,18 @@ const RESTORE_TEAM_SIZE = 5; | ||
| 329 | 329 | * the flip, rather than trusting `save.playerTeam` at face value, which past |
| 330 | 330 | * half time would hand a player the slot, and the gear, of whoever was on |
| 331 | 331 | * their side before the swap. |
| 332 | * | |
| 333 | * The map comes from `save.map`, not a parameter: a resumed match was played | |
| 334 | * on one specific layout, and only `snapshot` gets to say which. | |
| 332 | 335 | */ |
| 333 | export function restoreWorld( | |
| 334 | save: Saved, | |
| 335 | difficulty: Difficulty = DEFAULT_DIFFICULTY, | |
| 336 | map: MapDef = DE_FORGE, | |
| 337 | ): World | null { | |
| 336 | export function restoreWorld(save: Saved, difficulty: Difficulty = DEFAULT_DIFFICULTY): World | null { | |
| 338 | 337 | if (save.players.length !== RESTORE_TEAM_SIZE * 2) return null; |
| 339 | 338 | if (save.score.t >= save.rules.winScore || save.score.ct >= save.rules.winScore) return null; |
| 340 | 339 | const pastHalf = save.round > rules.halfTime(save.rules); |
| 341 | 340 | const startTeam = pastHalf ? rules.other(save.playerTeam) : save.playerTeam; |
| 342 | const world = buildWorld(save.rules, save.name, startTeam, RESTORE_TEAM_SIZE, save.seed, difficulty, map); | |
| 341 | const world = buildWorld( | |
| 342 | save.rules, save.name, startTeam, RESTORE_TEAM_SIZE, save.seed, difficulty, MAPS[save.map], | |
| 343 | ); | |
| 343 | 344 | world.match.round = save.round; |
| 344 | 345 | world.match.score = { ...save.score }; |
| 345 | 346 | world.match.losses = { ...save.losses }; |