fix(monsters): enforce 1.13c champion [2,4] and unique [3,6] minion pack sizes (Fixes #448)
This commit is contained in:
parent
d9ad6df63f
commit
a7110143fc
|
|
@ -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<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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1180,9 +1180,11 @@ export function planMonsterGroups(
|
||||||
// 1.13c MonUMod.txt: 20% Champions, 80% Uniques + Minions
|
// 1.13c MonUMod.txt: 20% Champions, 80% Uniques + Minions
|
||||||
const isChampion = rng.next() < (MONUMOD_CONSTANTS.championChance / 100)
|
const isChampion = rng.next() < (MONUMOD_CONSTANTS.championChance / 100)
|
||||||
const rank: Exclude<MonsterRank, 'minion'> = isChampion ? 'champion' : 'unique'
|
const rank: Exclude<MonsterRank, 'minion'> = isChampion ? 'champion' : 'unique'
|
||||||
const minGrp = Math.max(1, kind.minGroup)
|
// 1.13c: Champions spawn in groups of 2 to 4 (2 + rand() % 3).
|
||||||
const maxGrp = Math.max(minGrp, kind.maxGroup)
|
// Uniques spawn with 1 boss + 3 to 6 minions (1 + (3 + rand() % 4)).
|
||||||
const count = Math.min(remaining, rng.int(minGrp, maxGrp))
|
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 modSeed = ((i + 1) * 0x9e3779b9) ^ (count * 0x85ebca6b) ^ (rank === 'champion' ? 1 : 2)
|
||||||
const modifiers = rollEliteModifiers(rank, new Rng(modSeed))
|
const modifiers = rollEliteModifiers(rank, new Rng(modSeed))
|
||||||
groups.push({ kind, count, rank, modifiers })
|
groups.push({ kind, count, rank, modifiers })
|
||||||
|
|
|
||||||
|
|
@ -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<string, MonsterKind>
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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))
|
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', () => {
|
it('draws elite packs from umon, not from the ordinary pool', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue