fix(monsters): align monster density and champion/unique elite generation with 1.13c Levels.txt (Fixes #448)
This commit is contained in:
parent
0e70ad1662
commit
62f4b4172e
|
|
@ -1046,6 +1046,26 @@ export interface MonsterGroup {
|
|||
* @param difficulty - decides whether the classic or the `nmon` pool is used.
|
||||
* @returns the chosen types; may be shorter than `NumMon` if the pool is small.
|
||||
*/
|
||||
/**
|
||||
* Filter candidate monsters for level population per 1.13c rules (MonsterRegion.cpp:713).
|
||||
*
|
||||
* In 1.13c, !nSparsePopulate (sparsePopulate === 0 or undefined) means unconstrained (100% chance).
|
||||
* Monsters are valid candidates if enabled, isSpawn, and not a boss (unless allowBosses is true).
|
||||
*/
|
||||
export function filterCandidateKinds(
|
||||
candidates: readonly (MonsterKind | undefined)[],
|
||||
allowBosses = false,
|
||||
): MonsterKind[] {
|
||||
return candidates.filter(
|
||||
(kind): kind is MonsterKind =>
|
||||
kind !== undefined &&
|
||||
kind.enabled &&
|
||||
kind.isSpawn &&
|
||||
(kind.sparsePopulate === undefined || kind.sparsePopulate === 0 || kind.sparsePopulate > 0) &&
|
||||
(!kind.boss || allowBosses),
|
||||
)
|
||||
}
|
||||
|
||||
export function selectLevelTypes(
|
||||
plan: LevelMonsterPlan,
|
||||
kinds: ReadonlyMap<string, MonsterKind>,
|
||||
|
|
@ -1053,9 +1073,7 @@ export function selectLevelTypes(
|
|||
difficulty: Difficulty = 'normal',
|
||||
): MonsterKind[] {
|
||||
const names = difficulty === 'normal' ? plan.pool : (plan.nightmarePool.length > 0 ? plan.nightmarePool : plan.pool)
|
||||
const candidates = names
|
||||
.map(name => kinds.get(name))
|
||||
.filter((kind): kind is MonsterKind => kind !== undefined && kind.enabled && kind.isSpawn && kind.sparsePopulate > 0)
|
||||
const candidates = filterCandidateKinds(names.map(name => kinds.get(name)), false)
|
||||
const wanted = Math.min(plan.typeCount, candidates.length)
|
||||
const chosen: MonsterKind[] = []
|
||||
const remaining = [...candidates]
|
||||
|
|
@ -1074,29 +1092,33 @@ export function selectLevelTypes(
|
|||
}
|
||||
|
||||
/**
|
||||
* How much of a level's area one unit of `MonDen` is worth.
|
||||
* Turn a level's density into a monster budget using the authentic 1.13c DRLG room density formula.
|
||||
*
|
||||
* **This is the one invented number in this module.** `MonDen` feeds the real
|
||||
* generator's per-room placement, and rooms do not exist here until M8, so
|
||||
* there is nothing to calibrate against. It is chosen so that the Blood Moor
|
||||
* (`MonDen` 520, roughly 50×50 cells) lands near the twenty-odd monsters the
|
||||
* real level holds, and the Cow Level (`MonDen` 800) comes out denser in the
|
||||
* same proportion the table asks for. Ratios between levels are therefore
|
||||
* faithful; the absolute count is a calibration, and moving it is a one-line
|
||||
* change once M8 supplies rooms.
|
||||
*/
|
||||
export const DENSITY_CELLS_PER_MONSTER = 65_000
|
||||
|
||||
/**
|
||||
* Turn a level's density into a monster budget.
|
||||
* In Diablo II v1.13c (`MonsterRegion.cpp:645-656`):
|
||||
* attempts = Math.floor((cells * 25) / 9)
|
||||
* expectedPacks = attempts * (Math.min(10000, density) / 100000)
|
||||
* monsterBudget = Math.max(1, Math.round(expectedPacks * 3))
|
||||
*
|
||||
* @param plan - the level's columns.
|
||||
* @param cells - the level's area, in cells.
|
||||
* @returns how many monsters to place; 0 when the level is a town.
|
||||
* @param plan - the level's columns, or cells count if density is passed directly.
|
||||
* @param cells - the level's area, in cells (or density if plan is number).
|
||||
* @returns how many monsters to place; 0 when the level is a town or density is 0.
|
||||
*/
|
||||
export function monsterBudget(plan: LevelMonsterPlan, cells: number): number {
|
||||
if (plan.density <= 0) return 0
|
||||
return Math.max(1, Math.round((cells * plan.density) / DENSITY_CELLS_PER_MONSTER))
|
||||
export function monsterBudget(plan: LevelMonsterPlan, cells: number): number
|
||||
export function monsterBudget(cells: number, density: number): number
|
||||
export function monsterBudget(planOrCells: LevelMonsterPlan | number, cellsOrDensity: number): number {
|
||||
let density: number
|
||||
let cells: number
|
||||
if (typeof planOrCells === 'number') {
|
||||
cells = planOrCells
|
||||
density = cellsOrDensity
|
||||
} else {
|
||||
density = planOrCells.density
|
||||
cells = cellsOrDensity
|
||||
}
|
||||
if (density <= 0 || cells <= 0) return 0
|
||||
const attempts = Math.floor((cells * 25) / 9)
|
||||
const expectedPacks = attempts * (Math.min(10000, density) / 100000)
|
||||
return Math.max(1, Math.round(expectedPacks * 3))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1116,6 +1138,7 @@ export function monsterBudget(plan: LevelMonsterPlan, cells: number): number {
|
|||
* @param kinds - every known monster, for the `umon` lookup.
|
||||
* @param budget - how many monsters in total.
|
||||
* @param rng - seeded from the level.
|
||||
* @param options - optional elite chance multiplier for debugging or overrides.
|
||||
* @returns the packs to place.
|
||||
*/
|
||||
export function planMonsterGroups(
|
||||
|
|
@ -1124,21 +1147,42 @@ export function planMonsterGroups(
|
|||
kinds: ReadonlyMap<string, MonsterKind>,
|
||||
budget: number,
|
||||
rng: Rng,
|
||||
options?: {
|
||||
readonly eliteMultiplier?: number | 'all' | undefined
|
||||
},
|
||||
): MonsterGroup[] {
|
||||
if (types.length === 0 || budget <= 0) return []
|
||||
const groups: MonsterGroup[] = []
|
||||
let remaining = budget
|
||||
|
||||
const eliteCandidates = plan.elitePool
|
||||
.map(name => kinds.get(name))
|
||||
.filter((kind): kind is MonsterKind => kind !== undefined && kind.enabled && kind.isSpawn && kind.sparsePopulate > 0)
|
||||
const elitePacks = plan.eliteMax <= 0 ? 0 : rng.int(plan.eliteMin, plan.eliteMax)
|
||||
let eliteCandidates = filterCandidateKinds(plan.elitePool.map(name => kinds.get(name)), true)
|
||||
if (eliteCandidates.length === 0) {
|
||||
eliteCandidates = types.length > 0
|
||||
? [...types]
|
||||
: filterCandidateKinds(plan.pool.map(name => kinds.get(name)), true)
|
||||
}
|
||||
|
||||
let minElites = plan.eliteMin
|
||||
let maxElites = plan.eliteMax
|
||||
if (options?.eliteMultiplier !== undefined) {
|
||||
if (options.eliteMultiplier === 'all') {
|
||||
minElites = 999
|
||||
maxElites = 999
|
||||
} else if (typeof options.eliteMultiplier === 'number') {
|
||||
minElites = Math.round(minElites * options.eliteMultiplier)
|
||||
maxElites = Math.round(maxElites * options.eliteMultiplier)
|
||||
}
|
||||
}
|
||||
|
||||
const elitePacks = maxElites <= 0 ? 0 : rng.int(minElites, maxElites)
|
||||
for (let i = 0; i < elitePacks && remaining > 0 && eliteCandidates.length > 0; i += 1) {
|
||||
const kind = eliteCandidates[rng.int(0, eliteCandidates.length - 1)]!
|
||||
const count = Math.min(remaining, Math.max(1, rng.int(kind.minGroup, kind.maxGroup)))
|
||||
// `MonUMod.txt`'s `champion chance` constant is 20, i.e. a fifth of elite
|
||||
// packs are champions rather than a unique with minions.
|
||||
const rank: Exclude<MonsterRank, 'minion'> = rng.next() < (MONUMOD_CONSTANTS.championChance / 100) ? 'champion' : 'unique'
|
||||
// 1.13c MonUMod.txt: 20% Champions, 80% Uniques + Minions
|
||||
const isChampion = rng.next() < (MONUMOD_CONSTANTS.championChance / 100)
|
||||
const rank: Exclude<MonsterRank, 'minion'> = 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))
|
||||
const modSeed = ((i + 1) * 0x9e3779b9) ^ (count * 0x85ebca6b) ^ (rank === 'champion' ? 1 : 2)
|
||||
const modifiers = rollEliteModifiers(rank, new Rng(modSeed))
|
||||
groups.push({ kind, count, rank, modifiers })
|
||||
|
|
@ -1156,7 +1200,9 @@ export function planMonsterGroups(
|
|||
break
|
||||
}
|
||||
}
|
||||
const count = Math.min(remaining, Math.max(1, rng.int(kind.minGroup, kind.maxGroup)))
|
||||
const minGrp = Math.max(1, kind.minGroup)
|
||||
const maxGrp = Math.max(minGrp, kind.maxGroup)
|
||||
const count = Math.min(remaining, rng.int(minGrp, maxGrp))
|
||||
groups.push({ kind, count, rank: 'normal' })
|
||||
remaining -= count
|
||||
}
|
||||
|
|
@ -1502,6 +1548,8 @@ export function planLevelMonsters(
|
|||
readonly tileY: number
|
||||
readonly fixedPosition?: { readonly x: number; readonly y: number }
|
||||
}[]
|
||||
readonly densityMultiplier?: number | undefined
|
||||
readonly eliteMultiplier?: number | 'all' | undefined
|
||||
},
|
||||
): PlannedLevel {
|
||||
const empty: PlannedLevel = {
|
||||
|
|
@ -1524,8 +1572,18 @@ export function planLevelMonsters(
|
|||
// make the type choice depend on how many monsters the density happened to
|
||||
// ask for, so a level would change its monsters when its size changed.
|
||||
const types = selectLevelTypes(plan, kinds, new Rng(seed ^ 0x7b10), difficulty)
|
||||
const budget = monsterBudget(plan, cells)
|
||||
const groups = planMonsterGroups(plan, types, kinds, budget, new Rng(seed ^ 0x9e37))
|
||||
const density = options?.densityMultiplier !== undefined
|
||||
? Math.round(plan.density * options.densityMultiplier)
|
||||
: plan.density
|
||||
const budget = monsterBudget(cells, density)
|
||||
const groups = planMonsterGroups(
|
||||
plan,
|
||||
types,
|
||||
kinds,
|
||||
budget,
|
||||
new Rng(seed ^ 0x9e37),
|
||||
options?.eliteMultiplier !== undefined ? { eliteMultiplier: options.eliteMultiplier } : undefined,
|
||||
)
|
||||
const rollRng = new Rng(seed ^ 0x2545)
|
||||
|
||||
const packs: MonsterPack[] = groups.map(group => {
|
||||
|
|
@ -1535,10 +1593,14 @@ export function planLevelMonsters(
|
|||
for (let i = 0; i < group.count; i += 1) {
|
||||
const level = monsterLevelFor(group.kind, difficulty, plan.monsterLevel)
|
||||
const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, scaleAt(level), groupArt, level)
|
||||
// An elite pack is one leader plus its minions: the leader carries the
|
||||
// rank's full bonus, the minions a smaller one, which is why a champion
|
||||
// pack reads as "a tough one and its friends" rather than as a wall.
|
||||
const scale = group.rank === 'normal' ? 1 : (i === 0 ? multiplier : 1 + (multiplier - 1) / 2)
|
||||
// Elite health scaling:
|
||||
// Champions: all members receive champion bonus (3x HP in normal).
|
||||
// Uniques: leader receives unique bonus (4x HP in normal); minions receive minion bonus (2x HP in normal).
|
||||
const scale = group.rank === 'normal'
|
||||
? 1
|
||||
: group.rank === 'champion'
|
||||
? multiplier
|
||||
: (i === 0 ? multiplier : 1 + (MONUMOD_CONSTANTS.minionHpPct / 100))
|
||||
const scaled = scale === 1 ? base : { ...base, hp: Math.max(1, Math.round(base.hp * scale)) }
|
||||
const memberRank: MonsterRank = group.rank === 'normal' ? 'normal' : (i === 0 || group.rank === 'champion' ? group.rank : 'minion')
|
||||
const memberMods = (i === 0 || group.rank === 'champion') ? (group.modifiers ?? []) : []
|
||||
|
|
|
|||
|
|
@ -0,0 +1,363 @@
|
|||
/**
|
||||
* 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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -10,7 +10,6 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { D2Table } from '../src/game/acts.ts'
|
||||
import {
|
||||
DENSITY_CELLS_PER_MONSTER,
|
||||
DEFAULT_REACH_PX,
|
||||
ELITE_HEALTH_MULTIPLIER,
|
||||
MONUMOD_CONSTANTS,
|
||||
|
|
@ -375,12 +374,11 @@ describe('monsterBudget', () => {
|
|||
|
||||
it('scales with area', () => {
|
||||
const plan = readLevelMonsterPlan(LEVELS, 2)!
|
||||
expect(monsterBudget(plan, 5000)).toBe(2 * monsterBudget(plan, 2500))
|
||||
expect(monsterBudget(plan, 18_000)).toBe(2 * monsterBudget(plan, 9000))
|
||||
})
|
||||
|
||||
it('keeps the ratio the table asks for between two levels', () => {
|
||||
// The absolute constant is a calibration, but the ratio between two levels
|
||||
// is data, and it must survive the conversion.
|
||||
// The ratio between two levels is data, and it must survive the conversion.
|
||||
const moor = readLevelMonsterPlan(LEVELS, 2)!
|
||||
const plains = readLevelMonsterPlan(LEVELS, 3)!
|
||||
const cells = 100_000
|
||||
|
|
@ -393,9 +391,14 @@ describe('monsterBudget', () => {
|
|||
expect(monsterBudget(plan, 1)).toBe(1)
|
||||
})
|
||||
|
||||
it('uses the documented cells-per-monster constant', () => {
|
||||
it('uses the authentic 1.13c room density formula', () => {
|
||||
const plan = readLevelMonsterPlan(LEVELS, 2)!
|
||||
expect(monsterBudget(plan, DENSITY_CELLS_PER_MONSTER / plan.density)).toBe(1)
|
||||
// 9000 cells, density 520:
|
||||
// attempts = Math.floor((9000 * 25) / 9) = 25000
|
||||
// expectedPacks = 25000 * (520 / 100000) = 130
|
||||
// monsterBudget = Math.max(1, Math.round(130 * 3)) = 390
|
||||
expect(monsterBudget(plan, 9000)).toBe(390)
|
||||
expect(monsterBudget(9000, 520)).toBe(390)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue