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

The round history strip: which rounds you lost, and how #207

opened this

Check · Review · Land · Live — Approved and green. Next: queue, approved and green.

first in the queue: no estimate: nothing landed in the last hour

Ready to land

scout/cs:scout/97-the-round-history-strip-which-rounds-you → main 3 commits fork 4 threads · 4 unresolved

  • Ready to merge — no conflicts with main. 3 commits will land on main.

    1 approval

    Check passeddeploy 1ccc524

    Website spec: 10 findings (report only)
    You need write access to merge.

Review

Sign in to review this change.
9 files changed +386 −15
README.md +11 −2
@@ -445,8 +445,8 @@ number, the same way clicking it would.
445445 **A refresh, a crash or a closed tab is not the end of a match.** At the start
446446 of every freeze time — the first round or the twentieth, a fresh match or a
447447 resumed one — the browser writes what it would take to pick the match back up
448 to `localStorage`: the map, the round, the score, the loss streak and each
449 player's money and gear. Nothing mid-round is in it — no position, no health,
448 to `localStorage`: the map, the round, the score, the loss streak, how each
449 round played so far ended, and each player's money and gear. Nothing mid-round is in it — no position, no health,
450450 no bomb — because there is no tick to resume into, only a round to start over
451451 from its freeze time, the same as any other. Coming back to the page while
452452 that is still there offers **Resume match — de_cinder, round 9, 5–3** next to
@@ -511,6 +511,15 @@ pad lives under the view rather than on it — on a phone the view is 4:3 and
511511 the HUD already owns all four of its corners, so a button on the glass would
512512 be sitting on the radar, the health or the ammo.
513513
514 **Under the score, one mark per round says how the match got there.** Colour
515 is who won it (gold the terrorists, blue the counter-terrorists) and shape is
516 how: a filled circle for an elimination, a triangle for the bomb going off, a
517 square for a defuse, a ring for the clock running out. The empty grey dot at
518 the end is the round being played, which nobody has won yet. The colours are
519 the side that won the round at the time, so after half time your own early
520 wins wear the other side's colour. A resumed match brings the strip back with
521 it, and a save whose strip does not add up to its score is not offered.
522
514523 When you die the camera moves to a teammate who is still up and the HUD becomes
515524 theirs — their health, armour, ammo and gun, no crosshair — over a line naming
516525 who killed you, with what, and how much health they had left, so a lost duel
src/client/hud.test.ts +145 −3
@@ -44,9 +44,11 @@ const hurtWorld = (yaw: number, from: [number, number, number], victim: number):
4444 };
4545
4646 // The damage wedges are arcs centred on the crosshair; the radar draws its
47 // own arcs too, off in the corner, so a plain count of "arc" calls is no
48 // longer enough to isolate them.
49 const wedgeArcs = (calls: Call[]): Call[] => calls.filter((c) => c.name === "arc" && c.args[0] === W / 2);
47 // own arcs too, off in the corner, and the round history strip draws its own
48 // at the top of the screen — both can land on the same x as the crosshair,
49 // so a plain count of "arc" calls is no longer enough to isolate them.
50 const wedgeArcs = (calls: Call[]): Call[] =>
51 calls.filter((c) => c.name === "arc" && c.args[0] === W / 2 && c.args[1] === H / 2);
5052
5153 test("a hit from behind puts the wedge below the crosshair, one from the left on its left", () => {
5254 const behind = recorder();
@@ -603,6 +605,146 @@ test("a plate across the map is a hint: fainter, and clamped small", () => {
603605 expect(backing(close.calls).args[3]).toBeCloseTo(backing(near.calls).args[3], 6);
604606 });
605607
608 /**
609 * Every call that opens one of the round-history strip's shapes: the arc
610 * every circle, ring and the in-progress dot draws, the rect the defuse
611 * square draws, and the moveTo that starts the detonated triangle's path.
612 * Nothing else in a frame draws any of those three with a y this close to
613 * the top of the screen — the radar, the plates and the crosshair all live
614 * well below it, and the kill feed is text, not a path — so this is the
615 * strip and only the strip.
616 */
617 function historyMarks(calls: Call[]): Call[] {
618 return calls.filter((c) =>
619 ["arc", "rect", "moveTo"].includes(c.name) && c.args[1] !== undefined && c.args[1] < 70);
620 }
621
622 test("the round history strip draws one mark per round played, plus the round still being played", () => {
623 const world = createWorld(SHORT, "me", "ct", 5, 101);
624 world.match.history = [
625 { winner: "t", reason: "elimination" },
626 { winner: "ct", reason: "defused" },
627 { winner: "t", reason: "detonated" },
628 { winner: "ct", reason: "time" },
629 ];
630 world.match.matchWinner = null; // still being played: one more mark for the live round
631 const frame = recorder();
632 draw(frame, world);
633 expect(historyMarks(frame.calls).length).toBe(5);
634
635 // Once the match is decided the round in progress is gone: one mark per
636 // round played and nothing more.
637 world.match.matchWinner = "t";
638 const done = recorder();
639 draw(done, world);
640 expect(historyMarks(done.calls).length).toBe(4);
641 });
642
643 /** Where the strip's box is at `width`, the arithmetic `drawHud` does: under the score bar, as wide as it. */
644 function historyBox(width: number): { left: number; right: number; top: number; bottom: number } {
outdated

The title says "never past its height", but the loop only checks x. Also assert y − r >= historyY and y + r <= historyY + historyH for arcs (and the same for rects and the triangle's moveTo), or the vertical half of the box can regress without this test noticing.

645 const scale = Math.min(1.4, Math.max(0.7, width / 1280));
646 const barW = Math.round(240 * scale), barH = Math.round(44 * scale);
647 const top = barH + Math.round(4 * scale);
648 return { left: width / 2 - barW / 2, right: width / 2 + barW / 2, top, bottom: top + Math.round(14 * scale) };
649 }
650
651 /** The x and y extent one mark can reach: an arc by its radius, a rect by its size, a triangle's first vertex as itself. */
652 function markExtent(call: Call): { x0: number; x1: number; y0: number; y1: number } {
653 if (call.name === "arc") {
654 const [x, y, r] = call.args as [number, number, number];
655 return { x0: x - r, x1: x + r, y0: y - r, y1: y + r };
656 }
657 if (call.name === "rect") {
658 const [x, y, w, h] = call.args as [number, number, number, number];
659 return { x0: x, x1: x + w, y0: y, y1: y + h };
660 }
661 const [x, y] = call.args as [number, number];
662 return { x0: x, x1: x, y0: y, y1: y };
663 }
664
665 for (const [width, height] of [[1280, 720], [360, 640]] as const) {
666 test(`the round history strip stays inside its box at ${width}px: the score bar's width, and never past its height`, () => {
667 const world = createWorld(SHORT, "me", "ct", 5, 103);
668 // Thirty rounds played and the thirty-first in progress — past any match
669 // the rules can play — still has to fit on the one line under the score.
670 world.match.history = Array.from({ length: 30 }, (_, i) => (
671 { winner: i % 2 === 0 ? "t" : "ct", reason: (["elimination", "defused", "detonated", "time"] as const)[i % 4] }
672 ));
673 world.match.matchWinner = null;
674 const frame = recorder();
675 drawHud(frame.ctx, world, world.me, cameraFor(world.me), width, height, false);
676 const marks = historyMarks(frame.calls);
677 expect(marks.length).toBe(31);
678
679 const box = historyBox(width);
680 for (const call of marks) {
681 const { x0, x1, y0, y1 } = markExtent(call);
682 expect(x0).toBeGreaterThanOrEqual(box.left - 0.5);
683 expect(x1).toBeLessThanOrEqual(box.right + 0.5);
684 expect(y0).toBeGreaterThanOrEqual(box.top - 0.5);
685 expect(y1).toBeLessThanOrEqual(box.bottom + 0.5);
686 }
687 });
outdated

This passes vacuously when no strip is drawn: 0 === 0 holds both before this change and if the strip ever stops drawing. Also assert the absolute count, e.g. toBe(3) for two rounds plus the live one, so the test fails without the feature.

688 }
689
690 test("a short match's marks grow into the room they have, so the shape reads", () => {
691 const world = createWorld(SHORT, "me", "ct", 5, 104);
692 world.match.history = [{ winner: "t", reason: "elimination" }, { winner: "ct", reason: "elimination" }];
693 for (const width of [1280, 360]) {
694 const frame = recorder();
695 drawHud(frame.ctx, world, world.me, cameraFor(world.me), width, 720, false);
696 const box = historyBox(width);
697 const dot = historyMarks(frame.calls)[0] as Call;
698 // A circle at least most of the strip's height across: not the 3px speck
699 // an 8px cap left it at, and still inside the box the test above holds.
700 expect(markExtent(dot).y1 - markExtent(dot).y0).toBeGreaterThan((box.bottom - box.top) * 0.75);
701 }
702 });
703
704 test("the round history strip says who won each round by colour and how by shape", () => {
705 const world = createWorld(SHORT, "me", "ct", 5, 105);
706 world.match.history = [
707 { winner: "t", reason: "elimination" },
708 { winner: "ct", reason: "defused" },
709 { winner: "t", reason: "detonated" },
710 { winner: "ct", reason: "time" },
711 ];
712 world.match.matchWinner = null;
713 const frame = recorder();
714 draw(frame, world);
715 const marks = historyMarks(frame.calls);
716 // Each mark's path is ended by the first fill or stroke after it: that call
717 // carries the colour the mark is painted in.
718 const ending = (mark: Call): Call =>
719 frame.calls.slice(frame.calls.indexOf(mark)).find((c) => c.name === "fill" || c.name === "stroke") as Call;
720 const read = marks.map((m) => {
721 const end = ending(m);
722 return { shape: m.name, paint: end.name, colour: end.name === "fill" ? end.fill : end.stroke };
723 });
724 expect(read).toEqual([
725 { shape: "arc", paint: "fill", colour: "#e2b04a" }, // T by elimination: a filled circle
726 { shape: "rect", paint: "fill", colour: "#6fa8e8" }, // CT by defuse: a square
727 { shape: "moveTo", paint: "fill", colour: "#e2b04a" }, // T by the bomb: a triangle
728 { shape: "arc", paint: "stroke", colour: "#6fa8e8" }, // CT on time: a ring
729 { shape: "arc", paint: "stroke", colour: "rgba(255,255,255,0.35)" }, // in progress: no side's colour
730 ]);
731 });
732
733 test("the round history strip is the match's, not the eyes it is borrowed through", () => {
734 const world = spectatedWorld();
735 world.match.history = [{ winner: "ct", reason: "elimination" }, { winner: "t", reason: "detonated" }];
736 const mate = world.players.find((p) => p.alive && p.team === "ct") as Player;
737
738 const asCorpse = recorder();
739 draw(asCorpse, world, world.me);
740 const asSpectator = recorder();
741 draw(asSpectator, world, mate);
742
743 // Two rounds played and the live one, through either pair of eyes.
744 expect(historyMarks(asCorpse.calls).length).toBe(3);
745 expect(historyMarks(asSpectator.calls).length).toBe(3);
746 });
747
606748 test("a name plate sits over the body the renderer drew, not the tick it left", () => {
607749 // The scene is drawn between two ticks; a plate taken from the newest one
608750 // would swim above the head it belongs to. `alpha` is the same number both.
src/client/hud.ts +91 −4
@@ -10,8 +10,9 @@
1010
1111 import { crosshairShape, DEFAULT_CROSSHAIR, type CrosshairStyle } from "./crosshair.ts";
1212 import { lerpPos } from "./interpolate.ts";
13 import { angleDelta, clamp, rad, type Vec3 } from "./math.ts";
1413 import { walkable, type MapDef } from "./map.ts";
14 import type { MatchState } from "./match.ts";
15 import { angleDelta, clamp, rad, type Vec3 } from "./math.ts";
1516 import { DEFAULT_MOTION, type Motion } from "./motion.ts";
1617 import { hullHeight, MOVE } from "./physics.ts";
1718 import { project, type Projected } from "./project.ts";
@@ -330,6 +331,79 @@ function drawPlates(
330331 ctx.globalAlpha = 1;
331332 }
332333
334 /**
335 * One mark per round played, tinted by the side that won it and shaped by
336 * how: a circle for an elimination, a triangle for the bomb going off, a
337 * square for a defuse, a ring for the clock running out. The round still
338 * being played, if the match is not decided, is an empty grey dot — nothing
339 * has won it yet. It belongs to the match, not to `view`: a spectator reads
340 * the same strip the person they are watching would.
341 *
342 * `maxWidth` is the whole budget, so a mark shrinks as the round count grows
343 * instead of spilling past it — the way a 30-round match still fits on the
344 * one line the score bar gives it. One path is opened per round, ended in
345 * exactly one `fill` or `stroke`: no second canvas, nothing allocated here
346 * that survives the frame.
347 */
348 function drawRoundHistory(

The legend (circle = elimination, triangle = detonated, square = defused, ring = time, grey dot = in progress) only exists in this comment. Put it in the README where the HUD is described so a player can read the strip.

349 ctx: CanvasRenderingContext2D,
350 match: MatchState,
351 centerX: number,
352 y: number,
353 maxWidth: number,
354 markH: number,
355 ): void {
356 const showCurrent = match.matchWinner === null;
357 const rounds = match.history.length + (showCurrent ? 1 : 0);
358 if (rounds === 0) return;
359 const gap = Math.max(1, markH * 0.25);
360 // Capped at markH so a mark is never taller than it is wide: the shapes
361 // below size themselves off `r`, half of this, and never draw past it —
362 // the strip's whole vertical budget is `markH`, so nothing it draws can
363 // reach above or below the box it was given.
364 const markW = clamp((maxWidth - gap * (rounds - 1)) / rounds, 2, markH);
365 const totalW = markW * rounds + gap * (rounds - 1);
366 const cy = y + markH / 2;
367 let x = centerX - totalW / 2;
368
369 for (let i = 0; i < rounds; i++) {
370 const entry = match.history[i];
371 const r = markW / 2;
372 const cx = x + r;
373 ctx.beginPath();
374 if (entry === undefined) {
375 // the round in progress: undecided, so it carries no side's colour
376 ctx.strokeStyle = "rgba(255,255,255,0.35)";
377 ctx.lineWidth = Math.max(1, r * 0.4);
378 ctx.arc(cx, cy, r * 0.55, 0, Math.PI * 2);
379 ctx.stroke();
380 } else {
381 const color = entry.winner === "t" ? T_COLOR : CT_COLOR;
382 if (entry.reason === "time") {
383 ctx.strokeStyle = color;
384 ctx.lineWidth = Math.max(1, r * 0.4);
385 ctx.arc(cx, cy, r * 0.55, 0, Math.PI * 2);
386 ctx.stroke();
387 } else {
388 ctx.fillStyle = color;
389 if (entry.reason === "elimination") {
390 ctx.arc(cx, cy, r * 0.85, 0, Math.PI * 2);
391 } else if (entry.reason === "defused") {
392 ctx.rect(cx - r * 0.7, cy - r * 0.7, r * 1.4, r * 1.4);
393 } else {
394 // detonated
395 ctx.moveTo(cx, cy - r * 0.9);
396 ctx.lineTo(cx + r * 0.85, cy + r * 0.65);
397 ctx.lineTo(cx - r * 0.85, cy + r * 0.65);
398 ctx.closePath();
399 }
400 ctx.fill();
401 }
402 }
403 x += markW + gap;
404 }
405 }
406
333407 export function drawHud(
334408 ctx: CanvasRenderingContext2D,
335409 world: World,
@@ -459,7 +533,13 @@ export function drawHud(
459533
460534 // --- round clock and score
461535 const barW = Math.round(240 * scale);
462 panel(width / 2 - barW / 2, 0, barW, Math.round(44 * scale));
536 const barH = Math.round(44 * scale);
537 // The round history strip sits on the same panel, so its marks read over a
538 // bright wall the way the clock does. Its height is a constant — sized off
539 // the round count, everything below it would walk down the screen.
540 const historyY = barH + Math.round(4 * scale);
541 const historyH = Math.round(14 * scale);
542 panel(width / 2 - barW / 2, 0, barW, historyY + historyH + Math.round(4 * scale));
463543 ctx.textAlign = "center";
464544 ctx.font = big;
465545 const phase = world.match.phase;
@@ -474,6 +554,13 @@ export function drawHud(
474554 ctx.textAlign = "left";
475555 ctx.fillText(String(world.match.score.ct), width / 2 + barW / 2 + 8 * scale, Math.round(32 * scale));
476556
557 // --- round history: which rounds were lost, and how, directly under the
558 // score bar so the eye that just read the score reads the trend next.
559 // Width-capped to the score bar itself, the "top bar" it has to stay
560 // inside, so the marks shrink rather than reach the kill feed.
561 drawRoundHistory(ctx, world.match, width / 2, historyY, barW, historyH);
562 const aliveY = historyY + historyH + Math.round(14 * scale);
563
477564 // --- health, armour, money
478565 ctx.textAlign = "left";
479566 ctx.font = big;
@@ -539,9 +626,9 @@ export function drawHud(
539626 const aliveT = world.players.filter((p) => p.team === "t" && p.alive).length;
540627 const aliveCT = world.players.filter((p) => p.team === "ct" && p.alive).length;
541628 ctx.fillStyle = T_COLOR;
542 ctx.fillText(`T ${aliveT}`, width / 2 - Math.round(60 * scale), Math.round(62 * scale));
629 ctx.fillText(`T ${aliveT}`, width / 2 - Math.round(60 * scale), aliveY);
543630 ctx.fillStyle = CT_COLOR;
544 ctx.fillText(`CT ${aliveCT}`, width / 2 + Math.round(60 * scale), Math.round(62 * scale));
631 ctx.fillText(`CT ${aliveCT}`, width / 2 + Math.round(60 * scale), aliveY);
545632
546633 // --- kill feed
547634 ctx.textAlign = "right";
src/client/match.test.ts +48 −2
@@ -1,7 +1,7 @@
11 import { expect, test } from "bun:test";
22 import {
3 COMPETITIVE, endRound, halfTime, LOSS_BONUS, newMatch, plantBomb, roundReward, SHORT, tick, winReward,
4 type MatchState, type Phase,
3 COMPETITIVE, endRound, halfTime, LOSS_BONUS, MAX_HISTORY, newMatch, plantBomb, roundReward, SHORT, tick,
4 winReward, type MatchState, type Phase,
55 } from "./match.ts";
66
77 const run = (state: ReturnType<typeof newMatch>, seconds: number): ReturnType<typeof tick> => {
@@ -49,6 +49,52 @@ test("a round can only end once", () => {
4949 expect(m.score).toEqual({ t: 1, ct: 0 });
5050 });
5151
52 test("every round played is appended to history, in the order it was played, and nothing else touches it", () => {
53 const m = newMatch(SHORT);
54 expect(m.history).toEqual([]);
55 endRound(m, "t", "elimination");
56 expect(m.history).toEqual([{ winner: "t", reason: "elimination" }]);
57 // A second call while the round is already over is a no-op everywhere,
58 // history included.
59 endRound(m, "ct", "time");
60 expect(m.history).toEqual([{ winner: "t", reason: "elimination" }]);
61
62 m.phase = "live";
63 endRound(m, "ct", "defused");
64 m.phase = "live";
65 endRound(m, "t", "detonated");
66 expect(m.history).toEqual([
67 { winner: "t", reason: "elimination" },
68 { winner: "ct", reason: "defused" },
69 { winner: "t", reason: "detonated" },
70 ]);
71 });
72
73 test("history is bounded: a round past the ceiling stops being appended", () => {
74 const m = newMatch({ ...SHORT, winScore: MAX_HISTORY + 5 });
75 for (let i = 0; i < MAX_HISTORY + 3; i++) {
76 endRound(m, "ct", "elimination");
77 m.phase = "live";
78 }
79 expect(m.history.length).toBe(MAX_HISTORY);
80 });
81
82 test("history survives half time: the sides swap, the history does not", () => {
83 const m = newMatch(SHORT, "ct");
84 for (let i = 0; i < halfTime(SHORT); i++) {
85 endRound(m, "t", "elimination");
86 if (m.matchWinner !== null) break;
87 run(m, SHORT.endDelay + SHORT.freezeTime + 0.2);
88 m.phase = "live";
89 }
90 // Every round so far was won by "t", and the half-time swap that just
91 // flipped the score and the sides must not have touched a single entry.
92 expect(m.history).toEqual(
93 Array.from({ length: halfTime(SHORT) }, () => ({ winner: "t", reason: "elimination" })),
94 );
95 expect(m.playerTeam).toBe("t"); // the swap did happen
96 });
97
5298 test("sides swap at half time and the score comes with them", () => {
5399 const m = newMatch(SHORT, "ct");
54100 for (let i = 0; i < halfTime(SHORT); i++) {
src/client/match.ts +12
@@ -42,6 +42,15 @@ export const COMPETITIVE: Rules = {
4242 /** The same rules over five rounds, for a match that fits in a coffee break. */
4343 export const SHORT: Rules = { ...COMPETITIVE, winScore: 5, freezeTime: 8 };
4444
45 export type RoundResult = { winner: Team; reason: EndReason };
46
47 /**
48 * Generous, not exact: `COMPETITIVE` (first to 13) is decided in at most 25
49 * rounds and `SHORT` in 9. Thirty is `saved.ts`'s own bound on a resumed
50 * match's round number, so a saved history never outgrows it — past it the
51 * history stops growing rather than trusting a match to end on schedule.
52 */
53 export const MAX_HISTORY = 30;
4554 export type MatchLength = "short" | "long";
4655 export const MATCH_LENGTHS: readonly MatchLength[] = ["short", "long"];
4756 /** The rules a menu's Match select actually means, keyed the way it stores them. */
@@ -58,6 +67,7 @@ export type MatchState = {
5867 winner: Team | null; // of the round that just ended
5968 reason: EndReason | null;
6069 matchWinner: Team | null;
70 history: RoundResult[]; // one entry per round played, in order, never touched by the half-time swap

This field isn't in Saved, so restoreWorld (world.ts:378) brings a match back with history: [] under a non-zero score. Persist it in saved.ts and validate it in parseSaved: at most MAX_HISTORY entries, enum-checked, length round − 1, counts matching score.

6171 };
6272
6373 export function newMatch(rules: Rules, playerTeam: Team = "ct"): MatchState {
@@ -72,6 +82,7 @@ export function newMatch(rules: Rules, playerTeam: Team = "ct"): MatchState {
7282 winner: null,
7383 reason: null,
7484 matchWinner: null,
85 history: [],
7586 };
7687 }
7788
@@ -144,6 +155,7 @@ export function endRound(state: MatchState, winner: Team, reason: EndReason): Ma
144155 state.score[winner] += 1;
145156 state.losses[winner] = Math.max(0, state.losses[winner] - 1);
146157 state.losses[other(winner)] = Math.min(LOSS_BONUS.length, state.losses[other(winner)] + 1);
158 if (state.history.length < MAX_HISTORY) state.history.push({ winner, reason });
147159 const events: MatchEvent[] = [{ type: "round_end", winner, reason }];
148160 if (state.score[winner] >= state.rules.winScore) state.matchWinner = winner;
149161 return events;
src/client/saved.test.ts +36
@@ -61,6 +61,10 @@ const validSaved: Saved = {
6161 headshots: Math.min(i, 2),
6262 damage: i * 37,
6363 })),
64 history: [
65 { winner: "t", reason: "detonated" },
66 { winner: "ct", reason: "defused" },
67 ],
6468 };
6569
6670 test("a whole, valid snapshot round-trips through JSON exactly", () => {
@@ -253,3 +257,35 @@ test("writeSaved, readSaved and clearSaved each swallow a storage failure, same
253257 expect(() => clearSaved()).not.toThrow();
254258 });
255259 });
260
261 test("a round history that is not the one the score came from is refused", () => {
262 const bad = (history: unknown): string => JSON.stringify({ ...validSaved, history });
263 expect(parseSaved(bad(undefined))).toBeNull(); // a save written before the strip existed
264 expect(parseSaved(bad("tt"))).toBeNull();
265 expect(parseSaved(bad([{ winner: "t", reason: "detonated" }]))).toBeNull(); // one short of round − 1
266 expect(parseSaved(bad([{ winner: "t", reason: "detonated" }, { winner: "t", reason: "time" }]))).toBeNull(); // 2–0, not 1–1
267 expect(parseSaved(bad([{ winner: "t", reason: "detonated" }, { winner: "ct", reason: "headshot" }]))).toBeNull();
268 expect(parseSaved(bad([{ winner: "t", reason: "detonated" }, { winner: "bots", reason: "time" }]))).toBeNull();
269 expect(parseSaved(bad([{ winner: "t", reason: "detonated" }, null]))).toBeNull();
270 });
271
272 test("after half time a first-half win counts for the side that won it, now across the bar", () => {
273 // SHORT swaps after round 4: the side now on T won rounds 1–3 as CT and round 5 as T.
274 const history = [
275 { winner: "ct", reason: "elimination" },
276 { winner: "ct", reason: "defused" },
277 { winner: "ct", reason: "time" },
278 { winner: "t", reason: "elimination" },
279 { winner: "t", reason: "detonated" },
280 ] as const;
281 const saved: Saved = { ...validSaved, round: 6, score: { t: 4, ct: 1 }, history: [...history] };
282 expect(parseSaved(JSON.stringify(saved))).toEqual(saved);
283 expect(parseSaved(JSON.stringify({ ...saved, score: { t: 2, ct: 3 } }))).toBeNull();
284 });
285
286 test("a full 30-round save, history and all, stays inside the byte bound", () => {
287 const history = Array.from({ length: 30 }, (_, i) => ({ winner: i % 2 === 0 ? "t" : "ct", reason: "elimination" }) as const);
288 const saved: Saved = { ...validSaved, rules: { ...validSaved.rules, winScore: 30 }, round: 31, score: { t: 14, ct: 16 }, history }; // 29 first-half rounds swap sides
289 expect(new TextEncoder().encode(JSON.stringify(saved)).length).toBeLessThan(4096);
290 expect(parseSaved(JSON.stringify(saved))).toEqual(saved);
291 });
src/client/saved.ts +37 −4
@@ -16,8 +16,8 @@
1616 */
1717
1818 import { MAP_NAMES, type MapName } from "./map.ts";
19 import type { Rules, Team } from "./match.ts";
20 import { LOSS_BONUS } from "./match.ts";
19 import type { EndReason, Rules, RoundResult, Team } from "./match.ts";
20 import { halfTime, LOSS_BONUS, MAX_HISTORY } from "./match.ts";
2121 import { WEAPONS, type WeaponId } from "./weapons.ts";
2222 import type { Player, World } from "./world.ts";
2323
@@ -56,6 +56,8 @@ export type Saved = {
5656 map: MapName;
5757 name: string;
5858 players: SavedPlayer[];
59 /** One entry per round played, so the HUD's round strip resumes agreeing with `score`. */
60 history: RoundResult[];
5961 };
6062
6163 /** `createWorld` is only ever called with a 5-a-side match. */
@@ -68,7 +70,7 @@ const MAX_NAME_LENGTH = 24;
6870 // or the server, it is `crypto.randomUUID()` or nothing.
6971 const MATCH_ID_SHAPE = /^[0-9a-fA-F-]{8,64}$/;
7072
71 // Generous over the ~1.9 KB a full 10-player, 30-round match actually
73 // Generous over the ~3.1 KB a full 10-player, 30-round match actually
7274 // serializes to: room to breathe, not room for someone else's blob.
7375 const MAX_BYTES = 4096;
7476
@@ -103,6 +105,7 @@ export function snapshot(world: World, matchId: string): Saved {
103105 map: world.map.name as MapName,
104106 name: world.me.name,
105107 players: world.players.map(savePlayer),
108 history: world.match.history.map((r) => ({ winner: r.winner, reason: r.reason })),
106109 };
107110 }
108111
@@ -183,6 +186,33 @@ function parsePlayer(v: unknown, rules: Rules): SavedPlayer | null {
183186 return { money, primary, secondary, armor, helmet, defuseKit, kills, deaths, headshots, damage };
184187 }
185188
189 const END_REASONS: readonly EndReason[] = ["elimination", "detonated", "defused", "time"];
190
191 /**
192 * A history is only believed when it is the one `score` came from: one entry
193 * per round already played, and each side's wins adding up to its score. The
194 * score swaps sides at half time and the history does not, so a first-half
195 * win counts for the other side once the half is past.
196 */
197 function parseHistory(v: unknown, round: number, score: { t: number; ct: number }, rules: Rules): RoundResult[] | null {
198 if (!Array.isArray(v) || v.length > MAX_HISTORY || v.length !== round - 1) return null;
199 const swapped = round > halfTime(rules);
200 const wins = { t: 0, ct: 0 };
201 const out: RoundResult[] = [];
202 for (let i = 0; i < v.length; i++) {
203 const entry: unknown = v[i];
204 if (!isRecord(entry) || Object.hasOwn(entry, "__proto__")) return null;
205 const { winner, reason } = entry;
206 if (winner !== "t" && winner !== "ct") return null;
207 if (typeof reason !== "string" || !(END_REASONS as readonly string[]).includes(reason)) return null;
208 const side: Team = swapped && i < halfTime(rules) ? (winner === "t" ? "ct" : "t") : winner;
209 wins[side] += 1;
210 out.push({ winner, reason: reason as EndReason });
211 }
212 if (wins.t !== score.t || wins.ct !== score.ct) return null;
213 return out;
214 }
215
186216 /** Pure: `null` for anything that is not exactly a snapshot `snapshot` could have written. Never throws. */
187217 export function parseSaved(raw: string | null): Saved | null {
188218 if (raw === null || byteLength(raw) > MAX_BYTES) return null;
@@ -230,7 +260,10 @@ export function parseSaved(raw: string | null): Saved | null {
230260 players.push(player);
231261 }
232262
233 return { rules, matchId, round, score, losses, playerTeam, seed, map, name, players };
263 const history = parseHistory(parsed.history, round, score, rules);
264 if (history === null) return null;
265
266 return { rules, matchId, round, score, losses, playerTeam, seed, map, name, players, history };
234267 }
235268
236269 const KEY = "cs.saved";
src/client/world.test.ts +5
@@ -1028,6 +1028,10 @@ test("restoreWorld(snapshot(world, id)) round-trips score, round, money and gear
10281028 world.match.round = 3;
10291029 world.match.score = { t: 1, ct: 2 };
10301030 world.match.losses = { t: 0, ct: 1 };
1031 world.match.history = [
1032 { winner: "ct", reason: "elimination" },
1033 { winner: "t", reason: "detonated" },
1034 ];
10311035 world.me.money = 4300;
10321036 world.me.primary = "m4a4";
10331037 world.me.armor = 100;
@@ -1047,6 +1051,7 @@ test("restoreWorld(snapshot(world, id)) round-trips score, round, money and gear
10471051 expect(restored.match.round).toBe(3);
10481052 expect(restored.match.score).toEqual({ t: 1, ct: 2 });
10491053 expect(restored.match.losses).toEqual({ t: 0, ct: 1 });
1054 expect(restored.match.history).toEqual(world.match.history); // the strip under the score resumes with it
10501055 expect(restored.me.money).toBe(4300);
10511056 expect(restored.me.primary).toBe("m4a4");
10521057 expect(restored.me.armor).toBe(100);
src/client/world.ts +1
@@ -379,6 +379,7 @@ export function restoreWorld(
379379 world.match.score = { ...save.score };
380380 world.match.losses = { ...save.losses };
381381 world.match.playerTeam = save.playerTeam;
382 world.match.history = save.history.map((r) => ({ ...r }));
382383 if (pastHalf) for (const p of world.players) p.team = rules.other(p.team);
383384 world.players.forEach((p, i) => restorePlayer(p, save.players[i]));
384385 // startRound below spawns the round the snapshot resumes; skipBotBuy stops

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs