557 lines
21 KiB
TypeScript
557 lines
21 KiB
TypeScript
/**
|
|
* Standalone Adversarial Stress-Test Harness for Milestone M1
|
|
* (DRLG Wilderness Remediation: RNG Stream Isolation, Parameter Validation & Multi-Seed Sensitivity)
|
|
*
|
|
* Verifies:
|
|
* 1. rng.fork('actN-topography') isolation:
|
|
* - Drawing numbers from child fork does not advance master rng.seed / rng.state.
|
|
* - Stream reproducibility, cross-label independence, interleaved execution equivalence.
|
|
* 2. Strict parameter validation:
|
|
* - stampAct5SiegeStrips rejects missing/invalid Rng or WildernessStats with Error.
|
|
* - stampAct3KurastCauseway rejects missing/invalid Rng with Error.
|
|
* 3. Seed sensitivity across 16 arbitrary random seeds (10+ required) across Acts 2..5 outdoor levels:
|
|
* - 100% collision-free layout digests for procedural outdoor levels.
|
|
* - Re-run determinism invariance.
|
|
* 4. Act 1 Bit-Identical Regression Guard:
|
|
* - All Act 1 outdoor levels remain 100% bit-identical to canonical baseline.
|
|
*/
|
|
|
|
import { existsSync } from 'node:fs'
|
|
import { Rng } from '../src/game/rng.ts'
|
|
import { stampAct5SiegeStrips } from '../src/game/wilderness-siege.ts'
|
|
import { stampAct3KurastCauseway } from '../src/game/wilderness-acts.ts'
|
|
import {
|
|
createCanvas,
|
|
generateWilderness,
|
|
type Canvas,
|
|
type WildernessPiece,
|
|
type WildernessSubstitution,
|
|
type WildernessStats,
|
|
} from '../src/game/wilderness.ts'
|
|
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, type D2Table } from '../src/game/acts.ts'
|
|
import { decodeDs1, type Ds1, type Ds1Cell } from '../src/formats/ds1.ts'
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* Canonical Hasher (FNV-1a)
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
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
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* Test Assertions Harness
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
let totalAssertions = 0
|
|
let passedAssertions = 0
|
|
let failedAssertions = 0
|
|
|
|
function assert(condition: boolean, description: string, details?: string): void {
|
|
totalAssertions += 1
|
|
if (condition) {
|
|
passedAssertions += 1
|
|
} else {
|
|
failedAssertions += 1
|
|
console.error(`[FAIL] ${description}`)
|
|
if (details) console.error(` Details: ${details}`)
|
|
}
|
|
}
|
|
|
|
function assertThrows(fn: () => void, expectedMessagePattern: RegExp, description: string): void {
|
|
totalAssertions += 1
|
|
try {
|
|
fn()
|
|
failedAssertions += 1
|
|
console.error(`[FAIL] ${description} - Expected function to throw, but it returned normally`)
|
|
} catch (err: unknown) {
|
|
if (err instanceof Error && expectedMessagePattern.test(err.message)) {
|
|
passedAssertions += 1
|
|
} else {
|
|
failedAssertions += 1
|
|
console.error(`[FAIL] ${description} - Threw unexpected error: ${(err as Error)?.message}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* Part 1: RNG Stream Isolation Stress Tests
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
console.log('=================================================================')
|
|
console.log('PART 1: RNG Stream Isolation Stress Tests (Mulberry32 fork)')
|
|
console.log('=================================================================')
|
|
|
|
const TEST_SEEDS_BASE = [0x12345678, 0xdeadbeef, 0x301cd095, 0x416d61c5, 0x7fffffff]
|
|
|
|
for (const initialSeed of TEST_SEEDS_BASE) {
|
|
const master = new Rng(initialSeed)
|
|
const control = new Rng(initialSeed)
|
|
|
|
const initialMasterSeed = master.seed
|
|
assert(initialMasterSeed === (initialSeed >>> 0), `Master initial seed matches input (0x${initialSeed.toString(16)})`)
|
|
|
|
// Fork child streams
|
|
const child2 = master.fork('act2-topography')
|
|
const child3 = master.fork('act3-topography')
|
|
const child4 = master.fork('act4-topography')
|
|
const child5 = master.fork('act5-topography')
|
|
|
|
assert(master.seed === initialMasterSeed, `Creating 4 forks does not change master.seed (0x${initialMasterSeed.toString(16)})`)
|
|
|
|
// Draw 50,000 numbers from each child
|
|
for (let i = 0; i < 50000; i += 1) {
|
|
child2.next()
|
|
child2.int(0, 10)
|
|
child3.range(0.0, 1.0)
|
|
child4.chance(0.5)
|
|
child5.pick(['a', 'b', 'c'])
|
|
}
|
|
|
|
assert(master.seed === initialMasterSeed, `Drawing 250,000 values from children leaves master.seed strictly unchanged`)
|
|
|
|
// Now draw 10,000 numbers from master and control; verify bit-for-bit identity
|
|
let drawsIdentical = true
|
|
for (let i = 0; i < 10000; i += 1) {
|
|
const vMaster = master.next()
|
|
const vControl = control.next()
|
|
if (vMaster !== vControl) {
|
|
drawsIdentical = false
|
|
break
|
|
}
|
|
}
|
|
assert(drawsIdentical, `10,000 draws from master match control 100% bit-for-bit after child execution (seed 0x${initialSeed.toString(16)})`)
|
|
}
|
|
|
|
// Interleaved draw stress test (1,000 random operations)
|
|
{
|
|
const master = new Rng(0xcafebebe)
|
|
const control = new Rng(0xcafebebe)
|
|
const child = master.fork('act2-topography')
|
|
|
|
let interleavedEqual = true
|
|
for (let step = 0; step < 500; step += 1) {
|
|
// Master draws 3
|
|
for (let m = 0; m < 3; m += 1) {
|
|
if (master.next() !== control.next()) {
|
|
interleavedEqual = false
|
|
}
|
|
}
|
|
// Child draws 17
|
|
for (let c = 0; c < 17; c += 1) {
|
|
child.next()
|
|
}
|
|
// Master draws 2
|
|
for (let m = 0; m < 2; m += 1) {
|
|
if (master.int(1, 100) !== control.int(1, 100)) {
|
|
interleavedEqual = false
|
|
}
|
|
}
|
|
}
|
|
assert(interleavedEqual, 'Interleaved draws between master and child preserve master stream exact identity')
|
|
}
|
|
|
|
// Cross-label distinction test
|
|
{
|
|
const root = new Rng(0x1337c0de)
|
|
const f2 = root.fork('act2-topography')
|
|
const f3 = root.fork('act3-topography')
|
|
const f4 = root.fork('act4-topography')
|
|
const f5 = root.fork('act5-topography')
|
|
|
|
const seq2 = Array.from({ length: 10 }, () => f2.next())
|
|
const seq3 = Array.from({ length: 10 }, () => f3.next())
|
|
const seq4 = Array.from({ length: 10 }, () => f4.next())
|
|
const seq5 = Array.from({ length: 10 }, () => f5.next())
|
|
|
|
assert(seq2[0] !== seq3[0] && seq3[0] !== seq4[0] && seq4[0] !== seq5[0], 'Forks with different labels produce distinct initial draws')
|
|
assert(f2.seed !== f3.seed && f3.seed !== f4.seed && f4.seed !== f5.seed, 'Forks with different labels produce distinct child initial seeds')
|
|
|
|
// Reproducibility test: identical label + identical master state -> identical child draws
|
|
const rootClone = new Rng(0x1337c0de)
|
|
const f2Clone = rootClone.fork('act2-topography')
|
|
const seq2Clone = Array.from({ length: 10 }, () => f2Clone.next())
|
|
assert(JSON.stringify(seq2) === JSON.stringify(seq2Clone), 'Fork with identical label and identical master state produces identical sequences')
|
|
}
|
|
|
|
console.log(`[PASS] Part 1 completed: ${passedAssertions}/${totalAssertions} assertions held.`)
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* Part 2: Strict Parameter Validation Stress Tests
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
console.log('\n=================================================================')
|
|
console.log('PART 2: Strict Parameter Validation Stress Tests')
|
|
console.log('=================================================================')
|
|
|
|
const mockCanvas: Canvas = createCanvas(64, 64, 1, 1, 1)
|
|
const validRng = new Rng(42)
|
|
const validStats: WildernessStats = {
|
|
substitutions: [],
|
|
notes: [],
|
|
} as unknown as WildernessStats
|
|
|
|
// 2.1 stampAct5SiegeStrips invalid RNG tests
|
|
const invalidRngs = [
|
|
null,
|
|
undefined,
|
|
42,
|
|
0,
|
|
-1,
|
|
'new Rng(42)',
|
|
{},
|
|
{ next: () => 0.5 },
|
|
false,
|
|
true,
|
|
NaN,
|
|
Symbol('rng'),
|
|
]
|
|
|
|
for (const badRng of invalidRngs) {
|
|
assertThrows(
|
|
() => stampAct5SiegeStrips(mockCanvas, [], badRng as unknown as Rng, validStats),
|
|
/missing required Rng parameter/,
|
|
`stampAct5SiegeStrips rejects invalid Rng: ${String(badRng)}`,
|
|
)
|
|
}
|
|
|
|
// 2.2 stampAct5SiegeStrips invalid WildernessStats tests
|
|
const invalidStats = [null, undefined, false, 0, '']
|
|
for (const badStats of invalidStats) {
|
|
assertThrows(
|
|
() => stampAct5SiegeStrips(mockCanvas, [], validRng, badStats as unknown as WildernessStats),
|
|
/missing required WildernessStats parameter/,
|
|
`stampAct5SiegeStrips rejects invalid stats: ${String(badStats)}`,
|
|
)
|
|
}
|
|
|
|
// 2.3 stampAct3KurastCauseway invalid RNG tests
|
|
for (const badRng of invalidRngs) {
|
|
assertThrows(
|
|
() => stampAct3KurastCauseway(mockCanvas, [], badRng as unknown as Rng, validStats),
|
|
/missing required Rng instance/,
|
|
`stampAct3KurastCauseway rejects invalid Rng: ${String(badRng)}`,
|
|
)
|
|
}
|
|
|
|
console.log(`[PASS] Part 2 completed: ${passedAssertions}/${totalAssertions} assertions held.`)
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* Part 3: Seed Sensitivity Across 16 Arbitrary Random Seeds (Acts 2..5)
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
console.log('\n=================================================================')
|
|
console.log('PART 3: Multi-Seed Sensitivity across 16 Seeds in Acts 2..5')
|
|
console.log('=================================================================')
|
|
|
|
const MULTI_SEEDS = [
|
|
0x12345678,
|
|
0x9abcdef0,
|
|
0xdeadbeef,
|
|
0x301cd095,
|
|
0x416d61c5,
|
|
0x5a5a5a5a,
|
|
0xa5a5a5a5,
|
|
0x11223344,
|
|
0x55667788,
|
|
0x99aabbcc,
|
|
0xddeeff00,
|
|
0x01020304,
|
|
0x05060708,
|
|
0x090a0b0c,
|
|
0x0d0e0f10,
|
|
0x7fffffff,
|
|
] as const
|
|
|
|
assert(MULTI_SEEDS.length >= 10, `Testing across ${MULTI_SEEDS.length} arbitrary random seeds (>= 10 required)`)
|
|
|
|
async function runSeedSensitivityTests(): Promise<void> {
|
|
if (!existsSync('samples/d2/d2data.mpq')) {
|
|
console.warn('[WARN] samples/d2/d2data.mpq not found, running hermetic mock tests only.')
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
const actTables = await loadActTables(archives)
|
|
const tables = {
|
|
levels: actTables.levels,
|
|
lvltypes: actTables.lvltypes,
|
|
lvlprest: actTables.lvlprest,
|
|
}
|
|
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 WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
|
|
'Act 1 - Wilderness': [
|
|
'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - Cave Entrance',
|
|
'Act 1 - DOE Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill',
|
|
'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond',
|
|
'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages',
|
|
'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones',
|
|
'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill',
|
|
'Act 1 - Graveyard',
|
|
],
|
|
'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 = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /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
|
|
}
|
|
|
|
async function generateLevel(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
|
|
}
|
|
|
|
// Test across Acts 2..5 procedural levels
|
|
const targetLevels = [
|
|
{ act: 2, id: 41, name: 'Rocky Waste' },
|
|
{ act: 2, id: 42, name: 'Dry Hills' },
|
|
{ act: 2, id: 43, name: 'Far Oasis' },
|
|
{ act: 2, id: 44, name: 'Lost City' },
|
|
{ act: 2, id: 45, name: 'Valley of Snakes' },
|
|
{ act: 2, id: 46, name: 'Canyon of the Magi' },
|
|
{ act: 3, id: 76, name: 'Spider Forest' },
|
|
{ act: 3, id: 77, name: 'Great Marsh' },
|
|
{ act: 3, id: 78, name: 'Flayer Jungle' },
|
|
{ act: 3, id: 79, name: 'Lower Kurast' },
|
|
{ act: 3, id: 80, name: 'Kurast Bazaar' },
|
|
{ act: 3, id: 81, name: 'Upper Kurast' },
|
|
{ act: 4, id: 104, name: 'Outer Steppes' },
|
|
{ act: 4, id: 105, name: 'Plains of Despair' },
|
|
{ act: 4, id: 106, name: 'City of the Damned' },
|
|
{ act: 5, id: 110, name: 'Bloody Foothills' },
|
|
{ act: 5, id: 111, name: 'Frigid Highlands' },
|
|
{ act: 5, id: 112, name: 'Arreat Plateau' },
|
|
{ act: 5, id: 117, name: 'Frozen Tundra' },
|
|
{ act: 5, id: 134, name: 'Nihlathak Temple Garden' },
|
|
]
|
|
|
|
for (const lvl of targetLevels) {
|
|
const digests: string[] = []
|
|
for (const seed of MULTI_SEEDS) {
|
|
const ds1 = await generateLevel(lvl.id, seed)
|
|
const digest = canonicalHash(ds1)
|
|
digests.push(digest)
|
|
}
|
|
|
|
const uniqueCount = new Set(digests).size
|
|
const passedUniq = uniqueCount === MULTI_SEEDS.length
|
|
assert(
|
|
passedUniq,
|
|
`Level ${lvl.id} (${lvl.name}, Act ${lvl.act}): ${uniqueCount}/${MULTI_SEEDS.length} unique digests (100% collision-free)`,
|
|
`Expected ${MULTI_SEEDS.length} unique digests, got ${uniqueCount}. Digests: ${digests.join(', ')}`,
|
|
)
|
|
|
|
// Verify determinism on seed 0
|
|
const rerun = await generateLevel(lvl.id, MULTI_SEEDS[0])
|
|
const rerunDigest = canonicalHash(rerun)
|
|
const passedDeterminism = rerunDigest === digests[0]
|
|
assert(
|
|
passedDeterminism,
|
|
`Level ${lvl.id} (${lvl.name}): Determinism hold (re-run digest ${rerunDigest} === ${digests[0]})`,
|
|
)
|
|
|
|
console.log(` ✓ Level ${String(lvl.id).padStart(3, ' ')} (${lvl.name.padEnd(24, ' ')} Act ${lvl.act}): ${uniqueCount}/${MULTI_SEEDS.length} collision-free digests | determinism OK`)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------------- *
|
|
* Part 4: Act 1 Bit-Identical Regression Guard
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
console.log('\n=================================================================')
|
|
console.log('PART 4: Act 1 Bit-Identical Regression Guard')
|
|
console.log('=================================================================')
|
|
|
|
const ACT1_BASELINE_HASHES: Readonly<Record<number, readonly [string, string, string]>> = {
|
|
2: ['7b00cdd4', 'ecf76547', 'cc83418d'], // Blood Moor
|
|
3: ['0724e8be', '3df74523', '3312522f'], // Cold Plains
|
|
4: ['a7d44b10', '23f8b483', '57598911'], // Stony Field
|
|
5: ['d867cfdc', '1fc6c41c', '2093e6c7'], // Dark Wood
|
|
6: ['79b5d613', '731695ab', 'eddff41d'], // Black Marsh
|
|
7: ['3838630e', 'b19aac67', 'dff3927c'], // Tamoe Highland
|
|
17: ['ef7f1b99', '7deeb4c7', 'dbfe44d3'], // Burial Grounds
|
|
39: ['63daa9b7', '32ea40dc', '406a3493'], // Secret Cow Level
|
|
}
|
|
const CANONICAL_TEST_SEEDS = [0x301cd095, 0x416d61c5, 0xdeadbeef] as const
|
|
|
|
const act1Levels = [2, 3, 4, 5, 6, 7, 17, 39]
|
|
for (const levelId of act1Levels) {
|
|
const expected = ACT1_BASELINE_HASHES[levelId]!
|
|
const actual: string[] = []
|
|
|
|
for (const seed of CANONICAL_TEST_SEEDS) {
|
|
const level = await generateLevel(levelId, seed)
|
|
actual.push(canonicalHash(level))
|
|
}
|
|
|
|
const matches = JSON.stringify(actual) === JSON.stringify(expected)
|
|
assert(
|
|
matches,
|
|
`Act 1 Level ${levelId} matches baseline table 100% bit-for-bit: [${actual.join(', ')}] === [${expected.join(', ')}]`,
|
|
)
|
|
console.log(` ✓ Act 1 Level ${String(levelId).padStart(2, ' ')} bit-identical match: [${actual.join(', ')}]`)
|
|
}
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const startTime = Date.now()
|
|
await runSeedSensitivityTests()
|
|
const durationSec = ((Date.now() - startTime) / 1000).toFixed(2)
|
|
|
|
console.log('\n=================================================================')
|
|
console.log('SUMMARY OF EMPIRICAL ADVERSARIAL STRESS TEST')
|
|
console.log('=================================================================')
|
|
console.log(`Total Assertions: ${totalAssertions}`)
|
|
console.log(`Passed: ${passedAssertions}`)
|
|
console.log(`Failed: ${failedAssertions}`)
|
|
console.log(`Execution Time: ${durationSec}s`)
|
|
|
|
if (failedAssertions > 0) {
|
|
console.error('\n>>> VERDICT: CHALLENGE_FAILED <<<')
|
|
process.exit(1)
|
|
} else {
|
|
console.log('\n>>> VERDICT: APPROVE <<<')
|
|
process.exit(0)
|
|
}
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error('Fatal error during stress test execution:', err)
|
|
process.exit(1)
|
|
})
|