diff --git a/deploy/scene-goldens/come-here-s10-board.png b/deploy/scene-goldens/come-here-s10-board.png new file mode 100644 index 0000000..0f8b715 Binary files /dev/null and b/deploy/scene-goldens/come-here-s10-board.png differ diff --git a/deploy/scene-goldens/come-here-s10-fpv.png b/deploy/scene-goldens/come-here-s10-fpv.png new file mode 100644 index 0000000..ff8888d Binary files /dev/null and b/deploy/scene-goldens/come-here-s10-fpv.png differ diff --git a/deploy/scene-goldens/come-here-s13-fpv.png b/deploy/scene-goldens/come-here-s13-fpv.png new file mode 100644 index 0000000..1d7a916 Binary files /dev/null and b/deploy/scene-goldens/come-here-s13-fpv.png differ diff --git a/deploy/scene-goldens/fire-in-the-webs-s11-board.png b/deploy/scene-goldens/fire-in-the-webs-s11-board.png new file mode 100644 index 0000000..fb6bb32 Binary files /dev/null and b/deploy/scene-goldens/fire-in-the-webs-s11-board.png differ diff --git a/deploy/scene-goldens/fire-in-the-webs-s11-fpv.png b/deploy/scene-goldens/fire-in-the-webs-s11-fpv.png new file mode 100644 index 0000000..8bad7ae Binary files /dev/null and b/deploy/scene-goldens/fire-in-the-webs-s11-fpv.png differ diff --git a/deploy/scene-goldens/fire-in-the-webs-s14-fpv.png b/deploy/scene-goldens/fire-in-the-webs-s14-fpv.png new file mode 100644 index 0000000..f395e74 Binary files /dev/null and b/deploy/scene-goldens/fire-in-the-webs-s14-fpv.png differ diff --git a/deploy/scene-goldens/shield-holds-s11-board.png b/deploy/scene-goldens/shield-holds-s11-board.png new file mode 100644 index 0000000..e38265d Binary files /dev/null and b/deploy/scene-goldens/shield-holds-s11-board.png differ diff --git a/deploy/scene-goldens/shield-holds-s11-fpv.png b/deploy/scene-goldens/shield-holds-s11-fpv.png new file mode 100644 index 0000000..c4d908a Binary files /dev/null and b/deploy/scene-goldens/shield-holds-s11-fpv.png differ diff --git a/deploy/scene-goldens/shield-holds-s12-fpv.png b/deploy/scene-goldens/shield-holds-s12-fpv.png new file mode 100644 index 0000000..1920826 Binary files /dev/null and b/deploy/scene-goldens/shield-holds-s12-fpv.png differ diff --git a/deploy/scene-goldens/springing-the-trap-s5-board.png b/deploy/scene-goldens/springing-the-trap-s5-board.png new file mode 100644 index 0000000..f2a6711 Binary files /dev/null and b/deploy/scene-goldens/springing-the-trap-s5-board.png differ diff --git a/deploy/scene-goldens/springing-the-trap-s5-fpv.png b/deploy/scene-goldens/springing-the-trap-s5-fpv.png new file mode 100644 index 0000000..d11af9e Binary files /dev/null and b/deploy/scene-goldens/springing-the-trap-s5-fpv.png differ diff --git a/deploy/scene-goldens/springing-the-trap-s6-fpv.png b/deploy/scene-goldens/springing-the-trap-s6-fpv.png new file mode 100644 index 0000000..baf5c56 Binary files /dev/null and b/deploy/scene-goldens/springing-the-trap-s6-fpv.png differ diff --git a/deploy/scene-goldens/stone-gaze-s10-board.png b/deploy/scene-goldens/stone-gaze-s10-board.png new file mode 100644 index 0000000..833972f Binary files /dev/null and b/deploy/scene-goldens/stone-gaze-s10-board.png differ diff --git a/deploy/scene-goldens/stone-gaze-s10-fpv.png b/deploy/scene-goldens/stone-gaze-s10-fpv.png new file mode 100644 index 0000000..9057ba5 Binary files /dev/null and b/deploy/scene-goldens/stone-gaze-s10-fpv.png differ diff --git a/deploy/scene-goldens/stone-gaze-s14-fpv.png b/deploy/scene-goldens/stone-gaze-s14-fpv.png new file mode 100644 index 0000000..5ae0580 Binary files /dev/null and b/deploy/scene-goldens/stone-gaze-s14-fpv.png differ diff --git a/deploy/scene-goldens/the-leap-s8-board.png b/deploy/scene-goldens/the-leap-s8-board.png new file mode 100644 index 0000000..f46af2f Binary files /dev/null and b/deploy/scene-goldens/the-leap-s8-board.png differ diff --git a/deploy/scene-goldens/the-leap-s8-fpv.png b/deploy/scene-goldens/the-leap-s8-fpv.png new file mode 100644 index 0000000..01de0af Binary files /dev/null and b/deploy/scene-goldens/the-leap-s8-fpv.png differ diff --git a/deploy/scene-goldens/the-leap-s9-fpv.png b/deploy/scene-goldens/the-leap-s9-fpv.png new file mode 100644 index 0000000..b81815f Binary files /dev/null and b/deploy/scene-goldens/the-leap-s9-fpv.png differ diff --git a/deploy/scene-goldens/the-rout-s11-board.png b/deploy/scene-goldens/the-rout-s11-board.png new file mode 100644 index 0000000..53fc7ce Binary files /dev/null and b/deploy/scene-goldens/the-rout-s11-board.png differ diff --git a/deploy/scene-goldens/the-rout-s11-fpv.png b/deploy/scene-goldens/the-rout-s11-fpv.png new file mode 100644 index 0000000..7200cff Binary files /dev/null and b/deploy/scene-goldens/the-rout-s11-fpv.png differ diff --git a/deploy/scene-goldens/the-rout-s4-fpv.png b/deploy/scene-goldens/the-rout-s4-fpv.png new file mode 100644 index 0000000..d243a33 Binary files /dev/null and b/deploy/scene-goldens/the-rout-s4-fpv.png differ diff --git a/deploy/scene-goldens/wall-comes-down-s6-board.png b/deploy/scene-goldens/wall-comes-down-s6-board.png new file mode 100644 index 0000000..393a8f8 Binary files /dev/null and b/deploy/scene-goldens/wall-comes-down-s6-board.png differ diff --git a/deploy/scene-goldens/wall-comes-down-s6-fpv.png b/deploy/scene-goldens/wall-comes-down-s6-fpv.png new file mode 100644 index 0000000..a9997bb Binary files /dev/null and b/deploy/scene-goldens/wall-comes-down-s6-fpv.png differ diff --git a/deploy/scene-goldens/wall-comes-down-s7-fpv.png b/deploy/scene-goldens/wall-comes-down-s7-fpv.png new file mode 100644 index 0000000..dfe7b80 Binary files /dev/null and b/deploy/scene-goldens/wall-comes-down-s7-fpv.png differ diff --git a/deploy/scene-goldens/warp-sniper-s16-board.png b/deploy/scene-goldens/warp-sniper-s16-board.png new file mode 100644 index 0000000..13fa995 Binary files /dev/null and b/deploy/scene-goldens/warp-sniper-s16-board.png differ diff --git a/deploy/scene-goldens/warp-sniper-s16-fpv.png b/deploy/scene-goldens/warp-sniper-s16-fpv.png new file mode 100644 index 0000000..5e262f1 Binary files /dev/null and b/deploy/scene-goldens/warp-sniper-s16-fpv.png differ diff --git a/deploy/scene-goldens/warp-sniper-s6-fpv.png b/deploy/scene-goldens/warp-sniper-s6-fpv.png new file mode 100644 index 0000000..0ed564d Binary files /dev/null and b/deploy/scene-goldens/warp-sniper-s6-fpv.png differ diff --git a/deploy/scene-goldens/wormhole-heist-s10-fpv.png b/deploy/scene-goldens/wormhole-heist-s10-fpv.png new file mode 100644 index 0000000..505e417 Binary files /dev/null and b/deploy/scene-goldens/wormhole-heist-s10-fpv.png differ diff --git a/deploy/scene-goldens/wormhole-heist-s5-board.png b/deploy/scene-goldens/wormhole-heist-s5-board.png new file mode 100644 index 0000000..7d1bdc1 Binary files /dev/null and b/deploy/scene-goldens/wormhole-heist-s5-board.png differ diff --git a/deploy/scene-goldens/wormhole-heist-s5-fpv.png b/deploy/scene-goldens/wormhole-heist-s5-fpv.png new file mode 100644 index 0000000..a2a9640 Binary files /dev/null and b/deploy/scene-goldens/wormhole-heist-s5-fpv.png differ diff --git a/deploy/verify-scenes.mjs b/deploy/verify-scenes.mjs new file mode 100644 index 0000000..b529253 --- /dev/null +++ b/deploy/verify-scenes.mjs @@ -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); diff --git a/deploy/verify-scenes.sh b/deploy/verify-scenes.sh new file mode 100755 index 0000000..7c00ec9 --- /dev/null +++ b/deploy/verify-scenes.sh @@ -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 "$@" diff --git a/package-lock.json b/package-lock.json index 4be0a47..f9bfd28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,9 @@ "workspaces": [ "packages/*" ], + "devDependencies": { + "playwright-core": "^1.62.1" + }, "engines": { "node": ">=20" } @@ -1796,6 +1799,19 @@ "dev": true, "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": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", diff --git a/package.json b/package.json index 5a33cdc..2acf571 100644 --- a/package.json +++ b/package.json @@ -14,5 +14,8 @@ }, "engines": { "node": ">=20" + }, + "devDependencies": { + "playwright-core": "^1.62.1" } } diff --git a/packages/web/src/fpv/screenplays.ts b/packages/web/src/fpv/screenplays.ts index 64215d4..c4829b9 100644 --- a/packages/web/src/fpv/screenplays.ts +++ b/packages/web/src/fpv/screenplays.ts @@ -28,6 +28,13 @@ export interface Screenplay { * instanceIds are `#rig`. */ rig?: Record; 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 { @@ -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.", pov: "Rival", rig: { Rival: ["number-6"] }, + beats: [5, 10], + boardBeat: 5, moves: [ { seat: "Rival", cmd: { type: "playNumberForMovement", instanceId: rigId("number-6") } }, { 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.", pov: "Wanderer", rig: { Wanderer: ["number-3", "fireball"], Rival: ["number-3"] }, + beats: [6, 16], + boardBeat: 16, moves: [ { seat: "Rival", cmd: { type: "playNumberForMovement", instanceId: rigId("number-3") } }, { 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.", pov: "Rival", rig: { Wanderer: ["opportunity-fire", "fireball"] }, + beats: [5, 6], + boardBeat: 5, moves: [ { seat: "Rival", cmd: { type: "move", direction: "W" } }, { 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.", pov: "Rival", rig: { Wanderer: ["create-pit"] }, + beats: [8, 9], + boardBeat: 8, moves: [ { seat: "Rival", cmd: { type: "move", direction: "W" } }, { 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.", pov: "Wanderer", rig: { Wanderer: ["mental-force"], Rival: ["number-2"] }, + beats: [10, 13], + boardBeat: 10, moves: [ ...RIVAL_TO_NORTH, { 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.", pov: "Rival", rig: { Rival: ["number-2", "medusa", "number-5", "fireball"] }, + beats: [10, 14], + boardBeat: 10, moves: [ ...STANDOFF, { 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.", pov: "Rival", rig: { Rival: ["number-2", "go-away", "number-5"] }, + beats: [4, 11], + boardBeat: 11, moves: [ ...STANDOFF, { 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.", pov: "Wanderer", rig: { Wanderer: ["number-2", "destroy-wall"] }, + beats: [6, 7], + boardBeat: 6, moves: [ { seat: "Rival", cmd: { type: "endTurn", draw: 0 } }, // stands his ground at home { 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.", pov: "Wanderer", rig: { Rival: ["number-2", "fireball"], Wanderer: ["full-shield"] }, + beats: [11, 12], + boardBeat: 11, moves: [ ...STANDOFF, { 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.", pov: "Rival", rig: { Rival: ["number-2", "sticky-wand", "fireball"] }, + beats: [11, 14], + boardBeat: 11, moves: [ ...STANDOFF, { seat: "Rival", cmd: { type: "cast", instanceId: rigId("sticky-wand"), target: { kind: "player", playerId: "Wanderer" } } },