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

Resume match: the menu offers the round you were in, and the row on the board continues #179

Merged opened this

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

Merged

scout/cs:scout/135-resume-match-the-menu-offers-the-round-y → main 1 commit fork

  • Merged. scout/135-resume-match-the-menu-offers-the-round-y was merged into main as 0644e74.

Review

Sign in to review this change.
5 files changed +142 −14
README.md +23
@@ -297,6 +297,29 @@ being shut — the reason is on the button, not just its colour, so a screen
297297 reader hears it too. The digits <kbd>1</kbd>–<kbd>9</kbd> buy the row they
298298 number, the same way clicking it would.
299299
300 **A refresh, a crash or a closed tab is not the end of a match.** At the start
301 of every freeze time — the first round or the twentieth, a fresh match or a
302 resumed one — the browser writes what it would take to pick the match back up
303 to `localStorage`: the map, the round, the score, the loss streak and each
304 player's money and gear. Nothing mid-round is in it — no position, no health,
305 no bomb — because there is no tick to resume into, only a round to start over
306 from its freeze time, the same as any other. Coming back to the page while
307 that is still there offers **Resume match — de_cinder, round 9, 5–3** next to
308 Play, naming the map, the round and the score; starting a new match instead
309 discards it, which the menu says before you do. A resumed match keeps the same
310 id the leaderboard already has for it, so the row it made continues rather
311 than forking into a second one — including a row the ten-minute rule had
312 already marked abandoned, which simply reads live again the moment a post
313 lands. It is one browser on one device: the snapshot is `localStorage`, which
314 does not follow you to another tab's private window, another browser or
315 another machine, and two tabs open on the same one both resuming it will both
316 post under that id — safe, because every number the server keeps only ever
317 moves up to the largest a post has claimed, never back down, so the worst two
318 tabs racing does is over-count progress neither alone made, not corrupt the
319 row or hand it to somebody else. The snapshot is cleared the moment a match
320 is decided or *Leave match* is pressed, so there is nothing left to offer once
321 either happens.
322
300323 **If the browser takes the 3D view away** — a phone that backgrounded the tab,
301324 an operating system reclaiming graphics memory, a driver that reset — the match
302325 pauses instead of playing on behind a frozen canvas. The renderer catches
src/client/main.ts +82 −11
@@ -27,11 +27,12 @@ import {
2727 DEFAULT_SETTINGS, loadSettings, parseCrosshair, saveSettings, SENSITIVITY_MAX, SENSITIVITY_MIN,
2828 } from "./settings.ts";
2929 import { CALL_LIFE, isCall, type Call } from "./plan.ts";
30 import { clearSaved, readSaved, resumeLine, snapshot, writeSaved } from "./saved.ts";
3031 import { nextWatched, watched } from "./spectate.ts";
3132 import { summarize, type MatchSummary } from "./summary.ts";
3233 import {
3334 buy, canBuy, createWorld, DEFAULT_MAP, DIFFICULTIES, eyePos, flinch, grenadeSlot, MAP_NAMES, mapFor,
34 radioCall, setDifficulty, update, type Difficulty, type Player, type World,
35 radioCall, restoreWorld, setDifficulty, update, type Difficulty, type Player, type World,
3536 } from "./world.ts";
3637
3738 /**
@@ -63,6 +64,8 @@ function boot(): void {
6364 const pauseDifficultyField = $<HTMLSelectElement>("pause-difficulty");
6465 const mapField = $<HTMLSelectElement>("map");
6566 const pauseMapLabel = $("pause-map");
67 const resumeMatchButton = $<HTMLButtonElement>("resume-match");
68 const discardWarning = $("discard-warning");
6669 const sensField = $<HTMLInputElement>("sens");
6770 const pauseSensField = $<HTMLInputElement>("pause-sens");
6871 const volumeField = $<HTMLInputElement>("volume");
@@ -337,6 +340,7 @@ function boot(): void {
337340 let scaledAt = 0;
338341 let posted = -1;
339342 let watching = 0; // the id whose eyes we are borrowing while dead
343 let savedRound = 0; // the round `saveSnapshot` last wrote, so a freeze time is saved once and not every frame
340344
341345 const show = (element: HTMLElement, on: boolean): void => {
342346 element.hidden = !on;
@@ -604,15 +608,33 @@ function boot(): void {
604608 $("again").focus();
605609 }
606610
607 function start(): void {
608 cancelCapture?.();
609 const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT;
610 const team = sideField.value === "t" ? "t" : "ct";
611 const name = nameField.value.trim().slice(0, 24) || "you";
612 world = createWorld(rules, name, team, 5, undefined, settings.difficulty, mapFor(settings.map));
611 /**
612 * A snapshot worth resuming, or `null` — read fresh every time rather than
613 * cached, since the only writer of `cs.saved` is this same tab and the only
614 * question worth asking is "is there one right now".
615 */
616 const refreshResumeOffer = (): void => {
617 const save = readSaved();
618 show(resumeMatchButton, save !== null);
619 show(discardWarning, save !== null);
620 if (save !== null) resumeMatchButton.textContent = `Resume match — ${resumeLine(save)}`;
621 };
622
623 /** Write the snapshot for the round this freeze time just started. Idempotent within a round: `savedRound` is what keeps it to once. */
624 function saveSnapshot(): void {
625 if (world === null) return;
626 writeSaved(snapshot(world, matchId));
627 savedRound = world.match.round;
628 }
629
630 // Everything a fresh match and a resumed one share, once `world` and
631 // `matchId` are set: leave the menu, arm the pad and the pointer, and save
632 // the round it opens on.
633 function enterMatch(): void {
634 if (world === null) return;
613635 pauseMapLabel.textContent = world.map.name;
614 matchId = crypto.randomUUID();
615636 posted = -1;
637 savedRound = 0;
616638 watching = world.me.id;
617639 controls.sensitivity = settings.sensitivity;
618640 running = true;
@@ -625,14 +647,49 @@ function boot(): void {
625647 refreshPad();
626648 audio.resume();
627649 lock();
628 localStorage.setItem("cs.name", name);
650 saveSnapshot();
629651 // Every overlay is down by now, so the only thing left that can be waiting
630652 // is a view the browser took away while the menu was up: a match started
631653 // into that one waits from its first frame rather than running unseen.
632654 if (paused()) pauseMatch();
633655 }
634656
657 function start(): void {
658 cancelCapture?.();
659 const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT;
660 const team = sideField.value === "t" ? "t" : "ct";
661 const name = nameField.value.trim().slice(0, 24) || "you";
662 world = createWorld(rules, name, team, 5, undefined, settings.difficulty, mapFor(settings.map));
663 // Minted before the snapshot is written, and only here: a saved match is
664 // never discarded by merely showing the menu, only by a new one actually
665 // starting.
666 matchId = crypto.randomUUID();
667 localStorage.setItem("cs.name", name);
668 enterMatch();
669 }
670
671 /** The menu's Resume button: continues under the saved `matchId` rather than minting a new one, so the leaderboard row it already has keeps updating instead of forking. */
672 function resumeMatch(): void {
673 const save = readSaved();
674 if (save === null) return;
675 const restored = restoreWorld(save, settings.difficulty);
676 // A snapshot that no longer builds — a rules or player-count shape
677 // `parseSaved` let through but `restoreWorld` still refuses, or a match
678 // already decided — is not one to offer again.
679 if (restored === null) {
680 clearSaved();
681 refreshResumeOffer();
682 return;
683 }
684 cancelCapture?.();
685 world = restored;
686 matchId = save.matchId;
687 enterMatch();
688 }
689
635690 $("play").addEventListener("click", start);
691 resumeMatchButton.addEventListener("click", resumeMatch);
692 refreshResumeOffer();
636693 resumeButton.addEventListener("click", () => {
637694 if (renderer.lost) return;
638695 cancelCapture?.();
@@ -644,8 +701,13 @@ function boot(): void {
644701 cancelCapture?.();
645702 // A match still going when the menu is reached — left from the pause
646703 // overlay, not from a summary already posted as finished — gets the one
647 // post that says so, sent before the world it reads is gone.
648 if (world !== null && world.match.phase !== "match_over") void postResult(false, true);
704 // post that says so, sent before the world it reads is gone, and its
705 // snapshot is cleared with it: *Leave match* means there is nothing left
706 // to resume.
707 if (world !== null && world.match.phase !== "match_over") {
708 void postResult(false, true);
709 clearSaved();
710 }
649711 running = false;
650712 world = null;
651713 show(pause, false);
@@ -654,6 +716,7 @@ function boot(): void {
654716 show(radioPanel, false);
655717 show(menu, true);
656718 refreshPad();
719 refreshResumeOffer();
657720 $("play").focus();
658721 };
659722 $("quit").addEventListener("click", toMenu);
@@ -750,6 +813,12 @@ function boot(): void {
750813 watching = nextWatched(world, watching, cycle);
751814 feelEffects(world, watched(world, watching));
752815
816 // The start of every freeze time, fresh round or resumed match alike:
817 // `world.match.round` changing is what a new one looks like, so a
818 // snapshot is written once for it and not every one of the frames it
819 // lasts.
820 if (world.match.phase === "freeze" && world.match.round !== savedRound) saveSnapshot();
821
753822 // The buy window closes itself when the round goes live; while it is up
754823 // the clock, the money and every row's state are kept honest every frame.
755824 if (!buyPanel.hidden) {
@@ -768,6 +837,8 @@ function boot(): void {
768837 show(buyPanel, false);
769838 show(radioPanel, false);
770839 refreshPad();
840 // A decided match has no round to come back to.
841 clearSaved();
771842 // The last post is the only one that says the match finished, and the
772843 // score it carries is the one the overlay is about to show.
773844 const summary = summarize(world);
src/client/saved.test.ts +20 −1
@@ -8,7 +8,7 @@
88 import { expect, test } from "bun:test";
99 import { SHORT } from "./match.ts";
1010 import { createWorld, type World } from "./world.ts";
11 import { clearSaved, parseSaved, readSaved, snapshot, writeSaved, type Saved } from "./saved.ts";
11 import { clearSaved, parseSaved, readSaved, resumeLine, snapshot, writeSaved, type Saved } from "./saved.ts";
1212
1313 /**
1414 * Bun has no `localStorage` (`typeof localStorage === "undefined"` in
@@ -203,6 +203,25 @@ test("a corrupt value already sitting in storage reads back as null, not a throw
203203 });
204204 });
205205
206 test("a valid snapshot in storage offers a resume naming the map, round and score; a corrupt one does not", () => {
207 const { stub, store } = memoryStorage();
208 withStorage(stub, () => {
209 writeSaved(validSaved);
210 const save = readSaved();
211 expect(save).not.toBeNull();
212 expect(resumeLine(save as Saved)).toBe("de_cinder, round 3, 1–1");
213 store.set("cs.saved", "not json");
214 expect(readSaved()).toBeNull();
215 });
216 });
217
218 test("resumeLine puts the saved player's own score first, whichever side they were on", () => {
219 const ctAhead: Saved = { ...validSaved, playerTeam: "ct", score: { t: 2, ct: 5 } };
220 expect(resumeLine(ctAhead)).toBe("de_cinder, round 3, 5–2");
221 const tAhead: Saved = { ...validSaved, playerTeam: "t", score: { t: 5, ct: 2 } };
222 expect(resumeLine(tAhead)).toBe("de_cinder, round 3, 5–2");
223 });
224
206225 test("writeSaved, readSaved and clearSaved each swallow a storage failure, same as saveSettings does", () => {
207226 const failing: Pick<Storage, "getItem" | "setItem" | "removeItem"> = {
208227 getItem: () => {
src/client/saved.ts +15 −2
@@ -10,8 +10,9 @@
1010 * Anything else is `null` — there is no partial match, so there is no
1111 * fallback to build one from.
1212 *
13 * Nothing calls `snapshot`, `writeSaved` or `readSaved` yet. Resuming a
14 * match — wiring this into `main.ts` and `world.ts` — is the next issue.
13 * `main.ts` is the wiring: it writes a snapshot at the start of every freeze
14 * time, offers a resume built from `resumeLine` when one is on the page load,
15 * and clears it when the match ends or *Leave match* is used.
1516 */
1617
1718 import { MAP_NAMES, type MapName } from "./map.ts";
@@ -105,6 +106,18 @@ export function snapshot(world: World, matchId: string): Saved {
105106 };
106107 }
107108
109 /**
110 * "de_cinder, round 9, 5–3" — the map, the round and the score from the
111 * saved player's own side, mine first. What the menu's Resume button and its
112 * discard warning both point at; the "Resume match — " itself is main.ts's
113 * copy to own, not this file's.
114 */
115 export function resumeLine(save: Saved): string {
116 const mine = save.score[save.playerTeam];
117 const theirs = save.score[save.playerTeam === "t" ? "ct" : "t"];
118 return `${save.map}, round ${save.round}, ${mine}–${theirs}`;
119 }
120
108121 function savePlayer(p: Player): SavedPlayer {
109122 return {
110123 money: p.money,
src/pages/game.ts +2
@@ -289,6 +289,8 @@ export function gamePage(db: Database, options: PageOptions): string {
289289 .join("")}</select></label>
290290 <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label>
291291 </div>
292 <div class="row"><button type="button" class="primary" id="resume-match" hidden>Resume match</button></div>
293 <p class="hint" id="discard-warning" hidden>Starting a new match discards the saved round above.</p>
292294 <div class="row"><button type="button" class="primary" id="play">Play</button>
293295 <button type="button" id="fullscreen-menu" aria-pressed="false" hidden>Fullscreen</button></div>
294296 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs