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
vesper/cs:vesper/120-what-the-hud-costs-counted-a-call-budget → main 2 commits fork 2 threads · 2 unresolved
README.md +24
| @@ -382,6 +382,30 @@ player fails the check rather than a phone; the worst round the game can build | ||
| 382 | 382 | the air, a planted bomb, a sprayed wall and a screen full of tracers) is 60 |
| 383 | 383 | draws against a budget of 80. |
| 384 | 384 | |
| 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
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 What I checked, at 1. 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. 4. The fixture guard matches text, not imports. 5. One frame computed twice. Preview. The deploy for this head reported ok, but https://cs--157--forge-trevato-apps.open-platform.sh/ now answers a 9-byte 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
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, No runtime code changed, no dependency added, no security surface touched, and the fixture is provably unreachable from |
||
| 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 | ||
| 385 | 409 | The radar in the bottom-left corner is your view, not the room: rotated so |
| 386 | 410 | the way you are facing is up, it shows your living teammates, the bomb once |
| 387 | 411 | 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 @@ | ||
| 3 | 3 | * calls it would have made. It is the only way to check the damage indicator |
| 4 | 4 | * points the right way round, which is the one thing about it that can be |
| 5 | 5 | * 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`. | |
| 6 | 10 | */ |
| 7 | 11 | |
| 8 | 12 | import { expect, test } from "bun:test"; |
| 9 | 13 | import { DEFAULT_CROSSHAIR, type CrosshairStyle } from "./crosshair.ts"; |
| 10 | 14 | import { drawHud } from "./hud.ts"; |
| 11 | 15 | import { SHORT } from "./match.ts"; |
| 16 | import { countByName, countOf, record2d as recorder, type Call2D as Call } from "./record2d.ts"; | |
| 12 | 17 | import type { Camera } from "./renderer.ts"; |
| 13 | 18 | import { createWorld, eyePos, type Player, type World } from "./world.ts"; |
| 14 | 19 | |
| 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 | ||
| 59 | 20 | const W = 1280, H = 720; |
| 60 | 21 | |
| 61 | 22 | /** 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", | ||
| 552 | 513 | expect(oldest).not.toBeCloseTo(newest, 0); |
| 553 | 514 | expect(half).toBeCloseTo((oldest + newest) / 2, 0); |
| 554 | 515 | }); |
| 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 @@ | ||
| 6 | 6 | * It is handed the player it is drawing rather than reaching for `world.me`: |
| 7 | 7 | * while the person is dead the camera is on a teammate, and the numbers under |
| 8 | 8 | * 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`. | |
| 9 | 14 | */ |
| 10 | 15 | |
| 11 | 16 | 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", () => | ||
| 70 | 70 | expect(section("Playing")).toContain(`a budget of ${(budget as RegExpExecArray)[1]}`); |
| 71 | 71 | }); |
| 72 | 72 | |
| 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 | ||
| 73 | 96 | test("the readme names the check and the build the same way the app does", () => { |
| 74 | 97 | const local = section("Run it locally"); |
| 75 | 98 | const { app } = JSON.parse(read("forge.json")) as { app: { test: string } }; |
probe