diablo2-web/scripts/verify-challenger-elite-sta...

210 lines
8.5 KiB
TypeScript

/**
* 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<number, number>
readonly uniqueMinionCountMin: number
readonly uniqueMinionCountMax: number
readonly uniqueMinionCountValid: boolean
readonly uniqueMinionCountHistogram: Record<number, number>
readonly hpMultipliersValid: boolean
readonly hpMultipliers: {
champion: number
uniqueBoss: number
minion: number
}
}
export async function runEliteMonteCarloSimulation(trials = 10000): Promise<MonteCarloReport> {
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<number, number>()
let minUniqueMinionCount = Infinity
let maxUniqueMinionCount = -Infinity
const uniqueMinionCounts = new Map<number, number>()
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)
})
}