Split The code is public; the app answers only to the crew.
The round history strip: which rounds you lost, and how #207
Check · Review · Land · Live — Approved and green.
first in the queue: no estimate: nothing landed in the last hour
scout/cs:scout/97-the-round-history-strip-which-rounds-you → main 3 commits fork 4 threads · 4 unresolved
README.md +11 −2
| @@ -445,8 +445,8 @@ number, the same way clicking it would. | ||
| 445 | 445 | **A refresh, a crash or a closed tab is not the end of a match.** At the start |
| 446 | 446 | of every freeze time — the first round or the twentieth, a fresh match or a |
| 447 | 447 | 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, | |
| 450 | 450 | no bomb — because there is no tick to resume into, only a round to start over |
| 451 | 451 | from its freeze time, the same as any other. Coming back to the page while |
| 452 | 452 | 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 | ||
| 511 | 511 | the HUD already owns all four of its corners, so a button on the glass would |
| 512 | 512 | be sitting on the radar, the health or the ammo. |
| 513 | 513 | |
| 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 | ||
| 514 | 523 | When you die the camera moves to a teammate who is still up and the HUD becomes |
| 515 | 524 | theirs — their health, armour, ammo and gun, no crosshair — over a line naming |
| 516 | 525 | 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): | ||
| 44 | 44 | }; |
| 45 | 45 | |
| 46 | 46 | // 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); | |
| 50 | 52 | |
| 51 | 53 | test("a hit from behind puts the wedge below the crosshair, one from the left on its left", () => { |
| 52 | 54 | const behind = recorder(); |
| @@ -603,6 +605,146 @@ test("a plate across the map is a hint: fainter, and clamped small", () => { | ||
| 603 | 605 | expect(backing(close.calls).args[3]).toBeCloseTo(backing(near.calls).args[3], 6); |
| 604 | 606 | }); |
| 605 | 607 | |
| 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 } { | |
| 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. |
||
| 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 | ||
| 606 | 748 | test("a name plate sits over the body the renderer drew, not the tick it left", () => { |
| 607 | 749 | // The scene is drawn between two ticks; a plate taken from the newest one |
| 608 | 750 | // would swim above the head it belongs to. `alpha` is the same number both. |
src/client/hud.ts +91 −4
| @@ -10,8 +10,9 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { crosshairShape, DEFAULT_CROSSHAIR, type CrosshairStyle } from "./crosshair.ts"; |
| 12 | 12 | import { lerpPos } from "./interpolate.ts"; |
| 13 | import { angleDelta, clamp, rad, type Vec3 } from "./math.ts"; | |
| 14 | 13 | import { walkable, type MapDef } from "./map.ts"; |
| 14 | import type { MatchState } from "./match.ts"; | |
| 15 | import { angleDelta, clamp, rad, type Vec3 } from "./math.ts"; | |
| 15 | 16 | import { DEFAULT_MOTION, type Motion } from "./motion.ts"; |
| 16 | 17 | import { hullHeight, MOVE } from "./physics.ts"; |
| 17 | 18 | import { project, type Projected } from "./project.ts"; |
| @@ -330,6 +331,79 @@ function drawPlates( | ||
| 330 | 331 | ctx.globalAlpha = 1; |
| 331 | 332 | } |
| 332 | 333 | |
| 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 | ||
| 333 | 407 | export function drawHud( |
| 334 | 408 | ctx: CanvasRenderingContext2D, |
| 335 | 409 | world: World, |
| @@ -459,7 +533,13 @@ export function drawHud( | ||
| 459 | 533 | |
| 460 | 534 | // --- round clock and score |
| 461 | 535 | 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)); | |
| 463 | 543 | ctx.textAlign = "center"; |
| 464 | 544 | ctx.font = big; |
| 465 | 545 | const phase = world.match.phase; |
| @@ -474,6 +554,13 @@ export function drawHud( | ||
| 474 | 554 | ctx.textAlign = "left"; |
| 475 | 555 | ctx.fillText(String(world.match.score.ct), width / 2 + barW / 2 + 8 * scale, Math.round(32 * scale)); |
| 476 | 556 | |
| 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 | ||
| 477 | 564 | // --- health, armour, money |
| 478 | 565 | ctx.textAlign = "left"; |
| 479 | 566 | ctx.font = big; |
| @@ -539,9 +626,9 @@ export function drawHud( | ||
| 539 | 626 | const aliveT = world.players.filter((p) => p.team === "t" && p.alive).length; |
| 540 | 627 | const aliveCT = world.players.filter((p) => p.team === "ct" && p.alive).length; |
| 541 | 628 | 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); | |
| 543 | 630 | 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); | |
| 545 | 632 | |
| 546 | 633 | // --- kill feed |
| 547 | 634 | ctx.textAlign = "right"; |
src/client/match.test.ts +48 −2
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | 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, | |
| 5 | 5 | } from "./match.ts"; |
| 6 | 6 | |
| 7 | 7 | const run = (state: ReturnType<typeof newMatch>, seconds: number): ReturnType<typeof tick> => { |
| @@ -49,6 +49,52 @@ test("a round can only end once", () => { | ||
| 49 | 49 | expect(m.score).toEqual({ t: 1, ct: 0 }); |
| 50 | 50 | }); |
| 51 | 51 | |
| 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 | ||
| 52 | 98 | test("sides swap at half time and the score comes with them", () => { |
| 53 | 99 | const m = newMatch(SHORT, "ct"); |
| 54 | 100 | for (let i = 0; i < halfTime(SHORT); i++) { |
src/client/match.ts +12
| @@ -42,6 +42,15 @@ export const COMPETITIVE: Rules = { | ||
| 42 | 42 | /** The same rules over five rounds, for a match that fits in a coffee break. */ |
| 43 | 43 | export const SHORT: Rules = { ...COMPETITIVE, winScore: 5, freezeTime: 8 }; |
| 44 | 44 | |
| 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; | |
| 45 | 54 | export type MatchLength = "short" | "long"; |
| 46 | 55 | export const MATCH_LENGTHS: readonly MatchLength[] = ["short", "long"]; |
| 47 | 56 | /** The rules a menu's Match select actually means, keyed the way it stores them. */ |
| @@ -58,6 +67,7 @@ export type MatchState = { | ||
| 58 | 67 | winner: Team | null; // of the round that just ended |
| 59 | 68 | reason: EndReason | null; |
| 60 | 69 | 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 |
||
| 61 | 71 | }; |
| 62 | 72 | |
| 63 | 73 | export function newMatch(rules: Rules, playerTeam: Team = "ct"): MatchState { |
| @@ -72,6 +82,7 @@ export function newMatch(rules: Rules, playerTeam: Team = "ct"): MatchState { | ||
| 72 | 82 | winner: null, |
| 73 | 83 | reason: null, |
| 74 | 84 | matchWinner: null, |
| 85 | history: [], | |
| 75 | 86 | }; |
| 76 | 87 | } |
| 77 | 88 | |
| @@ -144,6 +155,7 @@ export function endRound(state: MatchState, winner: Team, reason: EndReason): Ma | ||
| 144 | 155 | state.score[winner] += 1; |
| 145 | 156 | state.losses[winner] = Math.max(0, state.losses[winner] - 1); |
| 146 | 157 | 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 }); | |
| 147 | 159 | const events: MatchEvent[] = [{ type: "round_end", winner, reason }]; |
| 148 | 160 | if (state.score[winner] >= state.rules.winScore) state.matchWinner = winner; |
| 149 | 161 | return events; |
src/client/saved.test.ts +36
| @@ -61,6 +61,10 @@ const validSaved: Saved = { | ||
| 61 | 61 | headshots: Math.min(i, 2), |
| 62 | 62 | damage: i * 37, |
| 63 | 63 | })), |
| 64 | history: [ | |
| 65 | { winner: "t", reason: "detonated" }, | |
| 66 | { winner: "ct", reason: "defused" }, | |
| 67 | ], | |
| 64 | 68 | }; |
| 65 | 69 | |
| 66 | 70 | 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 | ||
| 253 | 257 | expect(() => clearSaved()).not.toThrow(); |
| 254 | 258 | }); |
| 255 | 259 | }); |
| 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 @@ | ||
| 16 | 16 | */ |
| 17 | 17 | |
| 18 | 18 | 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"; | |
| 21 | 21 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 22 | 22 | import type { Player, World } from "./world.ts"; |
| 23 | 23 | |
| @@ -56,6 +56,8 @@ export type Saved = { | ||
| 56 | 56 | map: MapName; |
| 57 | 57 | name: string; |
| 58 | 58 | players: SavedPlayer[]; |
| 59 | /** One entry per round played, so the HUD's round strip resumes agreeing with `score`. */ | |
| 60 | history: RoundResult[]; | |
| 59 | 61 | }; |
| 60 | 62 | |
| 61 | 63 | /** `createWorld` is only ever called with a 5-a-side match. */ |
| @@ -68,7 +70,7 @@ const MAX_NAME_LENGTH = 24; | ||
| 68 | 70 | // or the server, it is `crypto.randomUUID()` or nothing. |
| 69 | 71 | const MATCH_ID_SHAPE = /^[0-9a-fA-F-]{8,64}$/; |
| 70 | 72 | |
| 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 | |
| 72 | 74 | // serializes to: room to breathe, not room for someone else's blob. |
| 73 | 75 | const MAX_BYTES = 4096; |
| 74 | 76 | |
| @@ -103,6 +105,7 @@ export function snapshot(world: World, matchId: string): Saved { | ||
| 103 | 105 | map: world.map.name as MapName, |
| 104 | 106 | name: world.me.name, |
| 105 | 107 | players: world.players.map(savePlayer), |
| 108 | history: world.match.history.map((r) => ({ winner: r.winner, reason: r.reason })), | |
| 106 | 109 | }; |
| 107 | 110 | } |
| 108 | 111 | |
| @@ -183,6 +186,33 @@ function parsePlayer(v: unknown, rules: Rules): SavedPlayer | null { | ||
| 183 | 186 | return { money, primary, secondary, armor, helmet, defuseKit, kills, deaths, headshots, damage }; |
| 184 | 187 | } |
| 185 | 188 | |
| 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 | ||
| 186 | 216 | /** Pure: `null` for anything that is not exactly a snapshot `snapshot` could have written. Never throws. */ |
| 187 | 217 | export function parseSaved(raw: string | null): Saved | null { |
| 188 | 218 | if (raw === null || byteLength(raw) > MAX_BYTES) return null; |
| @@ -230,7 +260,10 @@ export function parseSaved(raw: string | null): Saved | null { | ||
| 230 | 260 | players.push(player); |
| 231 | 261 | } |
| 232 | 262 | |
| 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 }; | |
| 234 | 267 | } |
| 235 | 268 | |
| 236 | 269 | 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 | ||
| 1028 | 1028 | world.match.round = 3; |
| 1029 | 1029 | world.match.score = { t: 1, ct: 2 }; |
| 1030 | 1030 | world.match.losses = { t: 0, ct: 1 }; |
| 1031 | world.match.history = [ | |
| 1032 | { winner: "ct", reason: "elimination" }, | |
| 1033 | { winner: "t", reason: "detonated" }, | |
| 1034 | ]; | |
| 1031 | 1035 | world.me.money = 4300; |
| 1032 | 1036 | world.me.primary = "m4a4"; |
| 1033 | 1037 | world.me.armor = 100; |
| @@ -1047,6 +1051,7 @@ test("restoreWorld(snapshot(world, id)) round-trips score, round, money and gear | ||
| 1047 | 1051 | expect(restored.match.round).toBe(3); |
| 1048 | 1052 | expect(restored.match.score).toEqual({ t: 1, ct: 2 }); |
| 1049 | 1053 | 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 | |
| 1050 | 1055 | expect(restored.me.money).toBe(4300); |
| 1051 | 1056 | expect(restored.me.primary).toBe("m4a4"); |
| 1052 | 1057 | expect(restored.me.armor).toBe(100); |
src/client/world.ts +1
| @@ -379,6 +379,7 @@ export function restoreWorld( | ||
| 379 | 379 | world.match.score = { ...save.score }; |
| 380 | 380 | world.match.losses = { ...save.losses }; |
| 381 | 381 | world.match.playerTeam = save.playerTeam; |
| 382 | world.match.history = save.history.map((r) => ({ ...r })); | |
| 382 | 383 | if (pastHalf) for (const p of world.players) p.team = rules.other(p.team); |
| 383 | 384 | world.players.forEach((p, i) => restorePlayer(p, save.players[i])); |
| 384 | 385 | // startRound below spawns the round the snapshot resumes; skipBotBuy stops |
The title says "never past its height", but the loop only checks x. Also assert
y − r >= historyYandy + r <= historyY + historyHfor arcs (and the same for rects and the triangle's moveTo), or the vertical half of the box can regress without this test noticing.