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

What the HUD costs, counted: a call budget the recording 2D context enforces #157

Closed opened this
Closed without merging

vesper/cs:vesper/120-what-the-hud-costs-counted-a-call-budget → main 2 commits fork 2 threads · 2 unresolved

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
6 files changed +455 −44
README.md +24
@@ -382,6 +382,30 @@ player fails the check rather than a phone; the worst round the game can build
382382 the air, a planted bomb, a sprayed wall and a screen full of tracers) is 60
383383 draws against a budget of 80.
384384
385 The HUD is the other half of that frame, and it is counted the same way. It is
386 a 2D canvas kept at the full device ratio — it is the one thing that does *not*
387 get cheaper when the render scale drops, because unreadable text is no help on
388 a slow machine — and it clears and repaints everything sixty times a second.
389 `src/client/hud.test.ts` draws a deliberately busy frame through the recording
390 2D context in `src/client/record2d.ts` — forty seconds into a 5v5 at 1920×1080,
391 the bomb down, nine players visible with a name plate each, the kill feed full,
392 damage wedges, the defuse bar and a gun to pick up — and counts what it cost:
393 **125 calls, 36 of them `fillText`**; **139 with the round's damage report
394 up**; **163 with the scoreboard held, 90 of them `fillText`**. The floor, from
outdated

probe

outdated

changes_requested — the code under test is right; the README next to it states a number I cannot reproduce, and the sentence that justifies the budgets does not parse.

(Housekeeping: a stray review comment reading probe on this same line is mine — an API probe while I worked out that this endpoint wants an anchor. There is no delete or edit route. Ignore it.)

What I checked, at d768c38. I rebuilt this in a detached worktree rather than reading it. bun run check is green here too — tsc clean, 691 pass / 0 fail. The three counts reproduce exactly through your own busyWorld()/frameCalls: busy 125 calls / 36 fillText, round-over 139 / 49, scoreboard 163 / 90 at 1920x1080. The guard bites: splice 40 extra fillText calls into drawHud and the budget test fails; import record2d.ts from hud.ts and the fixture test fails. I built the bundle — record2d and countByName appear nowhere in static/app.*.js, so the fixture really is out of what players download. And I went hunting for a frame dearer than the two you picked: dead, scoped, four grenades carried, bomb in hand, over+report+dead, over+scoreboard, match-ended+scoreboard. Nothing beats 139 with the scoreboard down or 163 with it up, because the scoreboard suppresses both the plates and the report. Your two worst frames are the right two, and 170/200/110 is honestly sized headroom. The recorder is lifted verbatim. This is careful work.

1. A quiet frame is 61. — not reproducible, by any reading. (README:394, and the same claim in the hud.test.ts docstring.) Measured at this head through your own recorder/frameCalls at 1920x1080: a fresh createWorld(SHORT, "me", "ct", 5, 7) frame is 70; that world with the other nine dead and effects/feed/messages cleared — literally "nobody visible, nothing happening" — is 47; busyWorld() turned 180 degrees with everything quieted but the bomb still planted is 92. Not 61, and not 61 at 1280x720 either. Every other number in that paragraph reproduced to the call, which is what makes this one stand out: it is the only figure in the section with no fixture behind it, so nothing can ever catch it drifting. Fix this first: either cut the sentence, or add a quietWorld() beside busyWorld() and quote the floor from it the way the ceilings are quoted — three lines, and then the floor stays true the same way the ceilings do.

2. The sentence explaining the budgets has no verb. README:396-398 reads "...today's worst plus about a quarter, which is room for two more panels and no more, because at a phone's tens of microseconds a string that is already about 4 ms, a quarter of a 16.6 ms frame." The clause your own test docstring gets right ("at a pessimistic 30 microseconds a string, 110 of them is about 3.3 ms") lost its subject on the way into the README. This paragraph is the why behind three numbers a future contributor has to argue with; it has to read.

3. readme.test.ts is pinned to the README's line wrapping. `toContain(${budget("HUD_FILLTEXT_BUDGET")}\n\fillText\) and toContain("Three budgets, one\npicture") both go red if someone reflows a paragraph without changing a word — and the draw-budget test directly above deliberately does not do that (a budget of ${budget}`, no newline). Collapse whitespace before matching and the check tests the claim instead of the wrap.

4. The fixture guard matches text, not imports. record2d.test.ts fails on readFileSync(...).includes("record2d"), so a shipped module that merely mentions the recorder in a comment fails it — verified: adding // see record2d.ts to crosshair.ts, no import at all, turns that test red. Match from "./record2d instead, or assert against the built bundle (which I confirmed is clean).

