Files
wizwar6e/deploy/verify-scenes.mjs
T
Eric WagonerandClaude Fable 5.1 e1a740119c Credibility pass: the session seams sanded from the clips-and-camera batch
Two blind reviews of everything since the last pass (afa0e17), every
finding checked against the code, no behavior changed: all thirty
scene goldens match without a re-bless and the engine suite is
untouched.

Reel and renderer: the camera's look-down rule is stated once, beside
LOOK_DOWN, instead of twice in the effect; the empty aim branch that
stood where a cutaway used to be is gone (the guard it implied is now
explicit); the pit events and the punch are handled by their own
types, not through "in" casts; smoothstep is one export used by every
tween instead of eleven inline copies; the two floor rings share one
painter; project() takes a Billboard instead of a third hand-typed
copy of its fields; the strides-left figure and the web rim no longer
shadow the reel's steps and the pane's fx; the die card's verdict is
built from events, not by matching an emoji; the workshop asks for the
hover cue by name instead of passing an empty click handler.

Server and engine: one requestBase() for the origin, one slug pattern
in store.ts gating both the clip page and its files, one 404 for both;
LOOPBACK sits above its only caller; doCounteract names what a counter
is played against once; fearCells sits beside its own docblock rather
than between sightedCellsFor and its.

Deploy: chromiumExe, the private server, ffmpeg, and the reel rewind
live in deploy/lib/harness.mjs, shared by the gate, the recorder, and
the card cutter instead of pasted three times; the recorder drops its
duplicate frame counters and names its poster settle; the card uses the
gallery's exact gold; the one-time Sentry URL bootstrap leaves
deploy.sh; the backup comment states the rule rather than the incident.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-03 11:55:57 -04:00

129 lines
5.7 KiB
JavaScript

// 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 } from "node:child_process";
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { chromium } from "playwright-core";
import { chromiumExe, rewindReel, startServer } from "./lib/harness.mjs";
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();
/** 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 server = startServer(PORT, "wizwar-scenes");
let failures = 0;
try {
await server.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, so a
// scene's later beats cost only their marginal steps.
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 rewindReel(page);
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 2.6 to settle its tween, hold, and fx.
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();
// A step the die decides holds the world for the roll's
// interlude before anything moves: wait it out on top.
const die = (await page.locator(".replay-caption").textContent()).includes("\u{1F3B2}");
await page.clock.runFor((at === beat - 1 ? 2600 : 600) + (die ? 2400 : 0));
}
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.stop();
}
if (!update) console.log(`--- ${failures ? `${failures} beat(s) drifted` : "every pinned beat matches its golden"}`);
process.exit(failures ? 1 : 0);