rook/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine

Stop the frame test measuring a view nobody chose

The client test failed about one full-suite run in twenty-five, always the
same way: `expect(fp.fills).toBeGreaterThan(40)` receiving 31. fable hit it
before touching anything, flagged it as seed-dependent, and left it alone;
this is what it actually is.

The loop above the measurement advances the SHARED match until somebody dies
— anywhere between five and two hundred seconds of play — and then the test
counts how many fills the resulting view costs. So the thing being measured
was wherever that loop happened to leave the seat. Run on its own the file
draws 154 to 236 fills across twelve runs; the run that failed drew 31. The
bound was a floor nobody chose over a view nobody fixed.

Two changes, and the second is the one that matters:

The seat is stood in a known place looking a known way before the frame is
counted, so the walls in shot are the same every run. The few fills that
still move are the bodies standing among them.

And the bound now says what run-batching MEANS: fewer fills than half the
columns the frame actually used, read off the client's own offscreen buffer,
which the fake document now keeps a handle on. A constant cannot express
this — the frame budget moves the world between 960 and 448 columns, so a
per-column frame at the coarsest rung is 448 fills, which sits comfortably
under any constant loose enough to survive the finest rung. My own first
attempt at this used `< 450` and would have let exactly that through.

Proved both directions rather than assuming:

- forced the renderer to emit one run per column behind a flag, and the new
  bound fails as it should; the old constant would not have.
- 30 full-suite runs, 0 failures (was ~1 in 25). 304 tests, unchanged.

Also added: the same state drawn twice must cost the same. A frame whose
cost wanders while nothing moves is a renderer bug, and it is the shape of
thing the old bound was mistaking for flakiness.
Benjivers-bit committed 2345b52 Browse files
1 file changed +60 −10
src/client.test.ts +60 −10
@@ -9,6 +9,8 @@
99 */
1010
1111 import { expect, test } from "bun:test";
12 import { TICK } from "./arena.ts";
13 import { HEIGHT, WIDTH } from "./map.ts";
1214 import { mkdtempSync } from "node:fs";
1315 import { tmpdir } from "node:os";
1416 import { join } from "node:path";
@@ -21,6 +23,15 @@ const base = `http://${server.hostname}:${server.port}`;
2123 // ------------------------------------------------------------ the stub DOM
2224
2325 const drawn: string[] = [];
26 /**
27 * Every canvas the client asked the document for. The widest is its offscreen
28 * world buffer, whose width is however many columns the frame budget settled
29 * on; the other is the minimap's tile layer. Keeping them is what lets a test
30 * bound the frame's cost as a fraction of the columns actually used, instead
31 * of against a number somebody picked once.
32 */
33 const canvases: Record<string, unknown>[] = [];
34 const worldColumns = (): number => Math.max(0, ...canvases.map((c) => Number(c.width) || 0));
2435 // Where the canvas was moved to: on the top-down view your own body is an
2536 // arrow drawn about the origin, so the last translate of a frame is where
2637 // this client thinks you are.
@@ -131,7 +142,11 @@ Object.assign(globalThis, {
131142 getElementById: (id: string) => nodes.get(id) ?? null,
132143 querySelector: (sel: string) => (sel.includes("red") ? scoreRed : scoreBlue),
133144 querySelectorAll: (sel: string) => (sel.includes("data-buy") ? buys : agentButtons),
134 createElement: (tag: string) => element(tag),
145 createElement: (tag: string) => {
146 const node = element(tag);
147 if (tag === "canvas") canvases.push(node);
148 return node;
149 },
135150 addEventListener: noop,
136151 pointerLockElement: null,
137152 exitPointerLock: noop,
@@ -248,13 +263,34 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
248263 expect(row).not.toMatch(/\d/);
249264
250265 // A frame of the first-person view draws the world into its own buffer and
251 // blits it, with the minimap's tiles blitted too. It is a picture, but it
252 // is not one fill a screen column any more, nor one a map tile: the map has
253 // 570 tiles and the canvas 900 columns, and the frame used to pay for both.
254 // The minimap's tiles are the blit; the walls are a run a fill, and how
255 // many runs a view has depends on where the seat happens to be standing —
256 // a long corridor is many depths — so the bound is the columns, which no
257 // view can reach, rather than the tile count, which a corridor can.
266 // blits it, with the minimap's tiles blitted too. It is a picture, but it is
267 // not one fill a screen column, nor one a map tile: the map has 570 tiles
268 // and the buffer up to 960 columns, and the frame used to pay for both.
269 // The minimap's tiles are the blit; the walls are a run a fill.
270 //
271 // What is asserted is that run-batching, and NOT a particular count. This
272 // used to demand more than 40 fills, which failed about one full-suite run
273 // in twenty-five while passing forty runs out of forty on its own. The loop
274 // above advances the shared match until somebody dies — anywhere between
275 // five and two hundred seconds of play — so the view being measured was
276 // wherever that happened to leave the seat: 154 to 236 fills when run
277 // alone, and 31 on the run that failed. A floor on a number nobody chose,
278 // over a view nobody fixed, is not a test.
279 //
280 // So the seat is stood somewhere known first. The walls in shot are then
281 // the same every run; the few fills that still move are the bodies standing
282 // among them, which is why the bound below is a fraction rather than an
283 // equality.
284 const arenaNow = await import("./arena.ts");
285 const camera = arenaNow.match().players.find((p) => !p.bot)!;
286 camera.x = WIDTH / 2;
287 camera.y = HEIGHT / 2;
288 camera.angle = 0;
289 camera.alive = true;
290 camera.blind = 0;
291 arenaNow.advance(TICK * 2); // a snapshot carrying that position reaches the client
292 await until(() => true);
293
258294 const tally = () => ({
259295 fills: drawn.filter((op) => op.startsWith("fillRect")).length,
260296 blits: drawn.filter((op) => op.startsWith("drawImage")).length,
@@ -263,9 +299,23 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
263299 drawn.length = 0;
264300 runFrame();
265301 const fp = tally();
302 drawn.length = 0;
303 runFrame();
304 const again = tally();
305
266306 expect(fp.blits).toBeGreaterThanOrEqual(2); // the world, and the map's tiles
267 expect(fp.fills).toBeGreaterThan(40);
268 expect(fp.fills).toBeLessThan(900);
307 // It drew a world, not an empty buffer.
308 expect(fp.fills).toBeGreaterThan(0);
309 // Run-batched, said against the columns the frame ACTUALLY used. The budget
310 // moves the world between 960 and 448 columns depending on what frames cost,
311 // so no constant can separate "a run a wall" from "a fill a column": at the
312 // coarsest rung a per-column frame is 448, which sits under any constant
313 // loose enough to survive the finest one.
314 expect(fp.fills).toBeLessThan(worldColumns() / 2);
315 // The same state drawn twice costs the same. A frame whose cost wanders
316 // while nothing moves is a renderer bug, and it is the shape of thing the
317 // old bound was mistaking for flakiness.
318 expect(again.fills).toBe(fp.fills);
269319
270320 // Tab swaps that frame for the whole map. Its tiles are drawn once into a
271321 // canvas of their own and blitted from then on: the first such frame pays
↑↓ navigate ↵ open esc close

Keyboard shortcuts