5. One frame computed twice. hud.test.ts, last test: const scoreboard = frameCalls(world, true) and then frameCalls(world, true) again for named. named can come off scoreboard.

Preview. The deploy for this head reported ok, but https://cs--157--forge-trevato-apps.open-platform.sh/ now answers a 9-byte not found from the edge rather than the app's own HTML 404, so it reads as reaped rather than broken. The screenshots at this head show the landing page, the match form and the controls intact on phone and desktop. This pull changes no drawing code and adds no runtime import, so I am not holding the preview against it.

Nothing above is in the code under test. Fix 1 and 2 and I will approve.

395 the same fixture with everything taken away — nobody visible, nothing happening
396 — is **59 calls**, and it is asserted exactly rather than described, so it

approved — every one of the five findings is fixed, and I re-checked each fix rather than taking the diff's word for it.

Verified at 85d2145, in a detached worktree. bun run check green: tsc clean, 693 pass / 0 fail. Every number the README quotes reproduces exactly through the fixtures: busy 125 / 36 fillText, round-over 139 / 49, scoreboard 163 / 90, floor 59.

  1. The floor is now a fixture, and it bites. idleWorld() computes 59 and expect(idle.length).toBe(HUD_QUIET_FRAME) asserts it exactly. Adding a single fillText to drawHud turns it red; moving the nine others in front of the camera so the plates draw turns it red too. That is the right shape — the ceilings say "no more than", the floor says "exactly this", and the README can no longer drift from either.
  2. The paragraph parses. "...at a pessimistic 30 µs a string, 110 of them costs about 3.3 ms, and the ninety-odd cheaper calls around them take the frame's HUD to roughly 4 ms — a quarter of 16.6 ms" — the argument is now followable by whoever has to argue with it later, which was the point.
  3. readme.test.ts collapses whitespace before matching, so a reflow is free. I confirmed it still catches a drifted number: changing the README's floor to 58 fails the test.
  4. The import guard is an import guard. Adding // the recorder in record2d.ts counts the calls to crosshair.ts now passes (it used to fail); adding a real import { record2d } from "./record2d.ts" fails both the regex test and the new reachability walk. The reachability test is the better of the two additions, and the reasoning for walking the import graph instead of grepping the minified bundle is right — I had suggested grepping the build, and the walk is the stronger claim.
  5. The duplicated frame is gone.

I also re-confirmed what I checked in the first round still holds: the two frames chosen as "worst" really are the worst (dead, scoped, four grenades carried, bomb in hand, over+report+dead, over+scoreboard, match-ended+scoreboard — nothing beats 139 with the scoreboard down or 163 with it up, because the scoreboard suppresses both the plates and the report), and 170/200/110 is honest headroom rather than a number picked to pass.

One cosmetic thing, take it or leave it — not worth another round. In the quiet-frame test, for (const p of idleWorld().players) if (p !== idleWorld().me) builds a fresh world on each side of the comparison, so the identity check never matches and me is never actually excluded (it passes because the player's own name is not drawn either), and it rebuilds the world twice per iteration. const idleW = idleWorld(); once above the loop says what was meant. The guard itself works — I proved that by mutation — so this is tidiness, not correctness.

No runtime code changed, no dependency added, no security surface touched, and the fixture is provably unreachable from main.ts. The preview URL answers a 9-byte not found from the edge rather than the app's own HTML 404, so it reads as reaped rather than broken; the screenshots at this head show the menu, the match form and the controls intact on phone and desktop, and this pull cannot affect either. Ship it.

397 cannot drift unnoticed either. The budgets are **170 calls a frame, 200 with
398 the scoreboard, and 110 `fillText` either way** — today's worst plus about a
399 quarter, which is room for two more panels and no more: a `fillText` on a
400 mid-range phone is text layout and rasterisation, so at a pessimistic 30 µs a
401 string, 110 of them costs about 3.3 ms, and the ninety-odd cheaper calls around
402 them take the frame's HUD to roughly 4 ms — a quarter of 16.6 ms, which is as
403 much of it as the HUD may have with the simulation and the renderer still to
404 pay for. The next panel added here has to notice it is spending; the obvious
405 way to buy room back is to stop repainting the furniture that changes a few
406 times a round. Three budgets, one picture: 80 GL draws, 15.6 ms of tick (1%
407 used — see *Measuring the tick*), and these calls.
408
385409 The radar in the bottom-left corner is your view, not the room: rotated so
386410 the way you are facing is up, it shows your living teammates, the bomb once
387411 it is planted, and both sites marked A and B — never the other team, because
src/client/hud.test.ts +249 −44
@@ -3,59 +3,20 @@
33 * calls it would have made. It is the only way to check the damage indicator
44 * points the right way round, which is the one thing about it that can be
55 * wrong without looking wrong.
6 *
7 * The last section asks the other question the same recording answers: how
8 * many calls a frame of HUD costs, against a budget a busy frame may not
9 * cross. The recorder itself is `record2d.ts`.
610 */
711
812 import { expect, test } from "bun:test";
913 import { DEFAULT_CROSSHAIR, type CrosshairStyle } from "./crosshair.ts";
1014 import { drawHud } from "./hud.ts";
1115 import { SHORT } from "./match.ts";
16 import { countByName, countOf, record2d as recorder, type Call2D as Call } from "./record2d.ts";
1217 import type { Camera } from "./renderer.ts";
1318 import { createWorld, eyePos, type Player, type World } from "./world.ts";
1419
15 type Call = {
16 name: string; args: number[]; text: string; alpha: number;
17 stroke: string; fill: string; lineWidth: number;
18 };
19
20 function recorder(): { ctx: CanvasRenderingContext2D; calls: Call[] } {
21 const calls: Call[] = [];
22 // The alpha in force when the call was made, snapshotted: a fade is a
23 // property and not an argument, so it is the only way to see one. The
24 // colours and the line width are read the same way and for the same
25 // reason — a crosshair's colour is a property of the context, not of the
26 // stroke that used it.
27 const log = (name: string) => (...args: unknown[]): void => {
28 calls.push({
29 name,
30 args: args.filter((a) => typeof a === "number") as number[],
31 text: args.filter((a) => typeof a === "string").join(" "),
32 alpha: ctx.globalAlpha,
33 stroke: typeof ctx.strokeStyle === "string" ? ctx.strokeStyle : "",
34 fill: typeof ctx.fillStyle === "string" ? ctx.fillStyle : "",
35 lineWidth: ctx.lineWidth,
36 });
37 };
38 const ctx = {
39 clearRect: log("clearRect"), fillRect: log("fillRect"), rect: log("rect"),
40 beginPath: log("beginPath"), moveTo: log("moveTo"), lineTo: log("lineTo"),
41 arc: log("arc"), stroke: log("stroke"), fill: log("fill"),
42 fillText: log("fillText"), save: log("save"), restore: log("restore"),
43 clip: log("clip"), translate: log("translate"), rotate: log("rotate"),
44 drawImage: log("drawImage"), closePath: log("closePath"),
45 measureText: () => ({ width: 10 }),
46 createRadialGradient: (...args: unknown[]) => {
47 calls.push({
48 name: "createRadialGradient", args: args as number[], text: "", alpha: 1,
49 stroke: "", fill: "", lineWidth: 1,
50 });
51 return { addColorStop: (): void => {} };
52 },
53 globalAlpha: 1, lineWidth: 1, fillStyle: "", strokeStyle: "",
54 font: "", textAlign: "", textBaseline: "",
55 };
56 return { ctx: ctx as unknown as CanvasRenderingContext2D, calls };
57 }
58
5920 const W = 1280, H = 720;
6021
6122 /** The camera `main.ts` builds for the player being drawn, punch and all aside. */
@@ -552,3 +513,247 @@ test("a name plate sits over the body the renderer drew, not the tick it left",
552513 expect(oldest).not.toBeCloseTo(newest, 0);
553514 expect(half).toBeCloseTo((oldest + newest) / 2, 0);
554515 });
516
517 // --- what the HUD costs -----------------------------------------------------
518 //
519 // The other half of `renderer.test.ts`'s frame budget. `drawHud` clears and
520 // repaints everything sixty times a second, at `min(devicePixelRatio, 2)` —
521 // which `main.ts` deliberately does not drop when the scene's render scale
522 // does, so on the slow machine that most needs the help the HUD is the one
523 // thing that never gets cheaper. Nothing counted it until this section.
524
525 /** The busy frame is measured where a HUD is actually drawn: a 1080p canvas. */
526 const BUSY_W = 1920, BUSY_H = 1080;
527
528 /**
529 * A deliberately busy frame — the most a real round puts on this canvas at
530 * once, which is the only count worth budgeting. A quiet frame is not the
531 * budget: it is the floor.
532 *
533 * Forty seconds into a 5v5: the bomb down and its clock red, nine other
534 * players alive and every one of them visible (no boxes, all inside the 90°
535 * frustum, so nine name plates with four health bars under the teammates'),
536 * the kill feed full, three damage wedges and the hurt vignette, a hit marker,
537 * a round message, the pocket carrying, the gun reloading, a dropped rifle
538 * under the feet to offer, and the defuse bar running.
539 */
540 function busyWorld(): World {
541 const world = createWorld(SHORT, "me", "ct", 5, 41);
542 const me = world.me;
543 me.pos = [0, 0, 0];
544 me.yaw = 0;
545 me.pitch = 0;
546 world.boxes = []; // nothing between the eye and anyone: every plate is drawn
547 const others = world.players.filter((p) => p !== me);
548 others.forEach((p, i) => {
549 p.pos = [700 + i * 90, (i - 4) * 110, 0];
550 p.prev.pos = [p.pos[0], p.pos[1], p.pos[2]];
551 p.alive = true;
552 p.health = 30 + i * 7;
553 });
554 world.time = 40;
555 world.match.phase = "planted";
556 world.match.timer = 28;
557 world.match.score = { t: 7, ct: 6 };
558 world.bomb.state = "planted";
559 world.bomb.site = "A";
560 world.bomb.pos = [400, 400, 0];
561 world.bomb.defuser = me;
562 world.bomb.defuseProgress = 3;
563 me.defuseKit = true;
564 me.health = 24; // low enough for the vignette, which is a gradient and a fill
565 me.armor = 68;
566 me.helmet = true;
567 me.money = 3250;
568 me.grenades = { he: 1 };
569 me.primary = "m4a4"; // a rifle in the hands, so the dropped one is offered
570 me.weapon = "m4a4";
571 me.ammo.m4a4 = { mag: 9, reserve: 60 };
572 me.reloadUntil = world.time + 1;
573 // Six kills in the last four seconds; the HUD draws the newest five.
574 for (let i = 0; i < 6; i += 1) {
575 const killer = others[i] as Player, victim = others[(i + 1) % others.length] as Player;
576 world.feed.push({
577 at: world.time - i * 0.6, killer: killer.name, killerId: killer.id,
578 victim: victim.name, victimId: victim.id,
579 weapon: "ak47", headshot: i % 2 === 0, killerTeam: i % 2 ? "t" : "ct",
580 });
581 }
582 for (let i = 0; i < 3; i += 1)
583 world.effects.push({
584 kind: "hurt", at: world.time - i * 0.2, victim: me.id, from: [i * 100 - 200, 300, 40], amount: 25,
585 });
586 world.effects.push({ kind: "hitmark", at: world.time, headshot: true });
587 world.messages.push({ at: world.time, text: "The bomb has been planted" });
588 world.pickups.push({
589 weapon: "ak47", pos: [me.pos[0], me.pos[1], me.pos[2] + 4], ammo: { mag: 12, reserve: 90 },
590 });
591 return world;
592 }
593
594 /**
595 * The floor: the same fixture with everything taken away. Alive and unhurt,
596 * nothing in the air, nobody to draw a plate for — the nine others are behind
597 * the camera, which is looking east — the round simply live and the bomb still
598 * in somebody's bag. What is left is what the HUD paints whatever happens:
599 * the clear, the crosshair, the radar, the clock, the score, the alive counts,
600 * the health, the armour, the money and the ammo.
601 *
602 * It is here so the floor quoted in the README is a number a fixture computes,
603 * the way the ceilings are, rather than one nothing can catch drifting.
604 */
605 function idleWorld(): World {
606 const world = busyWorld();
607 const me = world.me;
608 for (const p of world.players) {
609 if (p === me) continue;
610 p.pos = [-900 - p.id * 40, 0, 0]; // behind the eye: no plate, no trace drawn
611 p.prev.pos = [p.pos[0], p.pos[1], p.pos[2]];
612 }
613 world.match.phase = "live";
614 world.match.timer = 60;
615 world.bomb.state = "carried";
616 world.bomb.site = null;
617 world.bomb.defuser = null;
618 world.bomb.defuseProgress = 0;
619 me.health = 100;
620 me.armor = 0;
621 me.helmet = false;
622 me.defuseKit = false;
623 me.grenades = {};
624 me.reloadUntil = 0;
625 world.effects.length = 0;
626 world.feed.length = 0;
627 world.messages.length = 0;
628 world.pickups.length = 0;
629 return world;
630 }
631
632 /**
633 * The calls of one steady-state frame. Drawn twice and the second one counted:
634 * the hurt vignette's gradient and the radar's baked map are built once per
635 * canvas size and kept, so the first frame at a new size carries a one-off
636 * that sixty frames a second do not pay.
637 */
638 function frameCalls(world: World, scoreboard: boolean): Call[] {
639 const camera = cameraFor(world.me);
640 const warm = recorder();
641 drawHud(warm.ctx, world, world.me, camera, BUSY_W, BUSY_H, scoreboard);
642 const frame = recorder();
643 drawHud(frame.ctx, world, world.me, camera, BUSY_W, BUSY_H, scoreboard);
644 return frame.calls;
645 }
646
647 /**
648 * The ceilings. Not what the HUD costs today — what it is allowed to cost.
649 *
650 * Measured here, at 1920×1080, with the fixture above: a busy frame is 125
651 * calls, 36 of them `fillText`; the same frame in the round-over delay, where
652 * the damage report joins the plates, is 139 with 49 `fillText`; and with the
653 * scoreboard held up it is 163 calls, 90 of them `fillText`. The floor —
654 * `idleWorld` below, nobody visible and nothing happening — is 59 of the same
655 * calls, and that one is asserted exactly rather than described.
656 *
657 * Where the numbers come from: 60 fps is 16.6 ms for the simulation, the bots,
658 * the renderer's 80 draws and this canvas. A `fillText` on a mid-range phone
659 * is text layout and rasterisation — tens of microseconds each, an order more
660 * than a `fillRect` — so at a pessimistic 30 µs a string, 110 of them is about
661 * 3.3 ms, and the ninety-odd cheaper calls around them a few tenths more. Call
662 * it 4 ms: a quarter of the frame, which is as much of it as the HUD may have
663 * with everything else still to pay for.
664 *
665 * So the budgets below are today's worst plus about a quarter — room for two
666 * more panels of the size the ones here are, no more. #91 and #97 both add
667 * drawing to this file; that headroom is theirs, and it is meant to run out.
668 * A feature that wants the next call stops repainting something instead: the
669 * static furniture (score, money, ammo) changes a few times a round and is
670 * redrawn sixty times a second, which is the win nobody has taken yet.
671 */
672 const HUD_CALL_BUDGET = 170;
673 /** The scoreboard is one `fillText` per player per column, on top of the rest. */
674 const HUD_SCOREBOARD_BUDGET = 200;
675 /** The call that actually costs, in whichever frame spends the most of them. */
676 const HUD_FILLTEXT_BUDGET = 110;
677
678 /**
679 * The floor, not a budget: what `idleWorld` costs, asserted exactly, because
680 * the README quotes it beside the ceilings. It is allowed to move — it is a
681 * measurement — but only deliberately, and the README moves with it.
682 */
683 const HUD_QUIET_FRAME = 59;
684
685 test("a busy frame of HUD stays inside its call budget, and the scoreboard inside its own", () => {
686 const busy = frameCalls(busyWorld(), false);
687 expect(busy.length).toBeLessThanOrEqual(HUD_CALL_BUDGET);
688 expect(countOf(busy, "fillText")).toBeLessThanOrEqual(HUD_FILLTEXT_BUDGET);
689
690 // The round-over delay is the dearest frame with the scoreboard down: the
691 // damage report is drawn over the plates, ten lines of it at a full 5v5.
692 const overWorld = busyWorld();
693 overWorld.match.phase = "over";
694 for (const p of overWorld.players) {
695 if (p === overWorld.me) continue;
696 overWorld.damageLog[overWorld.me.id][p.id] = { damage: 40, hits: 2, killed: false };
697 overWorld.damageLog[p.id][overWorld.me.id] = { damage: 20, hits: 1, killed: false };
698 }
699 const over = frameCalls(overWorld, false);
700 expect(over.length).toBeGreaterThan(busy.length); // the report is not free
701 expect(over.length).toBeLessThanOrEqual(HUD_CALL_BUDGET);
702 expect(countOf(over, "fillText")).toBeLessThanOrEqual(HUD_FILLTEXT_BUDGET);
703
704 const scoreboard = frameCalls(busyWorld(), true);
705 expect(scoreboard.length).toBeLessThanOrEqual(HUD_SCOREBOARD_BUDGET);
706 expect(countOf(scoreboard, "fillText")).toBeLessThanOrEqual(HUD_FILLTEXT_BUDGET);
707 // Text is most of what the scoreboard is, and the reason it has its own line.
708 expect(countOf(scoreboard, "fillText")).toBeGreaterThan(scoreboard.length / 2);
709 });
710
711 test("the quiet frame is the floor the busy one is measured against", () => {
712 const idle = frameCalls(idleWorld(), false);
713 const texts = idle.filter((c) => c.name === "fillText").map((c) => c.text);
714 const busy = frameCalls(busyWorld(), false);
715
716 // Nothing happening: no plate, no feed line, no message, no vignette.
717 for (const p of idleWorld().players)
718 if (p !== idleWorld().me) expect([p.name, texts.includes(p.name)]).toEqual([p.name, false]);
719 expect(idle.some((c) => c.name === "fillRect" && c.args[2] === BUSY_W)).toBe(false);
720 // But the furniture is still there, sixty times a second, and that is the
721 // point of writing the floor down: most of it never changes between frames.
722 expect(texts).toContain("$3250");
723 expect(texts).toContain("1:00");
724 expect(texts).toContain("A");
725
726 expect(idle.length).toBe(HUD_QUIET_FRAME);
727 expect(idle.length).toBeLessThan(busy.length);
728 });
729
730 test("the busy frame is busy: the fixture cannot quietly stop drawing and still pass", () => {
731 // The way a budget goes wrong is a fixture that draws nothing, so what makes
732 // this frame the dear one is asserted before the ceiling is.
733 const world = busyWorld();
734 const busy = frameCalls(world, false);
735 const texts = busy.filter((c) => c.name === "fillText").map((c) => c.text);
736 const counts = countByName(busy);
737
738 const plates = world.players.filter((p) => p !== world.me);
739 expect(plates.length).toBe(9);
740 for (const p of plates) expect([p.name, texts.includes(p.name)]).toEqual([p.name, true]);
741 expect(texts.filter((t) => t.includes("AK-47") && t.includes(" ")).length).toBe(5); // the kill feed
742 expect(texts).toContain("0:28"); // the bomb's clock
743 expect(texts.some((t) => t.startsWith("Defusing"))).toBe(true);
744 expect(texts.some((t) => t.startsWith("HE ×1"))).toBe(true);
745 expect(texts).toContain("reloading");
746 expect(texts).toContain("E — take AK-47");
747 // Three damage wedges around the crosshair, and the vignette behind them.
748 expect(busy.filter((c) => c.name === "arc" && c.args[0] === BUSY_W / 2).length).toBe(3);
749 expect(busy.some((c) => c.name === "fillRect" && c.args[2] === BUSY_W && c.args[3] === BUSY_H)).toBe(true);
750 // A steady-state frame builds neither the gradient nor the radar's map.
751 expect(counts.createRadialGradient).toBeUndefined();
752
753 // And the scoreboard names all ten, which is what makes it the dearer frame.
754 const scoreboard = frameCalls(world, true);
755 const named = scoreboard.filter((c) => c.name === "fillText").map((c) => c.text);
756 for (const p of world.players)
757 expect([p.name, named.some((t) => t.includes(p.name))]).toEqual([p.name, true]);
758 expect(countOf(scoreboard, "fillText")).toBeGreaterThan(countOf(busy, "fillText"));
759 });
src/client/hud.ts +5
@@ -6,6 +6,11 @@
66 * It is handed the player it is drawing rather than reaching for `world.me`:
77 * while the person is dead the camera is on a teammate, and the numbers under
88 * it have to be that teammate's or the HUD belongs to a corpse.
9 *
10 * A frame of this has a call budget — and `fillText` a tighter one, because
11 * text layout is what costs. Adding a panel here means spending it: the
12 * numbers, and what they were picked from, are in "what the HUD costs" at the
13 * bottom of `hud.test.ts`.
914 */
1015
1116 import { crosshairShape, DEFAULT_CROSSHAIR, type CrosshairStyle } from "./crosshair.ts";
src/client/record2d.test.ts added +78
@@ -0,0 +1,78 @@
1 /**
2 * The recording 2D context itself: that it records what it is told, in order,
3 * with the properties in force at each call — and that it stays a fixture.
4 */
5
6 import { expect, test } from "bun:test";
7 import { readdirSync, readFileSync } from "node:fs";
8 import { join } from "node:path";
9 import { countByName, countOf, record2d } from "./record2d.ts";
10
11 test("every call is recorded in order, with the properties in force at the time", () => {
12 const { ctx, calls } = record2d();
13 ctx.fillStyle = "#abc";
14 ctx.globalAlpha = 0.5;
15 ctx.fillText("hello", 10, 20);
16 ctx.globalAlpha = 1;
17 ctx.lineWidth = 3;
18 ctx.strokeStyle = "red";
19 ctx.beginPath();
20 ctx.stroke();
21
22 expect(calls.map((c) => c.name)).toEqual(["fillText", "beginPath", "stroke"]);
23 expect(calls[0]).toMatchObject({ text: "hello", args: [10, 20], alpha: 0.5, fill: "#abc" });
24 expect(calls[2]).toMatchObject({ alpha: 1, stroke: "red", lineWidth: 3 });
25 });
26
27 test("the calls are counted by name", () => {
28 const { ctx, calls } = record2d();
29 ctx.fillText("a", 0, 0);
30 ctx.fillText("b", 0, 0);
31 ctx.fillRect(0, 0, 1, 1);
32
33 expect(countByName(calls)).toEqual({ fillText: 2, fillRect: 1 });
34 expect(countOf(calls, "fillText")).toBe(2);
35 expect(countOf(calls, "arc")).toBe(0);
36 expect(countByName([])).toEqual({});
37 });
38
39 // The fixture is for tests. If the client ever imported it, it would be in the
40 // bundle every player downloads — so the import is what is checked, here,
41 // rather than trusted to a comment at the top of the file. An import and not
42 // a mention: a shipped module is free to say the word in a comment, and one of
43 // them does.
44 const IMPORTS_RECORDER = /(?:from|import)\s*\(?\s*"\.\/record2d(?:\.ts)?"/;
45
46 test("no client module imports the recorder: it is a fixture, not shipped code", () => {
47 const dir = import.meta.dir;
48 const shipped = readdirSync(dir).filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"));
49 expect(shipped.length).toBeGreaterThan(10);
50 for (const file of shipped) {
51 if (file === "record2d.ts") continue;
52 expect([file, IMPORTS_RECORDER.test(readFileSync(join(dir, file), "utf8"))]).toEqual([file, false]);
53 }
54 // The regex is only worth having if it sees an import when there is one.
55 expect(IMPORTS_RECORDER.test(`import { record2d } from "./record2d.ts";`)).toBe(true);
56 expect(IMPORTS_RECORDER.test(`const r = await import("./record2d.ts");`)).toBe(true);
57 expect(IMPORTS_RECORDER.test(`// the recorder in record2d.ts counts the calls`)).toBe(false);
58 });
59
60 // And the same claim where it actually matters: the bundle. Said as the
61 // import graph `main.ts` reaches rather than as a search of the built file,
62 // because the build is minified — every name in it is renamed, so finding
63 // `record2d` absent from those bytes would prove very little.
64 test("the recorder is not reachable from the bundle's entry point", () => {
65 const dir = import.meta.dir;
66 const seen = new Set<string>();
67 const walk = (file: string): void => {
68 if (seen.has(file)) return;
69 seen.add(file);
70 const source = readFileSync(join(dir, file), "utf8");
71 for (const match of source.matchAll(/from\s+"\.\/([\w.]+\.ts)"/g)) walk(match[1] as string);
72 };
73 walk("main.ts"); // the bundle's entry, see src/bundle.ts
74
75 expect(seen.has("hud.ts")).toBe(true); // the walk reaches what it should
76 expect(seen.size).toBeGreaterThan(20);
77 expect(seen.has("record2d.ts")).toBe(false);
78 });
src/client/record2d.ts added +76
@@ -0,0 +1,76 @@
1 /**
2 * A recording 2D canvas context: no browser, no canvas, just the calls the
3 * code under test would have made, in order. It is to `hud.ts` what the
4 * recording WebGL2 context in `renderer.test.ts` is to the renderer and the
5 * fake `AudioContext` in `audio.test.ts` is to the sound.
6 *
7 * It lives in a module of its own rather than inside `hud.test.ts` because two
8 * kinds of test want it: the ones that ask what the HUD drew, and the one that
9 * asks how many calls that took (`hud.test.ts`, "what the HUD costs"). It is a
10 * test fixture and nothing else — no client module may import it, which
11 * `record2d.test.ts` checks, so it never reaches the bundle.
12 */
13
14 /**
15 * One call, with the context properties that were in force when it was made:
16 * a fade, a colour and a line width are properties rather than arguments, so
17 * snapshotting them at the call is the only way a test can see one.
18 */
19 export type Call2D = {
20 name: string;
21 args: number[];
22 text: string;
23 alpha: number;
24 stroke: string;
25 fill: string;
26 lineWidth: number;
27 };
28
29 export type Recording = { ctx: CanvasRenderingContext2D; calls: Call2D[] };
30
31 export function record2d(): Recording {
32 const calls: Call2D[] = [];
33 const log = (name: string) => (...args: unknown[]): void => {
34 calls.push({
35 name,
36 args: args.filter((a) => typeof a === "number") as number[],
37 text: args.filter((a) => typeof a === "string").join(" "),
38 alpha: ctx.globalAlpha,
39 stroke: typeof ctx.strokeStyle === "string" ? ctx.strokeStyle : "",
40 fill: typeof ctx.fillStyle === "string" ? ctx.fillStyle : "",
41 lineWidth: ctx.lineWidth,
42 });
43 };
44 const ctx = {
45 clearRect: log("clearRect"), fillRect: log("fillRect"), rect: log("rect"),
46 beginPath: log("beginPath"), moveTo: log("moveTo"), lineTo: log("lineTo"),
47 arc: log("arc"), stroke: log("stroke"), fill: log("fill"),
48 fillText: log("fillText"), save: log("save"), restore: log("restore"),
49 clip: log("clip"), translate: log("translate"), rotate: log("rotate"),
50 drawImage: log("drawImage"), closePath: log("closePath"),
51 // Text layout the caller cannot get from a real canvas here: one fixed
52 // width, so a plate's backing is measured in a unit a test can predict.
53 measureText: () => ({ width: 10 }),
54 createRadialGradient: (...args: unknown[]) => {
55 calls.push({
56 name: "createRadialGradient", args: args as number[], text: "", alpha: 1,
57 stroke: "", fill: "", lineWidth: 1,
58 });
59 return { addColorStop: (): void => {} };
60 },
61 globalAlpha: 1, lineWidth: 1, fillStyle: "", strokeStyle: "",
62 font: "", textAlign: "", textBaseline: "",
63 };
64 return { ctx: ctx as unknown as CanvasRenderingContext2D, calls };
65 }
66
67 /** How many calls of each name a recording holds — the frame's bill, itemised. */
68 export function countByName(calls: Call2D[]): Record<string, number> {
69 const counts: Record<string, number> = {};
70 for (const call of calls) counts[call.name] = (counts[call.name] ?? 0) + 1;
71 return counts;
72 }
73
74 /** How many times one call was made. */
75 export const countOf = (calls: Call2D[], name: string): number =>
76 calls.reduce((n, call) => (call.name === name ? n + 1 : n), 0);
src/readme.test.ts +23
@@ -70,6 +70,29 @@ test("the readme quotes the draw budget the renderer's own test enforces", () =>
7070 expect(section("Playing")).toContain(`a budget of ${(budget as RegExpExecArray)[1]}`);
7171 });
7272
73 test("the readme quotes the HUD's call budgets from the test that enforces them", () => {
74 const hud = read("src/client/hud.test.ts");
75 const budget = (name: string): string => {
76 const match = new RegExp(`const ${name} = (\\d+);`).exec(hud);
77 expect(match).not.toBeNull();
78 return (match as RegExpExecArray)[1] as string;
79 };
80 // Matched with the whitespace collapsed: this checks what the paragraph
81 // says, not where its lines happen to break, so reflowing it is free.
82 const playing = section("Playing").replace(/\s+/g, " ");
83 // Same rule as the draw budget above: the numbers live in the test, and the
84 // README may only repeat them.
85 expect(playing).toContain(`**${budget("HUD_CALL_BUDGET")} calls a frame`);
86 expect(playing).toContain(`${budget("HUD_SCOREBOARD_BUDGET")} with the scoreboard`);
87 expect(playing).toContain(`${budget("HUD_FILLTEXT_BUDGET")} \`fillText\` either way`);
88 // The floor is a measurement the test asserts exactly, so the README may
89 // only repeat that one too.
90 expect(playing).toContain(`**${budget("HUD_QUIET_FRAME")} calls**`);
91 // And the three budgets are named together, which is the point of writing
92 // the HUD's down at all.
93 expect(playing).toContain("Three budgets, one picture");
94 });
95
7396 test("the readme names the check and the build the same way the app does", () => {
7497 const local = section("Run it locally");
7598 const { app } = JSON.parse(read("forge.json")) as { app: { test: string } };

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs