Split The code is public; the app answers only to the crew.

A refresh does not end your match: resume the one you were playing #193

Closed opened this
Closed without merging

scout/cs:scout/71-a-refresh-does-not-end-your-match-resume → main 6 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
7 files changed +715 −11
README.md +38
@@ -246,6 +246,42 @@ being shut — the reason is on the button, not just its colour, so a screen
246246 reader hears it too. The digits <kbd>1</kbd>–<kbd>9</kbd> buy the row they
247247 number, the same way clicking it would.
248248
249 **A refresh does not end your match.** At the start of every freeze time —
250 including a resumed one's — the round and score, the loss streak, the clock,
251 which side each player (not just you) is actually on, the seed the bots came
252 from, your name, and every player's money, armour, kit and gun (never a
253 mid-round position, health or the bomb) are written to one bounded key in
254 localStorage (`src/client/saved.ts`), and cleared the moment the match ends
255 or you use *Leave match*. Come back — a refresh, a crash, a closed tab — and
256 the menu offers **Resume match — round 9, 5–3** instead of only *Play*, and
257 says what starting a new one over it would throw away. Each player's own
258 side is saved rather than re-derived from yours: half time flips everyone's
259 in place, and a restore that only knew which side *you* ended up on would
260 rebuild the other nine mirrored — Ts holding a CT's pistol and kit — after
261 any match that had already swapped once. `parseSaved` is the boundary: an
262 unknown weapon id, a side that is not five-a-side, `me` disagreeing with
263 their own recorded side, money or a clock out of range, the wrong number of
264 players, a hostile or oversized blob, or a `matchId` that is not the shape
265 the server issues all fall back to no offer at all rather than a world built
266 from garbage. A resumed match posts under the same match id it always had,
267 with the clock picked up from where the snapshot left it — never restarted
268 at zero, which is what let the server's own sanity check on a result's
269 `seconds` (a round takes at least ten of them) start refusing every post a
270 resumed match made — so the leaderboard row it already has picks up again,
271 including flipping an **abandoned** one (the ten-minute age rule, not one you
272 left on purpose; leaving on purpose is what clears the snapshot in the first
273 place) back to **live**, rather than starting a second row for the same
274 match. The snapshot stores which of the two rule sets it was — never the
275 numbers themselves — so a resumed match always plays under whatever `SHORT`
276 or `COMPETITIVE` mean today; if a later version ever renamed or dropped one,
277 a snapshot naming it stops parsing rather than resuming under rules the game
278 no longer ships. Two things do not survive a resume: a grenade already
279 bought and still in your pocket is gone (the money it cost is not refunded),
280 and a match started as a guest and then resumed after signing in posts under
281 the new, signed identity — the server refuses that as someone else's match
282 id, so the guest row it began under is left behind, `abandoned` on its own
283 age rather than continued.
284
249285 **If the browser takes the 3D view away** — a phone that backgrounded the tab,
250286 an operating system reclaiming graphics memory, a driver that reset — the match
251287 pauses instead of playing on behind a frozen canvas. The renderer catches
@@ -373,6 +409,8 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured.
373409 weapon, pitch, amount — for `audio.ts` to play and `flinch` to feel),
374410 `settings` (mute, volume, sensitivity and difficulty: read, validated, and
375411 never NaN or an unknown name),
412 `saved` (a match's round, score and every player's economy, read back the
413 same validated way and handed to `createWorld` to resume one),
376414 `renderer`, `hud`, `input`, `audio`, `main`.
377415 Everything except the last five is headless and tested; `renderer` and `hud`
378416 are checked against recording contexts, which is how a frame's draws are
src/client/main.ts +69 −8
@@ -17,10 +17,11 @@ import { drawHud } from "./hud.ts";
1717 import { ACTIONS, attachButtons, createControls, DEFAULT_BINDINGS, type Action } from "./input.ts";
1818 import { advance, TICK, waiting } from "./loop.ts";
1919 import { clamp } from "./math.ts";
20 import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
20 import { COMPETITIVE, other, SHORT, type Rules } from "./match.ts";
2121 import { MAX_SCALE, nextRenderScale } from "./quality.ts";
2222 import { createRenderer, type Camera } from "./renderer.ts";
2323 import type { CrosshairStyle } from "./crosshair.ts";
24 import { clearSnapshot, loadSnapshot, restore, saveSnapshot, snapshot, type Snapshot } from "./saved.ts";
2425 import {
2526 DEFAULT_SETTINGS, loadSettings, parseCrosshair, saveSettings, SENSITIVITY_MAX, SENSITIVITY_MIN,
2627 } from "./settings.ts";
@@ -67,6 +68,9 @@ function boot(): void {
6768 const fullscreenPauseButton = $<HTMLButtonElement>("fullscreen-pause");
6869 const controlsMenu = $("controls-menu");
6970 const controlsPause = $("controls-pause");
71 const resumeRow = $("resume-row");
72 const resumeHint = $("resume-hint");
73 const resumeMatchButton = $<HTMLButtonElement>("resume-match");
7074
7175 const viewLost = $("view-lost");
7276 const resumeButton = $<HTMLButtonElement>("resume");
@@ -320,11 +324,36 @@ function boot(): void {
320324 let scaledAt = 0;
321325 let posted = -1;
322326 let watching = 0; // the id whose eyes we are borrowing while dead
327 // The round a snapshot was last written for — `-1` so the very first freeze
328 // (a fresh match's or a resumed one's) writes one straight away.
329 let lastSavedRound = -1;
330 let savedMatch: Snapshot | null = loadSnapshot();
323331
324332 const show = (element: HTMLElement, on: boolean): void => {
325333 element.hidden = !on;
326334 };
327335
336 /**
337 * Paint the menu's Resume offer off whatever `savedMatch` currently is —
338 * the score from the saved player's own side, so "5–3" always means
339 * "you–them" the way the match-over card already does.
340 */
341 const refreshResumeUi = (): void => {
342 if (savedMatch === null) {
343 show(resumeRow, false);
344 show(resumeHint, false);
345 return;
346 }
347 const mine = savedMatch.score[savedMatch.playerTeam];
348 const theirs = savedMatch.score[other(savedMatch.playerTeam)];
349 const line = `round ${savedMatch.round}, ${mine}–${theirs}`;
350 resumeMatchButton.textContent = `Resume match — ${line}`;
351 resumeHint.textContent = `Starting a new match will discard your saved match — ${line}.`;
352 show(resumeRow, true);
353 show(resumeHint, true);
354 };
355 refreshResumeUi();
356
328357 /**
329358 * Is the match waiting? Read off the overlays every frame, so there is no
330359 * second copy of the answer to go stale — which of them count is loop.ts's
@@ -586,14 +615,13 @@ function boot(): void {
586615 $("again").focus();
587616 }
588617
589 function start(): void {
618 /** Whatever put a world in play — a fresh one or a restored one — settles into it the same way. */
619 function enterMatch(newWorld: World, id: string): void {
590620 cancelCapture?.();
591 const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT;
592 const team = sideField.value === "t" ? "t" : "ct";
593 const name = nameField.value.trim().slice(0, 24) || "you";
594 world = createWorld(rules, name, team, 5, undefined, settings.difficulty);
595 matchId = crypto.randomUUID();
621 world = newWorld;
622 matchId = id;
596623 posted = -1;
624 lastSavedRound = -1;
597625 watching = world.me.id;
598626 controls.sensitivity = settings.sensitivity;
599627 running = true;
@@ -606,14 +634,32 @@ function boot(): void {
606634 refreshPad();
607635 audio.resume();
608636 lock();
609 localStorage.setItem("cs.name", name);
610637 // Every overlay is down by now, so the only thing left that can be waiting
611638 // is a view the browser took away while the menu was up: a match started
612639 // into that one waits from its first frame rather than running unseen.
613640 if (paused()) pauseMatch();
614641 }
615642
643 function start(): void {
644 const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT;
645 const team = sideField.value === "t" ? "t" : "ct";
646 const name = nameField.value.trim().slice(0, 24) || "you";
647 // A new match is only ever discarding the old snapshot once it actually
648 // starts, never just because the menu is showing the warning about it.
649 clearSnapshot();
650 savedMatch = null;
651 refreshResumeUi();
652 localStorage.setItem("cs.name", name);
653 enterMatch(createWorld(rules, name, team, 5, undefined, settings.difficulty), crypto.randomUUID());
654 }
655
656 function resumeMatch(): void {
657 if (savedMatch === null) return;
658 enterMatch(restore(savedMatch, settings.difficulty), savedMatch.matchId);
659 }
660
616661 $("play").addEventListener("click", start);
662 resumeMatchButton.addEventListener("click", resumeMatch);
617663 resumeButton.addEventListener("click", () => {
618664 if (renderer.lost) return;
619665 cancelCapture?.();
@@ -629,6 +675,9 @@ function boot(): void {
629675 if (world !== null && world.match.phase !== "match_over") void postResult(false, true);
630676 running = false;
631677 world = null;
678 clearSnapshot();
679 savedMatch = null;
680 refreshResumeUi();
632681 show(pause, false);
633682 show(summaryPanel, false);
634683 show(buyPanel, false);
@@ -735,6 +784,16 @@ function boot(): void {
735784 if (canBuy(world, me)) paintBuy();
736785 else openBuy(false);
737786 }
787 // A round nobody can walk back in on: the freeze time it opens is written
788 // once, right away, so a refresh, a crash or a closed tab comes back here
789 // and not thirteen rounds ago. `lastSavedRound` is reset to -1 by
790 // `enterMatch`, so a fresh match's round one and a resumed match's own
791 // round both get their first snapshot the moment they are on screen.
792 if (world.match.phase === "freeze" && world.match.round !== lastSavedRound) {
793 lastSavedRound = world.match.round;
794 savedMatch = snapshot(world, matchId);
795 saveSnapshot(savedMatch);
796 }
738797 const played = world.match.score.t + world.match.score.ct;
739798 if (played !== posted) {
740799 posted = played;
@@ -743,6 +802,8 @@ function boot(): void {
743802 if (world.match.phase === "match_over" && running) {
744803 running = false;
745804 posted = played;
805 clearSnapshot();
806 savedMatch = null;
746807 document.exitPointerLock();
747808 show(buyPanel, false);
748809 show(radioPanel, false);
src/client/saved.test.ts added +219
@@ -0,0 +1,219 @@
1 /**
2 * `parseSaved` is the boundary this whole file exists for: a snapshot round
3 * trips through it, and anything hostile, truncated or oversized comes out
4 * as `null` instead of a world with `NaN` money or a weapon nothing sells.
5 */
6
7 import { expect, test } from "bun:test";
8 import { LOSS_BONUS, SHORT } from "./match.ts";
9 import { buy, createWorld, update, type Input, type World, NO_INPUT } from "./world.ts";
10 import { clearSnapshot, loadSnapshot, parseSaved, restore, saveSnapshot, snapshot, type Snapshot } from "./saved.ts";
11
12 // bun has no `localStorage` outside a browser; a plain in-memory stand-in,
13 // shaped like the real `Storage` so the cast below needs no escape to
14 // `unknown`, is enough for `saveSnapshot`/`loadSnapshot`/`clearSnapshot`,
15 // which are nothing but its getItem/setItem/removeItem.
16 class FakeStorage implements Storage {
17 private store = new Map<string, string>();
18 get length(): number { return this.store.size; }
19 key(index: number): string | null { return [...this.store.keys()][index] ?? null; }
20 getItem(key: string): string | null { return this.store.get(key) ?? null; }
21 setItem(key: string, value: string): void { this.store.set(key, value); }
22 removeItem(key: string): void { this.store.delete(key); }
23 clear(): void { this.store.clear(); }
24 }
25 (globalThis as { localStorage?: Storage }).localStorage = new FakeStorage();
26
27 const TICK = 1 / 64;
28 const run = (world: World, seconds: number, input: Input = NO_INPUT): void => {
29 for (let t = 0; t < seconds; t += TICK) update(world, TICK, input);
30 };
31
32 /** Step ticks, whatever it takes, until `round`'s freeze begins. */
33 function toRoundFreeze(world: World, round: number): void {
34 for (let i = 0; i < Math.ceil(round * 300 / TICK); i++) {
35 update(world, TICK, NO_INPUT);
36 if (world.match.round === round && world.match.phase === "freeze") return;
37 }
38 throw new Error(`round ${round} never started`);
39 }
40
41 function validSnapshot(): Snapshot {
42 const world = createWorld(SHORT, "me", "ct", 5, 17);
43 world.me.money = 4300;
44 buy(world, world.me, "m4a4");
45 toRoundFreeze(world, 2);
46 run(world, 1); // sit partway into round 2's freeze
47 return snapshot(world, "0123456789abcdef-0123-4567-89ab-cdef01234567");
48 }
49
50 test("snapshot -> parse -> restore round-trips score, money and loadouts", () => {
51 const saved = validSnapshot();
52 const parsed = parseSaved(JSON.stringify(saved));
53 expect(parsed).toEqual(saved);
54 const world = restore(parsed as Snapshot, "normal");
55 expect(world.match.round).toBe(saved.round);
56 expect(world.match.score).toEqual(saved.score);
57 expect(world.match.losses).toEqual(saved.losses);
58 expect(world.match.phase).toBe("freeze");
59 expect(world.time).toBe(saved.seconds);
60 world.players.forEach((p, i) => {
61 const s = saved.players[i];
62 expect(p.team).toBe(s.team);
63 expect(p.money).toBe(s.money);
64 expect(p.primary).toBe(s.primary);
65 expect(p.secondary).toBe(s.secondary);
66 expect(p.armor).toBe(s.armor);
67 expect(p.helmet).toBe(s.helmet);
68 expect(p.defuseKit).toBe(s.kit);
69 expect(p.kills).toBe(s.kills);
70 expect(p.deaths).toBe(s.deaths);
71 expect(p.headshots).toBe(s.headshots);
72 expect(p.damage).toBe(s.damage);
73 });
74 });
75
76 test("a resume after the half-time swap keeps every player on the side it actually flipped to", () => {
77 // A cheap ruleset that reaches its own half time (round 2, at winScore - 1
78 // + 1 = 2) in one short round rather than SHORT's four — the swap is the
79 // same `half_time` event either way, just reached without simulating
80 // minutes of bot play for it.
81 const fast = { ...SHORT, winScore: 2, freezeTime: 1, roundTime: 40, endDelay: 1 };
82 const world = createWorld(fast, "me", "ct", 5, 23);
83 toRoundFreeze(world, 2);
84 expect(world.match.playerTeam).toBe("t"); // swapped from the "ct" it started on
85 const before = world.players.map((p) => ({ team: p.team, secondary: p.secondary, money: p.money }));
86 const saved = snapshot(world, "0123456789abcdef-0123-4567-89ab-cdef01234567");
87 const parsed = parseSaved(JSON.stringify(saved));
88 expect(parsed).not.toBeNull();
89 const restored = restore(parsed as Snapshot, "normal");
90 restored.players.forEach((p, i) => {
91 expect(p.team).toBe(before[i].team);
92 expect(p.secondary).toBe(before[i].secondary);
93 expect(p.money).toBe(before[i].money);
94 });
95 });
96
97 test("nothing stored is nothing to resume", () => {
98 expect(parseSaved(null)).toBeNull();
99 });
100
101 test("malformed JSON, and JSON that isn't an object, parse to null", () => {
102 expect(parseSaved("not json")).toBeNull();
103 expect(parseSaved("42")).toBeNull();
104 expect(parseSaved("null")).toBeNull();
105 expect(parseSaved("[]")).toBeNull();
106 });
107
108 test("a blob bigger than a match could ever need is refused outright", () => {
109 const saved = validSnapshot();
110 const huge = JSON.stringify({ ...saved, name: "x".repeat(5000) });
111 expect(huge.length).toBeGreaterThan(4000);
112 expect(parseSaved(huge)).toBeNull();
113 });
114
115 test("a matchId that isn't the shape the server issues is refused", () => {
116 const saved = validSnapshot();
117 for (const bad of ["short", "", "not-a-uuid!", "../../etc/passwd", "a".repeat(65)]) {
118 expect(parseSaved(JSON.stringify({ ...saved, matchId: bad }))).toBeNull();
119 }
120 });
121
122 test("an unknown weapon id is refused", () => {
123 const saved = validSnapshot();
124 const players = saved.players.map((p, i) => (i === 0 ? { ...p, secondary: "railgun" } : p));
125 expect(parseSaved(JSON.stringify({ ...saved, players }))).toBeNull();
126 });
127
128 test("a primary that is actually a pistol, or a secondary that is a rifle, is refused", () => {
129 const saved = validSnapshot();
130 const withRiflePrimary = saved.players.map((p, i) => (i === 0 ? { ...p, primary: "glock" } : p));
131 expect(parseSaved(JSON.stringify({ ...saved, players: withRiflePrimary }))).toBeNull();
132 const withRifleSecondary = saved.players.map((p, i) => (i === 0 ? { ...p, secondary: "ak47" } : p));
133 expect(parseSaved(JSON.stringify({ ...saved, players: withRifleSecondary }))).toBeNull();
134 });
135
136 test("money out of range is refused", () => {
137 const saved = validSnapshot();
138 const tooRich = saved.players.map((p, i) => (i === 0 ? { ...p, money: 999999 } : p));
139 expect(parseSaved(JSON.stringify({ ...saved, players: tooRich }))).toBeNull();
140 const negative = saved.players.map((p, i) => (i === 0 ? { ...p, money: -1 } : p));
141 expect(parseSaved(JSON.stringify({ ...saved, players: negative }))).toBeNull();
142 const notANumber = saved.players.map((p, i) => (i === 0 ? { ...p, money: "NaN" } : p));
143 expect(parseSaved(JSON.stringify({ ...saved, players: notANumber }))).toBeNull();
144 });
145
146 test("the wrong number of players is refused", () => {
147 const saved = validSnapshot();
148 expect(parseSaved(JSON.stringify({ ...saved, players: saved.players.slice(0, 9) }))).toBeNull();
149 expect(parseSaved(JSON.stringify({ ...saved, players: [...saved.players, saved.players[0]] }))).toBeNull();
150 });
151
152 test("a score that does not add up to the rounds already played is refused", () => {
153 const saved = validSnapshot();
154 expect(parseSaved(JSON.stringify({ ...saved, score: { t: 99, ct: 0 } }))).toBeNull();
155 });
156
157 test("headshots outnumbering kills is refused", () => {
158 const saved = validSnapshot();
159 const players = saved.players.map((p, i) => (i === 0 ? { ...p, headshots: p.kills + 1 } : p));
160 expect(parseSaved(JSON.stringify({ ...saved, players }))).toBeNull();
161 });
162
163 test("a roster that isn't five a side, or where `me` disagrees with playerTeam, is refused", () => {
164 const saved = validSnapshot();
165 // Six on ct, four on t.
166 const lopsided = saved.players.map((p, i) => (i === 5 ? { ...p, team: "ct" as const } : p));
167 expect(parseSaved(JSON.stringify({ ...saved, players: lopsided }))).toBeNull();
168 // `me` (index 0) says ct while playerTeam says t.
169 const mismatched = saved.players.map((p, i) => (i === 0 ? { ...p, team: "t" as const } : p));
170 expect(parseSaved(JSON.stringify({ ...saved, players: mismatched, playerTeam: "ct" }))).toBeNull();
171 });
172
173 test("an unknown team on a player is refused", () => {
174 const saved = validSnapshot();
175 const players = saved.players.map((p, i) => (i === 0 ? { ...p, team: "blue" } : p));
176 expect(parseSaved(JSON.stringify({ ...saved, players }))).toBeNull();
177 });
178
179 test("a clock nobody could have played through is refused", () => {
180 const saved = validSnapshot();
181 expect(parseSaved(JSON.stringify({ ...saved, seconds: 100000 }))).toBeNull();
182 expect(parseSaved(JSON.stringify({ ...saved, seconds: -1 }))).toBeNull();
183 expect(parseSaved(JSON.stringify({ ...saved, seconds: "NaN" }))).toBeNull();
184 });
185
186 test("an unknown rules keyword, side or round is refused", () => {
187 const saved = validSnapshot();
188 expect(parseSaved(JSON.stringify({ ...saved, rules: "medium" }))).toBeNull();
189 expect(parseSaved(JSON.stringify({ ...saved, playerTeam: "blue" }))).toBeNull();
190 expect(parseSaved(JSON.stringify({ ...saved, round: 0 }))).toBeNull();
191 expect(parseSaved(JSON.stringify({ ...saved, round: 1000 }))).toBeNull();
192 });
193
194 test("the round after a restore pays the right loss bonus", () => {
195 const world = createWorld(SHORT, "me", "ct", 5, 17);
196 world.match.losses.ct = 2; // two rounds into a losing streak already
197 const saved = snapshot(world, "0123456789abcdef-0123-4567-89ab-cdef01234567");
198 const restored = restore(saved, "normal");
199 const before = restored.me.money;
200 // Nobody is alive on either side: `checkRoundEnd` reads that as the
201 // terrorists eliminating the CTs, the losing side ct's streak was on.
202 restored.players.forEach((p) => { p.alive = false; });
203 run(restored, SHORT.freezeTime + 0.5);
204 const streak = Math.min(saved.losses.ct, LOSS_BONUS.length - 1); // one more loss, minus the reward's own -1
205 expect(restored.me.money - before).toBe(LOSS_BONUS[streak]);
206 });
207
208 test("saveSnapshot -> loadSnapshot round-trips through storage", () => {
209 const saved = validSnapshot();
210 saveSnapshot(saved);
211 expect(loadSnapshot()).toEqual(saved);
212 });
213
214 test("the snapshot is gone after the match ends", () => {
215 saveSnapshot(validSnapshot());
216 expect(loadSnapshot()).not.toBeNull();
217 clearSnapshot(); // what main.ts does once `world.match.phase === "match_over"`
218 expect(loadSnapshot()).toBeNull();
219 });
src/client/saved.ts added +239
@@ -0,0 +1,239 @@
1 /**
2 * A match, boiled down to what a refresh should not lose: the rules, the
3 * round and score, the loss streak, which side is mine, the seed the bots
4 * came from, my name, and every player's money and gear. Stored in
5 * localStorage beside `cs.settings` and `cs.name`, written once at the start
6 * of each freeze time and cleared the moment the match ends or is left.
7 *
8 * `parseSaved` is the boundary: this is the largest untrusted thing the
9 * client ever reads back, so a missing field, an unknown weapon id, money
10 * out of range, the wrong number of players, a `matchId` that is not the
11 * shape the server issues, or a blob bigger than a match could ever need all
12 * become `null` — and the menu simply does not offer a resume. `world.ts`
13 * knows nothing of any of this: it only takes the already-valid `MatchSave`
14 * this file hands `createWorld`.
15 */
16
17 import { COMPETITIVE, LOSS_BONUS, SHORT, type Rules, type Team } from "./match.ts";
18 import { WEAPONS, type WeaponId } from "./weapons.ts";
19 import type { Difficulty } from "./bots.ts";
20 import { createWorld, type MatchSave, type PlayerSave, type World } from "./world.ts";
21
22 export type SavedPlayer = {
23 // A player's own, saved rather than inferred from `playerTeam`: half time
24 // flips it in place for everyone, and a match that has already swapped
25 // once has a layout no fixed construction order can reproduce from
26 // `playerTeam` alone.
27 team: Team;
28 money: number;
29 primary: WeaponId | null;
30 secondary: WeaponId;
31 armor: number;
32 helmet: boolean;
33 kit: boolean;
34 kills: number;
35 deaths: number;
36 headshots: number;
37 damage: number;
38 };
39
40 export type Snapshot = {
41 matchId: string;
42 rules: "short" | "long";
43 round: number;
44 score: { t: number; ct: number };
45 losses: { t: number; ct: number };
46 playerTeam: Team;
47 seed: number;
48 name: string;
49 seconds: number;
50 players: SavedPlayer[];
51 };
52
53 const KEY = "cs.saved";
54 export const TEAM_SIZE = 5;
55 const PLAYER_COUNT = TEAM_SIZE * 2;
56 // Small numbers, ten times over, with room to spare — a blob anywhere near
57 // this is not a snapshot this file wrote.
58 const MAX_CHARS = 4000;
59 // Five enemies is all there are, the same reasoning `result.ts` bounds by.
60 const KILLS_PER_ROUND = 5;
61 // Mirrors result.ts's own clock bounds — not imported, for the same reason
62 // the matchId regex below isn't: that file is server code.
63 const MAX_SECONDS_PER_ROUND = 200;
64 const MIN_SECONDS_PER_ROUND = 10;
65
66 const rulesFor = (kind: "short" | "long"): Rules => (kind === "long" ? COMPETITIVE : SHORT);
67 const kindOf = (rules: Rules): "short" | "long" => (rules.winScore === COMPETITIVE.winScore ? "long" : "short");
68
69 /** Build a snapshot from the world as it stands right now. Pure: no storage. */
70 export function snapshot(world: World, matchId: string): Snapshot {
71 return {
72 matchId,
73 rules: kindOf(world.match.rules),
74 round: world.match.round,
75 score: { ...world.match.score },
76 losses: { ...world.match.losses },
77 playerTeam: world.match.playerTeam,
78 seed: world.seed,
79 name: world.me.name,
80 seconds: Math.round(world.time),
81 players: world.players.map((p) => ({
82 team: p.team,
83 money: p.money,
84 primary: p.primary,
85 secondary: p.secondary,
86 armor: p.armor,
87 helmet: p.helmet,
88 kit: p.defuseKit,
89 kills: p.kills,
90 deaths: p.deaths,
91 headshots: p.headshots,
92 damage: p.damage,
93 })),
94 };
95 }
96
97 /** Start a world from a snapshot instead of from scratch. */
98 export function restore(saved: Snapshot, difficulty: Difficulty): World {
99 const players: PlayerSave[] = saved.players.map((p) => ({
100 team: p.team,
101 money: p.money,
102 primary: p.primary,
103 secondary: p.secondary,
104 armor: p.armor,
105 helmet: p.helmet,
106 defuseKit: p.kit,
107 kills: p.kills,
108 deaths: p.deaths,
109 headshots: p.headshots,
110 damage: p.damage,
111 }));
112 const save: MatchSave = {
113 round: saved.round, score: { ...saved.score }, losses: { ...saved.losses }, seconds: saved.seconds, players,
114 };
115 return createWorld(
116 rulesFor(saved.rules), saved.name, saved.playerTeam, TEAM_SIZE, saved.seed, difficulty, undefined, save,
117 );
118 }
119
120 // The same shape POST /api/match already requires of a match id. Not
121 // imported from result.ts — that is server code, and the client bundle has
122 // no other reason to pull it in.
123 const MATCH_ID = /^[0-9a-fA-F-]{8,64}$/;
124
125 const int = (value: unknown, lo: number, hi: number): number | null => {
126 if (typeof value !== "number" || !Number.isFinite(value)) return null;
127 const n = Math.floor(value);
128 return n < lo || n > hi ? null : n;
129 };
130
131 const isWeaponId = (value: unknown): value is WeaponId =>
132 typeof value === "string" && Object.prototype.hasOwnProperty.call(WEAPONS, value);
133 const isPistol = (id: WeaponId): boolean => WEAPONS[id].kind === "pistol";
134 const isPrimaryCapable = (id: WeaponId): boolean =>
135 ["rifle", "smg", "sniper"].includes(WEAPONS[id].kind);
136
137 function parseTeamCounts(value: unknown, hi: number): { t: number; ct: number } | null {
138 if (typeof value !== "object" || value === null) return null;
139 const obj = value as Record<string, unknown>;
140 const t = int(obj.t, 0, hi);
141 const ct = int(obj.ct, 0, hi);
142 return t === null || ct === null ? null : { t, ct };
143 }
144
145 /** `played` is how many rounds are actually behind this player, i.e. `round - 1`. */
146 function parsePlayer(value: unknown, rules: Rules, played: number): SavedPlayer | null {
147 if (typeof value !== "object" || value === null) return null;
148 const obj = value as Record<string, unknown>;
149 if (obj.team !== "t" && obj.team !== "ct") return null;
150 if (obj.primary !== null && !(isWeaponId(obj.primary) && isPrimaryCapable(obj.primary))) return null;
151 if (!isWeaponId(obj.secondary) || !isPistol(obj.secondary)) return null;
152 if (typeof obj.helmet !== "boolean" || typeof obj.kit !== "boolean") return null;
153 const money = int(obj.money, 0, rules.maxMoney);
154 const armor = int(obj.armor, 0, 100);
155 const kills = int(obj.kills, 0, played * KILLS_PER_ROUND);
156 const deaths = int(obj.deaths, 0, played);
157 const headshots = int(obj.headshots, 0, played * KILLS_PER_ROUND);
158 const damage = int(obj.damage, 0, played * KILLS_PER_ROUND * 200);
159 if (
160 money === null || armor === null || kills === null || deaths === null ||
161 headshots === null || damage === null || headshots > kills
162 ) return null;
163 return {
164 team: obj.team, money, primary: obj.primary as WeaponId | null, secondary: obj.secondary, armor,
165 helmet: obj.helmet, kit: obj.kit, kills, deaths, headshots, damage,
166 };
167 }
168
169 /** Pure: whatever was in storage, in or out of shape, becomes a snapshot or nothing. */
170 export function parseSaved(raw: string | null): Snapshot | null {
171 if (raw === null || raw.length > MAX_CHARS) return null;
172 let parsed: unknown;
173 try {
174 parsed = JSON.parse(raw);
175 } catch {
176 return null;
177 }
178 if (typeof parsed !== "object" || parsed === null) return null;
179 const obj = parsed as Record<string, unknown>;
180 if (typeof obj.matchId !== "string" || !MATCH_ID.test(obj.matchId)) return null;
181 if (obj.rules !== "short" && obj.rules !== "long") return null;
182 const rules = rulesFor(obj.rules);
183 const round = int(obj.round, 1, 2 * rules.winScore - 1);
184 if (round === null) return null;
185 if (obj.playerTeam !== "t" && obj.playerTeam !== "ct") return null;
186 const seed = int(obj.seed, 0, 0xffffffff);
187 if (seed === null) return null;
188 if (typeof obj.name !== "string" || obj.name.length < 1 || obj.name.length > 24) return null;
189 const score = parseTeamCounts(obj.score, round - 1);
190 if (score === null || score.t + score.ct !== round - 1) return null;
191 const losses = parseTeamCounts(obj.losses, LOSS_BONUS.length);
192 if (losses === null) return null;
193 // The same slack `result.ts` gives the clock: one round of freeze, a fight
194 // and a bomb on top of what the completed rounds alone would need.
195 const seconds = int(obj.seconds, 0, (round - 1 + 1) * MAX_SECONDS_PER_ROUND);
196 if (seconds === null || seconds < (round - 1) * MIN_SECONDS_PER_ROUND) return null;
197 if (!Array.isArray(obj.players) || obj.players.length !== PLAYER_COUNT) return null;
198 const players: SavedPlayer[] = [];
199 for (const entry of obj.players) {
200 const player = parsePlayer(entry, rules, round - 1);
201 if (player === null) return null;
202 players.push(player);
203 }
204 // Five a side, always — and the one at index 0 is always `me`, whose team
205 // this match already keeps in lockstep with `playerTeam` (both flip
206 // together at half time), so a snapshot where they disagree is corrupt
207 // rather than mid-swap.
208 if (players.filter((p) => p.team === "t").length !== TEAM_SIZE) return null;
209 if (players.filter((p) => p.team === "ct").length !== TEAM_SIZE) return null;
210 if (players[0]?.team !== obj.playerTeam) return null;
211 return {
212 matchId: obj.matchId, rules: obj.rules, round, score, losses,
213 playerTeam: obj.playerTeam, seed, name: obj.name, seconds, players,
214 };
215 }
216
217 export function loadSnapshot(): Snapshot | null {
218 try {
219 return parseSaved(localStorage.getItem(KEY));
220 } catch {
221 return null;
222 }
223 }
224
225 export function saveSnapshot(saved: Snapshot): void {
226 try {
227 localStorage.setItem(KEY, JSON.stringify(saved));
228 } catch {
229 // a private tab or a full quota is not a reason to stop playing
230 }
231 }
232
233 export function clearSnapshot(): void {
234 try {
235 localStorage.removeItem(KEY);
236 } catch {
237 // as above
238 }
239 }
src/client/world.test.ts +76
@@ -885,6 +885,82 @@ test("the planted bomb's damage lands in the ledger with nobody credited", () =>
885885 expect(taken[0].entry.killed).toBe(true);
886886 });
887887
888 test("a `save` starts the world from that round, economy and clock, not from round one", () => {
889 const save = {
890 round: 9,
891 score: { t: 3, ct: 4 },
892 losses: { t: 1, ct: 0 },
893 seconds: 512,
894 players: Array.from({ length: 10 }, (_, i) => ({
895 team: (i < 5 ? "ct" : "t") as "ct" | "t", // matches playerTeam "ct" below for index 0
896 money: 1234 + i, primary: null, secondary: "usp" as const, armor: 50, helmet: true,
897 defuseKit: false, kills: i, deaths: 0, headshots: 0, damage: 0,
898 })),
899 };
900 const world = createWorld(SHORT, "me", "ct", 5, 41, "normal", DE_FORGE, save);
901 expect(world.match.round).toBe(9);
902 expect(world.match.score).toEqual({ t: 3, ct: 4 });
903 expect(world.match.losses).toEqual({ t: 1, ct: 0 });
904 expect(world.match.phase).toBe("freeze");
905 expect(world.time).toBe(512);
906 world.players.forEach((p, i) => {
907 expect(p.team).toBe(i < 5 ? "ct" : "t");
908 expect(p.money).toBe(1234 + i);
909 expect(p.armor).toBe(50);
910 expect(p.helmet).toBe(true);
911 expect(p.kills).toBe(i);
912 });
913 });
914
915 test("a `save` puts each player back on the side it names, not the side a fresh roster would", () => {
916 // Mirrors what a restore after a half-time swap looks like: `playerTeam`
917 // is "t" (today's side), but half the roster is saved as "ct" in a split
918 // a fresh 5v5 built from "t" alone would never produce on its own.
919 const save = {
920 round: 9,
921 score: { t: 4, ct: 3 },
922 losses: { t: 0, ct: 1 },
923 seconds: 512,
924 players: [
925 { team: "t" as const, money: 1, primary: null, secondary: "glock" as const, armor: 0, helmet: false, defuseKit: false, kills: 0, deaths: 0, headshots: 0, damage: 0 },
926 { team: "ct" as const, money: 2, primary: null, secondary: "usp" as const, armor: 0, helmet: false, defuseKit: true, kills: 0, deaths: 0, headshots: 0, damage: 0 },
927 { team: "ct" as const, money: 3, primary: null, secondary: "usp" as const, armor: 0, helmet: false, defuseKit: false, kills: 0, deaths: 0, headshots: 0, damage: 0 },
928 { team: "ct" as const, money: 4, primary: null, secondary: "usp" as const, armor: 0, helmet: false, defuseKit: false, kills: 0, deaths: 0, headshots: 0, damage: 0 },
929 { team: "ct" as const, money: 5, primary: null, secondary: "usp" as const, armor: 0, helmet: false, defuseKit: false, kills: 0, deaths: 0, headshots: 0, damage: 0 },
930 { team: "ct" as const, money: 6, primary: null, secondary: "usp" as const, armor: 0, helmet: false, defuseKit: false, kills: 0, deaths: 0, headshots: 0, damage: 0 },
931 { team: "t" as const, money: 7, primary: null, secondary: "glock" as const, armor: 0, helmet: false, defuseKit: false, kills: 0, deaths: 0, headshots: 0, damage: 0 },
932 { team: "t" as const, money: 8, primary: null, secondary: "glock" as const, armor: 0, helmet: false, defuseKit: false, kills: 0, deaths: 0, headshots: 0, damage: 0 },
933 { team: "t" as const, money: 9, primary: null, secondary: "glock" as const, armor: 0, helmet: false, defuseKit: false, kills: 0, deaths: 0, headshots: 0, damage: 0 },
934 { team: "t" as const, money: 10, primary: null, secondary: "glock" as const, armor: 0, helmet: false, defuseKit: false, kills: 0, deaths: 0, headshots: 0, damage: 0 },
935 ],
936 };
937 const world = createWorld(SHORT, "me", "t", 5, 41, "normal", DE_FORGE, save);
938 world.players.forEach((p, i) => {
939 expect(p.team).toBe(save.players[i].team);
940 expect(p.money).toBe(save.players[i].money);
941 // The gun follows the money, not a fresh guess from the (now correct) side.
942 expect(p.secondary).toBe(save.players[i].secondary);
943 });
944 expect(world.players.filter((p) => p.team === "t").length).toBe(5);
945 expect(world.players.filter((p) => p.team === "ct").length).toBe(5);
946 });
947
948 test("a `save` stops the bots buying again on money they already spent for this freeze", () => {
949 const bot = {
950 team: "t" as const, money: 200, primary: "ak47" as const, secondary: "glock" as const, armor: 0,
951 helmet: false, defuseKit: false, kills: 0, deaths: 0, headshots: 0, damage: 0,
952 };
953 const save = {
954 round: 2,
955 score: { t: 1, ct: 0 },
956 losses: { t: 0, ct: 1 },
957 seconds: 60,
958 players: Array.from({ length: 10 }, (_, i) => ({ ...bot, team: i < 5 ? "t" as const : "ct" as const })),
959 };
960 const world = createWorld(SHORT, "me", "t", 5, 41, "normal", DE_FORGE, save);
961 for (const p of world.players) expect(p.money).toBe(200);
962 });
963
888964 // --- the radio ------------------------------------------------------------
889965
890966 test("a call is one line of data on the person's own side, and the last one wins", () => {
src/client/world.ts +68 −3
@@ -190,6 +190,36 @@ const BOT_NAMES = [
190190
191191 const emptyAmmo = (id: WeaponId): Ammo => ({ mag: WEAPONS[id].magazine, reserve: WEAPONS[id].reserve });
192192
193 /**
194 * One player's economy and score, the shape `saved.ts` restores a match
195 * from. `team` is a player's own — half time flips it in place for everyone,
196 * which is why it travels with the rest rather than being inferred from
197 * `playerTeam` and a fixed construction order: that order only ever matches
198 * the side layout a match started on, never one it has since swapped.
199 */
200 export type PlayerSave = {
201 team: Team;
202 money: number;
203 primary: WeaponId | null;
204 secondary: WeaponId;
205 armor: number;
206 helmet: boolean;
207 defuseKit: boolean;
208 kills: number;
209 deaths: number;
210 headshots: number;
211 damage: number;
212 };
213
214 /** A match's round, score, clock and every player's economy — nothing mid-round. */
215 export type MatchSave = {
216 round: number;
217 score: { t: number; ct: number };
218 losses: { t: number; ct: number };
219 seconds: number;
220 players: PlayerSave[];
221 };
222
193223 function makePlayer(id: number, team: Team, name: string, bot: boolean): Player {
194224 const secondary: WeaponId = team === "t" ? "glock" : "usp";
195225 return {
@@ -216,6 +246,13 @@ export function createWorld(
216246 seed = (Date.now() & 0xffff) >>> 0,
217247 difficulty: Difficulty = DEFAULT_DIFFICULTY,
218248 map: MapDef = DE_FORGE,
249 /**
250 * The round, score and every player's economy to start from instead of a
251 * fresh one — `saved.ts`'s way back in after a refresh. Applied before
252 * `startRound` below, so the freeze time a resumed match opens on already
253 * has the right money in every pocket and pays the right loss bonus next.
254 */
255 save?: MatchSave,
219256 ): World {
220257 const match = rules.newMatch(ruleSet, playerTeam);
221258 const players: Player[] = [];
@@ -245,7 +282,35 @@ export function createWorld(
245282 random, map, boxes: map.boxes, bombPlanted: false, pendingBuy: false,
246283 difficulty, pendingDifficulty: null,
247284 };
248 startRound(world);
285 if (save !== undefined) {
286 world.match.round = save.round;
287 world.match.score = { ...save.score };
288 world.match.losses = { ...save.losses };
289 world.time = save.seconds;
290 world.players.forEach((p, i) => {
291 const saved = save.players[i];
292 if (saved === undefined) return;
293 // `team` above everything else: the fresh roster this function just
294 // built put every id on the side a match starting fresh would, which
295 // is only ever right if this match never swapped sides. Fixing it
296 // first is what makes applying the rest by the same index correct.
297 p.team = saved.team;
298 p.money = saved.money;
299 p.primary = saved.primary;
300 p.secondary = saved.secondary;
301 p.armor = saved.armor;
302 p.helmet = saved.helmet;
303 p.defuseKit = saved.defuseKit;
304 p.kills = saved.kills;
305 p.deaths = saved.deaths;
306 p.headshots = saved.headshots;
307 p.damage = saved.damage;
308 });
309 }
310 // A restored round's bots already made their buy for it — that decision is
311 // what `save.players` carries — so `startRound` must not let them shop a
312 // second time on the money they kept from it.
313 startRound(world, save !== undefined);
249314 return world;
250315 }
251316
@@ -284,7 +349,7 @@ export function radioCall(world: World, call: Call): boolean {
284349
285350 // --- rounds ---------------------------------------------------------------
286351
287 export function startRound(world: World): void {
352 export function startRound(world: World, skipBotBuy = false): void {
288353 // The round's plan first, so a bot waking up in the freeze time already
289354 // knows where its side is going. It is a function of the seed and the round
290355 // rather than a draw from `world.random`: the training arena steps this same
@@ -345,7 +410,7 @@ export function startRound(world: World): void {
345410 entry.killed = false;
346411 }
347412 world.feed = [];
348 for (const p of world.players) if (p.bot) botBuy(world, p);
413 if (!skipBotBuy) for (const p of world.players) if (p.bot) botBuy(world, p);
349414 }
350415
351416 /** Pay everyone for the round and put a line on the screen about it. */
src/pages/game.ts +6
@@ -149,6 +149,10 @@ small.commit { color:var(--dim); font-size:.75rem; }
149149 .overlay h2 { margin:0; font-size:1.3rem; }
150150 .overlay p { margin:.2rem 0; color:var(--dim); max-width:34rem; }
151151 .row { display:flex; gap:.5rem; flex-wrap:wrap; justify-content:center; align-items:center; }
152 /* .row sets its own display, which otherwise beats the UA's [hidden] rule
153 at equal specificity and leaves an empty button on the first paint — the
154 same reason #touch[hidden] below exists. */
155 #resume-row[hidden] { display:none; }
152156 label { display:flex; flex-direction:column; gap:.25rem; font-size:.8rem; color:var(--dim); text-align:left; }
153157 label small { font-size:.7rem; }
154158 input, select { min-height:44px; padding:.5rem .7rem; font:inherit; color:var(--text);
@@ -257,6 +261,8 @@ export function gamePage(db: Database, options: PageOptions): string {
257261 <div class="overlay" id="menu">
258262 <h2>Defuse or detonate</h2>
259263 <p>Five against five on <b>de_forge</b>. Bomb rounds, CS movement and economy, bots that push the sites.</p>
264 <div class="row" id="resume-row" hidden><button type="button" class="primary" id="resume-match"></button></div>
265 <p class="hint" id="resume-hint" hidden></p>
260266 <div class="row">
261267 <label>Name<input id="name" maxlength="24" placeholder="you" autocomplete="nickname"><small>in the match only</small></label>
262268 <label>Match<select id="length"><option value="short">Short — first to 5</option><option value="long">Competitive — first to 13</option></select></label>

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs