204 lines
8.4 KiB
TypeScript
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
|
|
}
|
|
})
|
|
})
|