159 lines
7.8 KiB
TypeScript
159 lines
7.8 KiB
TypeScript
/**
|
|
* Act I outdoor DRLG: bit-exact parity of the TypeScript port (src/game/drlg) with the native D2MOO
|
|
* oracle (tools/d2moo-oracle), and of the level maps the bake makes from it (src/game/drlg/drlg-map.ts).
|
|
*
|
|
* - The committed fixtures are the oracle's schema-3 dumps of four game seeds: 0x12345678 and the
|
|
* three seeds the bake generates Act I from (0x5eed0100..0102). The port, fed from the MPQ data
|
|
* exactly as the bake feeds it (scripts/lib/drlg-mpq-source.ts), must reproduce every stage: the act
|
|
* layout, the level grids, the rooms, every room's activation and its tile lists.
|
|
* - The oracle's dump and the port's dump then go through the same adapter; the canvases, DT1 library
|
|
* lists, entrances and landmarks must be identical, so a baked map is the map the oracle generates.
|
|
* - With the oracle binary built, one seed is re-run live and its RNG trace compared roll by roll.
|
|
* - Anti-cheat guards keep fixture data, seed literals and level-name branches out of the port.
|
|
*
|
|
* The MPQ data (samples/d2) is required: without it the suite fails instead of skipping.
|
|
*/
|
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
import { join, resolve } from 'node:path'
|
|
import { gunzipSync } from 'node:zlib'
|
|
import { beforeAll, describe, expect, it } from 'vitest'
|
|
import type { Dt1 } from '../src/formats/dt1.ts'
|
|
import { parseTable } from '../src/game/acts.ts'
|
|
import { DrlgTraceWriter, dumpAct1, type DrlgDump } from '../src/game/drlg/drlg-dump.ts'
|
|
import { act1OutdoorLevelIds, buildDrlgLevelMap, drlgLevelInputFromDump, type DrlgLevelMap } from '../src/game/drlg/drlg-map.ts'
|
|
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
|
|
import { loadDrlgTables } from '../src/game/drlg/drlg-tables.ts'
|
|
import { MpqArchive } from '../src/mpq/archive.ts'
|
|
import { fileSource } from '../src/mpq/file-source.ts'
|
|
import { MountedArchives } from '../src/mpq/mount.ts'
|
|
import { drlgLevelDt1s, drlgSuperUniqueIds, loadDrlgMpqData, type DrlgMpqData } from '../scripts/lib/drlg-mpq-source.ts'
|
|
import {
|
|
firstDiff,
|
|
firstTraceDivergence,
|
|
formatDiff,
|
|
fsDrlgSource,
|
|
ORACLE_BIN,
|
|
ORACLE_DATA,
|
|
runOracle,
|
|
} from '../scripts/lib/drlg-oracle.ts'
|
|
|
|
/** The Act I levels Levels.txt generates with the outdoor DRLG (checked against the table below). */
|
|
const ACT1_OUTDOOR_LEVELS = [2, 3, 4, 5, 6, 7, 17, 39]
|
|
const FIXTURE_DIR = resolve(import.meta.dirname, 'fixtures/d2moo-oracle')
|
|
const FIXTURE_SEEDS = [0x12345678, 0x5eed0100, 0x5eed0101, 0x5eed0102]
|
|
const ARCHIVE_DIR = resolve(import.meta.dirname, '../samples/d2')
|
|
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
|
|
const SUPERUNIQUES_TXT = 'data\\global\\excel\\SuperUniques.txt'
|
|
|
|
function hexSeed(seed: number): string {
|
|
return seed.toString(16).padStart(8, '0')
|
|
}
|
|
|
|
function readFixture(seed: number): DrlgDump {
|
|
const doc = JSON.parse(gunzipSync(readFileSync(resolve(FIXTURE_DIR, `act1-seed-${hexSeed(seed)}.json.gz`))).toString('utf8')) as DrlgDump
|
|
delete doc.oracle
|
|
return doc
|
|
}
|
|
|
|
/** What the bake takes from a mapped level: the canvas and everything it links or places. */
|
|
function bakedPart(map: DrlgLevelMap): unknown {
|
|
return { ds1: map.ds1, dt1Names: map.dt1Names, entrances: map.entrances, landmarks: map.landmarks, stats: map.stats }
|
|
}
|
|
|
|
describe('Act I outdoor DRLG 1:1 parity against native D2MOO oracle', () => {
|
|
let data: DrlgMpqData
|
|
let superUniqueIds: string[]
|
|
const dt1Cache = new Map<string, Dt1>()
|
|
|
|
beforeAll(async () => {
|
|
const missing = MOUNTS.filter(name => !existsSync(join(ARCHIVE_DIR, name)))
|
|
if (missing.length > 0) throw new Error(`the DRLG parity suite needs the 1.13c archives in ${ARCHIVE_DIR}; missing ${missing.join(', ')}`)
|
|
const archives = new MountedArchives()
|
|
for (const name of MOUNTS) archives.add(name, await MpqArchive.open(await fileSource(join(ARCHIVE_DIR, name))))
|
|
data = await loadDrlgMpqData(member => archives.read(member), 1)
|
|
superUniqueIds = drlgSuperUniqueIds(parseTable(await archives.read(SUPERUNIQUES_TXT)), data.tables)
|
|
}, 120_000)
|
|
|
|
function mapLevel(dump: DrlgDump, levelId: number): DrlgLevelMap {
|
|
const input = drlgLevelInputFromDump(dump, levelId, data.tables)
|
|
return buildDrlgLevelMap(input, { tables: data.tables, dt1: drlgLevelDt1s(input, data.source, dt1Cache), superUniqueIds })
|
|
}
|
|
|
|
it('takes the outdoor level list from Levels.txt', () => {
|
|
expect(act1OutdoorLevelIds(data.tables)).toEqual(ACT1_OUTDOOR_LEVELS)
|
|
})
|
|
|
|
for (const seed of FIXTURE_SEEDS) {
|
|
const hex = hexSeed(seed)
|
|
it(`seed 0x${hex}: the MPQ-fed port reproduces every oracle stage, and both map to identical canvases`, () => {
|
|
const expected = readFixture(seed)
|
|
const actual = dumpAct1(createDrlgEnv(data.source, data.tables), seed, ACT1_OUTDOOR_LEVELS, {
|
|
difficulty: 0,
|
|
isolated: false,
|
|
})
|
|
const diff = firstDiff(expected, actual, '$')
|
|
if (diff) throw new Error(`Mismatch on seed 0x${hex}: ${formatDiff(diff)}`)
|
|
|
|
for (const levelId of ACT1_OUTDOOR_LEVELS) {
|
|
const oracleMap = mapLevel(expected, levelId)
|
|
const portMap = mapLevel(actual, levelId)
|
|
const canvasDiff = firstDiff(bakedPart(oracleMap), bakedPart(portMap), `$.L${levelId}`)
|
|
if (canvasDiff) throw new Error(`Canvas mismatch on seed 0x${hex}: ${formatDiff(canvasDiff)}`)
|
|
|
|
// The canvas is the level's tile rectangle (plus the closing row/column a DS1 has), every tile
|
|
// D2 placed is on it, and every level link and warp of the level became an entrance.
|
|
const { coord, ds1, stats } = portMap
|
|
expect(ds1.width).toBe(coord.width + 1)
|
|
expect(ds1.height).toBe(coord.height + 1)
|
|
expect(stats.floors).toBeGreaterThan(0)
|
|
expect(stats.walls).toBeGreaterThan(0)
|
|
const level = actual.levels.find(l => l.id === levelId)!
|
|
const orths = level.levelGrid!.roomData.length
|
|
const warpUnits = level.activation.reduce((n, room) => n + room.mapTiles.units.filter(u => u[0] === 5).length, 0)
|
|
expect(portMap.entrances.filter(e => e.kind === 'gate')).toHaveLength(orths)
|
|
expect(portMap.entrances.filter(e => e.kind === 'preset')).toHaveLength(warpUnits)
|
|
}
|
|
}, 60_000)
|
|
}
|
|
|
|
it.skipIf(!existsSync(ORACLE_BIN))('matches live native oracle + RNG trace on seed 0x12345678', () => {
|
|
const seed = 0x12345678
|
|
const oracleRun = runOracle({
|
|
seed,
|
|
levels: ACT1_OUTDOOR_LEVELS,
|
|
difficulty: 0,
|
|
isolated: false,
|
|
dataDir: ORACLE_DATA,
|
|
trace: true,
|
|
})
|
|
delete oracleRun.doc.oracle
|
|
const source = fsDrlgSource(ORACLE_DATA)
|
|
const trace = new DrlgTraceWriter()
|
|
const actual = dumpAct1(createDrlgEnv(source, loadDrlgTables(source)), seed, ACT1_OUTDOOR_LEVELS, {
|
|
difficulty: 0,
|
|
isolated: false,
|
|
trace,
|
|
})
|
|
const diff = firstDiff(oracleRun.doc, actual, '$')
|
|
if (diff) {
|
|
throw new Error(`Dump mismatch: ${formatDiff(diff)}`)
|
|
}
|
|
const traceDiff = firstTraceDivergence(oracleRun.trace ?? [], trace.lines)
|
|
expect(traceDiff).toBeNull()
|
|
}, 60_000)
|
|
|
|
it('enforces anti-cheat guards on src/game/drlg/* (no fixture reads, no seed literal branches, no levelName conditionals)', () => {
|
|
const drlgDir = resolve(import.meta.dirname, '../src/game/drlg')
|
|
const files = readdirSync(drlgDir).filter(f => f.endsWith('.ts'))
|
|
expect(files.length).toBeGreaterThan(10)
|
|
for (const file of files) {
|
|
const content = readFileSync(join(drlgDir, file), 'utf8')
|
|
expect(content, `${file} must not reference tests/fixtures`).not.toContain('tests/fixtures')
|
|
expect(content, `${file} must not reference act1-seed-`).not.toContain('act1-seed-')
|
|
expect(content, `${file} must not hardcode fixture seed 0x12345678`).not.toMatch(/0x12345678/i)
|
|
expect(content, `${file} must not hardcode fixture seed 0x5eed`).not.toMatch(/0x5eed/i)
|
|
expect(content, `${file} must not branch on levelName`).not.toContain('levelName')
|
|
}
|
|
})
|
|
})
|