diablo2-web/tests/wilderness-seed-sensitivity...

411 lines
15 KiB
TypeScript

/**
* 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<string, Ds1>()
async function loadDs1(relative: string): Promise<Ds1> {
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<Ds1[]> {
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<Record<string, readonly string[]>> = {
'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<WildernessPiece[]> {
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<WildernessSubstitution[]> {
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<Ds1> {
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)
})