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