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
scout/cs:scout/71-a-refresh-does-not-end-your-match-resume → main 6 commits fork
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 | ||
| 246 | 246 | reader hears it too. The digits <kbd>1</kbd>–<kbd>9</kbd> buy the row they |
| 247 | 247 | number, the same way clicking it would. |
| 248 | 248 | |
| 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 | ||
| 249 | 285 | **If the browser takes the 3D view away** — a phone that backgrounded the tab, |
| 250 | 286 | an operating system reclaiming graphics memory, a driver that reset — the match |
| 251 | 287 | 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. | ||
| 373 | 409 | weapon, pitch, amount — for `audio.ts` to play and `flinch` to feel), |
| 374 | 410 | `settings` (mute, volume, sensitivity and difficulty: read, validated, and |
| 375 | 411 | 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), | |
| 376 | 414 | `renderer`, `hud`, `input`, `audio`, `main`. |
| 377 | 415 | Everything except the last five is headless and tested; `renderer` and `hud` |
| 378 | 416 | 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"; | ||
| 17 | 17 | import { ACTIONS, attachButtons, createControls, DEFAULT_BINDINGS, type Action } from "./input.ts"; |
| 18 | 18 | import { advance, TICK, waiting } from "./loop.ts"; |
| 19 | 19 | import { clamp } from "./math.ts"; |
| 20 | import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; | |
| 20 | import { COMPETITIVE, other, SHORT, type Rules } from "./match.ts"; | |
| 21 | 21 | import { MAX_SCALE, nextRenderScale } from "./quality.ts"; |
| 22 | 22 | import { createRenderer, type Camera } from "./renderer.ts"; |
| 23 | 23 | import type { CrosshairStyle } from "./crosshair.ts"; |
| 24 | import { clearSnapshot, loadSnapshot, restore, saveSnapshot, snapshot, type Snapshot } from "./saved.ts"; | |
| 24 | 25 | import { |
| 25 | 26 | DEFAULT_SETTINGS, loadSettings, parseCrosshair, saveSettings, SENSITIVITY_MAX, SENSITIVITY_MIN, |
| 26 | 27 | } from "./settings.ts"; |
| @@ -67,6 +68,9 @@ function boot(): void { | ||
| 67 | 68 | const fullscreenPauseButton = $<HTMLButtonElement>("fullscreen-pause"); |
| 68 | 69 | const controlsMenu = $("controls-menu"); |
| 69 | 70 | const controlsPause = $("controls-pause"); |
| 71 | const resumeRow = $("resume-row"); | |
| 72 | const resumeHint = $("resume-hint"); | |
| 73 | const resumeMatchButton = $<HTMLButtonElement>("resume-match"); | |
| 70 | 74 | |
| 71 | 75 | const viewLost = $("view-lost"); |
| 72 | 76 | const resumeButton = $<HTMLButtonElement>("resume"); |
| @@ -320,11 +324,36 @@ function boot(): void { | ||
| 320 | 324 | let scaledAt = 0; |
| 321 | 325 | let posted = -1; |
| 322 | 326 | 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(); | |
| 323 | 331 | |
| 324 | 332 | const show = (element: HTMLElement, on: boolean): void => { |
| 325 | 333 | element.hidden = !on; |
| 326 | 334 | }; |
| 327 | 335 | |
| 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 | ||
| 328 | 357 | /** |
| 329 | 358 | * Is the match waiting? Read off the overlays every frame, so there is no |
| 330 | 359 | * second copy of the answer to go stale — which of them count is loop.ts's |
| @@ -586,14 +615,13 @@ function boot(): void { | ||
| 586 | 615 | $("again").focus(); |
| 587 | 616 | } |
| 588 | 617 | |
| 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 { | |
| 590 | 620 | 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; | |
| 596 | 623 | posted = -1; |
| 624 | lastSavedRound = -1; | |
| 597 | 625 | watching = world.me.id; |
| 598 | 626 | controls.sensitivity = settings.sensitivity; |
| 599 | 627 | running = true; |
| @@ -606,14 +634,32 @@ function boot(): void { | ||
| 606 | 634 | refreshPad(); |
| 607 | 635 | audio.resume(); |
| 608 | 636 | lock(); |
| 609 | localStorage.setItem("cs.name", name); | |
| 610 | 637 | // Every overlay is down by now, so the only thing left that can be waiting |
| 611 | 638 | // is a view the browser took away while the menu was up: a match started |
| 612 | 639 | // into that one waits from its first frame rather than running unseen. |
| 613 | 640 | if (paused()) pauseMatch(); |
| 614 | 641 | } |
| 615 | 642 | |
| 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 | ||
| 616 | 661 | $("play").addEventListener("click", start); |
| 662 | resumeMatchButton.addEventListener("click", resumeMatch); | |
| 617 | 663 | resumeButton.addEventListener("click", () => { |
| 618 | 664 | if (renderer.lost) return; |
| 619 | 665 | cancelCapture?.(); |
| @@ -629,6 +675,9 @@ function boot(): void { | ||
| 629 | 675 | if (world !== null && world.match.phase !== "match_over") void postResult(false, true); |
| 630 | 676 | running = false; |
| 631 | 677 | world = null; |
| 678 | clearSnapshot(); | |
| 679 | savedMatch = null; | |
| 680 | refreshResumeUi(); | |
| 632 | 681 | show(pause, false); |
| 633 | 682 | show(summaryPanel, false); |
| 634 | 683 | show(buyPanel, false); |
| @@ -735,6 +784,16 @@ function boot(): void { | ||
| 735 | 784 | if (canBuy(world, me)) paintBuy(); |
| 736 | 785 | else openBuy(false); |
| 737 | 786 | } |
| 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 | } | |
| 738 | 797 | const played = world.match.score.t + world.match.score.ct; |
| 739 | 798 | if (played !== posted) { |
| 740 | 799 | posted = played; |
| @@ -743,6 +802,8 @@ function boot(): void { | ||
| 743 | 802 | if (world.match.phase === "match_over" && running) { |
| 744 | 803 | running = false; |
| 745 | 804 | posted = played; |
| 805 | clearSnapshot(); | |
| 806 | savedMatch = null; | |
| 746 | 807 | document.exitPointerLock(); |
| 747 | 808 | show(buyPanel, false); |
| 748 | 809 | 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", () => | ||
| 885 | 885 | expect(taken[0].entry.killed).toBe(true); |
| 886 | 886 | }); |
| 887 | 887 | |
| 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 | ||
| 888 | 964 | // --- the radio ------------------------------------------------------------ |
| 889 | 965 | |
| 890 | 966 | 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 = [ | ||
| 190 | 190 | |
| 191 | 191 | const emptyAmmo = (id: WeaponId): Ammo => ({ mag: WEAPONS[id].magazine, reserve: WEAPONS[id].reserve }); |
| 192 | 192 | |
| 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 | ||
| 193 | 223 | function makePlayer(id: number, team: Team, name: string, bot: boolean): Player { |
| 194 | 224 | const secondary: WeaponId = team === "t" ? "glock" : "usp"; |
| 195 | 225 | return { |
| @@ -216,6 +246,13 @@ export function createWorld( | ||
| 216 | 246 | seed = (Date.now() & 0xffff) >>> 0, |
| 217 | 247 | difficulty: Difficulty = DEFAULT_DIFFICULTY, |
| 218 | 248 | 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, | |
| 219 | 256 | ): World { |
| 220 | 257 | const match = rules.newMatch(ruleSet, playerTeam); |
| 221 | 258 | const players: Player[] = []; |
| @@ -245,7 +282,35 @@ export function createWorld( | ||
| 245 | 282 | random, map, boxes: map.boxes, bombPlanted: false, pendingBuy: false, |
| 246 | 283 | difficulty, pendingDifficulty: null, |
| 247 | 284 | }; |
| 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); | |
| 249 | 314 | return world; |
| 250 | 315 | } |
| 251 | 316 | |
| @@ -284,7 +349,7 @@ export function radioCall(world: World, call: Call): boolean { | ||
| 284 | 349 | |
| 285 | 350 | // --- rounds --------------------------------------------------------------- |
| 286 | 351 | |
| 287 | export function startRound(world: World): void { | |
| 352 | export function startRound(world: World, skipBotBuy = false): void { | |
| 288 | 353 | // The round's plan first, so a bot waking up in the freeze time already |
| 289 | 354 | // knows where its side is going. It is a function of the seed and the round |
| 290 | 355 | // rather than a draw from `world.random`: the training arena steps this same |
| @@ -345,7 +410,7 @@ export function startRound(world: World): void { | ||
| 345 | 410 | entry.killed = false; |
| 346 | 411 | } |
| 347 | 412 | 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); | |
| 349 | 414 | } |
| 350 | 415 | |
| 351 | 416 | /** 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; } | ||
| 149 | 149 | .overlay h2 { margin:0; font-size:1.3rem; } |
| 150 | 150 | .overlay p { margin:.2rem 0; color:var(--dim); max-width:34rem; } |
| 151 | 151 | .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; } | |
| 152 | 156 | label { display:flex; flex-direction:column; gap:.25rem; font-size:.8rem; color:var(--dim); text-align:left; } |
| 153 | 157 | label small { font-size:.7rem; } |
| 154 | 158 | 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 { | ||
| 257 | 261 | <div class="overlay" id="menu"> |
| 258 | 262 | <h2>Defuse or detonate</h2> |
| 259 | 263 | <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> | |
| 260 | 266 | <div class="row"> |
| 261 | 267 | <label>Name<input id="name" maxlength="24" placeholder="you" autocomplete="nickname"><small>in the match only</small></label> |
| 262 | 268 | <label>Match<select id="length"><option value="short">Short — first to 5</option><option value="long">Competitive — first to 13</option></select></label> |