Files
wizwar6e/deploy/verify-scenes.mjs
T
Eric WagonerandClaude Fable 5.1 7c0a761c59 The eyes look down, the die stops the reel, and a punch is a fist
A day of Eric watching the clips as a newcomer would.

The camera can pitch: FirstPerson takes a `pitch` that shears the
horizon up the pane (walls center on it, the eye stays half a wall
high), so the ground at the pane's foot comes within half a cell. The
director uses it for deeds at your own feet — a conjuration on your
square, a treasure taken up, the pit you fell down — instead of the
cutaway that stepped outside your body.

Everything shows through a warp. The floor pass now runs between the
cast and the wall draw, so a column that bent through a mouth maps its
ground through the warp's rigid motion and wears the far room's decals
— the pit past the wormhole included — under the same violet haze as
its walls.

A wizard down a pit is public (inPit on the player view), drawn sunk to
the floor line in first person and dimmed on the board. A body sharing
your square, which had no depth to draw at, stands right in front of
you. A pit leap glides like a shove instead of cutting. And the
director's blocked-sight test no longer counts a warp BEYOND the
target as a block, nor stands a cutaway camera inside another wizard's
square — the pair that put Wanderer's face across the whole pane.

The reel stops for a die: a card over the held world names who rolls
and for what, tumbles the faces on the reel's own clock, lands the
roll, and only then plays the outcome; the beat, the fx, the glides
and the camera all wait it out. A punch by you drives a fist into the
scene; one at you comes at the camera; both land with a comic POW.

film-clips rolls until the reel's last move rather than a fixed count,
and the gate settles longer on a step whose caption rolls a die. All
thirty goldens re-blessed.

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

162 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
// 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.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);