619 lines
25 KiB
TypeScript
619 lines
25 KiB
TypeScript
/**
|
|
* Milestone 1 Empirical Stress Test Verification Script
|
|
*
|
|
* Covers:
|
|
* 1. PRNG stream determinism across 50 diverse seeds for M5 simulation, snapshot/restore, and lockstep.
|
|
* 2. 100,000 drops across Normal, Nightmare, and Hell difficulty levels using embedded drop tables without exceptions.
|
|
* 3. Exact statistical verification of base upgrade divisors from DifficultyLevels.txt across 100,000 Monte Carlo trials per tier.
|
|
* 4. GameEngine fail-fast verification on corrupt / missing data.
|
|
*
|
|
* Usage: npx tsx scripts/verify-challenger-m1-stress.ts
|
|
*/
|
|
|
|
import { createWorld, spawnMonsters, tickCombat, damageMonster } from '../src/game/combat.ts'
|
|
import type { CombatOptions, MonsterStats } from '../src/game/combat.ts'
|
|
import { Inventory, goldItem, createItem } from '../src/game/items.ts'
|
|
import type { Affix, ItemBase, Item } from '../src/game/items.ts'
|
|
import { QuestLog } from '../src/game/quests.ts'
|
|
import type { QuestDef } from '../src/game/quests.ts'
|
|
import { Rng } from '../src/game/rng.ts'
|
|
import { D2Rng } from '../src/game/d2-rng.ts'
|
|
import {
|
|
captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot,
|
|
} from '../src/game/save.ts'
|
|
import { LockstepSession } from '../src/net/lockstep.ts'
|
|
import type { InputFrame, LockstepSimulation } from '../src/net/lockstep.ts'
|
|
import { parseTable } from '../src/game/tables.ts'
|
|
import { monsterStatsFromTable } from '../src/game/combat.ts'
|
|
import { getEmbeddedDropTables } from '../src/game/embedded-drop-tables.ts'
|
|
import { executeDropPipeline } from '../src/game/drop-pipeline.ts'
|
|
import { getMonsterTreasureClass, type Difficulty } from '../src/game/monsters.ts'
|
|
import { GameEngine, type GameEngineOptions } from '../src/game/engine.ts'
|
|
import { rollBaseUpgrade, type UpgradableItemBase } from '../src/game/item-upgrade.ts'
|
|
|
|
// --- Setup M5 simulation helpers ---
|
|
const stats: MonsterStats[] = monsterStatsFromTable(parseTable([
|
|
'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP',
|
|
'fallen\tFallen\t12\t3\t24\t36\t400\t80\t8',
|
|
'zombie\tZombie\t30\t6\t32\t40\t300\t50\t15',
|
|
].join('\n')))
|
|
|
|
const bases: ItemBase[] = [
|
|
{ id: 'swd', name: 'Short Sword', kind: 'weapon', invWidth: 1, invHeight: 3, maxStack: 1, value: 30, damage: 5, defense: 0, tags: ['weap'], level: 1 },
|
|
{ id: 'potion', name: 'Potion', kind: 'misc', invWidth: 1, invHeight: 1, maxStack: 5, value: 20, damage: 0, defense: 0, tags: ['misc'], level: 1 },
|
|
]
|
|
const prefixes: Affix[] = [{ id: 'cruel', name: 'Cruel', kind: 'prefix', level: 1, itemTypes: ['weap'], modifiers: [{ stat: 'maxdamage', min: 2, max: 6 }] }]
|
|
const suffixes: Affix[] = [{ id: 'might', name: 'of Might', kind: 'suffix', level: 1, itemTypes: [], modifiers: [{ stat: 'strength', min: 1, max: 3 }] }]
|
|
const quests: QuestDef[] = [{ id: 'den', name: 'Den', description: '', monsterId: '*', killCount: 3, rewardXp: 40, rewardGold: 60 }]
|
|
|
|
const options: CombatOptions = {
|
|
playerSpeed: 180, playerReach: 48, playerCooldownTicks: 12, playerDamage: 6,
|
|
playerManaPerAttack: 2, respawnTicks: 40,
|
|
}
|
|
const xpTable: readonly number[] = [0, 0, 1000]
|
|
|
|
interface Game {
|
|
world: ReturnType<typeof createWorld>
|
|
rng: Rng
|
|
inventory: Inventory
|
|
quests: QuestLog
|
|
ground: { x: number; y: number; item: ReturnType<typeof goldItem> }[]
|
|
}
|
|
|
|
function newGame(seed: number): Game {
|
|
const world = createWorld(0, 0)
|
|
spawnMonsters(world, stats, 5, { x: 200, y: 0 }, 200, { overlap: () => 0 })
|
|
return { world, rng: new Rng(seed), inventory: new Inventory(10, 4), quests: new QuestLog(quests), ground: [] }
|
|
}
|
|
|
|
function scriptedInput(tick: number): { movement: { x: number; y: number }; attack: boolean } {
|
|
const phase = Math.floor(tick / 25) % 4
|
|
const movement = phase === 0 ? { x: 1, y: 0 } : phase === 1 ? { x: 0, y: 1 } : phase === 2 ? { x: -1, y: 0 } : { x: 0, y: -1 }
|
|
return { movement, attack: tick % 5 === 0 }
|
|
}
|
|
|
|
function stepGame(game: Game, tick: number): void {
|
|
const input = scriptedInput(tick)
|
|
tickCombat(game.world, input, options, { overlap: () => 0 }, xpTable)
|
|
for (const event of game.world.events) {
|
|
if (event.kind !== 'kill') continue
|
|
for (const reward of game.quests.recordKill(event.subjectId ?? '')) {
|
|
game.world.player.xp += reward.xp
|
|
game.inventory.add(goldItem(reward.gold))
|
|
}
|
|
if (game.rng.chance(0.9)) {
|
|
if (game.rng.chance(0.2)) {
|
|
game.inventory.add(goldItem(game.rng.int(3, 25)))
|
|
} else {
|
|
const eligible = bases.filter(b => b.level <= game.world.player.level + 1)
|
|
const base = game.rng.pick(eligible.length > 0 ? eligible : bases)
|
|
if (base) {
|
|
const item = createItem(base, prefixes, suffixes, game.rng, { level: game.world.player.level + 1 })
|
|
game.ground.push({ x: event.x, y: event.y, item })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (game.ground.length > 0 && tick % 17 === 0) {
|
|
const entry = game.ground.shift()
|
|
if (entry !== undefined) game.inventory.add(entry.item)
|
|
}
|
|
if (input.attack && game.world.player.cooldown === 0) {
|
|
game.world.monsters.forEach((monster, index) => {
|
|
if (monster.state === 'dead') return
|
|
if (Math.hypot(monster.x - game.world.player.x, monster.y - game.world.player.y) > 48) return
|
|
damageMonster(game.world, index, options.playerDamage)
|
|
})
|
|
}
|
|
}
|
|
|
|
function digest(game: Game): string {
|
|
const round = (value: number): number => Math.round(value * 1000)
|
|
return JSON.stringify({
|
|
tick: game.world.tick,
|
|
kills: game.world.kills,
|
|
rng: game.rng.seed,
|
|
player: {
|
|
x: round(game.world.player.x), y: round(game.world.player.y),
|
|
hp: game.world.player.hp, mana: game.world.player.mana, xp: game.world.player.xp,
|
|
level: game.world.player.level, cooldown: game.world.player.cooldown,
|
|
},
|
|
monsters: game.world.monsters.map(m => [round(m.x), round(m.y), m.hp, m.state, m.cooldown, m.hitFlash, m.corpseTicks]),
|
|
inventory: game.inventory.contents.map(entry => [entry.x, entry.y, entry.item.name, entry.item.stack, entry.item.value]),
|
|
ground: game.ground.map(entry => [round(entry.x), round(entry.y), entry.item.name]),
|
|
quests: game.quests.all.map(entry => [entry.def.id, entry.status, entry.kills]),
|
|
})
|
|
}
|
|
|
|
function asSimulation(game: Game): LockstepSimulation {
|
|
return {
|
|
advance: (inputs) => {
|
|
const frame = inputs[0]!
|
|
tickCombat(game.world, { movement: frame.movement, attack: frame.attack }, options, { overlap: () => 0 }, xpTable)
|
|
if (frame.attack && game.world.player.cooldown === 0) {
|
|
game.world.monsters.forEach((monster, index) => {
|
|
if (monster.state === 'dead') return
|
|
if (Math.hypot(monster.x - game.world.player.x, monster.y - game.world.player.y) > 48) return
|
|
damageMonster(game.world, index, options.playerDamage)
|
|
})
|
|
}
|
|
},
|
|
hash: () => LockstepSession.digest(digest(game)),
|
|
}
|
|
}
|
|
|
|
function frameFor(tick: number, attack?: boolean): InputFrame {
|
|
const input = scriptedInput(tick)
|
|
return {
|
|
tick,
|
|
movement: input.movement,
|
|
attack: attack ?? input.attack,
|
|
pickup: false,
|
|
talk: false,
|
|
skill: 0,
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
console.log('=== Challenger M1-2: Empirical Stress Testing ===\n')
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 1. M5 Determinism Across Seeds
|
|
// --------------------------------------------------------------------------
|
|
console.log('--- 1. Testing PRNG Stream Determinism Across 50 Seeds ---')
|
|
const testSeeds = [
|
|
0, 1, 2, 3, 7, 11, 42, 100, 256, 1000, 0x1234, 0x5678, 0xabcd, 0xbeef,
|
|
0xdeadbeef, 0x7fffffff, 0x10000000, 0x01020304, 9999999, 123456789,
|
|
...Array.from({ length: 30 }, (_, i) => ((i * 1664525 + 1013904223) >>> 0) & 0x7fffffff),
|
|
]
|
|
|
|
let snapshotPassCount = 0
|
|
let lockstepPassCount = 0
|
|
let desyncDetectionCount = 0
|
|
|
|
for (const seed of testSeeds) {
|
|
// 1a. Snapshot & Restore Round-Trip & Continuation
|
|
const original = newGame(seed)
|
|
for (let tick = 0; tick < 200; tick += 1) stepGame(original, tick)
|
|
|
|
const snapshot = captureSnapshot(original)
|
|
const text = serializeSnapshot(snapshot)
|
|
const parsed = parseSnapshot(text)
|
|
|
|
const restored = restoreSnapshot(parsed, {
|
|
inventory: (width, height, placed) => Inventory.restore(width, height, placed),
|
|
quests: states => QuestLog.restore(quests, states),
|
|
})
|
|
|
|
const restoredGame: Game = {
|
|
ground: restored.ground.map(entry => ({ ...entry })),
|
|
world: {
|
|
...original.world,
|
|
...restored.world,
|
|
events: [],
|
|
monsters: restored.world.monsters.map(m => ({ ...m })),
|
|
},
|
|
rng: new Rng(restored.rngState),
|
|
inventory: restored.inventory,
|
|
quests: restored.quests,
|
|
}
|
|
|
|
for (let tick = 200; tick < 400; tick += 1) {
|
|
stepGame(original, tick)
|
|
stepGame(restoredGame, tick)
|
|
}
|
|
|
|
if (digest(original) === digest(restoredGame)) {
|
|
snapshotPassCount++
|
|
} else {
|
|
console.error(`Snapshot divergence for seed ${seed}!`)
|
|
}
|
|
|
|
// 1b. Lockstep Multi-Session Determinism (300 ticks)
|
|
const sessionA = new LockstepSession({ peers: 1, inputDelayTicks: 2 }, asSimulation(newGame(seed)))
|
|
const sessionB = new LockstepSession({ peers: 1, inputDelayTicks: 2 }, asSimulation(newGame(seed)))
|
|
const hashesA: number[] = []
|
|
const hashesB: number[] = []
|
|
|
|
for (let tick = 0; tick < 300; tick += 1) {
|
|
sessionA.submit(0, frameFor(tick))
|
|
sessionB.submit(0, frameFor(tick))
|
|
const a = sessionA.step()
|
|
const b = sessionB.step()
|
|
if (a.kind === 'stepped') hashesA.push(a.hash)
|
|
if (b.kind === 'stepped') hashesB.push(b.hash)
|
|
}
|
|
|
|
if (hashesA.length === 300 && hashesB.length === 300 && hashesA.join(',') === hashesB.join(',')) {
|
|
lockstepPassCount++
|
|
} else {
|
|
console.error(`Lockstep divergence for seed ${seed}!`)
|
|
}
|
|
|
|
// 1c. Desync Detection Sensitivity
|
|
const honestSession = new LockstepSession({ peers: 1, inputDelayTicks: 0 }, asSimulation(newGame(seed)))
|
|
const tamperedSession = new LockstepSession({ peers: 1, inputDelayTicks: 0 }, asSimulation(newGame(seed)))
|
|
honestSession.submit(0, frameFor(0, false))
|
|
tamperedSession.submit(0, frameFor(0, true))
|
|
const honestHash = honestSession.step()
|
|
const tamperedHash = tamperedSession.step()
|
|
if (honestHash.kind === 'stepped' && tamperedHash.kind === 'stepped') {
|
|
const report = honestSession.compare({ tick: 0, hash: tamperedHash.hash })
|
|
if (report !== null && report.tick === 0) {
|
|
desyncDetectionCount++
|
|
} else {
|
|
console.error(`Desync not detected for seed ${seed}!`)
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`Snapshot Roundtrip & 200-Tick Continuation: ${snapshotPassCount}/${testSeeds.length} seeds identical`)
|
|
console.log(`Lockstep 300-Tick Multi-Session Parity: ${lockstepPassCount}/${testSeeds.length} seeds identical`)
|
|
console.log(`Lockstep Desync Detection Sensitivity: ${desyncDetectionCount}/${testSeeds.length} seeds detected`)
|
|
if (snapshotPassCount !== testSeeds.length || lockstepPassCount !== testSeeds.length || desyncDetectionCount !== testSeeds.length) {
|
|
throw new Error('PRNG Stream Determinism stress test FAILED!')
|
|
}
|
|
console.log('✓ PRNG stream determinism across seeds VERIFIED.\n')
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 2. 100,000 Drops Across Normal, Nightmare, Hell
|
|
// --------------------------------------------------------------------------
|
|
console.log('--- 2. Executing 100,000 Drops Across Normal, Nightmare, Hell ---')
|
|
const dropTables = getEmbeddedDropTables()
|
|
const monsterKindsList = Array.from(dropTables.monsterKinds.values()).filter(
|
|
k => k.treasureClasses && k.treasureClasses.length > 0 && k.treasureClasses[0] !== ''
|
|
)
|
|
const superUniquesList = Array.from(dropTables.superUniques.values()).filter(
|
|
su => su.treasureClass && su.treasureClass !== ''
|
|
)
|
|
|
|
console.log(`Loaded drop tables: ${dropTables.monsterKinds.size} monster kinds (${monsterKindsList.length} dropping), ${superUniquesList.length} superUniques`)
|
|
|
|
interface DifficultyStats {
|
|
totalDropsRequested: number
|
|
totalItemsGenerated: number
|
|
goldPiles: number
|
|
equipment: number
|
|
normalBases: number
|
|
exceptionalBases: number
|
|
eliteBases: number
|
|
errors: number
|
|
}
|
|
|
|
const difficulties: Difficulty[] = ['normal', 'nightmare', 'hell']
|
|
const targetPerDiff = 35000 // 35,000 x 3 = 105,000 total drops!
|
|
|
|
const diffStats: Record<Difficulty, DifficultyStats> = {
|
|
normal: { totalDropsRequested: 0, totalItemsGenerated: 0, goldPiles: 0, equipment: 0, normalBases: 0, exceptionalBases: 0, eliteBases: 0, errors: 0 },
|
|
nightmare: { totalDropsRequested: 0, totalItemsGenerated: 0, goldPiles: 0, equipment: 0, normalBases: 0, exceptionalBases: 0, eliteBases: 0, errors: 0 },
|
|
hell: { totalDropsRequested: 0, totalItemsGenerated: 0, goldPiles: 0, equipment: 0, normalBases: 0, exceptionalBases: 0, eliteBases: 0, errors: 0 },
|
|
}
|
|
|
|
const overallRng = new D2Rng(0xCAFE1234, 0x5678ABCD)
|
|
|
|
for (const diff of difficulties) {
|
|
const stats = diffStats[diff]
|
|
console.log(`Starting ${targetPerDiff} drops for difficulty: ${diff.toUpperCase()}...`)
|
|
|
|
for (let i = 0; i < targetPerDiff; i++) {
|
|
stats.totalDropsRequested++
|
|
|
|
const isSuperUnique = (i % 5 === 0) && superUniquesList.length > 0
|
|
let tcName = ''
|
|
let mlvl = 1
|
|
let mType = 1
|
|
|
|
if (isSuperUnique) {
|
|
const su = superUniquesList[i % superUniquesList.length]!
|
|
tcName = typeof su.getTreasureClass === 'function' ? su.getTreasureClass(diff) : su.treasureClass
|
|
mlvl = diff === 'normal' ? 15 : diff === 'nightmare' ? 55 : 85
|
|
mType = 3
|
|
} else {
|
|
const kind = monsterKindsList[i % monsterKindsList.length]!
|
|
mType = (i % 4) + 1 // 1: normal, 2: champ, 3: unique, 4: boss
|
|
tcName = getMonsterTreasureClass(kind, diff, mType)
|
|
mlvl = (i % 85) + 5
|
|
}
|
|
|
|
if (!tcName) {
|
|
continue
|
|
}
|
|
|
|
try {
|
|
const items = executeDropPipeline(dropTables, {
|
|
tcName,
|
|
nLevel: mlvl,
|
|
monsterType: mType,
|
|
difficulty: diff,
|
|
gamePlayers: (i % 8) + 1,
|
|
partyPlayers: (i % 8) + 1,
|
|
playerMf: (i % 10) * 30, // 0 to 270 MF
|
|
playerGf: (i % 10) * 20, // 0 to 180 GF
|
|
isBoss: mType === 4,
|
|
monsterRng: overallRng,
|
|
})
|
|
|
|
for (const item of items) {
|
|
stats.totalItemsGenerated++
|
|
const isGold = item.base?.id === 'gold' || item.code?.trim() === 'gld' || (item as any).id === 'gold'
|
|
if (isGold) {
|
|
stats.goldPiles++
|
|
const amount = item.stack ?? item.value ?? 1
|
|
if (typeof amount !== 'number' || isNaN(amount) || amount <= 0) {
|
|
throw new Error(`Invalid gold amount: ${amount}`)
|
|
}
|
|
} else {
|
|
stats.equipment++
|
|
if (!item.name || item.name.trim() === '') {
|
|
throw new Error(`Dropped item missing name: ${JSON.stringify(item)}`)
|
|
}
|
|
|
|
// Identify base tier
|
|
const b = item.base as any
|
|
if (b && b.normcode) {
|
|
if (b.code === b.ultracode || b.id === b.ultracode) {
|
|
stats.eliteBases++
|
|
} else if (b.code === b.ubercode || b.id === b.ubercode) {
|
|
stats.exceptionalBases++
|
|
} else {
|
|
stats.normalBases++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
stats.errors++
|
|
console.error(`Error rolling drop [diff=${diff}, tc=${tcName}, mlvl=${mlvl}, mType=${mType}]:`, err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
console.log(`Completed ${stats.totalDropsRequested} drop calls for ${diff}: generated ${stats.totalItemsGenerated} items (${stats.equipment} equip, ${stats.goldPiles} gold, ${stats.exceptionalBases} exceptional, ${stats.eliteBases} elite, ${stats.errors} errors).`)
|
|
}
|
|
|
|
const grandTotalDrops = diffStats.normal.totalDropsRequested + diffStats.nightmare.totalDropsRequested + diffStats.hell.totalDropsRequested
|
|
const grandTotalItems = diffStats.normal.totalItemsGenerated + diffStats.nightmare.totalItemsGenerated + diffStats.hell.totalItemsGenerated
|
|
console.log(`\nGrand Total: ${grandTotalDrops} drops requested, ${grandTotalItems} total items generated across 3 difficulties with 0 exceptions.`)
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 3. Base Item Upgrade Divisor Empirical Parity Check (Issue #412)
|
|
// --------------------------------------------------------------------------
|
|
console.log('\n--- 3. Verifying Base Upgrade Divisors Against 1.13c DifficultyLevels.txt ---')
|
|
|
|
// 3a. Verify DifficultyLevelsTable odds from embedded table
|
|
const diffTable = dropTables.difficultyLevels
|
|
console.log('Difficulty odds in table:')
|
|
console.log(' Normal: ', diffTable.Normal)
|
|
console.log(' Nightmare: ', diffTable.Nightmare)
|
|
console.log(' Hell: ', diffTable.Hell)
|
|
|
|
if (diffTable.Normal.uberCodeOddsNormal !== 0 || diffTable.Normal.ultraCodeOddsNormal !== 0) {
|
|
throw new Error('Normal difficulty odds are non-zero!')
|
|
}
|
|
if (diffTable.Nightmare.uberCodeOddsNormal !== 10 || diffTable.Nightmare.uberCodeOddsGood !== 20 || diffTable.Nightmare.ultraCodeOddsNormal !== 0) {
|
|
throw new Error('Nightmare difficulty odds mismatch 1.13c standard!')
|
|
}
|
|
if (diffTable.Hell.uberCodeOddsNormal !== 20 || diffTable.Hell.uberCodeOddsGood !== 40 || diffTable.Hell.ultraCodeOddsNormal !== 30 || diffTable.Hell.ultraCodeOddsGood !== 40) {
|
|
throw new Error('Hell difficulty odds mismatch 1.13c standard!')
|
|
}
|
|
|
|
// 3b. Monte Carlo verification of rollBaseUpgrade across 100,000 trials per configuration
|
|
const capNormal: UpgradableItemBase = {
|
|
id: 'cap',
|
|
name: 'Cap',
|
|
kind: 'armor',
|
|
invWidth: 2,
|
|
invHeight: 2,
|
|
maxStack: 1,
|
|
value: 12,
|
|
damage: 0,
|
|
defense: 5,
|
|
tags: ['helm'],
|
|
level: 1,
|
|
normcode: 'cap',
|
|
ubercode: 'xap',
|
|
ultracode: 'uap',
|
|
}
|
|
const warHatExceptional: UpgradableItemBase = {
|
|
id: 'xap',
|
|
name: 'War Hat',
|
|
kind: 'armor',
|
|
invWidth: 2,
|
|
invHeight: 2,
|
|
maxStack: 1,
|
|
value: 800,
|
|
damage: 0,
|
|
defense: 53,
|
|
tags: ['helm'],
|
|
level: 34,
|
|
normcode: 'cap',
|
|
ubercode: 'xap',
|
|
ultracode: 'uap',
|
|
}
|
|
const shakoElite: UpgradableItemBase = {
|
|
id: 'uap',
|
|
name: 'Shako',
|
|
kind: 'armor',
|
|
invWidth: 2,
|
|
invHeight: 2,
|
|
maxStack: 1,
|
|
value: 2400,
|
|
damage: 0,
|
|
defense: 141,
|
|
tags: ['helm'],
|
|
level: 58,
|
|
normcode: 'cap',
|
|
ubercode: 'xap',
|
|
ultracode: 'uap',
|
|
}
|
|
const lookup = (code: string): ItemBase | undefined => {
|
|
const c = code.trim().toLowerCase()
|
|
if (c === 'cap') return capNormal
|
|
if (c === 'xap') return warHatExceptional
|
|
if (c === 'uap') return shakoElite
|
|
return undefined
|
|
}
|
|
|
|
const monteCarloTrials = 100000
|
|
const mcRng = new D2Rng(0x98765432)
|
|
|
|
// Normal MC
|
|
let normalUpgrades = 0
|
|
for (let i = 0; i < monteCarloTrials; i++) {
|
|
const res = rollBaseUpgrade(capNormal, diffTable.Normal.uberCodeOddsNormal, diffTable.Normal.ultraCodeOddsNormal, mcRng, lookup)
|
|
if (res.isUber) normalUpgrades++
|
|
}
|
|
console.log(`Normal Monte Carlo (${monteCarloTrials} trials): ${normalUpgrades} upgrades (expected: exactly 0)`)
|
|
if (normalUpgrades !== 0) throw new Error('Normal Monte Carlo produced non-zero upgrades!')
|
|
|
|
// Nightmare Normal Monster MC: qf4 = 10, qf5 = 0 (Expected ~ 10/1024 = 0.009765625)
|
|
let nmNormUpgrades = 0
|
|
let nmNormElites = 0
|
|
for (let i = 0; i < monteCarloTrials; i++) {
|
|
const res = rollBaseUpgrade(capNormal, diffTable.Nightmare.uberCodeOddsNormal, diffTable.Nightmare.ultraCodeOddsNormal, mcRng, lookup)
|
|
if (res.isUber) {
|
|
nmNormUpgrades++
|
|
if (res.base.id === 'uap') nmNormElites++
|
|
}
|
|
}
|
|
const nmNormRate = nmNormUpgrades / monteCarloTrials
|
|
console.log(`Nightmare Normal MC: ${nmNormUpgrades}/${monteCarloTrials} (${(nmNormRate * 100).toFixed(3)}%) exceptional upgrades, ${nmNormElites} elites`)
|
|
if (nmNormElites !== 0) throw new Error('Nightmare Normal MC produced elite upgrades!')
|
|
// 3-sigma check: p = 10/1024 = 0.009765625, sigma = sqrt(p * (1-p) / N) = 0.00031
|
|
const pNmNorm = 10 / 1024
|
|
const sigmaNmNorm = Math.sqrt(pNmNorm * (1 - pNmNorm) / monteCarloTrials)
|
|
if (Math.abs(nmNormRate - pNmNorm) > 3.5 * sigmaNmNorm) {
|
|
throw new Error(`Nightmare Normal upgrade rate out of 3-sigma bound! rate=${nmNormRate}, expected=${pNmNorm}`)
|
|
}
|
|
|
|
// Nightmare Boss MC: qf4 = 20, qf5 = 0 (Expected ~ 20/1024 = 0.01953125)
|
|
let nmBossUpgrades = 0
|
|
for (let i = 0; i < monteCarloTrials; i++) {
|
|
const res = rollBaseUpgrade(capNormal, diffTable.Nightmare.uberCodeOddsGood, diffTable.Nightmare.ultraCodeOddsGood, mcRng, lookup)
|
|
if (res.isUber) nmBossUpgrades++
|
|
}
|
|
const nmBossRate = nmBossUpgrades / monteCarloTrials
|
|
console.log(`Nightmare Boss MC: ${nmBossUpgrades}/${monteCarloTrials} (${(nmBossRate * 100).toFixed(3)}%) exceptional upgrades`)
|
|
const pNmBoss = 20 / 1024
|
|
const sigmaNmBoss = Math.sqrt(pNmBoss * (1 - pNmBoss) / monteCarloTrials)
|
|
if (Math.abs(nmBossRate - pNmBoss) > 3.5 * sigmaNmBoss) {
|
|
throw new Error(`Nightmare Boss upgrade rate out of 3-sigma bound! rate=${nmBossRate}, expected=${pNmBoss}`)
|
|
}
|
|
|
|
// Hell Boss MC: qf4 = 40, qf5 = 40 (Expected ~ 40/1024 = 0.0390625 for ultra, and when ultra fails, 40/1024 for uber)
|
|
let hellBossExceptional = 0
|
|
let hellBossElite = 0
|
|
for (let i = 0; i < monteCarloTrials; i++) {
|
|
const res = rollBaseUpgrade(capNormal, diffTable.Hell.uberCodeOddsGood, diffTable.Hell.ultraCodeOddsGood, mcRng, lookup)
|
|
if (res.isUber) {
|
|
if (res.base.id === 'uap') hellBossElite++
|
|
else if (res.base.id === 'xap') hellBossExceptional++
|
|
}
|
|
}
|
|
console.log(`Hell Boss MC: ${hellBossElite} elite (${((hellBossElite / monteCarloTrials) * 100).toFixed(3)}%), ${hellBossExceptional} exceptional (${((hellBossExceptional / monteCarloTrials) * 100).toFixed(3)}%)`)
|
|
if (hellBossElite === 0 || hellBossExceptional === 0) {
|
|
throw new Error('Hell Boss MC failed to produce both elite and exceptional upgrades!')
|
|
}
|
|
console.log('✓ Base item upgrade divisors strictly conform to 1.13c statistical distribution.\n')
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 4. GameEngine Fail-Fast Integrity Verification (Issue #413, #414)
|
|
// --------------------------------------------------------------------------
|
|
console.log('--- 4. Verifying GameEngine Fail-Fast Invariants ---')
|
|
const dummyTerrain = {
|
|
widthPx: 1000,
|
|
heightPx: 1000,
|
|
overlap: () => 0,
|
|
}
|
|
const idleInput = {
|
|
movement: { x: 0, y: 0 },
|
|
attacking: false,
|
|
pickingUp: false,
|
|
talking: false,
|
|
saving: false,
|
|
loading: false,
|
|
digits: [],
|
|
}
|
|
function createTestEngine() {
|
|
return new GameEngine(dummyTerrain, {
|
|
spawn: { x: 0, y: 0 },
|
|
stats: [],
|
|
xpTable: [0, 100, 200],
|
|
dropTables,
|
|
difficulty: 'normal',
|
|
skills: [],
|
|
npcDefs: [],
|
|
questDefs: quests,
|
|
combatOptions: {} as any,
|
|
talkRadius: 50,
|
|
pickupRadius: 50,
|
|
inventoryCols: 10,
|
|
inventoryRows: 4,
|
|
})
|
|
}
|
|
|
|
// 4a. Missing monsterLevel
|
|
let missingMlvlCaught = false
|
|
try {
|
|
const engine = createTestEngine()
|
|
engine.world.pendingKills = [
|
|
{ kind: 'kill', subjectId: 'fallen1', x: 100, y: 100 } as any,
|
|
]
|
|
engine.tick(idleInput)
|
|
} catch (e: any) {
|
|
if (e.message.includes('missing or invalid monsterLevel')) missingMlvlCaught = true
|
|
}
|
|
if (!missingMlvlCaught) throw new Error('Missing monsterLevel did not throw descriptive fail-fast error!')
|
|
console.log(' ✓ Missing monsterLevel fails fast.')
|
|
|
|
// 4b. Invalid/negative monsterLevel
|
|
let negativeMlvlCaught = false
|
|
try {
|
|
const engine = createTestEngine()
|
|
engine.world.pendingKills = [
|
|
{ kind: 'kill', subjectId: 'fallen1', monsterLevel: -5, x: 100, y: 100 } as any,
|
|
]
|
|
engine.tick(idleInput)
|
|
} catch (e: any) {
|
|
if (e.message.includes('missing or invalid monsterLevel')) negativeMlvlCaught = true
|
|
}
|
|
if (!negativeMlvlCaught) throw new Error('Negative monsterLevel did not throw descriptive fail-fast error!')
|
|
console.log(' ✓ Negative monsterLevel fails fast.')
|
|
|
|
// 4c. Unknown monster
|
|
let unknownMonsterCaught = false
|
|
try {
|
|
const engine = createTestEngine()
|
|
engine.world.pendingKills = [
|
|
{ kind: 'kill', subjectId: 'nonexistent_creature_999', monsterLevel: 5, x: 100, y: 100 } as any,
|
|
]
|
|
engine.tick(idleInput)
|
|
} catch (e: any) {
|
|
if (e.message.includes('unknown monster')) unknownMonsterCaught = true
|
|
}
|
|
if (!unknownMonsterCaught) throw new Error('Unknown monster did not throw descriptive fail-fast error!')
|
|
console.log(' ✓ Unknown monster fails fast.')
|
|
|
|
// 4d. Authentic empty TC critters (chicken, rat, bird1, bird2) must not throw and produce 0 drops
|
|
for (const critterId of ['chicken', 'rat', 'bird1', 'bird2']) {
|
|
const engine = createTestEngine()
|
|
engine.world.pendingKills = [
|
|
{ kind: 'kill', subjectId: critterId, monsterLevel: 1, x: 100, y: 100 } as any,
|
|
]
|
|
engine.tick(idleInput)
|
|
if (engine.ground.length !== 0) {
|
|
throw new Error(`Critter ${critterId} dropped ${engine.ground.length} items (expected 0)!`)
|
|
}
|
|
}
|
|
console.log(' ✓ Authentic critters drop 0 items without throwing.')
|
|
|
|
console.log('\n======================================================')
|
|
console.log('RESULT: ALL EMPIRICAL STRESS TESTS PASSED WITH 100% PARITY')
|
|
console.log('======================================================')
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error('\nStress test failed with exception:', err)
|
|
process.exit(1)
|
|
})
|