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
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/135-resume-match-the-menu-offers-the-round-y → main 1 commit fork
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 | ||
| 297 | 297 | reader hears it too. The digits <kbd>1</kbd>–<kbd>9</kbd> buy the row they |
| 298 | 298 | number, the same way clicking it would. |
| 299 | 299 | |
| 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 | ||
| 300 | 323 | **If the browser takes the 3D view away** — a phone that backgrounded the tab, |
| 301 | 324 | an operating system reclaiming graphics memory, a driver that reset — the match |
| 302 | 325 | pauses instead of playing on behind a frozen canvas. The renderer catches |
src/client/main.ts +82 −11
| @@ -27,11 +27,12 @@ import { | ||
| 27 | 27 | DEFAULT_SETTINGS, loadSettings, parseCrosshair, saveSettings, SENSITIVITY_MAX, SENSITIVITY_MIN, |
| 28 | 28 | } from "./settings.ts"; |
| 29 | 29 | import { CALL_LIFE, isCall, type Call } from "./plan.ts"; |
| 30 | import { clearSaved, readSaved, resumeLine, snapshot, writeSaved } from "./saved.ts"; | |
| 30 | 31 | import { nextWatched, watched } from "./spectate.ts"; |
| 31 | 32 | import { summarize, type MatchSummary } from "./summary.ts"; |
| 32 | 33 | import { |
| 33 | 34 | 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, | |
| 35 | 36 | } from "./world.ts"; |
| 36 | 37 | |
| 37 | 38 | /** |
| @@ -63,6 +64,8 @@ function boot(): void { | ||
| 63 | 64 | const pauseDifficultyField = $<HTMLSelectElement>("pause-difficulty"); |
| 64 | 65 | const mapField = $<HTMLSelectElement>("map"); |
| 65 | 66 | const pauseMapLabel = $("pause-map"); |
| 67 | const resumeMatchButton = $<HTMLButtonElement>("resume-match"); | |
| 68 | const discardWarning = $("discard-warning"); | |
| 66 | 69 | const sensField = $<HTMLInputElement>("sens"); |
| 67 | 70 | const pauseSensField = $<HTMLInputElement>("pause-sens"); |
| 68 | 71 | const volumeField = $<HTMLInputElement>("volume"); |
| @@ -337,6 +340,7 @@ function boot(): void { | ||
| 337 | 340 | let scaledAt = 0; |
| 338 | 341 | let posted = -1; |
| 339 | 342 | 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 | |
| 340 | 344 | |
| 341 | 345 | const show = (element: HTMLElement, on: boolean): void => { |
| 342 | 346 | element.hidden = !on; |
| @@ -604,15 +608,33 @@ function boot(): void { | ||
| 604 | 608 | $("again").focus(); |
| 605 | 609 | } |
| 606 | 610 | |
| 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; | |
| 613 | 635 | pauseMapLabel.textContent = world.map.name; |
| 614 | matchId = crypto.randomUUID(); | |
| 615 | 636 | posted = -1; |
| 637 | savedRound = 0; | |
| 616 | 638 | watching = world.me.id; |
| 617 | 639 | controls.sensitivity = settings.sensitivity; |
| 618 | 640 | running = true; |
| @@ -625,14 +647,49 @@ function boot(): void { | ||
| 625 | 647 | refreshPad(); |
| 626 | 648 | audio.resume(); |
| 627 | 649 | lock(); |
| 628 | localStorage.setItem("cs.name", name); | |
| 650 | saveSnapshot(); | |
| 629 | 651 | // Every overlay is down by now, so the only thing left that can be waiting |
| 630 | 652 | // is a view the browser took away while the menu was up: a match started |
| 631 | 653 | // into that one waits from its first frame rather than running unseen. |
| 632 | 654 | if (paused()) pauseMatch(); |
| 633 | 655 | } |
| 634 | 656 | |
| 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 | ||
| 635 | 690 | $("play").addEventListener("click", start); |
| 691 | resumeMatchButton.addEventListener("click", resumeMatch); | |
| 692 | refreshResumeOffer(); | |
| 636 | 693 | resumeButton.addEventListener("click", () => { |
| 637 | 694 | if (renderer.lost) return; |
| 638 | 695 | cancelCapture?.(); |
| @@ -644,8 +701,13 @@ function boot(): void { | ||
| 644 | 701 | cancelCapture?.(); |
| 645 | 702 | // A match still going when the menu is reached — left from the pause |
| 646 | 703 | // 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 | } | |
| 649 | 711 | running = false; |
| 650 | 712 | world = null; |
| 651 | 713 | show(pause, false); |
| @@ -654,6 +716,7 @@ function boot(): void { | ||
| 654 | 716 | show(radioPanel, false); |
| 655 | 717 | show(menu, true); |
| 656 | 718 | refreshPad(); |
| 719 | refreshResumeOffer(); | |
| 657 | 720 | $("play").focus(); |
| 658 | 721 | }; |
| 659 | 722 | $("quit").addEventListener("click", toMenu); |
| @@ -750,6 +813,12 @@ function boot(): void { | ||
| 750 | 813 | watching = nextWatched(world, watching, cycle); |
| 751 | 814 | feelEffects(world, watched(world, watching)); |
| 752 | 815 | |
| 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 | ||
| 753 | 822 | // The buy window closes itself when the round goes live; while it is up |
| 754 | 823 | // the clock, the money and every row's state are kept honest every frame. |
| 755 | 824 | if (!buyPanel.hidden) { |
| @@ -768,6 +837,8 @@ function boot(): void { | ||
| 768 | 837 | show(buyPanel, false); |
| 769 | 838 | show(radioPanel, false); |
| 770 | 839 | refreshPad(); |
| 840 | // A decided match has no round to come back to. | |
| 841 | clearSaved(); | |
| 771 | 842 | // The last post is the only one that says the match finished, and the |
| 772 | 843 | // score it carries is the one the overlay is about to show. |
| 773 | 844 | const summary = summarize(world); |
src/client/saved.test.ts +20 −1
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | import { expect, test } from "bun:test"; |
| 9 | 9 | import { SHORT } from "./match.ts"; |
| 10 | 10 | 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"; | |
| 12 | 12 | |
| 13 | 13 | /** |
| 14 | 14 | * 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 | ||
| 203 | 203 | }); |
| 204 | 204 | }); |
| 205 | 205 | |
| 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 | ||
| 206 | 225 | test("writeSaved, readSaved and clearSaved each swallow a storage failure, same as saveSettings does", () => { |
| 207 | 226 | const failing: Pick<Storage, "getItem" | "setItem" | "removeItem"> = { |
| 208 | 227 | getItem: () => { |
src/client/saved.ts +15 −2
| @@ -10,8 +10,9 @@ | ||
| 10 | 10 | * Anything else is `null` — there is no partial match, so there is no |
| 11 | 11 | * fallback to build one from. |
| 12 | 12 | * |
| 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. | |
| 15 | 16 | */ |
| 16 | 17 | |
| 17 | 18 | import { MAP_NAMES, type MapName } from "./map.ts"; |
| @@ -105,6 +106,18 @@ export function snapshot(world: World, matchId: string): Saved { | ||
| 105 | 106 | }; |
| 106 | 107 | } |
| 107 | 108 | |
| 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 | ||
| 108 | 121 | function savePlayer(p: Player): SavedPlayer { |
| 109 | 122 | return { |
| 110 | 123 | money: p.money, |
src/pages/game.ts +2
| @@ -289,6 +289,8 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 289 | 289 | .join("")}</select></label> |
| 290 | 290 | <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label> |
| 291 | 291 | </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> | |
| 292 | 294 | <div class="row"><button type="button" class="primary" id="play">Play</button> |
| 293 | 295 | <button type="button" id="fullscreen-menu" aria-pressed="false" hidden>Fullscreen</button></div> |
| 294 | 296 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
new above
offline