Split The code is public; the app answers only to the crew.
A world that starts from a snapshot: `restoreWorld`, without touching `createWorld`'s signature #148
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/134-a-world-that-starts-from-a-snapshot-rest → main 3 commits fork 2 threads · 2 unresolved
src/client/world.test.ts +92 −2
| @@ -3,13 +3,14 @@ import { GRENADE } from "./grenades.ts"; | ||
| 3 | 3 | import { teleported } from "./interpolate.ts"; |
| 4 | 4 | import { dist } from "./math.ts"; |
| 5 | 5 | import { DE_FORGE, MAP_NAMES, MAPS, siteAt } from "./map.ts"; |
| 6 | import { SHORT, winReward, type Rules } from "./match.ts"; | |
| 6 | import { halfTime, SHORT, winReward, type Rules } from "./match.ts"; | |
| 7 | 7 | import { GAIT_CYCLE, STEP_STRIDE } from "./physics.ts"; |
| 8 | 8 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 9 | import { snapshot, type Saved } from "./saved.ts"; | |
| 9 | 10 | import { |
| 10 | 11 | aimAt, applyInput, bodyCentre, BOMB_BLAST_RADIUS, buy, createWorld, currentWeapon, damageDealt, |
| 11 | 12 | damageTaken, ENVIRONMENT, eyePos, flinch, grenadeCount, grenadeSlot, MAX_GRENADES, newBomb, nextGrenade, |
| 12 | NO_INPUT, offeredPickup, radioCall, shoot, startRound, update, | |
| 13 | NO_INPUT, offeredPickup, radioCall, restoreWorld, shoot, startRound, update, | |
| 13 | 14 | type Input, type Player, type World, |
| 14 | 15 | } from "./world.ts"; |
| 15 | 16 | |
| @@ -639,6 +640,95 @@ test("a match plays itself to the end inside its own time", () => { | ||
| 639 | 640 | expect(Math.max(world.match.score.t, world.match.score.ct)).toBe(2); |
| 640 | 641 | }); |
| 641 | 642 | |
| 643 | test("restoreWorld(snapshot(world, id)) round-trips score, round, money and gear", () => { | |
| 644 | const world = createWorld(SHORT, "me", "ct", 5, 301); | |
| 645 | world.match.round = 3; | |
| 646 | world.match.score = { t: 1, ct: 2 }; | |
| 647 | world.match.losses = { t: 0, ct: 1 }; | |
| 648 | world.me.money = 4300; | |
| 649 | world.me.primary = "m4a4"; | |
| 650 | world.me.armor = 100; | |
| 651 | world.me.helmet = true; | |
| 652 | world.me.kills = 4; | |
| 653 | world.me.deaths = 2; | |
| 654 | world.me.headshots = 1; | |
| 655 | world.me.damage = 250; | |
| 656 | world.players[3].money = 2100; | |
| 657 | world.players[3].primary = "ak47"; | |
| 658 | world.players[3].defuseKit = true; | |
| 659 | ||
| 660 | const saved = snapshot(world, crypto.randomUUID()); | |
| 661 | const restored = restoreWorld(saved); | |
| 662 | expect(restored).not.toBeNull(); | |
| 663 | if (restored === null) return; | |
| 664 | expect(restored.match.round).toBe(3); | |
| 665 | expect(restored.match.score).toEqual({ t: 1, ct: 2 }); | |
| 666 | expect(restored.match.losses).toEqual({ t: 0, ct: 1 }); | |
| 667 | expect(restored.me.money).toBe(4300); | |
| 668 | expect(restored.me.primary).toBe("m4a4"); | |
| 669 | expect(restored.me.armor).toBe(100); | |
| 670 | expect(restored.me.helmet).toBe(true); | |
| 671 | expect(restored.me.kills).toBe(4); | |
| 672 | expect(restored.me.deaths).toBe(2); | |
| 673 | expect(restored.me.headshots).toBe(1); | |
| 674 | expect(restored.me.damage).toBe(250); | |
| 675 | expect(restored.players[3].money).toBe(2100); | |
| 676 | expect(restored.players[3].primary).toBe("ak47"); | |
| 677 | expect(restored.players[3].defuseKit).toBe(true); | |
| 678 | // Otherwise indistinguishable from a fresh world: same phase, same clock, same bots. | |
| 679 | expect(restored.match.phase).toBe("freeze"); | |
| 680 | expect(restored.match.timer).toBe(SHORT.freezeTime); | |
| 681 | expect(restored.players.length).toBe(10); | |
| 682 | expect(restored.difficulty).toBe(world.difficulty); | |
| 683 | }); | |
| 684 | ||
| 685 | test("a restored round leaves bot money alone", () => { | |
| 686 | const world = createWorld(SHORT, "me", "ct", 5, 401); | |
| 687 | world.match.round = 2; | |
| 688 | for (const p of world.players) p.money = 5000; // enough that a fresh buy would spend it | |
| 689 | const saved = snapshot(world, crypto.randomUUID()); | |
| 690 | const restored = restoreWorld(saved); | |
| 691 | expect(restored).not.toBeNull(); | |
| 692 | if (restored === null) return; | |
| 693 | for (const p of restored.players) if (p.bot) expect(p.money).toBe(5000); | |
| 694 | }); | |
| 695 | ||
| 696 | test("a snapshot whose player count does not match the team size is refused before it can build half a world", () => { | |
| 697 | const world = createWorld(SHORT, "me", "ct", 5, 501); | |
| 698 | const saved = snapshot(world, crypto.randomUUID()); | |
| 699 | const short: Saved = { ...saved, players: saved.players.slice(0, 8) }; | |
| 700 | expect(restoreWorld(short)).toBeNull(); | |
| 701 | }); | |
| 702 | ||
| 703 | test("a snapshot whose score already reaches winScore is refused: a decided match has no freeze time to resume into", () => { | |
| 704 | const world = createWorld(SHORT, "me", "ct", 5, 601); | |
| 705 | world.match.score = { t: SHORT.winScore, ct: 0 }; | |
| 706 | const saved = snapshot(world, crypto.randomUUID()); | |
| 707 | expect(restoreWorld(saved)).toBeNull(); | |
| 708 | }); | |
| 709 | ||
| 710 | test("past half time, a restored player lands back in their own slot, not the pre-swap one", () => { | |
| 711 | const world = createWorld(SHORT, "me", "ct", 5, 777); | |
| 712 | const half = halfTime(SHORT); | |
| 713 | // Play the match out for real — elimination, the bomb, the clock, whatever | |
| 714 | // ends a round — until the side swap `saved.ts` warns about has happened. | |
| 715 | for (let t = 0; t < 900 && world.match.round <= half; t += TICK) update(world, TICK, NO_INPUT); | |
| 716 | expect(world.match.round).toBeGreaterThan(half); // otherwise this test proves nothing | |
| 717 | ||
| 718 | world.me.primary = "awp"; | |
| 719 | world.me.money = 1234; | |
| 720 | world.players[7].primary = "ak47"; | |
| 721 | const slotOf = (w: World) => | |
| 722 | w.players.map((p) => ({ name: p.name, team: p.team, money: p.money, primary: p.primary, secondary: p.secondary })); | |
| 723 | const want = slotOf(world); | |
| 724 | ||
| 725 | const restored = restoreWorld(snapshot(world, crypto.randomUUID())); | |
| 726 | expect(restored).not.toBeNull(); | |
| 727 | if (restored === null) return; | |
| 728 | expect(slotOf(restored)).toEqual(want); | |
| 729 | expect(restored.match.playerTeam).toBe(world.match.playerTeam); | |
| 730 | }); | |
| 731 | ||
| 642 | 732 | /** |
| 643 | 733 | * Grenades. The buy, the slot, the throw and the blast — stepped through |
| 644 | 734 | * `update` the way a round steps them, so the fuse, the kill feed and the |
src/client/world.ts +91 −8
| @@ -20,6 +20,7 @@ import { angleVectors, angleDelta, clamp, dist, dist2d, normalize, rng, sub, typ | ||
| 20 | 20 | import * as rules from "./match.ts"; |
| 21 | 21 | import { eyeHeight, GAIT_CYCLE, hullHeight, move, STEP_STRIDE, type Body, type Wish, MOVE } from "./physics.ts"; |
| 22 | 22 | import { callLabel, isCall, roundPlan, type Call, type Plan, type RadioCall } from "./plan.ts"; |
| 23 | import type { Saved, SavedPlayer } from "./saved.ts"; | |
| 23 | 24 | import { visible } from "./trace.ts"; |
| 24 | 25 | import { |
| 25 | 26 | ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, recoilOffset, |
| @@ -203,6 +204,13 @@ export type World = { | ||
| 203 | 204 | difficulty: Difficulty; |
| 204 | 205 | /** Queued by `setDifficulty`; `startRound` applies it and clears it. */ |
| 205 | 206 | pendingDifficulty: Difficulty | null; |
| 207 | /** | |
| 208 | * Set by `restoreWorld` for the round it resumes, cleared by the | |
| 209 | * `startRound` that reads it: a resumed freeze time has already spent the | |
| 210 | * money the snapshot says is spent, so that one round's bots must not shop | |
| 211 | * on top of it. | |
| 212 | */ | |
| 213 | skipBotBuy: boolean; | |
| 206 | 214 | }; |
| 207 | 215 | |
| 208 | 216 | const BOT_NAMES = [ |
| @@ -247,14 +255,15 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player | ||
| 247 | 255 | }; |
| 248 | 256 | } |
| 249 | 257 | |
| 250 | export function createWorld( | |
| 258 | /** Everything `createWorld` and `restoreWorld` share: the player list, seeded the same way, before the first round is spawned. */ | |
| 259 | function buildWorld( | |
| 251 | 260 | ruleSet: rules.Rules, |
| 252 | 261 | playerName: string, |
| 253 | playerTeam: Team = "ct", | |
| 254 | teamSize = 5, | |
| 255 | seed = (Date.now() & 0xffff) >>> 0, | |
| 256 | difficulty: Difficulty = DEFAULT_DIFFICULTY, | |
| 257 | map: MapDef = DE_FORGE, | |
| 262 | playerTeam: Team, | |
| 263 | teamSize: number, | |
| 264 | seed: number, | |
| 265 | difficulty: Difficulty, | |
| 266 | map: MapDef, | |
| 258 | 267 | ): World { |
| 259 | 268 | const match = rules.newMatch(ruleSet, playerTeam); |
| 260 | 269 | const players: Player[] = []; |
| @@ -271,7 +280,7 @@ export function createWorld( | ||
| 271 | 280 | players.push(makePlayer(id++, team, name, true)); |
| 272 | 281 | } |
| 273 | 282 | } |
| 274 | const world: World = { | |
| 283 | return { | |
| 275 | 284 | match, players, me, time: 0, |
| 276 | 285 | seed: seed >>> 0, |
| 277 | 286 | plan: roundPlan(seed, match.round), |
| @@ -283,11 +292,81 @@ export function createWorld( | ||
| 283 | 292 | effects: [], feed: [], messages: [], |
| 284 | 293 | random, map, boxes: map.boxes, bombPlanted: false, pendingBuy: false, |
| 285 | 294 | difficulty, pendingDifficulty: null, |
| 295 | skipBotBuy: false, | |
| 286 | 296 | }; |
| 297 | } | |
| 298 | ||
| 299 | export function createWorld( | |
| 300 | ruleSet: rules.Rules, | |
| 301 | playerName: string, | |
| 302 | playerTeam: Team = "ct", | |
| 303 | teamSize = 5, | |
| 304 | seed = (Date.now() & 0xffff) >>> 0, | |
| 305 | difficulty: Difficulty = DEFAULT_DIFFICULTY, | |
| 306 | map: MapDef = DE_FORGE, | |
| 307 | ): World { | |
| 308 | const world = buildWorld(ruleSet, playerName, playerTeam, teamSize, seed, difficulty, map); | |
| 287 | 309 | startRound(world); |
| 288 | 310 | return world; |
| 289 | 311 | } |
| 290 | 312 | |
| 313 | /** `createWorld`'s own 5-a-side default: a saved match (see saved.ts's `PLAYERS_PER_MATCH`) was never any other size. */ | |
| 314 | const RESTORE_TEAM_SIZE = 5; | |
| 315 | ||
| 316 | /** | |
| 317 | * Start a match from where `snapshot` left it: the world `createWorld` would | |
| 318 | * have built for the saved seed, rules, team and name, with the saved round, | |
| 319 | * score and loss streak, and each player's money and gear, laid on top | |
| 320 | * before the first freeze time. `null` if `save.players` is not a 5-a-side | |
| 321 | * match's worth, or `save.score` already reaches `save.rules.winScore` — a | |
| 322 | * decided match has no freeze time to resume into, so both are refused | |
| 323 | * before either can build a world. | |
| 324 | * | |
| 325 | * `save.players` is `world.players` in creation order (`snapshot`'s), and | |
| 326 | * `update`'s half-time swap never reorders that list — only `.team` flips in | |
| 327 | * place on each player already in it. So this builds on the round-1 side | |
| 328 | * (`other(save.playerTeam)` once the match is past half time) and replays | |
| 329 | * the flip, rather than trusting `save.playerTeam` at face value, which past | |
| 330 | * half time would hand a player the slot, and the gear, of whoever was on | |
| 331 | * their side before the swap. | |
| 332 | */ | |
| 333 | export function restoreWorld( | |
| 334 | save: Saved, | |
| 335 | difficulty: Difficulty = DEFAULT_DIFFICULTY, | |
| 336 | map: MapDef = DE_FORGE, | |
| 337 | ): World | null { | |
| 338 | if (save.players.length !== RESTORE_TEAM_SIZE * 2) return null; | |
| 339 | if (save.score.t >= save.rules.winScore || save.score.ct >= save.rules.winScore) return null; | |
| 340 | const pastHalf = save.round > rules.halfTime(save.rules); | |
| 341 | 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); | |
| 343 | world.match.round = save.round; | |
| 344 | world.match.score = { ...save.score }; | |
outdated
The shape is right and the logic is right — I checked the hard part by hand and it works. The one thing to fix: the half-time replay has no test. Every test in the diff uses
with the naive Concretely, one test, and it passes on your code today:
(Stepping the match forward to past half time takes well under a second with One smaller thing worth handling while you are in the file: Nothing else: no dead code, no TODO, no commented-out code, and the preview renders as it did before, which is the right answer for a change nothing calls yet. |
||
| 345 | world.match.losses = { ...save.losses }; | |
| 346 | world.match.playerTeam = save.playerTeam; | |
| 347 | if (pastHalf) for (const p of world.players) p.team = rules.other(p.team); | |
| 348 | world.players.forEach((p, i) => restorePlayer(p, save.players[i])); | |
| 349 | // startRound below spawns the round the snapshot resumes; skipBotBuy stops | |
| 350 | // it from having every bot shop the freeze time open on top of the money | |
| 351 | // and gear restorePlayer just gave them. | |
| 352 | world.skipBotBuy = true; | |
| 353 | startRound(world); | |
| 354 | return world; | |
| 355 | } | |
| 356 | ||
| 357 | function restorePlayer(p: Player, saved: SavedPlayer): void { | |
| 358 | p.money = saved.money; | |
| 359 | p.primary = saved.primary; | |
| 360 | p.secondary = saved.secondary; | |
| 361 | p.armor = saved.armor; | |
| 362 | p.helmet = saved.helmet; | |
| 363 | p.defuseKit = saved.defuseKit; | |
| 364 | p.kills = saved.kills; | |
| 365 | p.deaths = saved.deaths; | |
| 366 | p.headshots = saved.headshots; | |
| 367 | p.damage = saved.damage; | |
| 368 | } | |
| 369 | ||
| 291 | 370 | /** |
| 292 | 371 | * Queue a difficulty change. It does not touch a bot mid-fight — `startRound` |
| 293 | 372 | * picks it up and re-skills every brain already in play, so the person being |
| @@ -389,7 +468,11 @@ export function startRound(world: World): void { | ||
| 389 | 468 | entry.killed = false; |
| 390 | 469 | } |
| 391 | 470 | world.feed = []; |
| 392 | for (const p of world.players) if (p.bot) botBuy(world, p); | |
| 471 | if (world.skipBotBuy) { | |
| 472 | world.skipBotBuy = false; | |
| 473 | } else { | |
| 474 | for (const p of world.players) if (p.bot) botBuy(world, p); | |
| 475 | } | |
| 393 | 476 | } |
| 394 | 477 | |
| 395 | 478 | /** Pay everyone for the round and put a line on the screen about it. */ |
Approved. Both findings from my last pass are fixed, and I checked the fixes rather than taking them on trust. (This forge's review API only stores an anchored comment — the verdict is here, on
restoreWorlditself.)What I ran at 159e0ba
bun run check: tsc clean, 690 pass / 0 fail across 63 files, on the merge ofmainthat brings #153's second map in./answers 200 in 17 ms, with the security floor intact (CSP,X-Content-Type-Options,Referrer-Policy). This diff touches no server code, no identity, no storage, notty.ts— nothing on the security or both-doors list is in reach of it.- naive
startTeam, no half-time flip →past half time, a restored player lands back in their own slotfails. Last round the whole branch could be deleted with 667/667 still green; now it cannot. The test plays a realSHORTmatch throughupdateuntil the swap has actually happened and assertsround > halfTimefirst, so it cannot pass vacuously — exactly right. - drop thewinScoreguard → the decided-match test fails. - dropskipBotBuy→ two tests fail. Covered from the first pass, still covered.The change itself.
buildWorldis the right seam andcreateWorld's signature is untouched, which is what #134 asked for.restorePlayerruns beforestartRound, so magazines and the held weapon are rebuilt from the restored slots rather than from the pistolmakePlayerhanded out.skipBotBuyis one named field with a comment saying why, set once and cleared by thestartRoundthat reads it. The two refusals sit together at the top, before anything is built. No dependency, no dead code, no TODO, no commented-out code. The preview renders as it did before, which is the correct answer for a module nothing calls yet — the wiring is #135's, explicitly out of scope here.One thing left, and it is not yours to fix here: #155.
Savedrecords no map. It was written whende_forgewas the only one; #153 landed a second, andrestoreWorlddefaultsmaptoDE_FORGE, so the resume wiring has no source of truth to pass it — a resumed round 9 would be played on whatever the Map select happens to hold, with the score and money of a match played somewhere else. That issaved.ts's shape, not this diff, and #135 is told to keep offworld.ts; I have opened #155 for the field and its validation and marked #135 blocked by it, so the resume never ships able to move the map under a player.Good, careful fix. Land it.