diablo2-web/tests/monsters-136-spectrum.test.ts

364 lines
14 KiB
TypeScript

/**
* Full-spectrum 136-level unit test verifying monster density and elite counts
* against Diablo II v1.13c ground truth (Levels.txt, MonUMod.txt, D2Common/D2Game).
*
* Verifies:
* 1. All 5 town hubs (Levels 1, 40, 75, 103, 109) enforce 0 density and 0 monsters.
* 2. All 12 zero-density special/transition zones have MonDen === 0 on Normal.
* 3. All 124 populated levels calculate monster budgets matching 1.13c room formula:
* attempts = Math.floor((cells * 25) / 9)
* expectedPacks = attempts * (Math.min(10000, density) / 100000)
* monsterBudget = Math.max(1, Math.round(expectedPacks * 3))
* 4. Elite generation matches Levels.txt MonUMin / MonUMax across Normal, Nightmare, Hell:
* - Blood Moor (2): 0 elites on Normal, 4..5 on Nightmare, 7..9 on Hell.
* - Cold Plains (3): 1 elite on Normal, 4..5 on Nightmare, 7..9 on Hell.
* - Stony Field (4): 1..2 elites on Normal, 4..6 on Nightmare, 7..9 on Hell.
* 5. MonUMod.txt authentic elite distribution: 20% Champions, 80% Uniques + Minions.
* 6. Candidate filtering allows unconstrained monsters (sparsePopulate === 0 or undefined).
*/
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 { cell, loadActTables, type D2Table } from '../src/game/acts.ts'
import {
monsterBudget,
planMonsterGroups,
readLevelMonsterPlan,
readMonsterKinds,
selectLevelTypes,
planLevelMonsters,
filterCandidateKinds,
MONUMOD_CONSTANTS,
type Difficulty,
type MonsterKind,
} from '../src/game/monsters.ts'
import { Rng } from '../src/game/rng.ts'
describe('136-Level Monster Spectrum & Density Ground Truth (Levels.txt / 1.13c)', () => {
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)
})
describe('Town Safe Zones & Zero-Density Levels', () => {
const TOWNS = [
{ id: 1, name: 'Act 1 - Town (Rogue Encampment)' },
{ id: 40, name: 'Act 2 - Town (Lut Gholein)' },
{ id: 75, name: 'Act 3 - Town (Kurast Docks)' },
{ id: 103, name: 'Act 4 - Town (Pandemonium Fortress)' },
{ id: 109, name: 'Act 5 - Town (Harrogath)' },
]
it.each(TOWNS)('strictly enforces 0 monsters and 0 elites in town hub $name ($id)', ({ id }) => {
for (const diff of ['normal', 'nightmare', 'hell'] as const) {
const plan = readLevelMonsterPlan(tables.levels, id, diff)
expect(plan).not.toBeNull()
expect(plan!.density).toBe(0)
expect(plan!.eliteMin).toBe(0)
expect(plan!.eliteMax).toBe(0)
// monsterBudget must return 0 regardless of map size
expect(monsterBudget(plan!, 100_000)).toBe(0)
expect(monsterBudget(100_000, plan!.density)).toBe(0)
// planLevelMonsters must yield 0 budget, 0 packs, and 0 elites
const planned = planLevelMonsters(tables, id, 6400, id * 1000 + 1, 170, diff)
expect(planned.budget).toBe(0)
expect(planned.packs).toHaveLength(0)
expect(planned.elitePacks).toBe(0)
}
})
it('confirms all 12 special zero-density levels on Normal difficulty', () => {
const ZERO_DEN_NORMAL = [1, 20, 40, 50, 75, 103, 109, 110, 120, 121, 132, 136]
const actualZeroDenLevels: number[] = []
for (let id = 1; id <= 136; id++) {
const plan = readLevelMonsterPlan(tables.levels, id, 'normal')
if (plan && plan.density === 0) {
actualZeroDenLevels.push(id)
}
}
expect(actualZeroDenLevels).toEqual(ZERO_DEN_NORMAL)
expect(actualZeroDenLevels).toHaveLength(12)
})
})
describe('124 Populated Levels Density Formula Parity across 136 levels', () => {
it('verifies monster budget calculation matches 1.13c room formula across all populated levels', () => {
const TEST_CELLS = 6400 // standard 80x80 cell area
let verifiedCount = 0
for (let id = 1; id <= 136; id++) {
for (const diff of ['normal', 'nightmare', 'hell'] as const) {
const plan = readLevelMonsterPlan(tables.levels, id, diff)
if (!plan || plan.density === 0) continue
const attempts = Math.floor((TEST_CELLS * 25) / 9)
const expectedPacks = attempts * (Math.min(10000, plan.density) / 100000)
const expectedBudget = Math.max(1, Math.round(expectedPacks * 3))
const actualBudget = monsterBudget(plan, TEST_CELLS)
expect(actualBudget).toBe(expectedBudget)
expect(actualBudget).toBeGreaterThanOrEqual(1)
verifiedCount += 1
}
}
// 124 populated on Normal + 125 populated on Nightmare/Hell = 374 plans
expect(verifiedCount).toBe(124 + 125 + 125)
})
})
describe('Elite Pack Ranges (MonUMin / MonUMax) Alignment', () => {
it('verifies Blood Moor (Level 2) elite progression across difficulties', () => {
const cells = 6400
// Normal: 0 elites
const planNorm = readLevelMonsterPlan(tables.levels, 2, 'normal')!
expect(planNorm.eliteMin).toBe(0)
expect(planNorm.eliteMax).toBe(0)
const plannedNorm = planLevelMonsters(tables, 2, cells, 42, 170, 'normal')
expect(plannedNorm.elitePacks).toBe(0)
expect(plannedNorm.packs.every(p => p.members.every(m => m.rank === 'normal'))).toBe(true)
// Nightmare: 4..5 elites
const planNM = readLevelMonsterPlan(tables.levels, 2, 'nightmare')!
expect(planNM.eliteMin).toBe(4)
expect(planNM.eliteMax).toBe(5)
for (let seed = 100; seed < 110; seed++) {
const plannedNM = planLevelMonsters(tables, 2, cells, seed, 170, 'nightmare')
expect(plannedNM.elitePacks).toBeGreaterThanOrEqual(4)
expect(plannedNM.elitePacks).toBeLessThanOrEqual(5)
}
// Hell: 7..9 elites
const planHell = readLevelMonsterPlan(tables.levels, 2, 'hell')!
expect(planHell.eliteMin).toBe(7)
expect(planHell.eliteMax).toBe(9)
for (let seed = 200; seed < 210; seed++) {
const plannedHell = planLevelMonsters(tables, 2, cells, seed, 170, 'hell')
expect(plannedHell.elitePacks).toBeGreaterThanOrEqual(7)
expect(plannedHell.elitePacks).toBeLessThanOrEqual(9)
}
})
it('verifies Cold Plains (Level 3) elite progression across difficulties', () => {
const planNorm = readLevelMonsterPlan(tables.levels, 3, 'normal')!
expect(planNorm.eliteMin).toBe(1)
expect(planNorm.eliteMax).toBe(1)
const planNM = readLevelMonsterPlan(tables.levels, 3, 'nightmare')!
expect(planNM.eliteMin).toBe(4)
expect(planNM.eliteMax).toBe(5)
const planHell = readLevelMonsterPlan(tables.levels, 3, 'hell')!
expect(planHell.eliteMin).toBe(7)
expect(planHell.eliteMax).toBe(9)
})
it('verifies all 136 levels clamp random elite packs strictly within [MonUMin, MonUMax]', () => {
const cells = 10_000
for (let id = 1; id <= 136; id++) {
for (const diff of ['normal', 'nightmare', 'hell'] as const) {
const plan = readLevelMonsterPlan(tables.levels, id, diff)
if (!plan || plan.density === 0) continue
const budget = monsterBudget(plan, cells)
const types = selectLevelTypes(plan, kinds, new Rng(id * 77), diff)
const groups = planMonsterGroups(plan, types, kinds, budget, new Rng(id * 99))
const eliteGroups = groups.filter(g => g.rank !== 'normal')
if (plan.eliteMax <= 0) {
expect(eliteGroups).toHaveLength(0)
} else {
expect(eliteGroups.length).toBeGreaterThanOrEqual(plan.eliteMin)
expect(eliteGroups.length).toBeLessThanOrEqual(plan.eliteMax)
}
}
}
})
})
describe('MonUMod.txt 1.13c Elite Distribution & Modifiers', () => {
it('verifies 20% Champions and 80% Uniques ratio over a large sample', () => {
const plan = readLevelMonsterPlan(tables.levels, 3, 'nightmare')!
const types = selectLevelTypes(plan, kinds, new Rng(1), 'nightmare')
const rng = new Rng(12345)
let totalElitePacks = 0
let championPacks = 0
let uniquePacks = 0
// Roll 1000 trials of elite groups
for (let trial = 0; trial < 1000; trial++) {
const groups = planMonsterGroups(plan, types, kinds, 200, rng)
for (const g of groups) {
if (g.rank === 'champion') {
championPacks += 1
totalElitePacks += 1
} else if (g.rank === 'unique') {
uniquePacks += 1
totalElitePacks += 1
}
}
}
expect(totalElitePacks).toBeGreaterThan(3000)
const champRatio = championPacks / totalElitePacks
// 20% champion chance with ±3.5% statistical confidence margin
expect(champRatio).toBeGreaterThan(0.165)
expect(champRatio).toBeLessThan(0.235)
expect(championPacks + uniquePacks).toBe(totalElitePacks)
})
it('verifies authentic champion and unique health scaling in planLevelMonsters', () => {
const planned = planLevelMonsters(tables, 4, 6400, 42, 170, 'normal')
for (const pack of planned.packs) {
if (pack.superUniqueId) continue
const ranks = pack.members.map(m => m.rank)
if (ranks.includes('champion')) {
// All champion pack members must be champions
expect(ranks.every(r => r === 'champion')).toBe(true)
} else if (ranks.includes('unique')) {
// Unique pack must have exactly 1 unique leader and remainder minions
expect(pack.members[0]!.rank).toBe('unique')
expect(pack.members[0]!.modifiers?.length).toBeGreaterThan(0)
for (let i = 1; i < pack.members.length; i++) {
expect(pack.members[i]!.rank).toBe('minion')
}
}
}
})
})
describe('Candidate Monster Filtering & sparsePopulate Invariants', () => {
it('allows monsters with sparsePopulate === 0, undefined, or > 0', () => {
const testCandidates: MonsterKind[] = [
{
id: 'unconstrained',
baseId: 'unconstrained',
nameKey: 'Unconstrained',
code: 'UC',
monType: 'demon',
ai: 'Fallen',
enabled: true,
isSpawn: true,
sparsePopulate: 0,
isMelee: true,
ranged: false,
npc: false,
interact: false,
inTown: false,
boss: false,
killable: true,
rarity: 1,
minGroup: 1,
maxGroup: 3,
level: [1, 30, 60],
velocity: 5,
runVelocity: 5,
threat: 10,
aiDistance: 0,
minHp: 10,
maxHp: 20,
armour: 10,
experience: 10,
attack1: { minDamage: 1, maxDamage: 2, toHit: 10 },
attack2: { minDamage: 1, maxDamage: 2, toHit: 10 },
resistances: { physical: 0, magic: 0, fire: 0, lightning: 0, cold: 0, poison: 0 },
treasureClasses: [],
minions: [],
setBoss: false,
},
{
id: 'sparse50',
baseId: 'sparse50',
nameKey: 'Sparse50',
code: 'S5',
monType: 'demon',
ai: 'Fallen',
enabled: true,
isSpawn: true,
sparsePopulate: 50,
isMelee: true,
ranged: false,
npc: false,
interact: false,
inTown: false,
boss: false,
killable: true,
rarity: 1,
minGroup: 1,
maxGroup: 3,
level: [1, 30, 60],
velocity: 5,
runVelocity: 5,
threat: 10,
aiDistance: 0,
minHp: 10,
maxHp: 20,
armour: 10,
experience: 10,
attack1: { minDamage: 1, maxDamage: 2, toHit: 10 },
attack2: { minDamage: 1, maxDamage: 2, toHit: 10 },
resistances: { physical: 0, magic: 0, fire: 0, lightning: 0, cold: 0, poison: 0 },
treasureClasses: [],
minions: [],
setBoss: false,
},
]
const filtered = filterCandidateKinds(testCandidates, false)
expect(filtered.map(k => k.id)).toEqual(['unconstrained', 'sparse50'])
})
it('filters out disabled, unspawnable, or boss monsters', () => {
const dummyBase = {
baseId: 'd', nameKey: 'D', code: 'DD', monType: 'demon', ai: 'Fallen',
isMelee: true, ranged: false, npc: false, interact: false, inTown: false,
killable: true, rarity: 1, minGroup: 1, maxGroup: 3, level: [1, 1, 1] as const,
velocity: 5, runVelocity: 5, threat: 10, aiDistance: 0, minHp: 1, maxHp: 2,
armour: 1, experience: 1, attack1: { minDamage: 1, maxDamage: 2, toHit: 1 },
attack2: { minDamage: 1, maxDamage: 2, toHit: 1 },
resistances: { physical: 0, magic: 0, fire: 0, lightning: 0, cold: 0, poison: 0 },
treasureClasses: [], minions: [], setBoss: false, sparsePopulate: 100,
}
const testList: MonsterKind[] = [
{ ...dummyBase, id: 'disabled', enabled: false, isSpawn: true, boss: false },
{ ...dummyBase, id: 'noSpawn', enabled: true, isSpawn: false, boss: false },
{ ...dummyBase, id: 'bossMonster', enabled: true, isSpawn: true, boss: true },
{ ...dummyBase, id: 'valid', enabled: true, isSpawn: true, boss: false },
]
expect(filterCandidateKinds(testList, false).map(k => k.id)).toEqual(['valid'])
expect(filterCandidateKinds(testList, true).map(k => k.id)).toEqual(['bossMonster', 'valid'])
})
})
})