diablo2-web/scripts/verify-animation-browser.ts

1048 lines
42 KiB
TypeScript

/**
* 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=<id>` 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 `<root>/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<Record<string, string>> = {
'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<Record<string, string>> = {
'.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<typeof createServer> {
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<void> => new Promise(r => setTimeout(r, ms))
interface NetworkRequestLog {
url: string
method: string
resourceType: string
}
interface ConsoleErrorLog {
source: string
text: string
}
interface CdpSession {
send: <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
evalJs: <T = unknown>(expression: string) => Promise<T>
close: () => void
}
async function connectCdp(debugPort: number, onEvent: (msg: Record<string, any>) => void): Promise<CdpSession> {
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<void>((res, rej) => {
ws.onopen = () => res()
ws.onerror = () => rej(new Error('CDP WebSocket failed to open'))
})
let idCounter = 1
const send = <T = unknown>(
method: string,
params: Record<string, unknown> = {},
timeoutMs = 20000,
): Promise<T> =>
new Promise<T>((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<string, any>
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<string, any>)
})
const evalJs = async <T = unknown>(expression: string): Promise<T> => {
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<void> {
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<void>(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<string> => {
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<SceneSnapshot>(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<SceneSnapshot>(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<SceneSnapshot>(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<void>(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<void> {
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<string>,
before: SceneSnapshot,
opts: Options,
): Promise<void> {
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<SceneSnapshot>(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<string>,
outDir: string,
): Promise<void> {
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<WeaponEvidence>(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<boolean>(`(() => {
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<WeaponEvidence>(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)
})