diablo2-web/scripts/audit-levels-browser.ts

806 lines
36 KiB
TypeScript

/**
* Headless browser audit of every baked level: all 136 Levels.txt ids, every copy the pack holds.
*
* Each index entry is opened as `acts.html?act=<act>&level=<label>&pack=<pack>` in a fresh tab of a
* headless Chrome (SwiftShader WebGL2), served by a Vite dev server over the working tree. The
* verdicts come from the page's published state (`window.__d2webAct`), the DevTools event stream,
* the screenshot and the pack's own scene.json, never from looking at pictures. Every entry must
* pass all of these:
*
* 1. Boot: the scene reaches `ready` with no `error` and shows the entry that was asked for (act and
* label, loaded from the pack); the simulation ticks and the renderer has drawn frames.
* 2. Clean page: no uncaught exception, no console error or failed assertion, no failed request, no
* HTTP error status. The only exception is the short, fixed KNOWN_ABSENT list (Chrome's favicon
* probe and four missiles the runtime aliases on purpose), which is reported instead.
* 3. Rendering: the map has floor tiles, every atlas page arrives, the screenshot is not blank, and
* every monster the level plans has its art (a monster without art is drawn as a red box).
* 4. Pack integrity: no missing tile, no unresolved tile reference, no unplaced link edge.
* 5. Spawn: the player stands on a walkable sub-tile of a cell that has a floor tile, inside the
* largest connected walkable region that holds floor (largestWalkableRegionWithFloors, the region
* finder the bake and the runtime use), so never in the void and never in a sealed pocket.
* 6. Stability: the level does not switch by itself while the page settles (no spawn on a warp).
*
* Screenshots of the chosen levels (default: the Act I outdoor levels the DRLG port generates) are
* kept in --out, next to report.json.
*
* Usage:
* npx tsx scripts/audit-levels-browser.ts [--pack=samples/d2-packs] [--out=/tmp/level-audit]
* [--jobs=6] [--act=N] [--levels=2-7,17,39] [--shots=2-7,17,39|all|none] [--timeout=180]
* [--chrome=/path/to/chrome]
*
* --pack must lie inside the repository: the dev server serves only the working tree.
*/
import { spawn, type ChildProcess } from 'node:child_process'
import { once } from 'node:events'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, relative, resolve, sep } from 'node:path'
import { inflateSync } from 'node:zlib'
import { createServer } from 'vite'
import { subTileAt, type CollisionGrid } from '../src/game/d2map.ts'
import { largestWalkableRegionWithFloors } from '../src/game/level-links.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
const ROOT = resolve(import.meta.dirname, '..')
/**
* Playwright's Chromium comes first: a plain build without the managed policies and extensions the
* workstation's google-chrome brings along.
*/
const CHROME_CANDIDATES = [
join(process.env.HOME ?? '', '.cache/ms-playwright/chromium-1228/chrome-linux64/chrome'),
'/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome',
'/usr/bin/google-chrome',
]
/** Longest wait for one DevTools command; a hung command fails its entry instead of the audit. */
const COMMAND_TIMEOUT_MS = 60_000
/** Levels.txt ids the audit screenshots by default: the Act I outdoor levels of the DRLG port. */
const DEFAULT_SHOTS = '2-7,17,39'
/** Levels.txt ids 1..136: every level of the game. */
const LEVEL_COUNT = 136
const WINDOW = { width: 1280, height: 840 } as const
/** How long a ready page is watched for late errors or a level switch. */
const SETTLE_MS = 1500
const POLL_MS = 250
/**
* A screenshot counts as blank when fewer than this share of the sampled pixels is lit, or when it
* has fewer distinct colours than this. A drawn map clears both by a wide margin; a black canvas, a
* clear colour or a single placeholder fails both.
*/
const MIN_LIT_SHARE = 0.1
const MIN_COLOURS = 64
/** The bottom part of the window holds the HUD, which draws even when the map does not. */
const MAP_SHARE_OF_HEIGHT = 0.75
const sleep = (ms: number): Promise<void> => new Promise(done => setTimeout(done, ms))
interface IndexEntry {
readonly act: number
readonly levelId: number
readonly label: string
readonly path: string
readonly missingTiles?: number
readonly unplacedEdges?: number
}
interface SceneJson {
readonly cellsX: number
readonly cellsY: number
readonly originX: number
readonly originY: number
readonly floors: readonly (readonly number[])[]
readonly collision: {
readonly width: number
readonly height: number
readonly runs: readonly (readonly number[])[]
}
readonly stats: {
readonly missingTiles: number
readonly missingRefs?: readonly unknown[]
readonly unplacedEdges?: readonly unknown[]
}
}
/** The part of `window.__d2webAct` the audit reads. */
interface PageState {
readonly ready: boolean
readonly error: string | null
readonly source: string
readonly act: number
readonly variant: string
readonly cellsX: number
readonly cellsY: number
readonly floors: number
readonly walls: number
readonly frames: number
readonly pagesLoaded: number
readonly pagesTotal: number
readonly pagesAtFirstFrame: number
readonly drawCalls: number
readonly quadsDrawn: number
readonly tick: number
readonly x: number
readonly y: number
readonly missingMonsterArt: readonly string[]
readonly monsterArtErrors: number
readonly monsterArtLayerFailures: number
readonly monstersPlanned: number
readonly loadMs: number
}
const STATE_EXPRESSION = `(() => {
const s = window.__d2webAct
if (!s) return null
return {
ready: s.ready, error: s.error ?? null, source: s.source, act: s.act, variant: s.variant,
cellsX: s.cellsX, cellsY: s.cellsY, floors: s.floors, walls: s.walls, frames: s.frames,
pagesLoaded: s.pagesLoaded, pagesTotal: s.pagesTotal, pagesAtFirstFrame: s.pagesAtFirstFrame,
drawCalls: s.drawCalls, quadsDrawn: s.quadsDrawn, tick: s.tick, x: s.x, y: s.y,
missingMonsterArt: [...(s.missingMonsterArt ?? [])], monsterArtErrors: s.monsterArtErrors,
monsterArtLayerFailures: s.monsterArtLayerFailures, monstersPlanned: s.monstersPlanned, loadMs: s.loadMs,
}
})()`
interface SpawnCheck {
readonly subX: number
readonly subY: number
readonly inBounds: boolean
readonly walkable: boolean
readonly floor: boolean
readonly inMainRegion: boolean
/** Sub-tiles of the walkable component the player stands in (0 when standing on a blocked one). */
readonly component: number
/** Sub-tiles of the largest walkable region that holds floor. */
readonly mainRegion: number
}
interface ScreenStats {
readonly litShare: number
readonly colours: number
}
interface Verdict {
readonly label: string
readonly act: number
readonly levelId: number
readonly ok: boolean
readonly problems: readonly string[]
readonly state: PageState | null
readonly spawn: SpawnCheck | null
readonly screen: ScreenStats | null
readonly screenshot: string | null
readonly ms: number
/** Known-absent URLs the page asked for ({@link KNOWN_ABSENT}); reported, not failed. */
readonly knownAbsent: readonly string[]
}
interface AuditContext {
readonly baseUrl: string
readonly packDir: string
readonly packPath: string
readonly outDir: string
readonly timeoutMs: number
readonly shots: 'all' | ReadonlySet<number>
}
// ---------------------------------------------------------------------------------------------
// DevTools protocol
interface CdpEvent {
readonly method: string
readonly params: Record<string, unknown>
}
interface CdpReply {
readonly id?: number
readonly method?: string
readonly params?: Record<string, unknown>
readonly result?: unknown
readonly error?: { readonly message: string }
}
/** One DevTools WebSocket: numbered commands and an event stream. */
class CdpSession {
private nextId = 1
private readonly pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>()
private listener: ((event: CdpEvent) => void) | null = null
private constructor(private readonly socket: WebSocket) {
socket.onmessage = message => {
const reply = JSON.parse(String(message.data)) as CdpReply
if (reply.id !== undefined) {
const waiter = this.pending.get(reply.id)
if (waiter === undefined) return
this.pending.delete(reply.id)
if (reply.error !== undefined) waiter.reject(new Error(reply.error.message))
else waiter.resolve(reply.result)
} else if (reply.method !== undefined) {
this.listener?.({ method: reply.method, params: reply.params ?? {} })
}
}
socket.onclose = () => {
for (const waiter of this.pending.values()) waiter.reject(new Error('the DevTools socket closed'))
this.pending.clear()
}
}
static async connect(url: string): Promise<CdpSession> {
const socket = new WebSocket(url)
await new Promise<void>((done, fail) => {
socket.onopen = () => done()
socket.onerror = () => fail(new Error(`cannot open ${url}`))
})
return new CdpSession(socket)
}
onEvent(listener: (event: CdpEvent) => void): void {
this.listener = listener
}
send<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
const id = this.nextId++
return new Promise<T>((done, fail) => {
const timer = setTimeout(() => {
this.pending.delete(id)
fail(new Error(`${method} got no answer within ${String(COMMAND_TIMEOUT_MS / 1000)} s`))
}, COMMAND_TIMEOUT_MS)
this.pending.set(id, {
resolve: value => { clearTimeout(timer); done(value as T) },
reject: error => { clearTimeout(timer); fail(error) },
})
this.socket.send(JSON.stringify({ id, method, params }))
})
}
close(): void {
this.socket.close()
}
}
/** A headless Chrome with its own profile; every audited entry gets a fresh tab. */
class Browser {
private constructor(
private readonly child: ChildProcess,
private readonly profile: string,
private readonly port: number,
) {}
static async launch(bin: string): Promise<Browser> {
const profile = mkdtempSync(join(tmpdir(), 'level-audit-'))
const child = spawn(bin, [
'--headless=new', '--no-sandbox', '--disable-dev-shm-usage', '--no-first-run', '--no-default-browser-check',
`--window-size=${String(WINDOW.width)},${String(WINDOW.height)}`, '--force-device-scale-factor=1',
// Software WebGL2: there is no GPU here, and SwiftShader runs the real renderer path.
'--use-angle=swiftshader', '--use-gl=angle', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist',
// A tab must keep animating while the next one opens.
'--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows',
// gLinux routes even 127.0.0.1 through its PAC proxy unless told otherwise (scripts/browser/README.md).
'--no-proxy-server',
// Without this every HTTP request hangs before it is sent: the cookie store waits for the OS
// keyring (Secret Service over D-Bus), which never answers a headless session. The netlog shows
// the main-frame request stop after COMPUTED_PRIVACY_MODE; data: URLs, which need no cookies,
// still load. The basic store keeps the cookie key in the profile.
'--password-store=basic',
// Nothing but the page under audit: no extension may sit between the tab and the dev server.
'--disable-extensions', '--disable-component-extensions-with-background-pages',
`--user-data-dir=${profile}`, '--remote-debugging-port=0', 'about:blank',
], { stdio: ['ignore', 'ignore', 'pipe'] })
let stderr = ''
child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
const portFile = join(profile, 'DevToolsActivePort')
for (let attempt = 0; attempt < 200; attempt += 1) {
await sleep(100)
if (child.exitCode !== null) throw new Error(`Chrome exited with ${String(child.exitCode)}: ${stderr.slice(-400)}`)
if (!existsSync(portFile)) continue
const port = Number(readFileSync(portFile, 'utf8').split('\n')[0])
if (Number.isInteger(port) && port > 0) return new Browser(child, profile, port)
}
child.kill('SIGKILL')
throw new Error(`Chrome opened no DevTools port within 20 s: ${stderr.slice(-400)}`)
}
async openTab(): Promise<{ readonly id: string; readonly session: CdpSession }> {
const response = await fetch(`http://127.0.0.1:${String(this.port)}/json/new?about:blank`, {
method: 'PUT', signal: AbortSignal.timeout(COMMAND_TIMEOUT_MS),
})
if (!response.ok) throw new Error(`DevTools /json/new answered HTTP ${String(response.status)}`)
const target = await response.json() as { id: string; webSocketDebuggerUrl: string }
return { id: target.id, session: await CdpSession.connect(target.webSocketDebuggerUrl) }
}
async closeTab(id: string): Promise<void> {
const response = await fetch(`http://127.0.0.1:${String(this.port)}/json/close/${id}`, { signal: AbortSignal.timeout(COMMAND_TIMEOUT_MS) })
if (!response.ok) throw new Error(`DevTools /json/close answered HTTP ${String(response.status)}`)
}
async close(): Promise<void> {
if (this.child.exitCode === null) {
const exited = once(this.child, 'exit')
this.child.kill('SIGKILL')
await exited
}
rmSync(this.profile, { recursive: true, force: true })
}
}
// ---------------------------------------------------------------------------------------------
// Checks
function text(value: unknown): string {
return typeof value === 'string' ? value : JSON.stringify(value)
}
/**
* Requests that fail on every page by design and belong to no level: Chrome's own favicon probe,
* and the missile art loadMissileArtMap (src/scene/act-scene.ts) looks for under each of its three
* candidate bases before it aliases exactly these four missiles to the firebolt / lightningstrike
* art (no pack bakes them). They are counted in the report instead of failing the entry; any other
* failed request still fails it.
*/
const KNOWN_ABSENT = /\/favicon\.ico$|\/missiles\/(?:hydra|hydrafire|lightning|lightningbolt)\.(?:json|png)$/
/** Record whatever the page reports as broken: exceptions, console errors, failed or erroring requests. */
function collectPageError(event: CdpEvent, requests: Map<string, string>, errors: Set<string>, knownAbsent: Set<string>): void {
const params = event.params
switch (event.method) {
case 'Network.requestWillBeSent': {
const request = params.request as { url?: string } | undefined
requests.set(text(params.requestId), request?.url ?? '')
break
}
case 'Network.responseReceived': {
const response = params.response as { status?: number; url?: string } | undefined
if (response?.status !== undefined && response.status >= 400) {
const url = response.url ?? ''
if (response.status === 404 && KNOWN_ABSENT.test(url)) knownAbsent.add(url)
else errors.add(`HTTP ${String(response.status)} ${url}`)
}
break
}
case 'Network.loadingFailed':
if (params.canceled !== true) {
errors.add(`request failed (${text(params.errorText)}): ${requests.get(text(params.requestId)) ?? text(params.requestId)}`)
}
break
case 'Runtime.exceptionThrown': {
const details = params.exceptionDetails as { text?: string; exception?: { description?: string } } | undefined
const description = details?.exception?.description ?? details?.text ?? 'exception'
errors.add(`uncaught ${description.split('\n').slice(0, 2).join(' | ').slice(0, 400)}`)
break
}
case 'Runtime.consoleAPICalled':
if (params.type === 'error' || params.type === 'assert') {
const args = (params.args as { value?: unknown; description?: string; type?: string }[] | undefined) ?? []
const message = args.map(arg => arg.value !== undefined ? text(arg.value) : arg.description ?? arg.type ?? '').join(' ')
errors.add(`console.${text(params.type)}: ${message.slice(0, 400)}`)
}
break
case 'Log.entryAdded': {
const entry = params.entry as { level?: string; source?: string; text?: string; url?: string } | undefined
if (entry?.level !== 'error') break
// Chrome's console line for a 404 already counted above.
if (entry.source === 'network' && entry.url !== undefined && KNOWN_ABSENT.test(entry.url) && entry.text?.includes('status of 404') === true) {
knownAbsent.add(entry.url)
break
}
errors.add(`${entry.source ?? 'log'}: ${entry.text ?? ''}${entry.url ? ` (${entry.url})` : ''}`)
break
}
default:
break
}
}
async function readState(session: CdpSession): Promise<PageState | null> {
const reply = await session.send<{ result: { value?: PageState | null }; exceptionDetails?: unknown }>(
'Runtime.evaluate', { expression: STATE_EXPRESSION, returnByValue: true },
)
if (reply.exceptionDetails !== undefined) throw new Error(`reading the scene state threw: ${text(reply.exceptionDetails).slice(0, 300)}`)
return reply.result.value ?? null
}
async function waitForState(
session: CdpSession,
done: (state: PageState) => boolean,
deadline: number,
): Promise<PageState | null> {
let state = await readState(session)
while ((state === null || !done(state)) && Date.now() < deadline) {
await sleep(POLL_MS)
state = await readState(session)
}
return state
}
function decodeCollision(collision: SceneJson['collision']): Uint8Array {
const blocked = new Uint8Array(collision.width * collision.height)
let at = 0
for (const run of collision.runs) {
const value = run[0]!
const length = run[1]!
if (value !== 0) blocked.fill(value, at, Math.min(at + length, blocked.length))
at += length
}
if (at !== blocked.length) throw new Error(`collision runs cover ${String(at)} sub-tiles, the grid has ${String(blocked.length)}`)
return blocked
}
/** Where the player stands, judged on the scene's own collision map and floor list. */
function checkSpawn(scene: SceneJson, x: number, y: number): SpawnCheck {
const width = scene.collision.width
const height = scene.collision.height
if (width !== scene.cellsX * SUB_TILES_PER_TILE || height !== scene.cellsY * SUB_TILES_PER_TILE) {
throw new Error(`collision grid ${String(width)}x${String(height)} does not match ${String(scene.cellsX)}x${String(scene.cellsY)} cells`)
}
const blocked = decodeCollision(scene.collision)
const floorCells = new Uint8Array(scene.cellsX * scene.cellsY)
for (const row of scene.floors) {
const cellX = row[3]!
const cellY = row[4]!
if (cellX >= 0 && cellX < scene.cellsX && cellY >= 0 && cellY < scene.cellsY) floorCells[cellY * scene.cellsX + cellX] = 1
}
const hasFloor = (cellX: number, cellY: number): boolean =>
cellX >= 0 && cellX < scene.cellsX && cellY >= 0 && cellY < scene.cellsY && floorCells[cellY * scene.cellsX + cellX] === 1
const grid: CollisionGrid = {
originX: scene.originX, originY: scene.originY, cellsX: scene.cellsX, cellsY: scene.cellsY, blocked, gridWidth: width,
}
const { subX, subY, inBounds } = subTileAt(grid, x, y)
const region = largestWalkableRegionWithFloors({ cellsX: scene.cellsX, cellsY: scene.cellsY, gridWidth: width, gridHeight: height, blocked }, hasFloor)
let mainRegion = 0
for (const inside of region) mainRegion += inside
const at = subY * width + subX
const walkable = inBounds && blocked[at] === 0
// The component the player stands in, four-connected like the region finder.
let component = 0
if (walkable) {
const seen = new Uint8Array(blocked.length)
const stack = [at]
seen[at] = 1
while (stack.length > 0) {
const here = stack.pop()!
component += 1
const hx = here % width
const neighbours = [
hx > 0 ? here - 1 : -1,
hx < width - 1 ? here + 1 : -1,
here >= width ? here - width : -1,
here + width < blocked.length ? here + width : -1,
]
for (const next of neighbours) {
if (next >= 0 && seen[next] === 0 && blocked[next] === 0) {
seen[next] = 1
stack.push(next)
}
}
}
}
return {
subX,
subY,
inBounds,
walkable,
floor: inBounds && hasFloor(Math.floor(subX / SUB_TILES_PER_TILE), Math.floor(subY / SUB_TILES_PER_TILE)),
inMainRegion: walkable && region[at] === 1,
component,
mainRegion,
}
}
/** Decode an 8-bit, non-interlaced RGB or RGBA PNG (what Page.captureScreenshot returns). */
function decodePng(png: Buffer): { width: number; height: number; channels: number; pixels: Uint8Array } {
const signature = [137, 80, 78, 71, 13, 10, 26, 10]
if (signature.some((byte, at) => png[at] !== byte)) throw new Error('the screenshot is not a PNG')
let width = 0
let height = 0
let bitDepth = 0
let colourType = -1
let interlace = 0
const chunks: Buffer[] = []
for (let offset = 8; offset < png.length;) {
const length = png.readUInt32BE(offset)
const type = png.toString('latin1', offset + 4, offset + 8)
const data = png.subarray(offset + 8, offset + 8 + length)
if (type === 'IHDR') {
width = data.readUInt32BE(0)
height = data.readUInt32BE(4)
bitDepth = data[8]!
colourType = data[9]!
interlace = data[12]!
} else if (type === 'IDAT') {
chunks.push(data)
} else if (type === 'IEND') {
break
}
offset += 12 + length
}
if (bitDepth !== 8 || interlace !== 0 || (colourType !== 2 && colourType !== 6)) {
throw new Error(`unsupported screenshot PNG: depth ${String(bitDepth)}, colour type ${String(colourType)}, interlace ${String(interlace)}`)
}
const channels = colourType === 6 ? 4 : 3
const raw = inflateSync(Buffer.concat(chunks))
const stride = width * channels
if (raw.length !== height * (stride + 1)) throw new Error('truncated screenshot PNG')
const pixels = new Uint8Array(height * stride)
for (let row = 0; row < height; row += 1) {
const filter = raw[row * (stride + 1)]!
const src = row * (stride + 1) + 1
const dst = row * stride
for (let i = 0; i < stride; i += 1) {
const left = i >= channels ? pixels[dst + i - channels]! : 0
const up = row > 0 ? pixels[dst - stride + i]! : 0
const upLeft = row > 0 && i >= channels ? pixels[dst - stride + i - channels]! : 0
const value = raw[src + i]!
let out: number
switch (filter) {
case 0: out = value; break
case 1: out = value + left; break
case 2: out = value + up; break
case 3: out = value + ((left + up) >> 1); break
case 4: {
const estimate = left + up - upLeft
const dLeft = Math.abs(estimate - left)
const dUp = Math.abs(estimate - up)
const dUpLeft = Math.abs(estimate - upLeft)
out = value + (dLeft <= dUp && dLeft <= dUpLeft ? left : dUp <= dUpLeft ? up : upLeft)
break
}
default: throw new Error(`bad PNG filter ${String(filter)}`)
}
pixels[dst + i] = out & 0xff
}
}
return { width, height, channels, pixels }
}
/** How much of the map area of a screenshot is lit, and how many colours it shows. */
function screenStats(png: Buffer): ScreenStats {
const image = decodePng(png)
const rows = Math.floor(image.height * MAP_SHARE_OF_HEIGHT)
const colours = new Set<number>()
let lit = 0
let sampled = 0
for (let row = 0; row < rows; row += 2) {
for (let column = 0; column < image.width; column += 2) {
const at = (row * image.width + column) * image.channels
const r = image.pixels[at]!
const g = image.pixels[at + 1]!
const b = image.pixels[at + 2]!
sampled += 1
if (Math.max(r, g, b) > 24) lit += 1
colours.add(((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3))
}
}
return { litShare: lit / sampled, colours: colours.size }
}
async function auditEntry(browser: Browser, entry: IndexEntry, context: AuditContext): Promise<Verdict> {
const started = Date.now()
const deadline = started + context.timeoutMs
const problems: string[] = []
const pageErrors = new Set<string>()
const knownAbsent = new Set<string>()
const requests = new Map<string, string>()
let state: PageState | null = null
let spawnCheck: SpawnCheck | null = null
let screen: ScreenStats | null = null
let screenshot: string | null = null
const scene = JSON.parse(readFileSync(join(context.packDir, entry.path, 'scene.json'), 'utf8')) as SceneJson
if ((entry.missingTiles ?? 0) !== 0 || scene.stats.missingTiles !== 0) problems.push(`missing tiles: index ${String(entry.missingTiles)}, scene ${String(scene.stats.missingTiles)}`)
if ((scene.stats.missingRefs?.length ?? 0) !== 0) problems.push(`unresolved tile references: ${text(scene.stats.missingRefs).slice(0, 300)}`)
if ((entry.unplacedEdges ?? 0) !== 0 || (scene.stats.unplacedEdges?.length ?? 0) !== 0) problems.push(`unplaced link edges: ${text(scene.stats.unplacedEdges).slice(0, 300)}`)
const { id, session } = await browser.openTab()
try {
session.onEvent(event => collectPageError(event, requests, pageErrors, knownAbsent))
await session.send('Runtime.enable')
await session.send('Log.enable')
await session.send('Network.enable')
await session.send('Page.enable')
const query = new URLSearchParams({ act: String(entry.act), level: entry.label, pack: context.packPath, lighting: 'noon' })
await session.send('Page.navigate', { url: `${context.baseUrl}/acts.html?${query.toString()}` })
const atReady = await waitForState(session, s => s.ready || s.error !== null, deadline)
if (atReady === null) {
problems.push('the page never published window.__d2webAct')
} else if (atReady.error !== null) {
problems.push(`scene error: ${atReady.error}`)
} else if (!atReady.ready) {
problems.push(`not ready after ${String(context.timeoutMs / 1000)} s`)
} else {
if (atReady.source !== 'pack') problems.push(`loaded from "${atReady.source}", not from the pack`)
if (atReady.act !== entry.act || atReady.variant !== entry.label) problems.push(`showed act ${String(atReady.act)} "${atReady.variant}"`)
const drawn = await waitForState(
session,
s => s.error !== null || (s.pagesLoaded >= s.pagesTotal && s.pagesAtFirstFrame >= 0 && s.tick > 0 && s.drawCalls > 0),
deadline,
)
// The spawn is read from the first ticked state: `ready` is published before the frame loop
// first writes the player position (state.x/y are written together with state.tick).
if (drawn === null || drawn.tick <= 0) {
problems.push('no simulation tick, so no spawn position to check')
} else {
spawnCheck = checkSpawn(scene, drawn.x, drawn.y)
if (!spawnCheck.inBounds) problems.push(`spawn outside the map at (${String(drawn.x)}, ${String(drawn.y)})`)
else if (!spawnCheck.walkable) problems.push(`spawn on a blocked sub-tile (${String(spawnCheck.subX)}, ${String(spawnCheck.subY)})`)
else if (!spawnCheck.inMainRegion) problems.push(`spawn in a walkable pocket of ${String(spawnCheck.component)} sub-tiles, outside the main region of ${String(spawnCheck.mainRegion)}`)
if (spawnCheck.inBounds && !spawnCheck.floor) problems.push(`spawn on a cell without a floor tile (sub-tile ${String(spawnCheck.subX)}, ${String(spawnCheck.subY)})`)
}
await sleep(SETTLE_MS)
state = await readState(session)
if (state === null) {
problems.push('the scene state disappeared')
} else {
if (state.error !== null) problems.push(`scene error after ready: ${state.error}`)
if (state.act !== entry.act || state.variant !== entry.label) problems.push(`switched by itself to act ${String(state.act)} "${state.variant}"`)
if (drawn === null || state.pagesLoaded < state.pagesTotal) problems.push(`atlas pages loaded ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`)
if (state.pagesAtFirstFrame < 0) problems.push('no frame drawn')
if (state.tick <= 0) problems.push('the simulation does not tick')
if (state.drawCalls <= 0 || state.quadsDrawn <= 0) problems.push(`nothing drawn (draw calls ${String(state.drawCalls)}, quads ${String(state.quadsDrawn)})`)
if (state.floors <= 0 || state.frames <= 0) problems.push(`empty map (floors ${String(state.floors)}, frames ${String(state.frames)})`)
if (state.cellsX !== scene.cellsX || state.cellsY !== scene.cellsY) problems.push(`page shows ${String(state.cellsX)}x${String(state.cellsY)} cells, the scene has ${String(scene.cellsX)}x${String(scene.cellsY)}`)
if (state.missingMonsterArt.length > 0) problems.push(`monsters without art: ${state.missingMonsterArt.join(', ')}`)
if (state.monsterArtErrors !== 0 || state.monsterArtLayerFailures !== 0) problems.push(`monster art failures: ${String(state.monsterArtErrors)} loads, ${String(state.monsterArtLayerFailures)} layers`)
}
const shot = await session.send<{ data: string }>('Page.captureScreenshot', { format: 'png' })
const png = Buffer.from(shot.data, 'base64')
screen = screenStats(png)
if (screen.litShare < MIN_LIT_SHARE || screen.colours < MIN_COLOURS) {
problems.push(`blank screen (lit ${(screen.litShare * 100).toFixed(1)}%, ${String(screen.colours)} colours)`)
}
if (context.shots === 'all' || context.shots.has(entry.levelId) || problems.length > 0 || pageErrors.size > 0) {
screenshot = join(context.outDir, `${entry.label}.png`)
writeFileSync(screenshot, png)
}
}
if (state === null) state = atReady
} finally {
session.close()
await browser.closeTab(id)
}
for (const error of pageErrors) problems.push(error)
return {
label: entry.label,
act: entry.act,
levelId: entry.levelId,
ok: problems.length === 0,
problems,
state,
spawn: spawnCheck,
screen,
screenshot,
ms: Date.now() - started,
knownAbsent: [...knownAbsent].sort(),
}
}
// ---------------------------------------------------------------------------------------------
// Driver
function parseIds(spec: string): Set<number> {
const ids = new Set<number>()
for (const part of spec.split(',')) {
const range = /^(\d+)(?:-(\d+))?$/.exec(part.trim())
if (range === null) throw new Error(`bad level list "${spec}"`)
const from = Number(range[1])
const to = range[2] === undefined ? from : Number(range[2])
if (to < from) throw new Error(`bad level range "${part}"`)
for (let levelId = from; levelId <= to; levelId += 1) ids.add(levelId)
}
return ids
}
function parseArgs(argv: readonly string[]): Map<string, string> {
const known = new Set(['pack', 'out', 'jobs', 'act', 'levels', 'shots', 'timeout', 'chrome'])
const args = new Map<string, string>()
for (const arg of argv) {
const match = /^--([a-z]+)=(.+)$/.exec(arg)
if (match === null || !known.has(match[1]!)) throw new Error(`unknown argument ${arg} (options: ${[...known].map(key => `--${key}=`).join(' ')})`)
args.set(match[1]!, match[2]!)
}
return args
}
function positiveInteger(value: string, name: string): number {
const number = Number(value)
if (!Number.isInteger(number) || number < 1) throw new Error(`--${name} must be a positive integer, got ${value}`)
return number
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2))
const packDir = resolve(ROOT, args.get('pack') ?? 'samples/d2-packs')
const packPath = relative(ROOT, packDir).split(sep).join('/')
if (packPath === '' || packPath.startsWith('..')) throw new Error(`--pack must be a directory inside ${ROOT}: the dev server serves only the repository`)
const index = JSON.parse(readFileSync(join(packDir, 'index.json'), 'utf8')) as { levels: IndexEntry[] }
const jobs = positiveInteger(args.get('jobs') ?? '6', 'jobs')
const timeoutMs = positiveInteger(args.get('timeout') ?? '180', 'timeout') * 1000
const outDir = resolve(args.get('out') ?? '/tmp/level-audit')
mkdirSync(outDir, { recursive: true })
const act = args.has('act') ? positiveInteger(args.get('act')!, 'act') : undefined
const levels = args.has('levels') ? parseIds(args.get('levels')!) : undefined
const shotsSpec = args.get('shots') ?? DEFAULT_SHOTS
const shots = shotsSpec === 'all' ? 'all' : shotsSpec === 'none' ? new Set<number>() : parseIds(shotsSpec)
const chrome = args.get('chrome') ?? CHROME_CANDIDATES.find(candidate => existsSync(candidate))
if (chrome === undefined || !existsSync(chrome)) throw new Error(`no Chrome binary; tried ${CHROME_CANDIDATES.join(', ')} (pass --chrome=)`)
const entries = index.levels.filter(entry => (act === undefined || entry.act === act) && (levels === undefined || levels.has(entry.levelId)))
if (entries.length === 0) throw new Error('no index entry matches the filters')
const coverageProblems: string[] = []
if (act === undefined && levels === undefined) {
const present = new Set(entries.map(entry => entry.levelId))
const absent: number[] = []
for (let levelId = 1; levelId <= LEVEL_COUNT; levelId += 1) if (!present.has(levelId)) absent.push(levelId)
if (absent.length > 0) coverageProblems.push(`the pack has no map for level ids ${absent.join(', ')}`)
}
const server = await createServer({
root: ROOT,
configFile: join(ROOT, 'vite.config.ts'),
logLevel: 'error',
clearScreen: false,
server: { host: '127.0.0.1', port: 5600 + Math.floor(Math.random() * 300), strictPort: false },
})
await server.listen()
const address = server.httpServer?.address()
if (address === null || address === undefined || typeof address === 'string') throw new Error('the dev server has no TCP address')
const context: AuditContext = { baseUrl: `http://127.0.0.1:${String(address.port)}`, packDir, packPath, outDir, timeoutMs, shots }
console.log(`auditing ${String(entries.length)} maps of ${packPath} (${String(new Set(entries.map(entry => entry.levelId)).size)} level ids) with ${String(jobs)} browsers; dev server ${context.baseUrl}; Chrome ${chrome}`)
const browsers: Browser[] = []
const verdicts: Verdict[] = []
const started = Date.now()
try {
for (let i = 0; i < Math.min(jobs, entries.length); i += 1) browsers.push(await Browser.launch(chrome))
const queue = [...entries]
const report = (verdict: Verdict): void => {
verdicts.push(verdict)
const tail = verdict.ok ? `${(verdict.ms / 1000).toFixed(1)} s` : verdict.problems.join(' ; ')
console.log(`[${String(verdicts.length).padStart(3)}/${String(entries.length)}] ${verdict.ok ? 'ok ' : 'FAIL'} ${verdict.label.padEnd(44)} ${tail}`)
}
const run = async (browser: Browser, entry: IndexEntry): Promise<void> => {
try {
report(await auditEntry(browser, entry, context))
} catch (error) {
report({
label: entry.label, act: entry.act, levelId: entry.levelId, ok: false,
problems: [`audit error: ${(error as Error).message}`], state: null, spawn: null, screen: null, screenshot: null, ms: 0,
knownAbsent: [],
})
}
}
// One page first: it makes the dev server transform the modules once instead of in every tab.
await run(browsers[0]!, queue.shift()!)
await Promise.all(browsers.map(async browser => {
for (let entry = queue.shift(); entry !== undefined; entry = queue.shift()) await run(browser, entry)
}))
} finally {
for (const browser of browsers) await browser.close()
await server.close()
}
const order = new Map(entries.map((entry, at) => [entry.label, at]))
verdicts.sort((a, b) => order.get(a.label)! - order.get(b.label)!)
const failed = verdicts.filter(verdict => !verdict.ok)
const failedIds = new Set(failed.map(verdict => verdict.levelId))
const knownAbsent = [...new Set(verdicts.flatMap(verdict => verdict.knownAbsent))].sort()
writeFileSync(join(outDir, 'report.json'), JSON.stringify({
pack: packPath,
generated: new Date().toISOString(),
maps: verdicts.length,
levelIds: new Set(verdicts.map(verdict => verdict.levelId)).size,
failedMaps: failed.length,
coverageProblems,
knownAbsent,
verdicts: verdicts.map(verdict => ({ ...verdict, knownAbsent: verdict.knownAbsent.length })),
}, null, 1))
console.log(`\n${String(verdicts.length - failed.length)}/${String(verdicts.length)} maps passed, ${String(new Set(verdicts.map(v => v.levelId)).size - failedIds.size)}/${String(new Set(verdicts.map(v => v.levelId)).size)} level ids clean, in ${((Date.now() - started) / 1000).toFixed(0)} s; report ${join(outDir, 'report.json')}`)
if (knownAbsent.length > 0) console.log(`known-absent requests (reported, not failed): ${knownAbsent.join(', ')}`)
for (const problem of coverageProblems) console.log(`COVERAGE: ${problem}`)
for (const verdict of failed) console.log(`FAIL ${verdict.label}: ${verdict.problems.join(' ; ')}`)
if (failed.length > 0 || coverageProblems.length > 0) process.exitCode = 1
}
await main()