diablo2-web/scripts/lib/drlg-oracle.ts

137 lines
5.7 KiB
TypeScript

/**
* Node-side helpers for the DRLG port's differential tooling (scripts/drlg-diff.ts and
* tests/drlg-act1-oracle.test.ts):
* - fsDrlgSource: a DrlgDataSource over the oracle data directory, with the exact path mapping of
* tools/d2moo-oracle/src/stubs.cpp ('\\' -> '/', lower case, under <data>/mpq and <data>/tables),
* so the port and the native oracle read byte-identical inputs;
* - runOracle: runs the native D2MOO oracle binary and parses its schema-2 JSON (and RNG trace);
* - firstDiff: structural comparison reporting the first differing JSON path.
*/
import { execFileSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import type { DrlgDataSource, DrlgTableName } from '../../src/game/drlg/drlg-tables.ts'
import type { DrlgDump } from '../../src/game/drlg/drlg-dump.ts'
export const ORACLE_DIR = resolve(import.meta.dirname, '../../tools/d2moo-oracle')
export const ORACLE_BIN = join(ORACLE_DIR, 'build', 'd2moo-oracle')
export const ORACLE_DATA = join(ORACLE_DIR, 'data')
/** Same layout as the oracle's LoadBinTable / ARCHIVE_AllocateBufferAndReadFile stubs. */
export function fsDrlgSource(dataDir: string = ORACLE_DATA): DrlgDataSource {
if (!existsSync(join(dataDir, 'tables'))) {
throw new Error(`${dataDir}/tables is missing: run npx tsx scripts/extract-d2moo-tables.ts samples/d2 ${dataDir}`)
}
return {
readTable(name: DrlgTableName): Uint8Array {
return new Uint8Array(readFileSync(join(dataDir, 'tables', `${name.toLowerCase()}.bin`)))
},
readFile(path: string): Uint8Array {
const rel = path.replace(/\\/g, '/').toLowerCase()
return new Uint8Array(readFileSync(join(dataDir, 'mpq', rel)))
},
}
}
export interface OracleRunOptions {
readonly seed: number
/** Empty: act stage only. */
readonly levels: readonly number[]
readonly difficulty?: number
readonly isolated?: boolean
readonly trace?: boolean
readonly dataDir?: string
}
export interface OracleRunResult {
readonly doc: DrlgDump
readonly trace: string[] | null
}
export function runOracle(opts: OracleRunOptions): OracleRunResult {
if (!existsSync(ORACLE_BIN)) throw new Error(`${ORACLE_BIN} is missing: run ./tools/d2moo-oracle/build.sh`)
const dir = mkdtempSync(join(tmpdir(), 'd2moo-oracle-'))
try {
const out = join(dir, 'out.json')
const tracePath = join(dir, 'trace.txt')
const args = ['--data', opts.dataDir ?? ORACLE_DATA, '--seed', `0x${(opts.seed >>> 0).toString(16)}`, '--out', out]
if (opts.levels.length > 0) args.push('--levels', opts.levels.join(','))
if (opts.difficulty !== undefined) args.push('--difficulty', String(opts.difficulty))
if (opts.isolated) args.push('--isolated')
if (opts.trace) args.push('--trace', tracePath)
execFileSync(ORACLE_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024 })
const doc = JSON.parse(readFileSync(out, 'utf8')) as DrlgDump
const trace = opts.trace ? readFileSync(tracePath, 'utf8').split('\n').filter(l => l.length > 0) : null
return { doc, trace }
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
export interface JsonDiff {
readonly path: string
readonly expected: unknown
readonly actual: unknown
}
const describe = (v: unknown): string => {
const s = JSON.stringify(v)
return s === undefined ? 'undefined' : s.length > 200 ? `${s.slice(0, 200)}...` : s
}
/** First difference between two JSON values (arrays by index, objects by key union), or null. */
export function firstDiff(expected: unknown, actual: unknown, path = '$'): JsonDiff | null {
if (expected === actual) return null
if (Array.isArray(expected) && Array.isArray(actual)) {
const n = Math.min(expected.length, actual.length)
for (let i = 0; i < n; i += 1) {
const d = firstDiff(expected[i], actual[i], `${path}[${i}]`)
if (d) return d
}
if (expected.length !== actual.length) {
return { path: `${path}.length`, expected: expected.length, actual: actual.length }
}
return null
}
if (expected && actual && typeof expected === 'object' && typeof actual === 'object' && !Array.isArray(expected) && !Array.isArray(actual)) {
const e = expected as Record<string, unknown>
const a = actual as Record<string, unknown>
const keys = [...new Set([...Object.keys(e), ...Object.keys(a)])]
for (const k of keys) {
if (!(k in e)) return { path: `${path}.${k}`, expected: undefined, actual: a[k] }
if (!(k in a)) return { path: `${path}.${k}`, expected: e[k], actual: undefined }
const d = firstDiff(e[k], a[k], `${path}.${k}`)
if (d) return d
}
return null
}
return { path, expected, actual }
}
export function formatDiff(d: JsonDiff): string {
return `${d.path}: expected ${describe(d.expected)}, got ${describe(d.actual)}`
}
/** Index of the first differing trace line and a few lines of context from both sides. */
export function firstTraceDivergence(expected: readonly string[], actual: readonly string[], context = 6): string | null {
const n = Math.min(expected.length, actual.length)
let i = 0
while (i < n && expected[i] === actual[i]) i += 1
if (i === n && expected.length === actual.length) return null
let ctx = ''
for (let j = i - 1; j >= 0; j -= 1) {
if (expected[j]!.startsWith('# ')) {
ctx = expected[j]!
break
}
}
const lo = Math.max(0, i - context)
const lines = [`first RNG divergence at trace line ${i + 1} (context "${ctx.slice(2)}"):`]
for (let j = lo; j < i; j += 1) lines.push(` ${expected[j]}`)
lines.push(` oracle: ${expected[i] ?? '<end of trace>'}`)
lines.push(` port: ${actual[i] ?? '<end of trace>'}`)
return lines.join('\n')
}