The render harness: every screenplay's beats pinned as golden frames

deploy/verify-scenes.sh replays each screenplay to its authored beats
under a paused Playwright clock — pulses, tweens, and fx advance only
on the harness's schedule, so the same beat renders the same bytes
every run — screenshots the reel, and diffs pixels against the goldens
in deploy/scene-goldens (30 frames: warp transits, impacts, curses
landing, in both views). A channel may drift 6 before a pixel counts,
0.2% of pixels before a beat fails; --update blesses intended changes.

Proven both ways before landing: a clean double-run reproduces all 30
goldens exactly, and resurrecting the warp-smear cutaway bug fails the
warp-transit beat at 61% pixel drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-09-01 16:58:43 -04:00
co-authored by Claude Fable 5
parent 6c48174376
commit 58d1d34cb0
35 changed files with 212 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

+158
View File
@@ -0,0 +1,158 @@
// Render-regression harness: every screenplay's pinned beats, screenshot
// under a paused Playwright clock, diffed pixel-by-pixel against the
// goldens in deploy/scene-goldens/. The clock is what makes the pixels
// reproducible — pulsing rings, tweens, and fx all advance only when the
// harness says so, so the same beat renders the same bytes every run.
//
// npx tsx deploy/verify-scenes.mjs # verify all scenes
// npx tsx deploy/verify-scenes.mjs --update # re-bless the goldens
// npx tsx deploy/verify-scenes.mjs the-rout # one scene (mixes with --update)
//
// Run from the repo root AFTER `npx vite build` in packages/web (the
// wrapper deploy/verify-scenes.sh does both). Needs the Playwright
// chromium cache (`npx playwright-core install chromium` if missing).
import { execFileSync, spawn } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { chromium } from "playwright-core";
import { SCREENPLAYS } from "../packages/web/src/fpv/screenplays.ts";
const args = process.argv.slice(2);
const update = args.includes("--update");
const only = args.filter((a) => !a.startsWith("--"));
const GOLDENS = join(import.meta.dirname, "scene-goldens");
const DIFFS = join(tmpdir(), "wizwar-scene-diffs");
const PORT = 8807;
// A channel may drift this far before a pixel counts as different, and
// this fraction of pixels may differ before a beat fails — headroom for
// raster jitter, far below any real regression.
const CHANNEL_TOL = 6;
const FAIL_FRAC = 0.002;
const EPOCH = new Date("2026-01-01T00:00:00Z").getTime();
function chromiumExe() {
if (process.env.WIZWAR_CHROME) return process.env.WIZWAR_CHROME;
const cache = join(process.env.HOME ?? "", "Library", "Caches", "ms-playwright");
const builds = existsSync(cache)
? readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort()
: [];
for (const build of builds.reverse()) {
const exe = join(cache, build, "chrome-mac-arm64",
"Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing");
if (existsSync(exe)) return exe;
}
throw new Error("no Playwright chromium found — run: npx playwright-core install chromium (or set WIZWAR_CHROME)");
}
/** Decode a PNG to raw RGBA via ffmpeg — the harness's only image dep. */
function rgba(png) {
return execFileSync("ffmpeg", ["-loglevel", "error", "-i", png,
"-f", "rawvideo", "-pix_fmt", "rgba", "-"], { maxBuffer: 64 * 1024 * 1024 });
}
function compare(goldenPng, actualPng) {
const a = rgba(goldenPng), b = rgba(actualPng);
if (a.length !== b.length) return { frac: 1, why: "size differs" };
let bad = 0;
for (let i = 0; i < a.length; i += 4) {
if (Math.abs(a[i] - b[i]) > CHANNEL_TOL ||
Math.abs(a[i + 1] - b[i + 1]) > CHANNEL_TOL ||
Math.abs(a[i + 2] - b[i + 2]) > CHANNEL_TOL) bad++;
}
return { frac: bad / (a.length / 4), why: null };
}
// --- A private server on a throwaway data dir. ---------------------------
const dataDir = mkdtempSync(join(tmpdir(), "wizwar-scenes-"));
const server = spawn("npx", ["tsx", "src/index.ts"], {
cwd: join(import.meta.dirname, "..", "packages", "server"),
env: { ...process.env, PORT: String(PORT), WIZWAR_DATA_DIR: join(dataDir, "rooms") },
stdio: "ignore",
});
const up = async () => {
for (let i = 0; i < 60; i++) {
try {
const r = await fetch(`http://127.0.0.1:${PORT}/`);
if (r.ok) return;
} catch { /* not yet */ }
await new Promise((r) => setTimeout(r, 500));
}
throw new Error("server never came up");
};
let failures = 0;
try {
await up();
mkdirSync(GOLDENS, { recursive: true });
rmSync(DIFFS, { recursive: true, force: true });
mkdirSync(DIFFS, { recursive: true });
const browser = await chromium.launch({
executablePath: chromiumExe(),
args: ["--disable-gpu", "--force-color-profile=srgb", "--font-render-hinting=none"],
});
const scenes = SCREENPLAYS.filter((sp) => !only.length || only.includes(sp.name));
if (!scenes.length) throw new Error(`no scenes match: ${only.join(", ")}`);
for (const sp of scenes) {
// One page per view: the reel steps forward beat to beat under the
// paused clock, so a scene's later beats cost only their marginal
// steps — and every beat's simulated capture time is still exactly
// (steps so far) × 2.6s, identical every run.
const takes = [
{ view: "fpv", beats: [...(sp.beats ?? [])].sort((a, b) => a - b) },
{ view: "board", beats: sp.boardBeat === undefined ? [] : [sp.boardBeat] },
];
for (const { view, beats } of takes) {
if (!beats.length) continue;
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1 });
const page = await ctx.newPage();
await page.clock.install({ time: EPOCH });
await page.goto(`http://127.0.0.1:${PORT}/?fpv&script=${sp.name}`, { waitUntil: "networkidle" });
await page.locator("button", { hasText: "❚❚" }).click();
for (let i = 0; i < 4; i++) await page.locator("button", { hasText: "◀" }).click();
if (view === "fpv") await page.getByText("your eyes").click();
// Freeze the world at a fixed instant, then advance it by hand.
// Steps on the way to a beat get 0.6 simulated seconds; the beat
// itself gets 2.6 to settle its tween, hold, and fx. The schedule
// is fixed, so every run renders the same bytes.
await page.clock.pauseAt(EPOCH + 60_000);
let at = 0;
for (const beat of beats) {
for (; at < beat; at++) {
await page.locator("button", { hasText: "▶" }).last().click();
await page.clock.runFor(at === beat - 1 ? 2600 : 600);
}
const box = await page.locator(".replay").boundingBox();
const name = `${sp.name}-s${beat}-${view}.png`;
const golden = join(GOLDENS, name);
const shot = update ? golden : join(DIFFS, name);
await page.screenshot({
path: shot,
clip: { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) },
});
if (update) {
console.log(`blessed ${name}`);
} else if (!existsSync(golden)) {
console.log(`MISSING golden ${name} — run with --update`);
failures++;
} else {
const { frac, why } = compare(golden, shot);
if (why || frac > FAIL_FRAC) {
console.log(`FAIL ${name}: ${why ?? `${(frac * 100).toFixed(2)}% of pixels drifted`} (actual kept at ${shot})`);
failures++;
} else {
console.log(`OK ${name}`);
}
}
}
await ctx.close();
}
}
await browser.close();
} finally {
server.kill();
rmSync(dataDir, { recursive: true, force: true });
}
if (!update) console.log(`--- ${failures ? `${failures} beat(s) drifted` : "every pinned beat matches its golden"}`);
process.exit(failures ? 1 : 0);
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# Render-regression check: build the web client, then screenshot every
# screenplay's pinned beats under a frozen clock and diff them against
# deploy/scene-goldens/. Run from the repo root. Pass --update to bless
# new goldens after an INTENDED renderer change (and commit them).
set -euo pipefail
(cd packages/web && npx vite build --logLevel error)
npx tsx deploy/verify-scenes.mjs "$@"
+16
View File
@@ -10,6 +10,9 @@
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
"devDependencies": {
"playwright-core": "^1.62.1"
},
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }
@@ -1796,6 +1799,19 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.26", "version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+3
View File
@@ -14,5 +14,8 @@
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
},
"devDependencies": {
"playwright-core": "^1.62.1"
} }
} }
+27
View File
@@ -28,6 +28,13 @@ export interface Screenplay {
* instanceIds are `<cardId>#rig<n>`. */ * instanceIds are `<cardId>#rig<n>`. */
rig?: Record<string, string[]>; rig?: Record<string, string[]>;
moves: ScreenplayMove[]; moves: ScreenplayMove[];
/** Step indices the render harness pins as golden frames, first-person:
* the scene's money shots — a warp transit, an impact, a curse landing.
* deploy/verify-scenes.sh screenshots each beat and diffs it against
* the committed golden. */
beats?: number[];
/** One step pinned from the board view. */
boardBeat?: number;
} }
export interface ScreenplayStep { export interface ScreenplayStep {
@@ -116,6 +123,8 @@ export const SCREENPLAYS: Screenplay[] = [
blurb: "Nine strides on a number card: through the south mouth, around the back row, out with a treasure.", blurb: "Nine strides on a number card: through the south mouth, around the back row, out with a treasure.",
pov: "Rival", pov: "Rival",
rig: { Rival: ["number-6"] }, rig: { Rival: ["number-6"] },
beats: [5, 10],
boardBeat: 5,
moves: [ moves: [
{ seat: "Rival", cmd: { type: "playNumberForMovement", instanceId: rigId("number-6") } }, { seat: "Rival", cmd: { type: "playNumberForMovement", instanceId: rigId("number-6") } },
{ seat: "Rival", cmd: { type: "move", direction: "W" } }, { seat: "Rival", cmd: { type: "move", direction: "W" } },
@@ -138,6 +147,8 @@ export const SCREENPLAYS: Screenplay[] = [
blurb: "Line of sight threads a wormhole: a fireball cast into one mouth arrives through the other.", blurb: "Line of sight threads a wormhole: a fireball cast into one mouth arrives through the other.",
pov: "Wanderer", pov: "Wanderer",
rig: { Wanderer: ["number-3", "fireball"], Rival: ["number-3"] }, rig: { Wanderer: ["number-3", "fireball"], Rival: ["number-3"] },
beats: [6, 16],
boardBeat: 16,
moves: [ moves: [
{ seat: "Rival", cmd: { type: "playNumberForMovement", instanceId: rigId("number-3") } }, { seat: "Rival", cmd: { type: "playNumberForMovement", instanceId: rigId("number-3") } },
{ seat: "Rival", cmd: { type: "move", direction: "W" } }, { seat: "Rival", cmd: { type: "move", direction: "W" } },
@@ -168,6 +179,8 @@ export const SCREENPLAYS: Screenplay[] = [
blurb: "An ambush watches its corridor through a wormhole — and fires the moment a wizard crosses the far sightline.", blurb: "An ambush watches its corridor through a wormhole — and fires the moment a wizard crosses the far sightline.",
pov: "Rival", pov: "Rival",
rig: { Wanderer: ["opportunity-fire", "fireball"] }, rig: { Wanderer: ["opportunity-fire", "fireball"] },
beats: [5, 6],
boardBeat: 5,
moves: [ moves: [
{ seat: "Rival", cmd: { type: "move", direction: "W" } }, { seat: "Rival", cmd: { type: "move", direction: "W" } },
{ seat: "Rival", cmd: { type: "move", direction: "S" } }, { seat: "Rival", cmd: { type: "move", direction: "S" } },
@@ -187,6 +200,8 @@ export const SCREENPLAYS: Screenplay[] = [
blurb: "A pit yawns in the corridor — but the die says the intruder sails clean over it, onto the trapper's doorstep.", blurb: "A pit yawns in the corridor — but the die says the intruder sails clean over it, onto the trapper's doorstep.",
pov: "Rival", pov: "Rival",
rig: { Wanderer: ["create-pit"] }, rig: { Wanderer: ["create-pit"] },
beats: [8, 9],
boardBeat: 8,
moves: [ moves: [
{ seat: "Rival", cmd: { type: "move", direction: "W" } }, { seat: "Rival", cmd: { type: "move", direction: "W" } },
{ seat: "Rival", cmd: { type: "move", direction: "S" } }, { seat: "Rival", cmd: { type: "move", direction: "S" } },
@@ -209,6 +224,8 @@ export const SCREENPLAYS: Screenplay[] = [
blurb: "Mental Force walks the enemy to exactly where you want them — which has its risks.", blurb: "Mental Force walks the enemy to exactly where you want them — which has its risks.",
pov: "Wanderer", pov: "Wanderer",
rig: { Wanderer: ["mental-force"], Rival: ["number-2"] }, rig: { Wanderer: ["mental-force"], Rival: ["number-2"] },
beats: [10, 13],
boardBeat: 10,
moves: [ moves: [
...RIVAL_TO_NORTH, ...RIVAL_TO_NORTH,
{ seat: "Rival", cmd: { type: "endTurn", draw: 0 } }, { seat: "Rival", cmd: { type: "endTurn", draw: 0 } },
@@ -229,6 +246,8 @@ export const SCREENPLAYS: Screenplay[] = [
blurb: "Medusa freezes a wizard solid — and stone, inconveniently, cannot be hurt.", blurb: "Medusa freezes a wizard solid — and stone, inconveniently, cannot be hurt.",
pov: "Rival", pov: "Rival",
rig: { Rival: ["number-2", "medusa", "number-5", "fireball"] }, rig: { Rival: ["number-2", "medusa", "number-5", "fireball"] },
beats: [10, 14],
boardBeat: 10,
moves: [ moves: [
...STANDOFF, ...STANDOFF,
{ seat: "Rival", cmd: { type: "cast", instanceId: rigId("medusa"), numberInstanceIds: [rigId("number-5")], target: { kind: "player", playerId: "Wanderer" } } }, { seat: "Rival", cmd: { type: "cast", instanceId: rigId("medusa"), numberInstanceIds: [rigId("number-5")], target: { kind: "player", playerId: "Wanderer" } } },
@@ -247,6 +266,8 @@ export const SCREENPLAYS: Screenplay[] = [
blurb: "Go Away hurls a wizard down his own corridor, as straight a line as the maze allows.", blurb: "Go Away hurls a wizard down his own corridor, as straight a line as the maze allows.",
pov: "Rival", pov: "Rival",
rig: { Rival: ["number-2", "go-away", "number-5"] }, rig: { Rival: ["number-2", "go-away", "number-5"] },
beats: [4, 11],
boardBeat: 11,
moves: [ moves: [
...STANDOFF, ...STANDOFF,
{ seat: "Rival", cmd: { type: "cast", instanceId: rigId("go-away"), numberInstanceIds: [rigId("number-5")], target: { kind: "player", playerId: "Wanderer" } } }, { seat: "Rival", cmd: { type: "cast", instanceId: rigId("go-away"), numberInstanceIds: [rigId("number-5")], target: { kind: "player", playerId: "Wanderer" } } },
@@ -261,6 +282,8 @@ export const SCREENPLAYS: Screenplay[] = [
blurb: "Blast the wall between you — the rubble bloodies both sides of it — then step through the breach.", blurb: "Blast the wall between you — the rubble bloodies both sides of it — then step through the breach.",
pov: "Wanderer", pov: "Wanderer",
rig: { Wanderer: ["number-2", "destroy-wall"] }, rig: { Wanderer: ["number-2", "destroy-wall"] },
beats: [6, 7],
boardBeat: 6,
moves: [ moves: [
{ seat: "Rival", cmd: { type: "endTurn", draw: 0 } }, // stands his ground at home { seat: "Rival", cmd: { type: "endTurn", draw: 0 } }, // stands his ground at home
{ seat: "Wanderer", cmd: { type: "playNumberForMovement", instanceId: rigId("number-2") } }, { seat: "Wanderer", cmd: { type: "playNumberForMovement", instanceId: rigId("number-2") } },
@@ -283,6 +306,8 @@ export const SCREENPLAYS: Screenplay[] = [
blurb: "Five points of fire meet a Full Shield, and the shield wins.", blurb: "Five points of fire meet a Full Shield, and the shield wins.",
pov: "Wanderer", pov: "Wanderer",
rig: { Rival: ["number-2", "fireball"], Wanderer: ["full-shield"] }, rig: { Rival: ["number-2", "fireball"], Wanderer: ["full-shield"] },
beats: [11, 12],
boardBeat: 11,
moves: [ moves: [
...STANDOFF, ...STANDOFF,
{ seat: "Rival", cmd: { type: "cast", instanceId: rigId("fireball"), target: { kind: "player", playerId: "Wanderer" } } }, { seat: "Rival", cmd: { type: "cast", instanceId: rigId("fireball"), target: { kind: "player", playerId: "Wanderer" } } },
@@ -298,6 +323,8 @@ export const SCREENPLAYS: Screenplay[] = [
blurb: "The Sticky Wand binds a wizard in silk — and fire burns twice as hot in a web.", blurb: "The Sticky Wand binds a wizard in silk — and fire burns twice as hot in a web.",
pov: "Rival", pov: "Rival",
rig: { Rival: ["number-2", "sticky-wand", "fireball"] }, rig: { Rival: ["number-2", "sticky-wand", "fireball"] },
beats: [11, 14],
boardBeat: 11,
moves: [ moves: [
...STANDOFF, ...STANDOFF,
{ seat: "Rival", cmd: { type: "cast", instanceId: rigId("sticky-wand"), target: { kind: "player", playerId: "Wanderer" } } }, { seat: "Rival", cmd: { type: "cast", instanceId: rigId("sticky-wand"), target: { kind: "player", playerId: "Wanderer" } } },