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

Merged opened this

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

Merged

scout/cs:scout/134-a-world-that-starts-from-a-snapshot-rest → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/134-a-world-that-starts-from-a-snapshot-rest was merged into main as 7b34305.

Review

Sign in to review this change.
2 files changed +183 −10
src/client/world.test.ts +92 −2
@@ -3,13 +3,14 @@ import { GRENADE } from "./grenades.ts";
33 import { teleported } from "./interpolate.ts";
44 import { dist } from "./math.ts";
55 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";
77 import { GAIT_CYCLE, STEP_STRIDE } from "./physics.ts";
88 import { WEAPONS, type WeaponId } from "./weapons.ts";
9 import { snapshot, type Saved } from "./saved.ts";
910 import {
1011 aimAt, applyInput, bodyCentre, BOMB_BLAST_RADIUS, buy, createWorld, currentWeapon, damageDealt,
1112 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,
1314 type Input, type Player, type World,
1415 } from "./world.ts";
1516
@@ -639,6 +640,95 @@ test("a match plays itself to the end inside its own time", () => {
639640 expect(Math.max(world.match.score.t, world.match.score.ct)).toBe(2);
640641 });
641642
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
642732 /**
643733 * Grenades. The buy, the slot, the throw and the blast — stepped through
644734 * `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
2020 import * as rules from "./match.ts";
2121 import { eyeHeight, GAIT_CYCLE, hullHeight, move, STEP_STRIDE, type Body, type Wish, MOVE } from "./physics.ts";
2222 import { callLabel, isCall, roundPlan, type Call, type Plan, type RadioCall } from "./plan.ts";
23 import type { Saved, SavedPlayer } from "./saved.ts";
2324 import { visible } from "./trace.ts";
2425 import {
2526 ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, recoilOffset,
@@ -203,6 +204,13 @@ export type World = {
203204 difficulty: Difficulty;
204205 /** Queued by `setDifficulty`; `startRound` applies it and clears it. */
205206 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;
206214 };
207215
208216 const BOT_NAMES = [
@@ -247,14 +255,15 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player
247255 };
248256 }
249257
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(
251260 ruleSet: rules.Rules,
252261 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,
258267 ): World {
259268 const match = rules.newMatch(ruleSet, playerTeam);
260269 const players: Player[] = [];
@@ -271,7 +280,7 @@ export function createWorld(
271280 players.push(makePlayer(id++, team, name, true));
272281 }
273282 }
274 const world: World = {
283 return {
275284 match, players, me, time: 0,
276285 seed: seed >>> 0,
277286 plan: roundPlan(seed, match.round),
@@ -283,11 +292,81 @@ export function createWorld(
283292 effects: [], feed: [], messages: [],
284293 random, map, boxes: map.boxes, bombPlanted: false, pendingBuy: false,
285294 difficulty, pendingDifficulty: null,
295 skipBotBuy: false,
286296 };
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);
287309 startRound(world);
288310 return world;
289311 }
290312
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(

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 restoreWorld itself.)

What I ran at 159e0ba

  • bun run check: tsc clean, 690 pass / 0 fail across 63 files, on the merge of main that brings #153's second map in.
  • The app boots and / 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, no tty.ts — nothing on the security or both-doors list is in reach of it.
  • Mutation, one behaviour at a time, to prove the tests bite:

- naive startTeam, no half-time flip → past half time, a restored player lands back in their own slot fails. Last round the whole branch could be deleted with 667/667 still green; now it cannot. The test plays a real SHORT match through update until the swap has actually happened and asserts round > halfTime first, so it cannot pass vacuously — exactly right. - drop the winScore guard → the decided-match test fails. - drop skipBotBuy → two tests fail. Covered from the first pass, still covered.

The change itself. buildWorld is the right seam and createWorld's signature is untouched, which is what #134 asked for. restorePlayer runs before startRound, so magazines and the held weapon are rebuilt from the restored slots rather than from the pistol makePlayer handed out. skipBotBuy is one named field with a comment saying why, set once and cleared by the startRound that 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. Saved records no map. It was written when de_forge was the only one; #153 landed a second, and restoreWorld defaults map to DE_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 is saved.ts's shape, not this diff, and #135 is told to keep off world.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.

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. buildWorld is the correct seam, createWorld's signature is untouched, skipBotBuy is one named thing with a comment saying why, restorePlayer runs before startRound so ammo and the held weapon are rebuilt from the restored slots, and the half-time replay does exactly what saved.ts's comment demands: I played a SHORT match past round 4, snapshotted it, restored it, and every one of the ten players came back with the same name, side, money, primary and pistol. No dependency, no server surface, no secret, nothing user-visible — correct for a change the issue scoped to world.ts alone. Check is green on the head (bun run check, 667 pass, run here).

The one thing to fix: the half-time replay has no test. Every test in the diff uses SHORT (halfTime = 4) at rounds 1, 2 and 3, so pastHalf is false in all three and the branch never runs. I confirmed it by mutation — replacing

const startTeam = pastHalf ? rules.other(save.playerTeam) : save.playerTeam;
...
if (pastHalf) for (const p of world.players) p.team = rules.other(p.team);

with the naive const startTeam = save.playerTeam; and no flip, and the full suite still passes, 667/667. That is the single subtlest line in the change, the one saved.ts wrote a paragraph warning about, and the one whose failure mode is silent and nasty: a restored player wearing the gear of whoever held their slot before the swap. It needs a test that would fail without it.

Concretely, one test, and it passes on your code today:

test("past half time, a restored player lands back in their own slot, not the pre-swap one", () => {
  const world = createWorld(SHORT, "me", "ct", 5, 777);
  while (world.match.round <= halfTime(SHORT)) update(world, NO_INPUT, 1 / 64);
  world.me.primary = "awp";
  world.me.money = 1234;
  world.players[7].primary = "ak47";
  const want = world.players.map((p) => ({ name: p.name, team: p.team, money: p.money, primary: p.primary, secondary: p.secondary }));
  const restored = restoreWorld(snapshot(world, crypto.randomUUID()));
  expect(restored).not.toBeNull();
  if (restored === null) return;
  expect(restored.players.map((p) => ({ name: p.name, team: p.team, money: p.money, primary: p.primary, secondary: p.secondary }))).toEqual(want);
  expect(restored.match.playerTeam).toBe(world.match.playerTeam);
});

(Stepping the match forward to past half time takes well under a second with SHORT; that is how I verified it.)

One smaller thing worth handling while you are in the file: parseSaved accepts score.t/score.ct up to winScore inclusive, so a snapshot of a decided match is valid input, and restoreWorld hands back a live freeze world with match.matchWinner === null — the match plays on past its own win condition. Either refuse a save whose score already reaches winScore (the same return null you already use for the player count, and it belongs next to it) or set matchWinner. A line of test either way.

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
291370 /**
292371 * Queue a difficulty change. It does not touch a bot mid-fight — `startRound`
293372 * 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 {
389468 entry.killed = false;
390469 }
391470 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 }
393476 }
394477
395478 /** Pay everyone for the round and put a line on the screen about it. */

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs