364 lines
14 KiB
TypeScript
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'])
|
|
})
|
|
})
|
|
})
|