diff --git a/scripts/verify-challenger-elite-stats.ts b/scripts/verify-challenger-elite-stats.ts new file mode 100644 index 0000000..ce43a62 --- /dev/null +++ b/scripts/verify-challenger-elite-stats.ts @@ -0,0 +1,209 @@ +/** + * scripts/verify-challenger-elite-stats.ts + * + * Empirical Challenger Verification Suite for Diablo II v1.13c Elite Monster Generation. + * + * Objective: + * 1. Monte Carlo simulation (>= 10,000 trials) testing `planMonsterGroups` for elite selection: + * - Champion ratio convergence to 20% within 3-sigma confidence interval. + * - Unique ratio convergence to 80% within 3-sigma confidence interval. + * - Minion counts for Uniques strictly bounded in [3, 6]. + * - Champion pack sizes strictly bounded in [2, 4]. + * - HP multipliers: Champions 3x, Boss 4x, Minions 2x. + */ + +import * as fs from '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 } from '../src/game/acts.ts' +import { + readLevelMonsterPlan, + readMonsterKinds, + selectLevelTypes, + planMonsterGroups, + planLevelMonsters, + ELITE_HEALTH_MULTIPLIER, + MONUMOD_CONSTANTS, + type MonsterKind, +} from '../src/game/monsters.ts' +import { Rng } from '../src/game/rng.ts' + +export interface MonteCarloReport { + readonly trials: number + readonly totalEliteGroups: number + readonly championGroups: number + readonly uniqueGroups: number + readonly championRatio: number + readonly uniqueRatio: number + readonly threeSigmaMargin: number + readonly championRatioWithin3Sigma: boolean + readonly uniqueRatioWithin3Sigma: boolean + readonly championPackSizeMin: number + readonly championPackSizeMax: number + readonly championPackSizeValid: boolean + readonly championPackSizeHistogram: Record + readonly uniqueMinionCountMin: number + readonly uniqueMinionCountMax: number + readonly uniqueMinionCountValid: boolean + readonly uniqueMinionCountHistogram: Record + readonly hpMultipliersValid: boolean + readonly hpMultipliers: { + champion: number + uniqueBoss: number + minion: number + } +} + +export async function runEliteMonteCarloSimulation(trials = 10000): Promise { + const dir = fs.existsSync('samples/d2/Patch_D2.mpq') + ? 'samples/d2' + : (fs.existsSync('/usr/local/google/home/taodao/d2-data/Patch_D2.mpq') + ? '/usr/local/google/home/taodao/d2-data' + : 'samples/d2') + + const archives = new MountedArchives() + for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { + archives.add(name, await MpqArchive.open(await fileSource(`${dir}/${name}`))) + } + const tables = await loadActTables(archives) + const kinds = readMonsterKinds(tables.monstats) + + const plan = readLevelMonsterPlan(tables.levels, 3, 'nightmare')! + const types = selectLevelTypes(plan, kinds, new Rng(1), 'nightmare') + + let totalEliteGroups = 0 + let championGroups = 0 + let uniqueGroups = 0 + + let minChampPackSize = Infinity + let maxChampPackSize = -Infinity + const champPackSizes = new Map() + + let minUniqueMinionCount = Infinity + let maxUniqueMinionCount = -Infinity + const uniqueMinionCounts = new Map() + + for (let trial = 0; trial < trials; trial++) { + const rng = new Rng(200000 + trial) + // Budget 200 ensures packs are not artificially truncated by remaining budget + const groups = planMonsterGroups(plan, types, kinds, 200, rng) + for (const g of groups) { + if (g.rank === 'champion') { + championGroups++ + totalEliteGroups++ + minChampPackSize = Math.min(minChampPackSize, g.count) + maxChampPackSize = Math.max(maxChampPackSize, g.count) + champPackSizes.set(g.count, (champPackSizes.get(g.count) || 0) + 1) + } else if (g.rank === 'unique') { + uniqueGroups++ + totalEliteGroups++ + const minions = g.count - 1 + minUniqueMinionCount = Math.min(minUniqueMinionCount, minions) + maxUniqueMinionCount = Math.max(maxUniqueMinionCount, minions) + uniqueMinionCounts.set(minions, (uniqueMinionCounts.get(minions) || 0) + 1) + } + } + } + + const championRatio = championGroups / totalEliteGroups + const uniqueRatio = uniqueGroups / totalEliteGroups + const threeSigmaMargin = 3 * Math.sqrt((0.20 * 0.80) / totalEliteGroups) + + const championRatioWithin3Sigma = Math.abs(championRatio - 0.20) <= threeSigmaMargin + const uniqueRatioWithin3Sigma = Math.abs(uniqueRatio - 0.80) <= threeSigmaMargin + + const championPackSizeValid = minChampPackSize >= 2 && maxChampPackSize <= 4 + const uniqueMinionCountValid = minUniqueMinionCount >= 3 && maxUniqueMinionCount <= 6 + + const championHp = ELITE_HEALTH_MULTIPLIER.champion + const uniqueHp = ELITE_HEALTH_MULTIPLIER.unique + const minionHp = 1 + (MONUMOD_CONSTANTS.minionHpPct / 100) + const hpMultipliersValid = championHp === 3 && uniqueHp === 4 && minionHp === 2 + + return { + trials, + totalEliteGroups, + championGroups, + uniqueGroups, + championRatio, + uniqueRatio, + threeSigmaMargin, + championRatioWithin3Sigma, + uniqueRatioWithin3Sigma, + championPackSizeMin: minChampPackSize, + championPackSizeMax: maxChampPackSize, + championPackSizeValid, + championPackSizeHistogram: Object.fromEntries(champPackSizes), + uniqueMinionCountMin: minUniqueMinionCount, + uniqueMinionCountMax: maxUniqueMinionCount, + uniqueMinionCountValid, + uniqueMinionCountHistogram: Object.fromEntries(uniqueMinionCounts), + hpMultipliersValid, + hpMultipliers: { + champion: championHp, + uniqueBoss: uniqueHp, + minion: minionHp, + }, + } +} + +async function main() { + console.log('======================================================================') + console.log('🔬 EMPIRICAL CHALLENGER MONTE CARLO TEST: ELITE STATISTICAL PROPERTIES') + console.log('======================================================================') + + const report = await runEliteMonteCarloSimulation(10000) + + console.log(`\nTrials Run: ${report.trials}`) + console.log(`Total Elite Groups Sampled: ${report.totalEliteGroups}`) + console.log(`Champion Groups: ${report.championGroups}`) + console.log(`Unique Groups: ${report.uniqueGroups}`) + + console.log('\n--- Criterion 1 & 2: Statistical Convergence (3-Sigma) ---') + console.log(`Observed Champion Ratio: ${(report.championRatio * 100).toFixed(3)}% (Target: 20.000%)`) + console.log(`3-Sigma Confidence Interval: [${((0.20 - report.threeSigmaMargin) * 100).toFixed(3)}%, ${((0.20 + report.threeSigmaMargin) * 100).toFixed(3)}%]`) + console.log(`Champion Ratio within 3-Sigma: ${report.championRatioWithin3Sigma ? '✅ PASS' : '❌ FAIL'}`) + + console.log(`\nObserved Unique Ratio: ${(report.uniqueRatio * 100).toFixed(3)}% (Target: 80.000%)`) + console.log(`3-Sigma Confidence Interval: [${((0.80 - report.threeSigmaMargin) * 100).toFixed(3)}%, ${((0.80 + report.threeSigmaMargin) * 100).toFixed(3)}%]`) + console.log(`Unique Ratio within 3-Sigma: ${report.uniqueRatioWithin3Sigma ? '✅ PASS' : '❌ FAIL'}`) + + console.log('\n--- Criterion 3: Minion Counts for Uniques strictly bounded in [3, 6] ---') + console.log(`Observed Minion Range: [${report.uniqueMinionCountMin}, ${report.uniqueMinionCountMax}]`) + console.log(`Histogram (minion count: occurrences):`, report.uniqueMinionCountHistogram) + console.log(`Strictly Bounded in [3, 6]: ${report.uniqueMinionCountValid ? '✅ PASS' : '❌ FAIL'}`) + + console.log('\n--- Criterion 4: Champion Pack Sizes strictly bounded in [2, 4] ---') + console.log(`Observed Champion Pack Size Range: [${report.championPackSizeMin}, ${report.championPackSizeMax}]`) + console.log(`Histogram (pack size: occurrences):`, report.championPackSizeHistogram) + console.log(`Strictly Bounded in [2, 4]: ${report.championPackSizeValid ? '✅ PASS' : '❌ FAIL'}`) + + console.log('\n--- Criterion 5: HP Multipliers (Champions 3x, Boss 4x, Minions 2x) ---') + console.log(`Champion HP Multiplier: ${report.hpMultipliers.champion}x (Expected: 3x)`) + console.log(`Unique Boss HP Multiplier: ${report.hpMultipliers.uniqueBoss}x (Expected: 4x)`) + console.log(`Minion HP Multiplier: ${report.hpMultipliers.minion}x (Expected: 2x)`) + console.log(`HP Multipliers Valid: ${report.hpMultipliersValid ? '✅ PASS' : '❌ FAIL'}`) + + console.log('\n======================================================================') + const overallPass = + report.championRatioWithin3Sigma && + report.uniqueRatioWithin3Sigma && + report.uniqueMinionCountValid && + report.championPackSizeValid && + report.hpMultipliersValid + + console.log(`FINAL EMPIRICAL VERDICT: ${overallPass ? 'APPROVE' : 'REQUEST_CHANGES'}`) + console.log('======================================================================') + + if (!overallPass) { + process.exit(1) + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/src/game/monsters.ts b/src/game/monsters.ts index 24c88cf..917d577 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -1180,9 +1180,11 @@ export function planMonsterGroups( // 1.13c MonUMod.txt: 20% Champions, 80% Uniques + Minions const isChampion = rng.next() < (MONUMOD_CONSTANTS.championChance / 100) const rank: Exclude = isChampion ? 'champion' : 'unique' - const minGrp = Math.max(1, kind.minGroup) - const maxGrp = Math.max(minGrp, kind.maxGroup) - const count = Math.min(remaining, rng.int(minGrp, maxGrp)) + // 1.13c: Champions spawn in groups of 2 to 4 (2 + rand() % 3). + // Uniques spawn with 1 boss + 3 to 6 minions (1 + (3 + rand() % 4)). + const count = rank === 'champion' + ? Math.min(remaining, rng.int(2, 4)) + : Math.min(remaining, 1 + rng.int(3, 6)) const modSeed = ((i + 1) * 0x9e3779b9) ^ (count * 0x85ebca6b) ^ (rank === 'champion' ? 1 : 2) const modifiers = rollEliteModifiers(rank, new Rng(modSeed)) groups.push({ kind, count, rank, modifiers }) diff --git a/tests/challenger-m1-elite-stats.test.ts b/tests/challenger-m1-elite-stats.test.ts new file mode 100644 index 0000000..05a6ca4 --- /dev/null +++ b/tests/challenger-m1-elite-stats.test.ts @@ -0,0 +1,189 @@ +/** + * tests/challenger-m1-elite-stats.test.ts + * + * Empirical Challenger Verification Suite for Diablo II v1.13c Elite Monster Generation. + * Milestone 1 (Issue #448) Elite Statistical Properties. + */ + +import { describe, expect, it, beforeAll } from 'vitest' +import * as fs from '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, type D2Table } from '../src/game/acts.ts' +import { + readLevelMonsterPlan, + readMonsterKinds, + selectLevelTypes, + planMonsterGroups, + planLevelMonsters, + ELITE_HEALTH_MULTIPLIER, + MONUMOD_CONSTANTS, + type MonsterKind, +} from '../src/game/monsters.ts' +import { Rng } from '../src/game/rng.ts' + +describe('Challenger M1: Elite Monster Generation Statistical Verification', () => { + let tables: { + readonly levels: D2Table + readonly monstats: D2Table + readonly monlvl: D2Table + readonly monstats2: D2Table + readonly montype: D2Table + readonly monumod: D2Table + readonly superuniques: D2Table + } + let kinds: Map + + beforeAll(async () => { + const dir = fs.existsSync('samples/d2/Patch_D2.mpq') + ? 'samples/d2' + : (fs.existsSync('/usr/local/google/home/taodao/d2-data/Patch_D2.mpq') + ? '/usr/local/google/home/taodao/d2-data' + : 'samples/d2') + + const archives = new MountedArchives() + for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { + archives.add(name, await MpqArchive.open(await fileSource(`${dir}/${name}`))) + } + tables = await loadActTables(archives) + kinds = readMonsterKinds(tables.monstats) + }) + + it('verifies Champion (20%) and Unique (80%) ratios converge within 3-sigma across 10,000 trials', () => { + const plan = readLevelMonsterPlan(tables.levels, 3, 'nightmare')! + const types = selectLevelTypes(plan, kinds, new Rng(1), 'nightmare') + + let totalEliteGroups = 0 + let championGroups = 0 + let uniqueGroups = 0 + + const TRIALS = 10000 + for (let trial = 0; trial < TRIALS; trial++) { + const rng = new Rng(300000 + trial) + const groups = planMonsterGroups(plan, types, kinds, 200, rng) + for (const g of groups) { + if (g.rank === 'champion') { + championGroups++ + totalEliteGroups++ + } else if (g.rank === 'unique') { + uniqueGroups++ + totalEliteGroups++ + } + } + } + + expect(totalEliteGroups).toBeGreaterThan(40000) + + const champRatio = championGroups / totalEliteGroups + const uniqueRatio = uniqueGroups / totalEliteGroups + const threeSigma = 3 * Math.sqrt((0.20 * 0.80) / totalEliteGroups) + + // Champion ratio converges to 20% within 3-sigma + expect(Math.abs(champRatio - 0.20)).toBeLessThanOrEqual(threeSigma) + // Unique ratio converges to 80% within 3-sigma + expect(Math.abs(uniqueRatio - 0.80)).toBeLessThanOrEqual(threeSigma) + }) + + it('verifies authentic 1.13c HP multipliers (Champions 3x, Boss 4x, Minions 2x)', () => { + // MonUMod constants: championHpPct = 200, uniqueHpPct = 300, minionHpPct = 100 + expect(MONUMOD_CONSTANTS.championHpPct).toBe(200) + expect(MONUMOD_CONSTANTS.uniqueHpPct).toBe(300) + expect(MONUMOD_CONSTANTS.minionHpPct).toBe(100) + + // Applied multipliers: 1 + pct / 100 + const champMultiplier = ELITE_HEALTH_MULTIPLIER.champion + const uniqueBossMultiplier = ELITE_HEALTH_MULTIPLIER.unique + const minionMultiplier = 1 + (MONUMOD_CONSTANTS.minionHpPct / 100) + + expect(champMultiplier).toBe(3) + expect(uniqueBossMultiplier).toBe(4) + expect(minionMultiplier).toBe(2) + + // Verify in generated pack members + const planned = planLevelMonsters(tables, 4, 6400, 42, 170, 'normal') + let foundChampion = false + let foundUnique = false + let foundMinion = false + + for (const pack of planned.packs) { + if (pack.superUniqueId) continue + for (const member of pack.members) { + if (member.rank === 'champion') foundChampion = true + if (member.rank === 'unique') foundUnique = true + if (member.rank === 'minion') foundMinion = true + } + } + + expect(foundChampion).toBe(true) + expect(foundUnique).toBe(true) + expect(foundMinion).toBe(true) + }) + + it('empirically audits minion count bounds for Uniques across 10,000 trials', () => { + const plan = readLevelMonsterPlan(tables.levels, 3, 'nightmare')! + const types = selectLevelTypes(plan, kinds, new Rng(1), 'nightmare') + + let minMinions = Infinity + let maxMinions = -Infinity + let violations = 0 + let totalUniques = 0 + + for (let trial = 0; trial < 10000; trial++) { + const rng = new Rng(400000 + trial) + const groups = planMonsterGroups(plan, types, kinds, 200, rng) + for (const g of groups) { + if (g.rank === 'unique') { + totalUniques++ + const minions = g.count - 1 + minMinions = Math.min(minMinions, minions) + maxMinions = Math.max(maxMinions, minions) + if (minions < 3 || minions > 6) { + violations++ + } + } + } + } + + // Diagnostic assertion reporting empirical findings + // Current implementation rolls kind.minGroup to kind.maxGroup instead of [3, 6] + const violationRate = violations / totalUniques + // Expectation: Strictly comply with 1.13c parity: minMinions >= 3 and maxMinions <= 6 + expect(violations).toBe(0) + expect(minMinions).toBe(3) + expect(maxMinions).toBe(6) + }) + + it('empirically audits champion pack size bounds across 10,000 trials', () => { + const plan = readLevelMonsterPlan(tables.levels, 3, 'nightmare')! + const types = selectLevelTypes(plan, kinds, new Rng(1), 'nightmare') + + let minPackSize = Infinity + let maxPackSize = -Infinity + let violations = 0 + let totalChampions = 0 + + for (let trial = 0; trial < 10000; trial++) { + const rng = new Rng(500000 + trial) + const groups = planMonsterGroups(plan, types, kinds, 200, rng) + for (const g of groups) { + if (g.rank === 'champion') { + totalChampions++ + minPackSize = Math.min(minPackSize, g.count) + maxPackSize = Math.max(maxPackSize, g.count) + if (g.count < 2 || g.count > 4) { + violations++ + } + } + } + } + + // Diagnostic assertion reporting empirical findings + const violationRate = violations / totalChampions + console.log(`[Challenger Audit] Champion pack size range: [${minPackSize}, ${maxPackSize}], violation rate: ${(violationRate * 100).toFixed(2)}%`) + // Expectation: Strictly comply with 1.13c parity: minPackSize >= 2 and maxPackSize <= 4 + expect(violations).toBe(0) + expect(minPackSize).toBe(2) + expect(maxPackSize).toBe(4) + }) +}) diff --git a/tests/challenger-m1-stress.test.ts b/tests/challenger-m1-stress.test.ts new file mode 100644 index 0000000..1587f54 --- /dev/null +++ b/tests/challenger-m1-stress.test.ts @@ -0,0 +1,470 @@ +/** + * Challenger M1 Empirical Stress Test Suite + * + * Exhaustively stress-tests monsterBudget and planLevelMonsters: + * 1. Extreme & adversarial inputs (0 cells, 1 cell, 100k cells, 0 density, 10k density, + * fractionals, negatives, over-cap density). + * 2. Monotonicity verification (cells monotonicity, density monotonicity, joint monotonicity). + * 3. 100,000 differential fuzzing iterations against an independent mathematical oracle. + * 4. All 136 levels across all 3 difficulties (408 level configurations) verifying no NaN, + * no negative values, no crashes, and 0 budget for all town hubs under all multipliers. + * 5. planLevelMonsters extreme & adversarial parameter resilience (invalid IDs, negative multipliers, + * extreme seeds, extreme cells). + */ +import { describe, expect, it, beforeAll } from 'vitest' +import * as fs from '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, type D2Table } from '../src/game/acts.ts' +import { + monsterBudget, + planLevelMonsters, + readLevelMonsterPlan, + readMonsterKinds, + type Difficulty, + type MonsterKind, +} from '../src/game/monsters.ts' +import { Rng } from '../src/game/rng.ts' + +/** + * Independent ground-truth mathematical oracle for monsterBudget. + * Defined strictly from D2Common/D2Game DRLG MonsterRegion room density specifications. + */ +function referenceMonsterBudget(cells: number, density: number): number { + if (cells <= 0 || density <= 0) return 0 + const attempts = Math.floor((cells * 25) / 9) + const clampedDen = Math.min(10000, density) + const packs = attempts * (clampedDen / 100000) + return Math.max(1, Math.round(packs * 3)) +} + +describe('Challenger M1: monsterBudget Empirical Stress Tests', () => { + describe('Extreme & Adversarial Inputs', () => { + it('returns 0 for zero or negative cells regardless of density', () => { + const testCells = [0, -1, -5, -100, -100000, -Infinity] + const testDensities = [0, 1, 50, 1000, 10000, 50000] + + for (const cells of testCells) { + for (const density of testDensities) { + expect(monsterBudget(cells, density)).toBe(0) + expect(monsterBudget({ density } as any, cells)).toBe(0) + } + } + }) + + it('returns 0 for zero or negative density regardless of cells', () => { + const testCells = [0, 1, 10, 100, 6400, 100000, 1000000] + const testDensities = [0, -1, -10, -500, -10000, -Infinity] + + for (const cells of testCells) { + for (const density of testDensities) { + expect(monsterBudget(cells, density)).toBe(0) + expect(monsterBudget({ density } as any, cells)).toBe(0) + } + } + }) + + it('handles minimal positive input (1 cell, 1 density)', () => { + const budget = monsterBudget(1, 1) + expect(budget).toBe(1) + expect(Number.isInteger(budget)).toBe(true) + expect(budget).toBe(referenceMonsterBudget(1, 1)) + }) + + it('handles minimal positive cells with maximum density cap (1 cell, 10,000 density)', () => { + const budget = monsterBudget(1, 10000) + expect(budget).toBe(1) + expect(Number.isInteger(budget)).toBe(true) + expect(budget).toBe(referenceMonsterBudget(1, 10000)) + }) + + it('handles maximum area (100,000 cells) and high density without overflow or NaN', () => { + const budget100k = monsterBudget(100000, 10000) + // attempts = floor(2500000 / 9) = 277777 + // packs = 277777 * 0.1 = 27777.7 + // round(packs * 3) = round(83333.1) = 83333 + expect(budget100k).toBe(83333) + expect(Number.isInteger(budget100k)).toBe(true) + expect(Number.isFinite(budget100k)).toBe(true) + expect(budget100k).toBe(referenceMonsterBudget(100000, 10000)) + }) + + it('enforces density cap at 10,000 per 1.13c ground truth (Math.min(10000, density))', () => { + const cells = 6400 + const atCap = monsterBudget(cells, 10000) + const overCap1 = monsterBudget(cells, 15000) + const overCap2 = monsterBudget(cells, 50000) + const overCap3 = monsterBudget(cells, 1000000) + + expect(overCap1).toBe(atCap) + expect(overCap2).toBe(atCap) + expect(overCap3).toBe(atCap) + }) + + it('handles fractional cells and fractional densities correctly', () => { + const fractionalCases = [ + { cells: 0.5, density: 100 }, + { cells: 1.5, density: 50.5 }, + { cells: 100.25, density: 0.1 }, + { cells: 6400.75, density: 999.99 }, + { cells: 99999.9, density: 10000.5 }, + ] + + for (const { cells, density } of fractionalCases) { + const actual = monsterBudget(cells, density) + const expected = referenceMonsterBudget(cells, density) + expect(actual).toBe(expected) + expect(actual).toBeGreaterThanOrEqual(1) + expect(Number.isInteger(actual)).toBe(true) + } + }) + + it('maintains strict parity between dual function signatures', () => { + const testCases = [ + [100, 500], + [6400, 750], + [10000, 1000], + [0, 500], + [500, 0], + [-10, 500], + [500, -10], + [100000, 10000], + ] as const + + for (const [cells, density] of testCases) { + const direct = monsterBudget(cells, density) + const objectPlan = monsterBudget({ density } as any, cells) + expect(direct).toBe(objectPlan) + } + }) + }) + + describe('Monotonicity Invariants', () => { + it('verifies cells monotonicity: budget never decreases as cells increase for fixed density', () => { + const testDensities = [1, 5, 20, 100, 500, 1000, 5000, 10000, 20000] + + for (const density of testDensities) { + let prevBudget = 0 + // Fine-grained sweep 0..1000 (step 1) + for (let cells = 0; cells <= 1000; cells += 1) { + const budget = monsterBudget(cells, density) + expect(budget).toBeGreaterThanOrEqual(prevBudget) + prevBudget = budget + } + // Medium sweep 1000..10000 (step 10) + for (let cells = 1000; cells <= 10000; cells += 10) { + const budget = monsterBudget(cells, density) + expect(budget).toBeGreaterThanOrEqual(prevBudget) + prevBudget = budget + } + // Large sweep 10000..100000 (step 100) + for (let cells = 10000; cells <= 100000; cells += 100) { + const budget = monsterBudget(cells, density) + expect(budget).toBeGreaterThanOrEqual(prevBudget) + prevBudget = budget + } + } + }) + + it('verifies density monotonicity: budget never decreases as density increases for fixed cells', () => { + const testCells = [1, 5, 20, 100, 500, 1000, 6400, 10000, 100000] + + for (const cells of testCells) { + let prevBudget = 0 + // Fine-grained sweep 0..1000 (step 1) + for (let density = 0; density <= 1000; density += 1) { + const budget = monsterBudget(cells, density) + expect(budget).toBeGreaterThanOrEqual(prevBudget) + prevBudget = budget + } + // Sweep up to cap 1000..10000 (step 10) + for (let density = 1000; density <= 10000; density += 10) { + const budget = monsterBudget(cells, density) + expect(budget).toBeGreaterThanOrEqual(prevBudget) + prevBudget = budget + } + // Sweep past cap 10000..20000 (step 100) - should stay flat + const capBudget = monsterBudget(cells, 10000) + for (let density = 10000; density <= 20000; density += 100) { + const budget = monsterBudget(cells, density) + expect(budget).toBe(capBudget) + } + } + }) + + it('verifies joint monotonicity across 50,000 random non-decreasing pairs', () => { + const rng = new Rng(0x1337cafe) + for (let i = 0; i < 50000; i++) { + const c1 = rng.int(0, 50000) + const c2 = c1 + rng.int(0, 50000) + const d1 = rng.int(0, 10000) + const d2 = d1 + rng.int(0, 10000) + + const b1 = monsterBudget(c1, d1) + const b2 = monsterBudget(c2, d2) + expect(b2).toBeGreaterThanOrEqual(b1) + } + }) + }) + + describe('Differential Fuzzing (100,000 iterations against Oracle)', () => { + it('matches reference oracle across 100,000 pseudo-random and adversarial inputs', () => { + const rng = new Rng(0xdeadbeef) + let failureCount = 0 + + for (let iter = 0; iter < 100000; iter++) { + let cells: number + let density: number + + const category = iter % 5 + if (category === 0) { + // Boundary / extreme values + const boundaryCells = [0, 1, 2, 3, 8, 9, 10, -1, -100, 100000, 500000] + const boundaryDen = [0, 1, 2, -1, -500, 9999, 10000, 10001, 20000, 100000] + cells = boundaryCells[rng.int(0, boundaryCells.length - 1)]! + density = boundaryDen[rng.int(0, boundaryDen.length - 1)]! + } else if (category === 1) { + // Random small values + cells = rng.int(0, 50) + density = rng.int(0, 500) + } else if (category === 2) { + // Random medium values + cells = rng.int(50, 5000) + density = rng.int(0, 2000) + } else if (category === 3) { + // Random large values + cells = rng.int(5000, 150000) + density = rng.int(0, 20000) + } else { + // Fractional / noisy values + cells = rng.range(-10, 10000) + density = rng.range(-100, 15000) + } + + const actual = monsterBudget(cells, density) + const expected = referenceMonsterBudget(cells, density) + if (actual !== expected) { + failureCount++ + if (failureCount <= 5) { + console.error(`Mismatch at iter ${iter}: cells=${cells}, density=${density} => actual=${actual}, expected=${expected}`) + } + } + } + + expect(failureCount).toBe(0) + }) + }) +}) + +describe('Challenger M1: planLevelMonsters Full Spectrum (136 Levels x 3 Difficulties)', () => { + let tables: { + readonly levels: D2Table + readonly monstats: D2Table + readonly monlvl: D2Table + readonly monstats2: D2Table + readonly montype: D2Table + readonly monumod: D2Table + readonly superuniques: D2Table + } + + beforeAll(async () => { + const dir = fs.existsSync('samples/d2/Patch_D2.mpq') + ? 'samples/d2' + : (fs.existsSync('/usr/local/google/home/taodao/d2-data/Patch_D2.mpq') + ? '/usr/local/google/home/taodao/d2-data' + : 'samples/d2') + + const archives = new MountedArchives() + for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { + archives.add(name, await MpqArchive.open(await fileSource(`${dir}/${name}`))) + } + tables = await loadActTables(archives) + }) + + const TOWNS = [ + { id: 1, name: 'Rogue Encampment' }, + { id: 40, name: 'Lut Gholein' }, + { id: 75, name: 'Kurast Docks' }, + { id: 103, name: 'Pandemonium Fortress' }, + { id: 109, name: 'Harrogath' }, + ] + + describe('Town Invariant Stress Testing', () => { + it.each(TOWNS)('guarantees 0 monsters, 0 packs, and 0 elites for $name ($id) across all difficulties and multipliers', ({ id }) => { + const multipliers = [ + { densityMultiplier: 1, eliteMultiplier: 1 }, + { densityMultiplier: 2, eliteMultiplier: 2 }, + { densityMultiplier: 8, eliteMultiplier: 4 }, + { densityMultiplier: 8, eliteMultiplier: 'all' as const }, + { densityMultiplier: 100, eliteMultiplier: 'all' as const }, + ] + const testCells = [0, 1, 6400, 100000] + + for (const diff of ['normal', 'nightmare', 'hell'] as const) { + for (const cells of testCells) { + for (const opts of multipliers) { + const planned = planLevelMonsters(tables, id, cells, 12345, 170, diff, opts) + expect(planned.budget).toBe(0) + expect(planned.packs).toHaveLength(0) + expect(planned.elitePacks).toBe(0) + } + } + } + }) + }) + + describe('All 136 Levels Sanity & Numeric Robustness', () => { + it('produces valid numbers (no NaN, no negatives, no crashes) for all 136 levels across all 3 difficulties', () => { + const cells = 6400 + let totalLevelsTested = 0 + + for (let id = 1; id <= 136; id++) { + for (const diff of ['normal', 'nightmare', 'hell'] as const) { + const planned = planLevelMonsters(tables, id, cells, id * 100 + 7, 170, diff) + totalLevelsTested++ + + // Basic contract checks + expect(planned.levelId).toBe(id) + expect(Number.isFinite(planned.budget)).toBe(true) + expect(Number.isNaN(planned.budget)).toBe(false) + expect(planned.budget).toBeGreaterThanOrEqual(0) + expect(Number.isFinite(planned.elitePacks)).toBe(true) + expect(Number.isNaN(planned.elitePacks)).toBe(false) + expect(planned.elitePacks).toBeGreaterThanOrEqual(0) + + // Inspect every pack and every member + let totalSpawnedMembers = 0 + for (const pack of planned.packs) { + expect(pack.members.length).toBeGreaterThan(0) + for (const member of pack.members) { + totalSpawnedMembers++ + expect(member.id).toBeTruthy() + expect(Number.isFinite(member.hp)).toBe(true) + expect(Number.isNaN(member.hp)).toBe(false) + expect(member.hp).toBeGreaterThanOrEqual(1) + expect(Number.isFinite(member.damage)).toBe(true) + expect(Number.isNaN(member.damage)).toBe(false) + expect(member.damage).toBeGreaterThanOrEqual(1) + expect(Number.isFinite(member.speed)).toBe(true) + expect(Number.isNaN(member.speed)).toBe(false) + expect(member.speed).toBeGreaterThanOrEqual(1) + expect(Number.isFinite(member.level)).toBe(true) + expect(Number.isNaN(member.level)).toBe(false) + expect(member.level).toBeGreaterThanOrEqual(1) + expect(member.level).toBeLessThanOrEqual(99) + expect(Number.isFinite(member.cooldownTicks)).toBe(true) + expect(Number.isFinite(member.reach)).toBe(true) + expect(Number.isFinite(member.aggroRadius)).toBe(true) + expect(Number.isFinite(member.xp)).toBe(true) + expect(['normal', 'champion', 'unique', 'minion']).toContain(member.rank) + } + } + + // If budget is 0, packs must be 0 + if (planned.budget === 0) { + // Only fixed SuperUniques (if any) could exist in a 0-budget level + const nonSuperUniquePacks = planned.packs.filter(p => !p.superUniqueId) + expect(nonSuperUniquePacks).toHaveLength(0) + } + } + } + + expect(totalLevelsTested).toBe(136 * 3) + }) + }) + + describe('planLevelMonsters Monotonicity & Scaling Invariants', () => { + it('verifies increasing cells increases or maintains planned budget across non-town levels', () => { + const cellSteps = [100, 500, 1000, 6400, 20000, 100000] + // Test representative levels from each Act: + // Act 1: 2 (Blood Moor), 3 (Cold Plains) + // Act 2: 41 (Rocky Waste), 43 (Far Oasis) + // Act 3: 76 (Spider Forest), 78 (Flayer Jungle) + // Act 4: 104 (Outer Steppes), 105 (Plains of Despair) + // Act 5: 111 (Frigid Highlands), 112 (Arreat Plateau) + const testLevels = [2, 3, 41, 43, 76, 78, 104, 105, 111, 112] + + for (const id of testLevels) { + for (const diff of ['normal', 'nightmare', 'hell'] as const) { + let prevBudget = 0 + for (const cells of cellSteps) { + const planned = planLevelMonsters(tables, id, cells, 42, 170, diff) + expect(planned.budget).toBeGreaterThanOrEqual(prevBudget) + prevBudget = planned.budget + } + } + } + }) + + it('verifies densityMultiplier scales budget monotonically without breaking bounds', () => { + const multipliers = [0.5, 1, 2, 4, 8] + const testLevels = [2, 3, 41, 76, 104, 111] + + for (const id of testLevels) { + let prevBudget = 0 + for (const densityMultiplier of multipliers) { + const planned = planLevelMonsters(tables, id, 6400, 42, 170, 'normal', { densityMultiplier }) + expect(planned.budget).toBeGreaterThanOrEqual(prevBudget) + prevBudget = planned.budget + } + } + }) + + it('verifies eliteMultiplier all option turns all groups into elites or fills elite quota', () => { + const plannedAll = planLevelMonsters(tables, 3, 6400, 42, 170, 'normal', { eliteMultiplier: 'all' }) + expect(plannedAll.elitePacks).toBeGreaterThan(0) + const elitePacksCount = plannedAll.packs.filter(p => + p.superUniqueId || p.members.some(m => m.rank === 'champion' || m.rank === 'unique'), + ).length + expect(elitePacksCount).toBe(plannedAll.elitePacks) + }) + }) + + describe('Adversarial & Malformed Inputs to planLevelMonsters', () => { + it('gracefully handles non-existent or invalid level IDs without throwing', () => { + const invalidIds = [-1, 0, 137, 999, 9999] + for (const id of invalidIds) { + const planned = planLevelMonsters(tables, id, 6400, 42, 170, 'normal') + expect(planned.levelId).toBe(id) + expect(planned.budget).toBe(0) + expect(planned.packs).toHaveLength(0) + expect(planned.elitePacks).toBe(0) + } + }) + + it('handles negative or zero cells gracefully', () => { + const plannedZero = planLevelMonsters(tables, 2, 0, 42, 170, 'normal') + expect(plannedZero.budget).toBe(0) + + const plannedNeg = planLevelMonsters(tables, 2, -500, 42, 170, 'normal') + expect(plannedNeg.budget).toBe(0) + }) + + it('handles extreme 32-bit integer seeds without degradation', () => { + const extremeSeeds = [-2147483648, 2147483647, 0, -1, 0x7fffffff] + for (const seed of extremeSeeds) { + const planned = planLevelMonsters(tables, 2, 6400, seed, 170, 'normal') + expect(planned.budget).toBeGreaterThan(0) + expect(planned.packs.length).toBeGreaterThan(0) + } + }) + + it('handles negative multipliers by clamping/safely returning 0', () => { + const plannedNegDensity = planLevelMonsters(tables, 2, 6400, 42, 170, 'normal', { densityMultiplier: -1 }) + expect(plannedNegDensity.budget).toBe(0) + + // On level 2 (no superuniques), negative eliteMultiplier yields 0 elite packs + const plannedNegEliteLvl2 = planLevelMonsters(tables, 2, 6400, 42, 170, 'normal', { eliteMultiplier: -1 }) + expect(plannedNegEliteLvl2.elitePacks).toBe(0) + + // On level 3 (has Bishibosh superunique), random elites must be 0, leaving only the 1 landmark superunique + const plannedNegEliteLvl3 = planLevelMonsters(tables, 3, 6400, 42, 170, 'normal', { eliteMultiplier: -1 }) + const randomElites = plannedNegEliteLvl3.packs.filter( + p => !p.superUniqueId && p.members.some(m => m.rank === 'champion' || m.rank === 'unique'), + ) + expect(randomElites).toHaveLength(0) + expect(plannedNegEliteLvl3.elitePacks).toBe(1) // exactly 1 for Bishibosh + }) + }) +}) diff --git a/tests/monsters.test.ts b/tests/monsters.test.ts index 5532708..826668e 100644 --- a/tests/monsters.test.ts +++ b/tests/monsters.test.ts @@ -451,9 +451,14 @@ describe('planMonsterGroups', () => { } }) - it('never builds a pack larger than the monster\'s MaxGrp', () => { + it('never builds a normal pack larger than the monster\'s MaxGrp and sizes elites per 1.13c', () => { const groups = planMonsterGroups(plan, types, kinds, 40, new Rng(6)) - expect(groups.every(group => group.count >= 1 && group.count <= group.kind.maxGroup)).toBe(true) + const normal = groups.filter(group => group.rank === 'normal') + expect(normal.every(group => group.count >= 1 && group.count <= group.kind.maxGroup)).toBe(true) + const champions = groups.filter(group => group.rank === 'champion') + expect(champions.every(group => group.count >= 2 && group.count <= 4)).toBe(true) + const uniques = groups.filter(group => group.rank === 'unique') + expect(uniques.every(group => group.count >= 4 && group.count <= 7)).toBe(true) }) it('draws elite packs from umon, not from the ordinary pool', () => {