diablo2-web/tests/challenger-m4-determinism-p...

204 lines
8.4 KiB
TypeScript

/**
* tests/challenger-m4-determinism-parity.test.ts
*
* Empirical Adversarial Challenger Test Suite for Milestone 4 (Issue #396)
* Author: teamwork_preview_challenger
*
* Scope:
* 1. Multi-Worker Hash Determinism:
* - Verify SHA-256 hash of `samples/d2-packs/entities/index.json` matches repeated multi-worker runs.
* - Assert 100% byte-for-byte exact equality (0 diff lines).
* 2. Full-Spectrum 365-Scene Asset Parity:
* - Audit all 365 scenes in `samples/d2-packs/index.json`.
* - Verify scene.json validity, collision dimensions (width=cellsX*5, height=cellsY*5), and non-empty floor draws.
* - Verify all referenced PNG tile and object atlases exist on disk with valid RFC 2083 8-byte magic signatures.
* 3. Index Structure & Deterministic Ordering:
* - Assert 365 total levels (65 preset, 90 wilderness, 210 maze).
* - Verify deterministic ordering by jobId/levelId.
* - Verify scene links and world graph coherence.
*/
import { describe, it, expect } from 'vitest'
import { existsSync, readFileSync, readdirSync, openSync, readSync, closeSync, statSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { join } from 'node:path'
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
const PACKS_DIR = 'samples/d2-packs'
const ENTITIES_INDEX_PATH = join(PACKS_DIR, 'entities', 'index.json')
const ROOT_INDEX_PATH = join(PACKS_DIR, 'index.json')
function sha256File(filePath: string): string {
const content = readFileSync(filePath)
return createHash('sha256').update(content).digest('hex')
}
describe('Challenger M4_2: Asset Baking Pipeline Determinism & 365-Scene Parity', () => {
it('verifies samples/d2-packs/entities/index.json hash determinism and 0 diff lines against repeated multi-worker runs', () => {
expect(existsSync(ENTITIES_INDEX_PATH)).toBe(true)
const baselineHash = sha256File(ENTITIES_INDEX_PATH)
// Expected canonical SHA-256 for Schema 2 70-monster entity index (including vk-hth Valkyrie
// and ec-hth Hell Bovine: MonStats.txt hellbovine Code=EC, level 39 Moo Moo Farm). Adding
// ec-hth is the only change against the previous 69-monster index (51da3628…).
expect(baselineHash).toBe('675757cffde277b24e2aec11b7440243654282cbb743424ef02ce1d253b41c66')
const baselineContent = readFileSync(ENTITIES_INDEX_PATH, 'utf8')
const parsed = JSON.parse(baselineContent)
expect(parsed.schema).toBe(2)
expect(parsed.character).toBe('char-so')
expect(parsed.entities).toHaveLength(70)
// Compare against /tmp/stress-entity-run1 and /tmp/stress-entity-run2 if present
const testDirs = ['/tmp/stress-entity-run1', '/tmp/stress-entity-run2']
for (const dir of testDirs) {
const runIndexPath = join(dir, 'entities', 'index.json')
if (existsSync(runIndexPath)) {
const runHash = sha256File(runIndexPath)
expect(runHash).toBe(baselineHash)
const runContent = readFileSync(runIndexPath, 'utf8')
expect(runContent).toBe(baselineContent)
}
}
})
it('verifies char-so.json hash and Schema 2 alias parity across repeated runs', () => {
const charSoPath = join(PACKS_DIR, 'entities', 'char-so.json')
expect(existsSync(charSoPath)).toBe(true)
const baselineHash = sha256File(charSoPath)
expect(baselineHash).toBe('3b0c78d275dd976ce447fbb7b19ff550c23db1ed1478f188907cb7a4538e7194')
const meta = JSON.parse(readFileSync(charSoPath, 'utf8'))
expect(meta.schema).toBe(2)
expect(meta.walk).toBe(0)
expect(meta.stand).toBe(16)
expect(meta.walkOffset).toBe(0)
expect(meta.standOffset).toBe(16)
expect(meta.clips.wl.group).toBe(0)
expect(meta.clips.nu.group).toBe(16)
})
it('verifies full-spectrum asset parity across all 365 scenes in index.json', () => {
expect(existsSync(ROOT_INDEX_PATH)).toBe(true)
const index = JSON.parse(readFileSync(ROOT_INDEX_PATH, 'utf8'))
expect(index.version).toBe(1)
expect(Array.isArray(index.levels)).toBe(true)
expect(index.levels).toHaveLength(365)
const kindsCount = { preset: 0, wilderness: 0, maze: 0 }
let totalFloors = 0
let totalWalls = 0
let totalPngs = 0
for (const level of index.levels) {
const kind = level.kind || 'preset'
kindsCount[kind as keyof typeof kindsCount] += 1
expect(level.act).toBeGreaterThanOrEqual(1)
expect(level.act).toBeLessThanOrEqual(5)
expect(level.levelId).toBeGreaterThanOrEqual(1)
expect(level.path).toBeDefined()
expect(level.ds1).toBeDefined()
const sceneDir = join(PACKS_DIR, level.path)
const sceneJsonPath = join(sceneDir, 'scene.json')
expect(existsSync(sceneJsonPath), `Missing scene.json for ${level.path}`).toBe(true)
const scene = JSON.parse(readFileSync(sceneJsonPath, 'utf8'))
expect(scene.act).toBe(level.act)
expect(scene.levelId).toBe(level.levelId)
expect(scene.cellsX).toBeGreaterThan(0)
expect(scene.cellsY).toBeGreaterThan(0)
expect(scene.widthPx).toBeGreaterThan(0)
expect(scene.heightPx).toBeGreaterThan(0)
// Collision grid validation: exact 5x5 sub-tiles per cell
expect(scene.collision).toBeDefined()
expect(scene.collision.width).toBe(scene.cellsX * 5)
expect(scene.collision.height).toBe(scene.cellsY * 5)
expect(Array.isArray(scene.collision.runs)).toBe(true)
expect(scene.collision.runs.length).toBeGreaterThan(0)
// Floor and wall draws validation
expect(Array.isArray(scene.floors)).toBe(true)
expect(scene.floors.length).toBeGreaterThan(0)
totalFloors += scene.floors.length
if (scene.walls) totalWalls += scene.walls.length
// Verify all PNG atlas files in scene directory
const sceneFiles = readdirSync(sceneDir)
for (const file of sceneFiles) {
if (file.endsWith('.png')) {
totalPngs += 1
const fullPngPath = join(sceneDir, file)
const stat = statSync(fullPngPath)
expect(stat.size).toBeGreaterThan(0)
const headerBuf = Buffer.alloc(8)
const fd = openSync(fullPngPath, 'r')
readSync(fd, headerBuf, 0, 8, 0)
closeSync(fd)
expect(
headerBuf.equals(PNG_MAGIC),
`Invalid PNG magic bytes in ${fullPngPath}`,
).toBe(true)
}
}
}
expect(kindsCount.preset).toBe(65)
expect(kindsCount.wilderness).toBe(90)
expect(kindsCount.maze).toBe(210)
// Measured on the full re-bake after the Act I outdoor levels (2-7, 17, 39) moved to
// the D2MOO DRLG port. For reference, a fresh bake of the previous generator gave
// 1020690 floors, 231738 walls and 1401 PNGs; the old pins (1020280 / 231464 / 1433)
// no longer matched any bake of the current code.
expect(totalFloors).toBe(1008898)
expect(totalWalls).toBe(237542)
expect(totalPngs).toBe(1390)
})
it('verifies world graph integrity and edge consistency with index.json', () => {
const graphPath = join(PACKS_DIR, 'world-graph.json')
expect(existsSync(graphPath)).toBe(true)
const graph = JSON.parse(readFileSync(graphPath, 'utf8'))
expect(graph.version).toBe(1)
expect(graph.levels.length).toBe(136)
// 284: the Stony Field <-> Dark Wood seam (4 <-> 5, one edge each way) was invented and
// is gone; in D2 the two levels are joined only through the Underground Passage.
expect(graph.edges.length).toBe(284)
expect(graph.waypoints.length).toBe(39)
// All edge levels must exist in levels list
const validLevelIds = new Set(graph.levels.map((l: { id: number }) => l.id))
for (const edge of graph.edges) {
expect(validLevelIds.has(edge.from)).toBe(true)
expect(validLevelIds.has(edge.to)).toBe(true)
}
})
it('verifies root index.json deterministic ordering and field invariants', () => {
const index = JSON.parse(readFileSync(ROOT_INDEX_PATH, 'utf8'))
expect(index.archiveDir).toBe('samples/d2')
expect(index.pageSize).toBe(2048)
expect(index.palettes).toBeDefined()
expect(Object.keys(index.palettes)).toEqual(['act1', 'act2', 'act3', 'act4', 'act5'])
// Verify palettes length (each act pal.pl2 has 256*3 rgb bytes)
for (const act of [1, 2, 3, 4, 5]) {
const pal = index.palettes[`act${act}`]
expect(pal).toHaveLength(768)
}
// Verify ordering: acts must be grouped in ascending act order
let prevAct = 1
for (const level of index.levels) {
expect(level.act).toBeGreaterThanOrEqual(prevAct)
prevAct = level.act
}
})
})