/** * Wilderness Seed Sensitivity Test Suite (Milestone M1). * * Verifies: * 1. Changing seed produces distinct map hashes for procedural outdoor levels in Acts 2..5. * 2. Permissive fallbacks are eliminated (stampAct5SiegeStrips & stampAct3KurastCauseway). * 3. Deterministic re-run invariance holds unconditionally. * * Act I outdoor levels come from the DRLG port (src/game/drlg) and are covered by * tests/drlg-act1-oracle.test.ts (bit-exact parity with D2) and tests/drlg-act1-population.test.ts. */ import { describe, expect, it, beforeAll } from 'vitest' import { existsSync } from 'node:fs' import { MountedArchives } from '../src/mpq/mount.ts' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { loadActTables, parseTable, cell, tileMemberPath } from '../src/game/acts.ts' import type { D2Table } from '../src/game/acts.ts' import { decodeDs1 } from '../src/formats/ds1.ts' import type { Ds1, Ds1Cell } from '../src/formats/ds1.ts' import { generateWilderness, createCanvas, type Canvas, type WildernessPiece, type WildernessSubstitution, type WildernessStats, } from '../src/game/wilderness.ts' import { stampAct3KurastCauseway } from '../src/game/wilderness-acts.ts' import { stampAct5SiegeStrips } from '../src/game/wilderness-siege.ts' import { Rng } from '../src/game/rng.ts' /* ------------------------------------------------------------------------- * * FNV-1a Canonical Map Hasher * ------------------------------------------------------------------------- */ class Hasher { private hash = 2166136261 push(value: number): this { const word = Math.trunc(value) | 0 for (let byte = 0; byte < 4; byte += 1) { this.hash ^= (word >>> (byte * 8)) & 0xff this.hash = Math.imul(this.hash, 16777619) >>> 0 } return this } get digest(): string { return (this.hash >>> 0).toString(16).padStart(8, '0') } } function canonicalHash(level: Ds1): string { const hasher = new Hasher() hasher.push(level.version).push(level.width).push(level.height).push(level.act) hasher.push(level.substitutionType).push(level.wallLayers).push(level.floorLayers) for (let y = 0; y < level.height; y += 1) { const row = level.cells[y] if (row === undefined) continue for (let x = 0; x < level.width; x += 1) { const cellRecord = row[x] if (cellRecord === undefined) continue for (const wall of cellRecord.walls) { hasher.push(wall.prop1).push(wall.sequence).push(wall.style).push(wall.type).push(wall.hidden ? 1 : 0) } for (const floor of cellRecord.floors) { hasher.push(floor.prop1).push(floor.sequence).push(floor.style).push(floor.hidden ? 1 : 0) } for (const shadow of cellRecord.shadows) hasher.push(shadow.prop1).push(shadow.sequence).push(shadow.style) for (const substitution of cellRecord.substitutions) hasher.push(substitution.value) } } for (const object of level.objects) hasher.push(object.type).push(object.id).push(object.x).push(object.y).push(object.flags) return hasher.digest } /* ------------------------------------------------------------------------- * * Mock Helpers for Hermetic Testing * ------------------------------------------------------------------------- */ function makeMockDs1(width: number, height: number, floorStyle: number = 1): Ds1 { const cells: Ds1Cell[][] = [] for (let y = 0; y < height; y += 1) { const row: Ds1Cell[] = [] for (let x = 0; x < width; x += 1) { row.push({ walls: [], floors: [{ prop1: 2, sequence: 0, style: floorStyle, unknown1: 0, unknown2: 0, hidden: false }], shadows: [], substitutions: [], }) } cells.push(row) } return { version: 18, width, height, act: 1, substitutionType: 0, wallLayers: 1, floorLayers: 1, cells, objects: [], npcPathOffset: null, } } function makeMockBorderPieces(actPrefix: string): WildernessPiece[] { const ds1 = makeMockDs1(8, 8) return [ { name: `${actPrefix} - Border 1`, border: true, levels: [ds1] }, { name: `${actPrefix} - Border 2`, border: true, levels: [ds1] }, { name: `${actPrefix} - Border 3`, border: true, levels: [ds1] }, { name: `${actPrefix} - Border 4`, border: true, levels: [ds1] }, { name: `${actPrefix} - Border 5`, border: true, levels: [ds1] }, { name: `${actPrefix} - Border 6`, border: true, levels: [ds1] }, ] } /* ------------------------------------------------------------------------- * * Canonical Master Test Seeds * ------------------------------------------------------------------------- */ const TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const /* ------------------------------------------------------------------------- * * Test Suite 1: Hermetic API & Strict Contract Safety * ------------------------------------------------------------------------- */ describe('Hermetic Contract & RNG Parameter Safety', () => { it('stampAct5SiegeStrips rejects missing or invalid Rng parameter with Error', () => { const canvas: Canvas = createCanvas(64, 64, 1, 1, 1) const stats: WildernessStats = { substitutions: [], notes: [], } as unknown as WildernessStats expect(() => { stampAct5SiegeStrips(canvas, [], null as unknown as Rng, stats) }).toThrow(/missing required Rng parameter/) expect(() => { stampAct5SiegeStrips(canvas, [], {} as unknown as Rng, stats) }).toThrow(/missing required Rng parameter/) }) it('stampAct5SiegeStrips rejects missing WildernessStats parameter with Error', () => { const canvas: Canvas = createCanvas(64, 64, 1, 1, 1) const rng = new Rng(42) expect(() => { stampAct5SiegeStrips(canvas, [], rng, null as unknown as WildernessStats) }).toThrow(/missing required WildernessStats parameter/) }) it('stampAct3KurastCauseway rejects missing or invalid Rng parameter with Error', () => { const canvas: Canvas = createCanvas(64, 64, 1, 1, 1) const stats: WildernessStats = { substitutions: [], notes: [], } as unknown as WildernessStats expect(() => { stampAct3KurastCauseway(canvas, [], null as unknown as Rng, stats) }).toThrow(/missing required Rng instance/) expect(() => { stampAct3KurastCauseway(canvas, [], {} as unknown as Rng, stats) }).toThrow(/missing required Rng instance/) }) it('hermetic mock generation produces distinct hashes for different seeds and deterministic re-runs', () => { const pieces = makeMockBorderPieces('Act 2 - Desert') const hashes: string[] = [] for (const seed of TEST_SEEDS) { const run1 = generateWilderness({ levelId: 41, levelName: 'Rocky Waste Mock', levelTypeName: 'Act 2 - Desert', sizeX: 64, sizeY: 64, subType: 0, subTheme: 0, seed, pieces, substitutions: [], }) const run2 = generateWilderness({ levelId: 41, levelName: 'Rocky Waste Mock', levelTypeName: 'Act 2 - Desert', sizeX: 64, sizeY: 64, subType: 0, subTheme: 0, seed, pieces, substitutions: [], }) const hash1 = canonicalHash(run1.level) const hash2 = canonicalHash(run2.level) expect(hash1).toBe(hash2) hashes.push(hash1) } // Changing seed produces distinct layouts expect(new Set(hashes).size).toBe(TEST_SEEDS.length) }) }) /* ------------------------------------------------------------------------- * * Test Suite 2: MPQ Ground-Truth Baseline & Seed Sensitivity * ------------------------------------------------------------------------- */ const hasMpqArchives = existsSync('samples/d2/d2data.mpq') // Act I outdoor levels come from the DRLG port (src/game/drlg): its determinism and seed sensitivity // are checked in tests/drlg-act1-population.test.ts, its parity with D2 in tests/drlg-act1-oracle.test.ts. describe.skipIf(!hasMpqArchives)('MPQ Ground-Truth Seed Sensitivity across Acts 2..5', () => { let archives: MountedArchives let tables: { levels: D2Table; lvltypes: D2Table; lvlprest: D2Table } let lvlsub: D2Table const ds1Cache = new Map() async function loadDs1(relative: string): Promise { const member = tileMemberPath(relative) const cached = ds1Cache.get(member) if (cached !== undefined) return cached const decoded = decodeDs1(await archives.read(member)) ds1Cache.set(member, decoded) return decoded } async function rowDs1s(table: D2Table, row: readonly string[]): Promise { const levels: Ds1[] = [] for (let slot = 1; slot <= 6; slot += 1) { const value = cell(table, row, `File${String(slot)}`) if (value === '' || value === '0') continue levels.push(await loadDs1(value)) } return levels } const WILDERNESS_PIECE_FAMILIES: Readonly> = { 'Act 2 - Desert': ['Act 2 - Desert'], 'Act 3 - Jungle': ['Act 3 - Jungle'], 'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'], 'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'], 'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo'], 'Act 5 - Siege': ['Act 5 - Siege'], 'Act 5 - Barricade': ['Act 5 - Barricade'], } async function buildWildernessPieces(levelTypeName: string): Promise { const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? [] const pieces: WildernessPiece[] = [] for (const row of tables.lvlprest.rows) { const name = cell(tables.lvlprest, row, 'Name') if (!families.some(family => name.startsWith(family))) continue if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue const levels = await rowDs1s(tables.lvlprest, row) if (levels.length === 0) continue const isBorder = /border|cliff/i.test(name) pieces.push({ name, levels, border: isBorder }) } return pieces } async function buildSubstitutions(type: number): Promise { if (type < 0) return [] const rows: WildernessSubstitution[] = [] for (const row of lvlsub.rows) { if (Number(cell(lvlsub, row, 'Type')) !== type) continue const file = cell(lvlsub, row, 'File') if (file === '' || file === '0') continue const levels = [await loadDs1(file)] rows.push({ name: cell(lvlsub, row, 'Name'), type, gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1, bordType: Number(cell(lvlsub, row, 'BordType')), dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0, prob: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Prob${String(i)}`)) || 0), trials: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Trials${String(i)}`)) || 0), max: [0, 1, 2, 3, 4].map(i => Number(cell(lvlsub, row, `Max${String(i)}`)) || 0), levels, }) } return rows } beforeAll(async () => { archives = new MountedArchives() for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { try { archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) } catch { // Skip missing optional archive } } const actTables = await loadActTables(archives) tables = { levels: actTables.levels, lvltypes: actTables.lvltypes, lvlprest: actTables.lvlprest, } lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt')) }) async function generateLevelForSeed(levelId: number, seed: number): Promise { const levelRow = tables.levels.rows.find(row => Number(cell(tables.levels, row, 'Id')) === levelId)! const typeId = cell(tables.levels, levelRow, 'LevelType') const typeRow = tables.lvltypes.rows.find(row => cell(tables.lvltypes, row, 'Id') === typeId) const levelTypeName = typeRow ? cell(tables.lvltypes, typeRow, 'Name') : '' const name = cell(tables.levels, levelRow, 'Name') const sizeX = Number(cell(tables.levels, levelRow, 'SizeX')) const sizeY = Number(cell(tables.levels, levelRow, 'SizeY')) const subType = Number(cell(tables.levels, levelRow, 'SubType')) const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine')) const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme')) const pieces = await buildWildernessPieces(levelTypeName) const subs = await buildSubstitutions(subType) const shrineSubs = await buildSubstitutions(subShrine) const result = generateWilderness({ levelId, levelName: name, levelTypeName, sizeX, sizeY, subType, subTheme: Math.max(0, subTheme), seed, pieces, substitutions: subs, shrineSubstitutions: shrineSubs, }) return result.level } it('Act 2 procedural outdoor levels produce distinct hashes across seeds', async () => { const act2Levels = [41, 42, 43, 44, 45, 46] for (const levelId of act2Levels) { const hashes: string[] = [] for (const seed of TEST_SEEDS) { const level = await generateLevelForSeed(levelId, seed) hashes.push(canonicalHash(level)) } expect(new Set(hashes).size).toBe(3) } }, 60000) it('Act 3 procedural outdoor levels produce distinct hashes across seeds', async () => { const act3Levels = [76, 77, 78, 79, 80, 81] for (const levelId of act3Levels) { const hashes: string[] = [] for (const seed of TEST_SEEDS) { const level = await generateLevelForSeed(levelId, seed) hashes.push(canonicalHash(level)) } expect(new Set(hashes).size).toBe(3) } }, 60000) it('Act 4 procedural outdoor levels produce distinct hashes across seeds', async () => { const act4Levels = [104, 105, 106] for (const levelId of act4Levels) { const hashes: string[] = [] for (const seed of TEST_SEEDS) { const level = await generateLevelForSeed(levelId, seed) hashes.push(canonicalHash(level)) } expect(new Set(hashes).size).toBe(3) } }, 60000) it('Act 5 procedural outdoor levels produce distinct hashes across seeds', async () => { const act5Levels = [110, 111, 112, 117, 134] for (const levelId of act5Levels) { const hashes: string[] = [] for (const seed of TEST_SEEDS) { const level = await generateLevelForSeed(levelId, seed) hashes.push(canonicalHash(level)) } expect(new Set(hashes).size).toBe(3) } }, 60000) it('Wilderness generation determinism invariance holds across repeated runs', async () => { // Spot check one level per Act (Act I: tests/drlg-act1-population.test.ts) const sampleLevels = [41, 79, 104, 111] for (const levelId of sampleLevels) { const seed = TEST_SEEDS[0] const first = await generateLevelForSeed(levelId, seed) const second = await generateLevelForSeed(levelId, seed) expect(canonicalHash(first)).toBe(canonicalHash(second)) } }, 60000) })