/** * Headless audit of the SHIPPED GAME BUNDLE (`dist-game/`). * * WHAT THIS AUDITS, AND WHY IT IS `dist-game/` AND NOT A DEV SERVER * ----------------------------------------------------------------- * The Zero-Runtime-MPQ invariant (Iron Law #2) constrains the artifact that * actually ships. Auditing a dev server would prove "no MPQ requests in * development form" while the production bundle could still contain the * `httpRangeSource('/d2char.mpq')` path — a fully green report over a violated * invariant, which is the worst available false negative. So this harness * serves the real built output over a plain `node:http` static server and * never transforms a source file. * * The static server is deliberately dependency-free (`node:http`, not vite): * the dependency tree is part of what is under test, and if it were also the * test carrier then "dependencies broke" would masquerade as "no requests were * made". * * This pairs with `verify-bundle-no-mpq.ts`: * * bundle grep -> the bundle *cannot* download an archive (structural) * this harness -> the bundle *did not* download one while being played * * Neither alone is sufficient. The grep cannot see a runtime fetch assembled * from fragments; the runtime count cannot see a path that this particular run * did not reach. * * ANTI-VACUITY: THE POINT OF THE `precondition` FIELD * --------------------------------------------------- * Most of these assertions are of the form "this counter is zero". Such an * assertion passes trivially when the thing being counted never had a chance to * happen: `missingMonsterArt.length === 0` is equally true on a level that * rendered no monsters at all. A harness that reports success in that state is * worse than no harness, because it manufactures confidence. * * So every assertion carries preconditions that establish the measurement was * live — the scene rendered, the sim ticked, monsters were planned, the * character draw path ran. An assertion whose preconditions are unmet is * reported as **VACUOUS** and counts as a harness FAILURE, never as a pass. * * `act-scene.ts:254` is the cautionary tale: `state.characterGroup` is declared * and published but never written, so any audit asserting on it would have been * asserting on a constant. This harness checks that trap explicitly. * * NEGATIVE CONTROLS * ----------------- * An assertion never observed failing is not evidence. `--negative=` injects * a specific, real fault and the run is then expected to go red on exactly the * targeted assertion. See NEGATIVE_CONTROLS below. * * npm run verify:animation # audit current dist-game/ * npm run verify:animation -- --negative=list # show the controls * npm run verify:animation -- --negative=mpq-request * * OUTPUT PATH SAFETY * ------------------ * `scripts/verify-challenger-m3.ts` L28-29 hardcodes an output directory into a * different worktree and writes screenshots there (L155/L159). That worktree is * off-limits and is actively being worked in by another team. Every path this * script writes is therefore passed through `assertWritablePath`, which throws * unless the resolved path is inside this worktree. It is a guard rather than a * convention so it cannot regress silently. */ import { spawn } from 'node:child_process' import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse } from 'node:http' import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { extname, join, relative, resolve } from 'node:path' // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- /** Repo root. This script lives in `/scripts`. */ const ROOT = resolve(process.cwd()) const DIST_DIR = join(ROOT, 'dist-game') const PACKS_DIR = join(ROOT, 'samples', 'd2-packs') const SRC_DIR = join(ROOT, 'src') /** * Refuse to write anywhere outside this worktree. * * The forked-from script wrote screenshots into a sibling worktree that is * explicitly off-limits and currently has another team's processes running in * it. Enforcing this in code, on every write, is the only version of that rule * that cannot rot. */ function assertWritablePath(candidate: string): string { const full = resolve(candidate) const rel = relative(ROOT, full) if (rel.startsWith('..') || resolve(rel) === rel) { throw new Error( `verify-animation-browser: refusing to write outside the worktree.\n` + ` requested: ${full}\n worktree : ${ROOT}`, ) } return full } // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- interface Options { readonly negative: string readonly outDir: string readonly keepOpenMs: number } function parseArgs(argv: readonly string[]): Options { let negative = '' let outDir = join(ROOT, '.agents', 'worker_mv', 'evidence') let keepOpenMs = 0 for (const arg of argv) { if (arg.startsWith('--negative=')) negative = arg.slice('--negative='.length) else if (arg.startsWith('--out=')) outDir = resolve(arg.slice('--out='.length)) else if (arg.startsWith('--keep-open-ms=')) keepOpenMs = Number(arg.slice('--keep-open-ms='.length)) } return { negative, outDir, keepOpenMs } } /** * The deliberate faults. Each names the assertion it must turn red. * * `page-*` controls run inside the page; `artifact-*` controls would require * mutating `dist-game/`, which is done by the separate bundle-gate controls * rather than here, so that this harness never writes to the build output. */ const NEGATIVE_CONTROLS: Readonly> = { 'mpq-request': 'page fetches a *.mpq URL -> must turn ZERO_MPQ_DLL_REQUESTS red', 'dll-request': 'page fetches a *.dll URL -> must turn ZERO_MPQ_DLL_REQUESTS red', 'console-error': 'page emits console.error -> must turn ZERO_CONSOLE_ERRORS red', 'missing-art': 'push a fake id into missingMonsterArt -> must turn NO_RED_PLACEHOLDER_SQUARES red', 'monster-art-error': 'bump monsterArtErrors -> must turn NO_RED_PLACEHOLDER_SQUARES red', 'freeze-facing': 'ignore facing writes -> must turn EIGHT_DIRECTIONS_EXERCISED red', 'broken-page': 'navigate to a URL that does not exist -> must turn SCENE_BOOTED red', } // --------------------------------------------------------------------------- // Static server for the built artifact // --------------------------------------------------------------------------- const MIME: Readonly> = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.wasm': 'application/wasm', '.map': 'application/json; charset=utf-8', '.r8': 'application/octet-stream', '.bin': 'application/octet-stream', '.dat': 'application/octet-stream', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg', } interface ServedRequest { readonly url: string readonly status: number } /** * Serve the production layout. * * The game is built with `--base=/diablo2/`, so the bundle must live under * `/diablo2/`. `DEFAULT_PACKS` (`act-scene.ts`) resolves its second entry to * `/diablo2/packs`, so the baked packs are mounted there — this reproduces the * deployed shape rather than inventing one. * * Note what is deliberately NOT mounted: nothing serves `samples/d2`, so no * `*.mpq` is reachable. That does not weaken the audit — a forbidden request is * counted when it is *issued*, regardless of the response — and it matches a * real deployment, where the archives are not published. */ function createStaticServer(served: ServedRequest[]): ReturnType { const mounts: readonly { readonly prefix: string; readonly dir: string }[] = [ { prefix: '/diablo2/packs/', dir: PACKS_DIR }, { prefix: '/diablo2/', dir: DIST_DIR }, ] return createServer((req: IncomingMessage, res: ServerResponse) => { const rawUrl = req.url ?? '/' const path = decodeURIComponent(rawUrl.split('?')[0] ?? '/') const finish = (status: number, body: Buffer | string, type: string): void => { served.push({ url: rawUrl, status }) res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' }) res.end(body) } if (path === '/' || path === '/index.html') { finish(302, '', 'text/plain') return } for (const mount of mounts) { if (!path.startsWith(mount.prefix)) continue const rest = path.slice(mount.prefix.length) // Path traversal guard: the resolved file must stay inside the mount. const target = resolve(mount.dir, rest) if (!target.startsWith(mount.dir)) { finish(403, 'forbidden', 'text/plain') return } if (!existsSync(target) || !statSync(target).isFile()) continue const body = readFileSync(target) finish(200, body, MIME[extname(target).toLowerCase()] ?? 'application/octet-stream') return } finish(404, 'not found', 'text/plain') }) } // --------------------------------------------------------------------------- // Assertion framework // --------------------------------------------------------------------------- type Verdict = 'PASS' | 'FAIL' | 'VACUOUS' interface Assertion { readonly id: string /** What acceptance-criterion text this maps to. */ readonly criterion: string readonly verdict: Verdict readonly detail: string /** Precondition results, so a reader can see the measurement was live. */ readonly preconditions: readonly { readonly name: string; readonly met: boolean; readonly value: string }[] /** * True when the assertion is expected to be red on current `main` because it * documents a pre-existing defect another milestone owns. Reported loudly and * excluded from the exit code, so a genuine regression is not buried under a * known baseline. Turning green is reported as "baseline improved". */ readonly knownRedBaseline?: string } class AssertionLog { readonly items: Assertion[] = [] add(a: Assertion): void { this.items.push(a) } /** * Build an assertion, evaluating preconditions first. * * If any precondition is unmet the verdict is VACUOUS regardless of the * predicate, because a green from an unexercised code path is not evidence. */ check(args: { id: string criterion: string preconditions: readonly { name: string; met: boolean; value: string }[] pass: boolean detail: string knownRedBaseline?: string }): void { const unmet = args.preconditions.filter(p => !p.met) const verdict: Verdict = unmet.length > 0 ? 'VACUOUS' : args.pass ? 'PASS' : 'FAIL' const detail = unmet.length > 0 ? `${args.detail} | VACUOUS: preconditions unmet -> ${unmet.map(p => `${p.name}=${p.value}`).join(', ')}` : args.detail this.add({ id: args.id, criterion: args.criterion, verdict, detail, preconditions: args.preconditions, ...(args.knownRedBaseline === undefined ? {} : { knownRedBaseline: args.knownRedBaseline }), }) } } // --------------------------------------------------------------------------- // CDP plumbing // --------------------------------------------------------------------------- const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)) interface NetworkRequestLog { url: string method: string resourceType: string } interface ConsoleErrorLog { source: string text: string } interface CdpSession { send: (method: string, params?: Record, timeoutMs?: number) => Promise evalJs: (expression: string) => Promise close: () => void } async function connectCdp(debugPort: number, onEvent: (msg: Record) => void): Promise { let wsUrl: string | null = null let lastErr = 'none' for (let i = 0; i < 80; i++) { await sleep(200) try { const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`) const pages = (await res.json()) as { type: string; webSocketDebuggerUrl?: string }[] const page = pages.find(p => p.type === 'page') if (page?.webSocketDebuggerUrl !== undefined) { wsUrl = page.webSocketDebuggerUrl break } lastErr = 'no page target yet' } catch (err) { // Bounded retry while Chrome boots. This is not a silent swallow: the // last error is retained and thrown if the loop never succeeds. lastErr = err instanceof Error ? err.message : String(err) } } if (wsUrl === null) throw new Error(`could not reach Chrome CDP endpoint (last error: ${lastErr})`) const ws = new WebSocket(wsUrl) await new Promise((res, rej) => { ws.onopen = () => res() ws.onerror = () => rej(new Error('CDP WebSocket failed to open')) }) let idCounter = 1 const send = ( method: string, params: Record = {}, timeoutMs = 20000, ): Promise => new Promise((res, rej) => { const id = idCounter++ const timer = setTimeout(() => { ws.removeEventListener('message', handler) rej(new Error(`CDP ${method} timed out after ${timeoutMs}ms`)) }, timeoutMs) const handler = (event: MessageEvent): void => { const msg = JSON.parse(String(event.data)) as Record if (msg['id'] === id) { clearTimeout(timer) ws.removeEventListener('message', handler) if (msg['error'] !== undefined) rej(new Error(`CDP ${method}: ${JSON.stringify(msg['error'])}`)) else res(msg['result'] as T) } } ws.addEventListener('message', handler) ws.send(JSON.stringify({ id, method, params })) }) ws.addEventListener('message', (event: MessageEvent) => { onEvent(JSON.parse(String(event.data)) as Record) }) const evalJs = async (expression: string): Promise => { const res = await send<{ result: { value: T }; exceptionDetails?: unknown }>('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, }) if (res.exceptionDetails !== undefined) { throw new Error(`in-page JS threw: ${JSON.stringify(res.exceptionDetails)}\n expression: ${expression}`) } return res.result.value } return { send, evalJs, close: () => ws.close() } } // --------------------------------------------------------------------------- // Scene state shape (the subset this audit reads) // --------------------------------------------------------------------------- interface SceneSnapshot { present: boolean ready: boolean error: string | null frames: number tick: number character: boolean characterFrames: number characterGroup: number missingMonsterArt: string[] monsterArtErrors: number monsterArtLayerFailures: number monstersPlanned: number facing: number x: number y: number npcs: number } const SNAPSHOT_EXPR = `(() => { const s = window.__d2webAct if (!s) return { present: false } return { present: true, ready: !!s.ready, error: s.error ?? null, frames: s.frames ?? -1, tick: s.tick ?? -1, character: !!s.character, characterFrames: s.characterFrames ?? -1, characterGroup: s.characterGroup ?? -999, missingMonsterArt: Array.isArray(s.missingMonsterArt) ? s.missingMonsterArt.slice() : null, monsterArtErrors: s.monsterArtErrors ?? -1, monsterArtLayerFailures: s.monsterArtLayerFailures ?? -1, monstersPlanned: s.monstersPlanned ?? -1, facing: s.facing ?? -1, x: s.x ?? -1, y: s.y ?? -1, npcs: s.npcs ?? -1, } })()` // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main(): Promise { const opts = parseArgs(process.argv.slice(2)) if (opts.negative === 'list') { console.log('Negative controls:') for (const [id, what] of Object.entries(NEGATIVE_CONTROLS)) console.log(` --negative=${id.padEnd(20)} ${what}`) process.exit(0) } if (opts.negative !== '' && NEGATIVE_CONTROLS[opts.negative] === undefined) { console.error(`Unknown negative control: ${opts.negative}`) console.error(`Known: ${Object.keys(NEGATIVE_CONTROLS).join(', ')}`) process.exit(2) } console.log('======================================================================') console.log('HEADLESS AUDIT of the SHIPPED BUNDLE (dist-game/)') if (opts.negative !== '') console.log(`NEGATIVE CONTROL ACTIVE: ${opts.negative} — ${NEGATIVE_CONTROLS[opts.negative]}`) console.log('======================================================================') // --- Precondition: a real, fresh build exists ----------------------------- // Auditing a missing or stale artifact is the silent-nothing failure mode. assertBuildPresentAndFresh() const outDir = assertWritablePath(opts.negative === '' ? opts.outDir : join(opts.outDir, `negative-${opts.negative}`)) mkdirSync(outDir, { recursive: true }) console.log(`Evidence directory: ${outDir}`) const log = new AssertionLog() const served: ServedRequest[] = [] const networkRequests: NetworkRequestLog[] = [] const consoleErrors: ConsoleErrorLog[] = [] const server = createStaticServer(served) await new Promise(r => server.listen(0, '127.0.0.1', r)) const addr = server.address() const port = typeof addr === 'object' && addr !== null ? addr.port : 0 const baseUrl = `http://127.0.0.1:${port}` console.log(`Static server (node:http, serving dist-game/): ${baseUrl}/diablo2/`) const debugPort = 9246 const chrome = spawn('/usr/bin/google-chrome', [ '--headless=new', `--remote-debugging-port=${debugPort}`, '--no-sandbox', '--disable-dev-shm-usage', '--enable-webgl', '--ignore-gpu-blocklist', '--use-gl=angle', '--use-angle=swiftshader', '--window-size=1280,840', 'about:blank', ]) let exitCode = 0 try { const cdp = await connectCdp(debugPort, msg => { const method = msg['method'] as string | undefined if (method === 'Network.requestWillBeSent') { const req = msg['params'].request as { url: string; method: string } networkRequests.push({ url: req.url, method: req.method, resourceType: String(msg['params'].type) }) } else if (method === 'Runtime.consoleAPICalled') { // This capture does not exist anywhere else in the repo; it is the // whole mechanism behind the "console.error count is 0" criterion. if (msg['params'].type === 'error') { const args = (msg['params'].args ?? []) as { value?: unknown; description?: string }[] const text = args.map(a => String(a.value ?? a.description ?? '')).join(' ') consoleErrors.push({ source: 'console.error', text }) } } else if (method === 'Runtime.exceptionThrown') { const d = msg['params'].exceptionDetails as { text?: string; exception?: { description?: string } } consoleErrors.push({ source: 'uncaught', text: d.exception?.description ?? d.text ?? 'unknown exception' }) } else if (method === 'Log.entryAdded') { // Browser-level errors (failed subresource loads, CORS, WebGL) never // reach `consoleAPICalled`. Without this the count would miss exactly // the failures that matter most for an asset-loading invariant. const e = msg['params'].entry as { level: string; text: string; source: string } if (e.level === 'error') consoleErrors.push({ source: `log:${e.source}`, text: e.text }) } }) await cdp.send('Page.enable') await cdp.send('Runtime.enable') await cdp.send('Network.enable') await cdp.send('Log.enable') const saveScreenshot = async (filename: string): Promise => { const shot = await cdp.send<{ data: string }>('Page.captureScreenshot', { format: 'png' }) const filePath = assertWritablePath(join(outDir, filename)) const buf = Buffer.from(shot.data, 'base64') writeFileSync(filePath, buf) console.log(` screenshot: ${filename} (${buf.length} bytes)`) return filePath } // --- Navigate ----------------------------------------------------------- const targetUrl = opts.negative === 'broken-page' ? `${baseUrl}/diablo2/this-page-does-not-exist.html` : `${baseUrl}/diablo2/acts.html?act=1` console.log(`\n--- Navigating to ${targetUrl}`) await cdp.send('Page.navigate', { url: targetUrl }) // The freeze-facing control must be installed before the scene can be // driven, so it goes in as soon as the document exists. if (opts.negative === 'freeze-facing') { await sleep(1500) await cdp.evalJs(`(() => { window.__mvFreezeFacing = true; return 1 })()`) } let snap: SceneSnapshot = { present: false } as SceneSnapshot for (let i = 0; i < 150; i++) { snap = await cdp.evalJs(SNAPSHOT_EXPR) if (snap.present && (snap.ready || snap.error !== null)) break await sleep(400) } console.log(` scene present=${snap.present} ready=${snap.ready} error=${String(snap.error)}`) log.check({ id: 'SCENE_BOOTED', criterion: 'audit precondition: the scene under test actually loaded and rendered', preconditions: [{ name: '__d2webAct published', met: snap.present === true, value: String(snap.present) }], pass: snap.ready === true && snap.error === null, detail: `ready=${snap.ready} error=${String(snap.error)} frames=${snap.frames} tick=${snap.tick}`, }) if (snap.present && snap.ready) { // --- Drive gameplay --------------------------------------------------- // Walking and swinging is what makes the audit cover "during play" rather // than "at load": it exercises the walk clip, the attack path, and the // monster draw path that produces the red placeholder square. console.log('\n--- Driving gameplay (walk + attack)') await cdp.evalJs(`(() => { const sleep = ms => new Promise(r => setTimeout(r, ms)) const press = (code, down) => window.dispatchEvent(new KeyboardEvent(down ? 'keydown' : 'keyup', { code, bubbles: true })) window.__mvDrive = (async () => { const dirs = ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp'] for (let round = 0; round < 4; round += 1) { const dir = dirs[round % dirs.length] press(dir, true) await sleep(700) press(dir, false) for (let i = 0; i < 3; i += 1) { press('Space', true); await sleep(160); press('Space', false); await sleep(80) } } return true })() return true })()`) await cdp.evalJs(`window.__mvDrive`) await sleep(500) const played = await cdp.evalJs(SNAPSHOT_EXPR) console.log( ` after play: frames=${played.frames} tick=${played.tick} characterFrames=${played.characterFrames} ` + `monstersPlanned=${played.monstersPlanned} missingMonsterArt=${JSON.stringify(played.missingMonsterArt)}`, ) // --- Inject the requested fault -------------------------------------- await injectNegativeControl(cdp, opts.negative) await sleep(600) // --- Assertions ------------------------------------------------------- const live = await cdp.evalJs(SNAPSHOT_EXPR) const rendered = { name: 'frames rendered', met: live.frames > 0, value: String(live.frames) } const ticked = { name: 'sim ticked', met: live.tick > 0, value: String(live.tick) } // 1. Zero runtime MPQ / DLL requests. const forbidden = networkRequests.filter(r => { const u = r.url.toLowerCase().split('?')[0] ?? '' return u.endsWith('.mpq') || u.endsWith('.dll') || u.includes('/mpq/') || u.includes('/dll/') }) log.check({ id: 'ZERO_MPQ_DLL_REQUESTS', criterion: 'AC: 加载并游玩场景期间,对 *.mpq / *.dll 的网络请求数为 0', preconditions: [ rendered, ticked, // Without this, "0 forbidden requests" could simply mean the network // layer was never observed at all. { name: 'network capture live', met: networkRequests.length > 0, value: String(networkRequests.length) }, ], pass: forbidden.length === 0, detail: `${forbidden.length} forbidden of ${networkRequests.length} total requests` + (forbidden.length > 0 ? ` -> ${forbidden.map(f => f.url).join(', ')}` : ''), }) // 2. Zero console.error. log.check({ id: 'ZERO_CONSOLE_ERRORS', criterion: 'AC: 审计期间 console.error 计数为 0', preconditions: [rendered, ticked], pass: consoleErrors.length === 0, detail: `${consoleErrors.length} error-level messages` + (consoleErrors.length > 0 ? ` -> ${consoleErrors.slice(0, 8).map(e => `[${e.source}] ${e.text.slice(0, 200)}`).join(' || ')}` : ''), }) // 3. No red placeholder squares. // // Read from published state, not pixels: `act-scene.ts:3352` already // records every monster id drawn as a red box. The precondition that // monsters were actually planned is what stops this being vacuous on a // town level, where zero red squares is true but meaningless. const missing = live.missingMonsterArt ?? [] log.check({ id: 'NO_RED_PLACEHOLDER_SQUARES', criterion: 'AC: 无红色占位方块', preconditions: [ rendered, ticked, { name: 'monsters planned on this level', met: live.monstersPlanned > 0, value: String(live.monstersPlanned) }, { name: 'missingMonsterArt field exists', met: live.missingMonsterArt !== null, value: String(live.missingMonsterArt !== null) }, ], pass: missing.length === 0 && live.monsterArtErrors === 0 && live.monsterArtLayerFailures === 0, detail: `missingMonsterArt=${JSON.stringify(missing)} monsterArtErrors=${live.monsterArtErrors} ` + `monsterArtLayerFailures=${live.monsterArtLayerFailures}`, }) // 3b. The published-state trap. // // `state.characterGroup` is declared (L254) and initialised to -1 (L328) // and never written. It is proof that a published field can be a // constant, so any audit reading state must verify the fields it trusts // are actually written. This assertion documents the trap rather than // depending on it. log.add({ id: 'PUBLISHED_STATE_TRAP_CHARACTERGROUP', criterion: 'harness self-check: a published field can lie (act-scene.ts:254 characterGroup)', verdict: live.characterGroup === -1 ? 'PASS' : 'PASS', detail: `characterGroup=${live.characterGroup} (init -1 at act-scene.ts:328, never written). ` + `${live.characterGroup === -1 ? 'Confirmed still dead — no assertion may depend on it.' : 'It is now written; it became usable.'}`, preconditions: [rendered], }) // 4. Eight-direction foot-anchor evidence. await captureEightDirections(cdp, log, saveScreenshot, live, opts) // 5. Weapon-swap evidence. await captureWeaponSwap(cdp, log, saveScreenshot, outDir) } // --- Report ------------------------------------------------------------- exitCode = report(log, opts, outDir, { networkRequests, consoleErrors, served }) if (opts.keepOpenMs > 0) await sleep(opts.keepOpenMs) cdp.close() } finally { chrome.kill('SIGKILL') await new Promise(r => server.close(() => r())) } process.exit(exitCode) } /** * Fail loudly when there is nothing real to audit. * * A missing `dist-game/` must not degrade into "audited nothing, all green", * and a stale one silently audits code that no longer exists. */ function assertBuildPresentAndFresh(): void { if (!existsSync(DIST_DIR) || !existsSync(join(DIST_DIR, 'acts.html'))) { console.error(`\nFAIL: no built artifact at ${DIST_DIR} (acts.html missing).`) console.error(' This harness audits the SHIPPED bundle. Build it first:') console.error(' npm run build:game') process.exit(1) } const newest = (dir: string): number => { let best = 0 const walk = (d: string): void => { for (const e of readdirSync(d)) { const f = join(d, e) const st = statSync(f) if (st.isDirectory()) walk(f) else best = Math.max(best, st.mtimeMs) } } walk(dir) return best } const distTime = newest(DIST_DIR) const srcTime = newest(SRC_DIR) if (srcTime > distTime) { console.error('\nFAIL: dist-game/ is STALE — a file under src/ is newer than the build.') console.error(` newest src/ : ${new Date(srcTime).toISOString()}`) console.error(` newest dist-game/ : ${new Date(distTime).toISOString()}`) console.error(' Auditing a stale bundle proves nothing about the current code.') console.error(' Rebuild: npm run build:game') process.exit(1) } console.log(`Artifact OK: dist-game/ built ${new Date(distTime).toISOString()} (newer than src/)`) } async function injectNegativeControl(cdp: CdpSession, negative: string): Promise { if (negative === '') return console.log(`\n--- Injecting negative control: ${negative}`) switch (negative) { case 'mpq-request': // A genuine network request for an archive, issued by the page. It is // expected to 404 — the invariant is about the request being made. await cdp.evalJs(`fetch('/diablo2/data/d2char.mpq').then(()=>1).catch(()=>1)`) break case 'dll-request': await cdp.evalJs(`fetch('/diablo2/data/D2Common.dll').then(()=>1).catch(()=>1)`) break case 'console-error': await cdp.evalJs(`(() => { console.error('MV negative control: synthetic console.error'); return 1 })()`) break case 'missing-art': await cdp.evalJs( `(() => { window.__d2webAct.missingMonsterArt.push('mv-synthetic-missing-monster'); return 1 })()`, ) break case 'monster-art-error': await cdp.evalJs(`(() => { window.__d2webAct.monsterArtErrors += 1; return 1 })()`) break case 'freeze-facing': case 'broken-page': // Handled at their own point in the flow. break default: throw new Error(`unhandled negative control ${negative}`) } } /** * Drive the player through all eight facings and capture one screenshot each. * * WHY THE MACHINE ASSERTION IS NOT "the screenshots differ". * The background has animated tiles and independently moving monsters, so any * two full-frame captures differ even if the player never turned. An assertion * on image difference would therefore pass unconditionally — vacuous in the * most dangerous way, since it *looks* like pixel evidence. * * What is actually checkable is the mechanism: that all eight distinct facings * were really reached, and that the character draw path was live at each one * (`characterFrames` strictly increasing). The screenshots are then human * review material for anchor drift, which is a judgement a script cannot make * without a per-frame draw rect the runtime does not yet publish. */ async function captureEightDirections( cdp: CdpSession, log: AssertionLog, saveScreenshot: (name: string) => Promise, before: SceneSnapshot, opts: Options, ): Promise { console.log('\n--- Capturing 8 facings') const observed: number[] = [] const charFrames: number[] = [] for (let dir = 0; dir < 8; dir++) { await cdp.evalJs(`(() => { if (window.__mvFreezeFacing) return 1 const e = window.__d2webEngine if (!e) throw new Error('__d2webEngine is not published; cannot set facing') e.world.player.facing = ${dir} return 1 })()`) // Two sim ticks at 25 Hz plus a render. await sleep(160) const s = await cdp.evalJs(SNAPSHOT_EXPR) observed.push(s.facing) charFrames.push(s.characterFrames) await saveScreenshot(`anchor_facing_${dir}.png`) } const uniqueFacings = new Set(observed) const charPathLive = charFrames.every((v, i) => i === 0 || v > (charFrames[i - 1] ?? -1)) console.log(` facings observed: ${JSON.stringify(observed)} (unique=${uniqueFacings.size})`) console.log(` characterFrames : ${JSON.stringify(charFrames)} (strictly increasing=${charPathLive})`) log.check({ id: 'EIGHT_DIRECTIONS_EXERCISED', criterion: 'AC: 8 个方向下角色脚底位置稳定(锚点正确),有截图证据 — mechanism half', preconditions: [ { name: '__d2webEngine published', met: true, value: 'act-scene.ts:2677' }, { name: 'character art loaded (not the marker box)', met: before.character === true, value: String(before.character) }, ], pass: uniqueFacings.size === 8 && charPathLive, detail: `observed facings=${JSON.stringify(observed)} unique=${uniqueFacings.size}/8; ` + `characterFrames=${JSON.stringify(charFrames)} strictlyIncreasing=${charPathLive}; ` + `8 screenshots written (anchor_facing_0..7.png)`, }) // The numeric half of the criterion is not yet evidenceable — say so rather // than quietly shipping screenshots as if they were a regression test. log.add({ id: 'FOOT_ANCHOR_NUMERIC_INVARIANT', criterion: 'AC: 脚底位置稳定(无漂移)— numeric half', verdict: 'VACUOUS', detail: 'NOT EVIDENCEABLE on current main. The draw rect is computed inline at act-scene.ts:3321 ' + '(`player.x - frame.width/2, player.y - frame.height + FEET_HEIGHT/2`) from closure-local ' + '`character`/`frame`, neither of which is published, so no in-page read can recover it. ' + 'REQUIRED OF M4: publish `state.playerDrawRect = {x,y,w,h}` each frame; this harness will ' + 'then assert |(y+h) - player.y| <= 1 across all 8 facings, which is the actual anti-drift test. ' + 'Screenshots alone are human-review evidence, not a regression gate.', preconditions: [{ name: 'state.playerDrawRect published', met: false, value: 'absent' }], }) if (opts.negative === 'freeze-facing') { console.log(' (freeze-facing control active: facing writes were suppressed in-page)') } } /** * Capture evidence for "swapping weapon class changes the attack animation". * * On current `main` the player COF is hardcoded to `hth` (tier-3 char load * passes the literal `'hth'`, `act-scene.ts:2388-2389`), and no weapon class * reaches the clip choice at all, so this is a known-red baseline that M3 owns. * The harness still captures the before/after so the change is reviewable, and * records the equipped codes structurally rather than claiming a screenshot * proves something it does not. */ async function captureWeaponSwap( cdp: CdpSession, log: AssertionLog, saveScreenshot: (name: string) => Promise, outDir: string, ): Promise { console.log('\n--- Weapon-swap evidence') const readWeapon = `(() => { const hud = window.__d2webHudInstance const inv = hud && hud.inventory const eq = inv && inv.equipped const w1 = eq && eq.weapon1 return { hudPresent: !!hud, inventoryPresent: !!inv, weapon1: w1 ? { code: w1.code, name: w1.name } : null, // The runtime does not publish a resolved weapon class or the COF clip in // use. Recorded as null so the gap is visible in the artifact rather than // implied by omission. resolvedWeaponClass: (window.__d2webAct && window.__d2webAct.weaponClass) ?? null, activeClip: (window.__d2webAct && window.__d2webAct.playerClip) ?? null, } })()` interface WeaponEvidence { hudPresent: boolean inventoryPresent: boolean weapon1: { code: string; name: string } | null resolvedWeaponClass: string | null activeClip: string | null } const before = await cdp.evalJs(readWeapon) await saveScreenshot('weapon_before.png') // Swap to a two-handed weapon so the weapon class genuinely differs. `2hs` // vs `hth` is the coarsest possible distinction, which is the right one for a // first regression signal. const swapped = await cdp.evalJs(`(() => { const inv = window.__d2webHudInstance && window.__d2webHudInstance.inventory if (!inv) return false inv.equipped.weapon1 = { id: 'mv-swap-2hs', code: '7wa', invFile: 'invgpa', name: 'Colossus Blade', quality: 'normal', invWidth: 2, invHeight: 4, allowedSlots: ['weapon1'], } return true })()`) await sleep(600) const after = await cdp.evalJs(readWeapon) await saveScreenshot('weapon_after.png') const evidencePath = assertWritablePath(join(outDir, 'weapon-swap-evidence.json')) writeFileSync(evidencePath, JSON.stringify({ swapped, before, after }, null, 2)) console.log(` weapon evidence: ${relative(ROOT, evidencePath)}`) console.log(` before=${JSON.stringify(before)}`) console.log(` after =${JSON.stringify(after)}`) log.check({ id: 'WEAPON_SWAP_EVIDENCE_CAPTURED', criterion: 'AC: 换装不同武器类后…有截图或日志证据 — capture half', preconditions: [{ name: 'HUD inventory reachable', met: before.inventoryPresent, value: String(before.inventoryPresent) }], pass: swapped && before.weapon1 !== null && after.weapon1 !== null && before.weapon1.code !== after.weapon1.code, detail: `equipped weapon1 ${before.weapon1?.code ?? 'none'} -> ${after.weapon1?.code ?? 'none'}; screenshots + JSON written`, }) log.check({ id: 'WEAPON_SWAP_CHANGES_ANIMATION', criterion: 'AC: 换装不同武器类后,攻击动作随之变化', preconditions: [ { name: 'runtime publishes the resolved weapon class / active clip', met: after.resolvedWeaponClass !== null || after.activeClip !== null, value: `weaponClass=${String(after.resolvedWeaponClass)} activeClip=${String(after.activeClip)}`, }, ], pass: before.activeClip !== after.activeClip, detail: 'Player COF weapon class is hardcoded to `hth` on current main ' + '(act-scene.ts:2388-2389 passes the literal), and neither the resolved weapon class nor the ' + 'active clip is published, so there is nothing to compare. REQUIRED OF M3: publish ' + '`state.weaponClass` and `state.playerClip`; this assertion then becomes real.', knownRedBaseline: 'M3 owns weapon-class-aware COF selection (R3). Expected red until M3 lands.', }) } function report( log: AssertionLog, opts: Options, outDir: string, raw: { networkRequests: NetworkRequestLog[]; consoleErrors: ConsoleErrorLog[]; served: ServedRequest[] }, ): number { console.log('\n======================================================================') console.log('RESULTS') console.log('======================================================================') let hardFail = 0 let vacuous = 0 let knownRed = 0 let baselineImproved = 0 for (const a of log.items) { const isKnownRed = a.knownRedBaseline !== undefined let tag: string if (a.verdict === 'PASS') { if (isKnownRed) { tag = 'BASELINE-IMPROVED' baselineImproved++ } else tag = 'PASS' } else if (a.verdict === 'VACUOUS') { if (isKnownRed) { tag = 'KNOWN-RED' knownRed++ } else { tag = 'VACUOUS(=FAIL)' vacuous++ } } else { if (isKnownRed) { tag = 'KNOWN-RED' knownRed++ } else { tag = 'FAIL' hardFail++ } } console.log(`\n[${tag}] ${a.id}`) console.log(` criterion: ${a.criterion}`) console.log(` detail : ${a.detail}`) for (const p of a.preconditions) { console.log(` precond : ${p.met ? 'met' : 'UNMET'} — ${p.name} = ${p.value}`) } if (a.knownRedBaseline !== undefined) console.log(` baseline : ${a.knownRedBaseline}`) } const summaryPath = assertWritablePath(join(outDir, 'audit-report.json')) writeFileSync( summaryPath, JSON.stringify( { negativeControl: opts.negative === '' ? null : opts.negative, generated: new Date().toISOString(), assertions: log.items, totals: { hardFail, vacuous, knownRed, baselineImproved }, networkRequestCount: raw.networkRequests.length, forbiddenRequests: raw.networkRequests .filter(r => { const u = r.url.toLowerCase().split('?')[0] ?? '' return u.endsWith('.mpq') || u.endsWith('.dll') }) .map(r => r.url), consoleErrors: raw.consoleErrors, serverRequestCount: raw.served.length, server404s: raw.served.filter(s => s.status === 404).map(s => s.url), }, null, 2, ), ) console.log('\n----------------------------------------------------------------------') console.log( `SUMMARY: ${log.items.length} assertions | FAIL=${hardFail} VACUOUS=${vacuous} ` + `KNOWN-RED=${knownRed} BASELINE-IMPROVED=${baselineImproved}`, ) console.log(`Report: ${relative(ROOT, summaryPath)}`) if (baselineImproved > 0) { console.log('\nNOTE: a known-red baseline turned green. Update the baseline annotation in this script.') } if (opts.negative !== '') { // Under a negative control the run is SUPPOSED to be red. Report the // inversion explicitly so a control that failed to bite is not mistaken // for a clean run. const red = hardFail + vacuous console.log('\n----------------------------------------------------------------------') if (red > 0) { console.log(`NEGATIVE CONTROL '${opts.negative}': harness went RED as required (${red} failing assertion(s)).`) return 0 } console.log(`NEGATIVE CONTROL '${opts.negative}': ***HARNESS STAYED GREEN — THE CONTROL DID NOT BITE***`) console.log('This means the assertion it targets cannot detect its own failure mode.') return 1 } return hardFail + vacuous > 0 ? 1 : 0 } main().catch((err: unknown) => { console.error('\nHARNESS ERROR:', err) process.exit(1) })