diablo2-web/scripts/stress-test-m1-seeds.ts

393 lines
15 KiB
TypeScript

/**
* Standalone Adversarial Stress-Test Script for Milestone M1
* Diablo II Web DRLG Engine: Seed Sensitivity & Determinism Profiler
*
* Requirements:
* 1. Stress-test seed sensitivity across 50 distinct random seeds for all legacy-generator outdoor levels (Acts 2..5).
* 2. Assert determinism: re-running identical seeds yields 100% identical digests.
* 3. Assert zero collision across different seeds for procedural outdoor levels.
*
* Act I's outdoor levels are generated by the DRLG port (src/game/drlg), not by generateWilderness;
* tests/drlg-act1-oracle.test.ts and tests/drlg-act1-invariants.test.ts cover them.
*
* Archetype: EMPIRICAL CHALLENGER
*/
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, type Ds1 } from '../src/formats/ds1.ts'
import {
generateWilderness,
type WildernessPiece,
type WildernessSubstitution,
} from '../src/game/wilderness.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
}
/* ------------------------------------------------------------------------- *
* Canonical Master Seeds
* ------------------------------------------------------------------------- */
const MASTER_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
/* ------------------------------------------------------------------------- *
* 50 Distinct Adversarial & Random Seeds
* ------------------------------------------------------------------------- */
// Construct exactly 50 distinct seeds:
// - 3 canonical master seeds
// - 7 boundary & edge cases (0, 1, 0x7fffffff, 0xffffffff, 42, 1337, 0x5eed1000)
// - 40 pseudo-random high-entropy seeds generated deterministically
function generate50Seeds(): number[] {
const seedSet = new Set<number>([
...MASTER_SEEDS,
0,
1,
0x7fffffff,
0xffffffff,
42,
1337,
0x5eed1000,
])
// Deterministic LCG to populate the remainder up to 50 distinct seeds
let state = 0x12345678
while (seedSet.size < 50) {
state = Math.imul(state, 1664525) + 1013904223
seedSet.add(state >>> 0)
}
return Array.from(seedSet)
}
/* ------------------------------------------------------------------------- *
* MPQ Data Cache & Level Resolution
* ------------------------------------------------------------------------- */
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'],
}
// Preset wilderness levels that have fixed layouts by definition
const PRESET_WILDERNESS_LEVELS = new Set<number>([82, 83, 108])
async function runStressTest(): Promise<void> {
const startTime = Date.now()
console.log('======================================================================')
console.log('🔥 EMPIRICAL STRESS-TEST: Milestone M1 DRLG Wilderness Generation')
console.log('======================================================================\n')
const samplePath = 'samples/d2/d2data.mpq'
if (!existsSync(samplePath)) {
console.error(`❌ MPQ archive missing at ${samplePath}`)
process.exit(1)
}
const 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 {
// Ignore missing optional archive
}
}
const tables = await loadActTables(archives)
const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
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 piecesCache = new Map<string, WildernessPiece[]>()
async function getWildernessPieces(levelTypeName: string): Promise<WildernessPiece[]> {
const cached = piecesCache.get(levelTypeName)
if (cached !== undefined) return cached
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 })
}
piecesCache.set(levelTypeName, pieces)
return pieces
}
const subsCache = new Map<number, WildernessSubstitution[]>()
async function getSubstitutions(type: number): Promise<WildernessSubstitution[]> {
if (type < 0) return []
const cached = subsCache.get(type)
if (cached !== undefined) return cached
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,
})
}
subsCache.set(type, rows)
return rows
}
// Identify all wilderness levels (DrlgType 3)
interface WildernessLevelMeta {
id: number
name: string
act: number
levelTypeName: string
sizeX: number
sizeY: number
subType: number
subShrine: number
subTheme: number
isProcedural: boolean
}
const wildernessLevels: WildernessLevelMeta[] = []
const drlgPortLevels: number[] = []
for (const row of tables.levels.rows) {
if (cell(tables.levels, row, 'DrlgType') !== '3') continue
const id = Number(cell(tables.levels, row, 'Id'))
const name = cell(tables.levels, row, 'Name')
const act = Number(cell(tables.levels, row, 'Act')) + 1
// Act I outdoor levels come from the DRLG port (src/game/drlg); generateWilderness throws for them.
if (act === 1) {
drlgPortLevels.push(id)
continue
}
const typeId = cell(tables.levels, row, 'LevelType')
const typeRow = tables.lvltypes.rows.find(r => cell(tables.lvltypes, r, 'Id') === typeId)
const levelTypeName = typeRow ? cell(tables.lvltypes, typeRow, 'Name') : ''
const sizeX = Number(cell(tables.levels, row, 'SizeX'))
const sizeY = Number(cell(tables.levels, row, 'SizeY'))
const subType = Number(cell(tables.levels, row, 'SubType'))
const subShrine = Number(cell(tables.levels, row, 'SubShrine'))
const subTheme = Number(cell(tables.levels, row, 'SubTheme'))
const isProcedural = !PRESET_WILDERNESS_LEVELS.has(id)
wildernessLevels.push({
id,
name,
act,
levelTypeName,
sizeX,
sizeY,
subType,
subShrine,
subTheme,
isProcedural,
})
}
console.log(`Discovered ${wildernessLevels.length} legacy-generator outdoor wilderness levels across Acts 2..5:`)
console.log(`- Procedural outdoor levels: ${wildernessLevels.filter(l => l.isProcedural).length}`)
console.log(`- Preset outdoor levels: ${wildernessLevels.filter(l => !l.isProcedural).length}`)
console.log(`(Act I outdoor levels ${drlgPortLevels.join(', ')} are the DRLG port's; see tests/drlg-act1-*.test.ts)\n`)
const seeds50 = generate50Seeds()
console.log(`Generated 50 distinct test seeds (includes 3 master seeds, 7 edge/boundary seeds, 40 random seeds).`)
console.log(`Seed sample: [${seeds50.slice(0, 6).map(s => '0x' + s.toString(16)).join(', ')} ... (${seeds50.length} total)]\n`)
let totalAssertions = 0
let passedAssertions = 0
let failedAssertions = 0
const failureList: string[] = []
function check(condition: boolean, description: string): void {
totalAssertions += 1
if (condition) {
passedAssertions += 1
} else {
failedAssertions += 1
failureList.push(description)
console.error(`❌ FAILED: ${description}`)
}
}
// ---------------------------------------------------------------------------
// TEST 1 & 2: Seed Sensitivity & Determinism across 50 seeds for every legacy-generator level
// ---------------------------------------------------------------------------
console.log('--- [Test 1 & 2] 50-Seed Sensitivity & Deterministic Re-run Invariance ---')
console.log(`Testing all ${wildernessLevels.length} wilderness levels across 50 distinct seeds...\n`)
for (const level of wildernessLevels) {
const pieces = await getWildernessPieces(level.levelTypeName)
const subs = await getSubstitutions(level.subType)
const shrineSubs = await getSubstitutions(level.subShrine)
const seedToDigest = new Map<number, string>()
const digests: string[] = []
for (const seed of seeds50) {
const req = {
levelId: level.id,
levelName: level.name,
levelTypeName: level.levelTypeName,
sizeX: level.sizeX,
sizeY: level.sizeY,
subType: level.subType,
subTheme: Math.max(0, level.subTheme),
seed,
pieces,
substitutions: subs,
shrineSubstitutions: shrineSubs,
}
// Run 1
const run1 = generateWilderness(req)
const digest1 = canonicalHash(run1.level)
// Run 2 (Determinism Verification)
const run2 = generateWilderness(req)
const digest2 = canonicalHash(run2.level)
check(
digest1 === digest2,
`Level ${level.id} (${level.name}) seed 0x${seed.toString(16)} non-deterministic: ${digest1} !== ${digest2}`
)
seedToDigest.set(seed, digest1)
digests.push(digest1)
}
const uniqueDigests = new Set(digests)
if (level.isProcedural) {
// Assertion 3: Zero collision across different seeds for procedural outdoor levels
const collisionCount = digests.length - uniqueDigests.size
check(
collisionCount === 0,
`Level ${level.id} (${level.name}) had ${collisionCount} hash collisions across 50 distinct seeds (${uniqueDigests.size}/50 unique)`
)
console.log(
` Act ${level.act} Level ${String(level.id).padStart(3)} | ${level.name.padEnd(25)} | ` +
`50/50 unique (${uniqueDigests.size} distinct hashes) | 100% Deterministic (50/50 pairs matched)`
)
} else {
// Preset level: determinism holds, hashes are consistent
console.log(
` Act ${level.act} Level ${String(level.id).padStart(3)} | ${level.name.padEnd(25)} | ` +
`[PRESET] ${uniqueDigests.size} variant(s) | 100% Deterministic (50/50 pairs matched)`
)
}
}
const elapsedSec = ((Date.now() - startTime) / 1000).toFixed(2)
console.log('\n======================================================================')
console.log(`📊 STRESS TEST SUMMARY (Completed in ${elapsedSec}s)`)
console.log('======================================================================')
console.log(`Total Assertions Executed: ${totalAssertions}`)
console.log(`Passed Assertions: ${passedAssertions}`)
console.log(`Failed Assertions: ${failedAssertions}`)
if (failedAssertions > 0) {
console.error('\n❌ FAILURES DETECTED:')
for (const f of failureList) {
console.error(` - ${f}`)
}
process.exit(1)
}
const proceduralCount = wildernessLevels.filter(l => l.isProcedural).length
console.log('\n✅ ALL 3 EMPIRICAL INVARIANTS SATISFIED WITHOUT EXCEPTION:')
console.log(`1. Seed sensitivity: ${seeds50.length} distinct random seeds tested across all ${wildernessLevels.length} legacy-generator outdoor levels.`)
console.log(`2. Determinism: ${wildernessLevels.length * seeds50.length} duplicate generation pairs produced 100% identical FNV-1a digests.`)
console.log(`3. Zero collision: ${proceduralCount}/${proceduralCount} procedural levels produced ${seeds50.length}/${seeds50.length} unique digests (0 collisions).`)
console.log('======================================================================\n')
}
runStressTest().catch(err => {
console.error('Fatal error running stress test:', err)
process.exit(1)
})