diablo2-web/tests/monsters.test.ts

515 lines
19 KiB
TypeScript

/**
* Monster table reading and level population.
*
* These tests use hand-built tables rather than the archives: the point is to
* pin the *logic* — which column feeds which field, how a budget becomes packs,
* that the same seed gives the same level — where a synthetic table can state
* the expected answer exactly. `scripts/verify-monsters.ts` covers the other
* half, that the column names match the real 255-column file.
*/
import { describe, expect, it } from 'vitest'
import type { D2Table } from '../src/game/acts.ts'
import {
DENSITY_CELLS_PER_MONSTER,
monsterBudget,
monsterStatsOf,
planMonsterGroups,
readEliteModifiers,
readLevelMonsterPlan,
readMonsterArt,
readMonsterKinds,
readSuperUniques,
selectLevelTypes,
} from '../src/game/monsters.ts'
import type { MonsterKind } from '../src/game/monsters.ts'
import { Rng } from '../src/game/rng.ts'
/**
* Build a table from a header and rows.
*
* @param header - the column names.
* @param rows - the rows, each the same length as the header.
* @returns the table.
*/
function table(header: readonly string[], rows: readonly (readonly string[])[]): D2Table {
return { header: [...header], rows: rows.map(row => [...row]) }
}
/** A `MonStats.txt` with only the columns the reader names. */
const MONSTATS = table(
[
'Id', 'BaseId', 'NameStr', 'Code', 'MonType', 'AI', 'enabled', 'isSpawn', 'isMelee',
'rangedtype', 'npc', 'interact', 'inTown', 'boss', 'killable', 'Rarity', 'MinGrp', 'MaxGrp',
'Level', 'Level(N)', 'Level(H)', 'Velocity', 'Run', 'threat', 'aidist',
'minHP', 'maxHP', 'MinHP(N)', 'MaxHP(N)', 'AC', 'Exp', 'Exp(N)',
'A1MinD', 'A1MaxD', 'A1TH', 'A2MinD', 'A2MaxD', 'A2TH',
'ResDm', 'ResMa', 'ResFi', 'ResLi', 'ResCo', 'ResPo',
'TreasureClass1', 'TreasureClass2', 'TreasureClass3', 'TreasureClass4',
'minion1', 'minion2', 'SetBoss',
],
[
// A Fallen: common, comes in small packs, brings its own kind as minions.
['fallen1', 'fallen1', 'Fallen', 'FA', 'fallen', 'Fallen', '1', '1', '1',
'', '', '', '', '', '1', '2', '2', '3',
'1', '36', '67', '5', '5', '10', '',
'21', '61', '25', '55', '84', '61', '65',
'51', '101', '101', '51', '101', '101',
'', '', '', '', '', '',
'Act 1 H2H A', 'Act 1 Champ A', 'Act 1 Unique A', '',
'fallen1', '', '1'],
// A Zombie: slow, rarely grouped, poison resistant.
['zombie1', 'zombie1', 'Zombie', 'ZM', 'zombie', 'Zombie', '1', '1', '1',
'', '', '', '', '', '1', '2', '1', '2',
'1', '36', '67', '1', '3', '10', '',
'101', '181', '', '', '90', '111', '',
'30', '60', '80', '', '', '',
'', '', '', '', '', '50',
'Act 1 H2H A', '', '', '',
'', '', ''],
// A ranged monster with no melee flag.
['quillrat1', 'quillrat1', 'QuillRat', 'SI', 'quillrat', 'QuillRat', '1', '1', '',
'1', '', '', '', '', '1', '2', '1', '2',
'1', '36', '67', '3', '3', '10', '12',
'21', '81', '', '', '70', '71', '',
'20', '40', '70', '', '', '',
'', '', '', '', '', '',
'Act 1 H2H A', '', '', '',
'', '', ''],
// A variant with no art row of its own.
['quillrat6', 'quillrat1', 'QuillRat', 'SI', 'quillrat', 'QuillRat', '1', '1', '',
'1', '', '', '', '', '1', '2', '1', '2',
'30', '50', '80', '3', '3', '10', '',
'400', '600', '', '', '300', '900', '',
'90', '120', '400', '', '', '',
'', '', '', '', '', '',
'Act 5 H2H A', '', '', '',
'', '', ''],
// A town NPC: enabled, but never a wild spawn.
['charsi', 'charsi', 'Charsi', 'CA', 'human', 'Npc', '1', '', '',
'', '1', '1', '1', '', '', '1', '1', '1',
'1', '1', '1', '3', '3', '0', '',
'50', '50', '', '', '0', '0', '',
'0', '0', '0', '', '', '',
'', '', '', '', '', '',
'', '', '', '',
'', '', ''],
// A disabled development leftover.
['unused1', 'unused1', 'Unused', 'XX', 'none', 'Idle', '', '1', '1',
'', '', '', '', '', '1', '1', '1', '1',
'1', '1', '1', '1', '1', '0', '',
'1', '1', '', '', '0', '0', '',
'0', '0', '0', '', '', '',
'', '', '', '', '', '',
'', '', '', '',
'', '', ''],
// A blank row, as the real files contain.
['', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '',
'', '', '', '', '', '', '',
'', '', '', '', '', '',
'', '', '', '', '', '',
'', '', '', '',
'', '', ''],
],
)
const MONSTATS2 = table(
['Id', 'SizeX', 'SizeY', 'pixHeight', 'MeleeRng', 'BaseW', 'TotalPieces', 'dDT'],
[
['fallen1', '2', '2', '64', '0', 'hth', '6', '8'],
['zombie1', '2', '2', '80', '0', 'hth', '1', '8'],
['quillrat1', '2', '2', '48', '2', 'hth', '1', '8'],
['charsi', '2', '2', '90', '0', 'hth', '5', '8'],
['unused1', '1', '1', '32', '0', 'hth', '1', '8'],
],
)
const LEVELS = table(
[
'Id', 'Name', 'MonDen', 'MonDen(N)', 'MonUMin', 'MonUMax', 'MonUMin(N)', 'MonUMax(N)',
'NumMon', 'MonLvl1', 'MonLvl1Ex', 'MonWndr',
'mon1', 'mon2', 'mon3', 'mon4', 'mon5', 'mon6', 'mon7', 'mon8', 'mon9', 'mon10',
'nmon1', 'nmon2', 'nmon3', 'nmon4', 'nmon5', 'nmon6', 'nmon7', 'nmon8', 'nmon9', 'nmon10',
'umon1', 'umon2', 'umon3', 'umon4', 'umon5', 'umon6', 'umon7', 'umon8', 'umon9', 'umon10',
],
[
['1', 'Town', '0', '0', '', '', '', '',
'0', '0', '0', '',
'', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', ''],
['2', 'Blood Moor', '520', '520', '', '', '4', '5',
'3', '1', '1', '1',
'zombie1', 'fallen1', 'quillrat1', '', '', '', '', '', '', '',
'quillrat6', 'fallen1', '', '', '', '', '', '', '', '',
'fallen1', '', '', '', '', '', '', '', '', ''],
['3', 'Cold Plains', '600', '600', '1', '2', '2', '3',
'2', '2', '2', '1',
'zombie1', 'fallen1', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', '', '',
'zombie1', 'fallen1', '', '', '', '', '', '', '', ''],
],
)
const SUPERUNIQUES = table(
['Superunique', 'Name', 'Class', 'Mod1', 'Mod2', 'Mod3', 'MinGrp', 'MaxGrp', 'EClass', 'AutoPos', 'Stacks', 'Replaceable', 'TC'],
[
['Bishibosh', 'Bishibosh', 'fallen1', '8', '9', '0', '2', '2', '0', '1', '', '', 'Act 1 Super A'],
// The section divider that ships in the real file.
['Expansion', '', '', '', '', '', '', '', '', '', '', '', ''],
['Rakanishu', 'Rakanishu', 'fallen1', '17', '6', '', '8', '8', '1', '', '', '1', 'Act 1 Super A'],
],
)
const MONUMOD = table(
['uniquemod', 'id', 'enabled', 'champion', 'exclude1', 'exclude2'],
[
['none', '0', '', '', '', ''],
['strong', '5', '1', '1', '', ''],
['fast', '6', '1', '1', 'slow', ''],
['cursed', '7', '1', '', '', ''],
['', '', '', '', '', ''],
],
)
describe('readMonsterKinds', () => {
const kinds = readMonsterKinds(MONSTATS)
it('reads one kind per non-blank Id', () => {
expect([...kinds.keys()].sort()).toEqual(['charsi', 'fallen1', 'quillrat1', 'quillrat6', 'unused1', 'zombie1'])
})
it('reads every field of a row from its own column', () => {
expect(kinds.get('fallen1')).toEqual({
id: 'fallen1',
baseId: 'fallen1',
nameKey: 'Fallen',
code: 'FA',
monType: 'fallen',
ai: 'Fallen',
enabled: true,
isSpawn: true,
isMelee: true,
ranged: false,
npc: false,
interact: false,
inTown: false,
boss: false,
killable: true,
rarity: 2,
minGroup: 2,
maxGroup: 3,
level: [1, 36, 67],
velocity: 5,
runVelocity: 5,
threat: 10,
aiDistance: 0,
minHp: 21,
maxHp: 61,
armour: 84,
experience: 61,
attack1: { minDamage: 51, maxDamage: 101, toHit: 101 },
attack2: { minDamage: 51, maxDamage: 101, toHit: 101 },
resistances: { physical: 0, magic: 0, fire: 0, lightning: 0, cold: 0, poison: 0 },
treasureClasses: ['Act 1 H2H A', 'Act 1 Champ A', 'Act 1 Unique A'],
minions: ['fallen1'],
setBoss: true,
})
})
it('treats a blank flag as false and a "1" as true', () => {
expect(kinds.get('quillrat1')?.isMelee).toBe(false)
expect(kinds.get('quillrat1')?.ranged).toBe(true)
expect(kinds.get('unused1')?.enabled).toBe(false)
expect(kinds.get('charsi')?.npc).toBe(true)
})
it('keeps resistances that are set and zeroes the rest', () => {
expect(kinds.get('zombie1')?.resistances.poison).toBe(50)
expect(kinds.get('zombie1')?.resistances.fire).toBe(0)
})
it('reads the difficulty columns rather than falling back to Normal', () => {
// The real table capitalises these differently per difficulty: `minHP` on
// Normal, `MinHP(N)` afterwards. A reader that keeps the lower-case form
// reads nothing and silently reports the Normal numbers.
const nightmare = readMonsterKinds(MONSTATS, 'nightmare')
expect(nightmare.get('fallen1')?.minHp).toBe(25)
expect(nightmare.get('fallen1')?.maxHp).toBe(55)
expect(nightmare.get('fallen1')?.experience).toBe(65)
expect(kinds.get('fallen1')?.minHp).toBe(21)
})
it('reports a missing difficulty column as zero rather than as the Normal value', () => {
// `zombie1` has no `MinHP(N)`. Silently substituting the Normal health
// would hide the gap; reporting zero makes it visible to M9.
expect(readMonsterKinds(MONSTATS, 'nightmare').get('zombie1')?.minHp).toBe(0)
})
})
describe('readMonsterArt', () => {
const kinds = readMonsterKinds(MONSTATS)
const art = readMonsterArt(MONSTATS2, kinds)
it('reads a monster with its own row', () => {
expect(art.get('fallen1')).toEqual({
rowId: 'fallen1',
sizeX: 2,
sizeY: 2,
pixelHeight: 64,
meleeRange: 0,
weaponClass: 'hth',
totalPieces: 6,
directions: 8,
})
})
it('falls back to BaseId for a variant with no row', () => {
// `quillrat6` is in MonStats but not MonStats2; the game draws it with
// `quillrat1`'s art. Without the fallback it would be invisible.
expect(art.get('quillrat6')?.rowId).toBe('quillrat1')
expect(art.get('quillrat6')?.pixelHeight).toBe(48)
})
it('omits a monster whose base has no row either', () => {
const orphan = readMonsterArt(table(['Id', 'SizeX'], [['fallen1', '2']]), kinds)
expect(orphan.has('zombie1')).toBe(false)
})
})
describe('readLevelMonsterPlan', () => {
it('reads the pools, dropping blanks', () => {
const plan = readLevelMonsterPlan(LEVELS, 2)
expect(plan?.levelName).toBe('Blood Moor')
expect(plan?.density).toBe(520)
expect(plan?.typeCount).toBe(3)
expect(plan?.pool).toEqual(['zombie1', 'fallen1', 'quillrat1'])
expect(plan?.nightmarePool).toEqual(['quillrat6', 'fallen1'])
expect(plan?.elitePool).toEqual(['fallen1'])
expect(plan?.wander).toBe(true)
})
it('reads a blank elite count as zero', () => {
expect(readLevelMonsterPlan(LEVELS, 2)?.eliteMin).toBe(0)
expect(readLevelMonsterPlan(LEVELS, 2)?.eliteMax).toBe(0)
})
it('reads the difficulty-specific density and elite counts', () => {
const nightmare = readLevelMonsterPlan(LEVELS, 2, 'nightmare')
expect(nightmare?.eliteMin).toBe(4)
expect(nightmare?.eliteMax).toBe(5)
})
it('returns null for a level with no row', () => {
expect(readLevelMonsterPlan(LEVELS, 999)).toBeNull()
})
})
describe('readSuperUniques', () => {
const entries = readSuperUniques(SUPERUNIQUES)
it('skips the section divider that has a name but no class', () => {
expect(entries.map(entry => entry.id)).toEqual(['Bishibosh', 'Rakanishu'])
})
it('drops zero and blank modifier slots', () => {
expect(entries[0]?.modifiers).toEqual([8, 9])
expect(entries[1]?.modifiers).toEqual([17, 6])
})
it('reads the minion group and the placement flags', () => {
expect(entries[1]).toMatchObject({
monsterId: 'fallen1',
minMinions: 8,
maxMinions: 8,
enhancementClass: 1,
autoPosition: false,
replaceable: true,
})
})
})
describe('readEliteModifiers', () => {
it('drops the blank and the "none" rows', () => {
expect(readEliteModifiers(MONUMOD).map(entry => entry.name)).toEqual(['strong', 'fast', 'cursed'])
})
it('reads the champion flag and the exclusions', () => {
const fast = readEliteModifiers(MONUMOD).find(entry => entry.name === 'fast')
expect(fast).toMatchObject({ id: 6, enabled: true, champion: true, excludes: ['slow'] })
expect(readEliteModifiers(MONUMOD).find(entry => entry.name === 'cursed')?.champion).toBe(false)
})
})
describe('monsterBudget', () => {
it('gives a town no monsters', () => {
const town = readLevelMonsterPlan(LEVELS, 1)!
expect(monsterBudget(town, 10_000)).toBe(0)
})
it('scales with area', () => {
const plan = readLevelMonsterPlan(LEVELS, 2)!
expect(monsterBudget(plan, 5000)).toBe(2 * monsterBudget(plan, 2500))
})
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.
const moor = readLevelMonsterPlan(LEVELS, 2)!
const plains = readLevelMonsterPlan(LEVELS, 3)!
const cells = 100_000
expect(monsterBudget(plains, cells) / monsterBudget(moor, cells))
.toBeCloseTo(plains.density / moor.density, 2)
})
it('never returns zero for a level that has any density', () => {
const plan = readLevelMonsterPlan(LEVELS, 2)!
expect(monsterBudget(plan, 1)).toBe(1)
})
it('uses the documented cells-per-monster constant', () => {
const plan = readLevelMonsterPlan(LEVELS, 2)!
expect(monsterBudget(plan, DENSITY_CELLS_PER_MONSTER / plan.density)).toBe(1)
})
})
describe('selectLevelTypes', () => {
const kinds = readMonsterKinds(MONSTATS)
it('picks exactly NumMon distinct types from the pool', () => {
const plan = readLevelMonsterPlan(LEVELS, 2)!
const chosen = selectLevelTypes(plan, kinds, new Rng(1))
expect(chosen).toHaveLength(3)
expect(new Set(chosen.map(kind => kind.id)).size).toBe(3)
expect(chosen.every(kind => plan.pool.includes(kind.id))).toBe(true)
})
it('picks the same types for the same seed', () => {
const plan = readLevelMonsterPlan(LEVELS, 2)!
const first = selectLevelTypes(plan, kinds, new Rng(7)).map(kind => kind.id)
const second = selectLevelTypes(plan, kinds, new Rng(7)).map(kind => kind.id)
expect(second).toEqual(first)
})
it('draws from the nightmare pool on nightmare', () => {
const plan = readLevelMonsterPlan(LEVELS, 2, 'nightmare')!
const chosen = selectLevelTypes(plan, readMonsterKinds(MONSTATS, 'nightmare'), new Rng(1), 'nightmare')
expect(chosen.every(kind => plan.nightmarePool.includes(kind.id))).toBe(true)
})
it('never picks a disabled or unspawnable monster', () => {
const plan = readLevelMonsterPlan(
table(['Id', 'Name', 'MonDen', 'NumMon', 'mon1', 'mon2', 'mon3'], [['9', 'Odd', '500', '3', 'unused1', 'charsi', 'fallen1']]),
9,
)!
expect(selectLevelTypes(plan, kinds, new Rng(1)).map(kind => kind.id)).toEqual(['fallen1'])
})
it('returns nothing for a town', () => {
expect(selectLevelTypes(readLevelMonsterPlan(LEVELS, 1)!, kinds, new Rng(1))).toEqual([])
})
})
describe('planMonsterGroups', () => {
const kinds = readMonsterKinds(MONSTATS)
const plan = readLevelMonsterPlan(LEVELS, 3)!
const types = selectLevelTypes(plan, kinds, new Rng(4))
it('spends the whole budget', () => {
for (const budget of [1, 5, 20, 57]) {
const groups = planMonsterGroups(plan, types, kinds, budget, new Rng(5))
expect(groups.reduce((sum, group) => sum + group.count, 0)).toBe(budget)
}
})
it('never builds a pack larger than the monster\'s MaxGrp', () => {
const groups = planMonsterGroups(plan, types, kinds, 40, new Rng(6))
expect(groups.every(group => group.count >= 1 && group.count <= group.kind.maxGroup)).toBe(true)
})
it('draws elite packs from umon, not from the ordinary pool', () => {
const groups = planMonsterGroups(plan, types, kinds, 30, new Rng(8))
const elites = groups.filter(group => group.rank !== 'normal')
expect(elites.length).toBeGreaterThan(0)
expect(elites.every(group => plan.elitePool.includes(group.kind.id))).toBe(true)
})
it('places no elites on a level whose MonUMax is blank', () => {
const moor = readLevelMonsterPlan(LEVELS, 2)!
const groups = planMonsterGroups(moor, selectLevelTypes(moor, kinds, new Rng(4)), kinds, 30, new Rng(8))
expect(groups.every(group => group.rank === 'normal')).toBe(true)
})
it('is deterministic for a seed and different across seeds', () => {
const encode = (seed: number): string =>
planMonsterGroups(plan, types, kinds, 40, new Rng(seed))
.map(group => `${group.kind.id}:${String(group.count)}:${group.rank}`)
.join('|')
expect(encode(11)).toBe(encode(11))
expect(encode(11)).not.toBe(encode(12))
})
it('returns nothing when there is no budget or no types', () => {
expect(planMonsterGroups(plan, types, kinds, 0, new Rng(1))).toEqual([])
expect(planMonsterGroups(plan, [], kinds, 10, new Rng(1))).toEqual([])
})
})
describe('monsterStatsOf', () => {
const kinds = readMonsterKinds(MONSTATS)
const WALK = 170
it('rolls health inside the table\'s range', () => {
const fallen = kinds.get('fallen1')!
for (let seed = 0; seed < 50; seed += 1) {
const stats = monsterStatsOf(fallen, new Rng(seed), WALK)
expect(stats.hp).toBeGreaterThanOrEqual(21)
expect(stats.hp).toBeLessThanOrEqual(61)
}
})
it('rolls a different health for different monsters in a pack', () => {
const fallen = kinds.get('fallen1')!
const rng = new Rng(3)
const rolls = new Set(Array.from({ length: 20 }, () => monsterStatsOf(fallen, rng, WALK).hp))
expect(rolls.size).toBeGreaterThan(1)
})
it('scales speed off the player rather than off a magic number', () => {
// Velocity 6 is the player's own walking speed, so a monster at 6 must
// match the player exactly; the rest follow that ratio.
const fallen = kinds.get('fallen1')!
const zombie = kinds.get('zombie1')!
expect(monsterStatsOf(fallen, new Rng(1), WALK).speed).toBe(Math.round((5 / 6) * WALK))
expect(monsterStatsOf(zombie, new Rng(1), WALK).speed).toBe(Math.round((1 / 6) * WALK))
expect(monsterStatsOf(zombie, new Rng(1), WALK).speed)
.toBeLessThan(monsterStatsOf(fallen, new Rng(1), WALK).speed)
})
it('turns aidist into a pixel radius and falls back when it is blank', () => {
expect(monsterStatsOf(kinds.get('quillrat1')!, new Rng(1), WALK).aggroRadius).toBe(12 * 16)
expect(monsterStatsOf(kinds.get('fallen1')!, new Rng(1), WALK).aggroRadius).toBe(240)
})
it('carries the experience through unchanged', () => {
expect(monsterStatsOf(kinds.get('fallen1')!, new Rng(1), WALK).xp).toBe(61)
expect(monsterStatsOf(kinds.get('zombie1')!, new Rng(1), WALK).xp).toBe(111)
})
it('never produces a monster that cannot be hit or cannot hit back', () => {
for (const kind of kinds.values()) {
const stats = monsterStatsOf(kind, new Rng(2), WALK)
expect(stats.hp).toBeGreaterThanOrEqual(1)
expect(stats.damage).toBeGreaterThanOrEqual(1)
expect(stats.speed).toBeGreaterThanOrEqual(1)
expect(stats.reach).toBeGreaterThan(0)
}
})
it('uses the id when the name key is blank', () => {
const nameless: MonsterKind = { ...kinds.get('fallen1')!, nameKey: '' }
expect(monsterStatsOf(nameless, new Rng(1), WALK).name).toBe('fallen1')
})
})