411 lines
15 KiB
TypeScript
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)
|
|
})
|