M7 第一步:真实怪物数据层

把 MonStats.txt 读进来,并给它一个能发现错误的检验。

新增 src/game/monsters.ts。它和 combat.ts 里的 monsterStatsFromRow
是两个读取器,不是一个:后者读的是九列的测试夹具(Id/Name/HP/...),
真实的 MonStats.txt 有 255 列且几乎没有一个同名——是 NameStr 不是
Name,是 minHP/maxHP 不是 HP,是 Exp 不是 XP。把夹具读取器指向真表
不会报错,只会让 735 只怪物拿到同一套默认值,看起来像是能用。

新增 scripts/verify-monsters.ts(52 项检查)与 tests/monsters.test.ts
(41 项)。检验脚本一上来就发现了七处问题,其中两处是数据本身的事实,
值得记下来:

- MinHP(N)/MinHP(H) 不是「更高难度的血量」。fallen1 普通 21-61,
  噩梦和地狱都是 25-55;594 个有血量的行里有 426 行的 MaxHP(N) 比
  maxHP 更低;Duriel 从普通的 4757 掉到地狱的 1295。真正的难度缩放
  来自 MonLvl.txt 对普通值的逐等级放大,这些列是只对 boss 生效的覆盖,
  而且数据自己用得并不一致。按铁律六,读取器照实读、照实记,不假装
  它们是缩放,缩放留给 M9。
- MonStats2.txt 只有 610 行,配 735 只怪物。缺的 119 只是编号变体
  (quillrat6、slinger7、deathmauler6 等),游戏用它们 BaseId 的美术
  来画。只按 id 查会让这 119 只变成一片空地而不是一个报错,所以
  readMonsterArt 走 BaseId 回退。

另外三处是数据里的既有瑕疵,现在被显式承认而不是被抹平:
MonStats.txt 里 cr_lancer8 重复出现两次(检验断言「唯一的重复是这一个」,
新的重复仍会失败);SuperUniques.txt 里的 Expansion 是分节行,有名字
没有 Class;还有两处是我手抄表格时抄错了(foulcrow1 的 MonType 是
foulcrow 不是 bat,hellbovine 的 NameStr 带空格)。

combat.ts 新增 spawnMonsterPacks。原有的 spawnMonsters 把怪物撒在黄金角
螺旋上,那是让互不相干的东西均匀散开的形状,恰好是堕落者营地最不该有的
形状——螺旋上相邻的两点是刻意离得远的。现在螺旋放的是营地,成员在自己
营地里成团。附 tests/monster-packs.test.ts(11 项),直接量距离而不是
看截图。

acts.ts 的 ActTables 增加 MonStats2/MonLvl/MonType/MonUMod/SuperUniques。

验证:tsc 零错误;npm test 502 通过(基线 461);
npm run verify:monsters 52/52。

TAG=agy
CONV=c89513df-cd12-4749-b5bd-5e9f16639391
This commit is contained in:
taodao 2026-09-15 08:06:52 +00:00
parent ae94755401
commit 70b299569e
7 changed files with 1978 additions and 1 deletions

View File

@ -21,6 +21,7 @@
"verify:renderer": "tsx scripts/verify-renderer-lifecycle.ts",
"verify:all": "tsx scripts/verify-combat.ts && tsx scripts/verify-items.ts && tsx scripts/verify-m4.ts && tsx scripts/verify-m5.ts && tsx scripts/verify-net.ts && tsx scripts/verify-collision-orientation.ts && tsx scripts/verify-tbl.ts && tsx scripts/verify-audio.ts && tsx scripts/verify-formats.ts",
"verify:acts": "tsx scripts/verify-acts.ts",
"verify:monsters": "tsx scripts/verify-monsters.ts",
"verify:generators": "tsx scripts/verify-generators.ts samples/d2",
"build:game": "vite build --base=/diablo2/ --outDir dist-game",
"pack:data": "tsx scripts/pack-act-assets.ts",

429
scripts/verify-monsters.ts Normal file
View File

@ -0,0 +1,429 @@
/**
* Read every monster row out of the real archives and check it survives the
* trip into the simulation's own shapes.
*
* The failure this exists to catch is silent, not loud. `MonStats.txt` has 255
* columns and a reader that misspells one gets `''` back rather than an error,
* so a wrong column name produces a monster with zero health and no damage
* instead of a crash. Counting rows is therefore not enough: the script asserts
* that the numbers are *present and plausible* for all 735 rows, and it spot
* checks seven monsters against values read by hand out of the table so that a
* column rename cannot pass unnoticed.
*
* Usage:
* npx tsx scripts/verify-monsters.ts [directory]
*/
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 } from '../src/game/acts.ts'
import {
monsterBudget,
monsterStatsOf,
planMonsterGroups,
readEliteModifiers,
readLevelMonsterPlan,
readMonsterKinds,
readMonsterArt,
readSuperUniques,
selectLevelTypes,
} from '../src/game/monsters.ts'
import type { Difficulty, MonsterKind } from '../src/game/monsters.ts'
import { Rng } from '../src/game/rng.ts'
const dir = process.argv[2] ?? 'samples/d2'
/** Mount order: later archives override earlier ones, exactly as the game loads them. */
const MOUNTS = ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']
let checks = 0
let failures = 0
/**
* Assert one expectation.
*
* @param ok - whether it held.
* @param message - what was checked.
*/
function check(ok: boolean, message: string): void {
checks += 1
if (!ok) failures += 1
console.log(` ${ok ? 'ok ' : 'FAIL'} ${message}`)
}
const archives = new MountedArchives()
for (const name of MOUNTS) {
try {
archives.add(name, await MpqArchive.open(await fileSource(`${dir}/${name}`)))
} catch (err) {
console.log(`skip ${name}: ${String(err)}`)
}
}
if (archives.size === 0) {
console.log(`no archives found in ${dir}`)
process.exit(2)
}
console.log(`== mounted ${String(archives.size)} archives ==`)
const tables = await loadActTables(archives)
console.log(`\n== tables ==`)
console.log(` MonStats.txt ${String(tables.monstats.rows.length)} rows, ${String(tables.monstats.header.length)} columns`)
console.log(` MonStats2.txt ${String(tables.monstats2.rows.length)} rows`)
console.log(` MonLvl.txt ${String(tables.monlvl.rows.length)} rows`)
console.log(` MonType.txt ${String(tables.montype.rows.length)} rows`)
console.log(` MonUMod.txt ${String(tables.monumod.rows.length)} rows`)
console.log(` SuperUniques.txt ${String(tables.superuniques.rows.length)} rows`)
// ---------------------------------------------------------------------------
// Every column the reader names must exist in the header.
// ---------------------------------------------------------------------------
//
// This is the check that turns a typo from a silent zero into a failure. The
// list is the union of every column `readMonsterKinds` reads on any difficulty.
console.log(`\n== MonStats.txt columns present ==`)
const REQUIRED_COLUMNS = [
'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)', 'MinHP(H)', 'MaxHP(H)',
'AC', 'AC(N)', 'AC(H)', 'Exp', 'Exp(N)', 'Exp(H)',
'A1MinD', 'A1MaxD', 'A1TH', 'A2MinD', 'A2MaxD', 'A2TH',
'ResDm', 'ResMa', 'ResFi', 'ResLi', 'ResCo', 'ResPo',
'TreasureClass1', 'TreasureClass2', 'TreasureClass3', 'TreasureClass4',
'minion1', 'minion2', 'SetBoss',
]
const missing = REQUIRED_COLUMNS.filter(name => !tables.monstats.header.includes(name))
check(missing.length === 0, `all ${String(REQUIRED_COLUMNS.length)} named columns exist${missing.length === 0 ? '' : `; missing ${missing.join(', ')}`}`)
// ---------------------------------------------------------------------------
// Full-table mapping: every row becomes a kind, and every kind becomes stats.
// ---------------------------------------------------------------------------
console.log(`\n== MonStats.txt → MonsterKind → MonsterStats ==`)
const ids = tables.monstats.rows.map(row => cell(tables.monstats, row, 'Id').trim()).filter(id => id !== '')
const kinds = readMonsterKinds(tables.monstats)
// `MonStats.txt` ships one duplicated row: `cr_lancer8` appears twice in 1.13c.
// Keying by `Id` therefore loses exactly one row, and that is correct — the
// duplicate is redundant, not a second monster. Naming it here means a *new*
// duplicate, which would be a real data problem, still fails this check.
const KNOWN_DUPLICATE_IDS = ['cr_lancer8']
const seen = new Map<string, number>()
for (const id of ids) seen.set(id, (seen.get(id) ?? 0) + 1)
const duplicates = [...seen].filter(([, count]) => count > 1).map(([id]) => id).sort()
check(
duplicates.join(',') === KNOWN_DUPLICATE_IDS.join(','),
`the only duplicated Id is the known ${KNOWN_DUPLICATE_IDS.join(', ')}${duplicates.join(',') === KNOWN_DUPLICATE_IDS.join(',') ? '' : `; found ${duplicates.join(', ')}`}`,
)
check(
kinds.size === ids.length - duplicates.length,
`every distinct Id became a kind (${String(kinds.size)} kinds from ${String(ids.length)} rows)`,
)
/** The player's walking speed in scene pixels, matching `act-scene.ts`. */
const WALK_SPEED = 170
let statFailures = 0
let spawnable = 0
let withHealth = 0
let withDamage = 0
const statRng = new Rng(1)
for (const kind of kinds.values()) {
const stats = monsterStatsOf(kind, statRng, WALK_SPEED)
if (kind.enabled && kind.isSpawn && !kind.npc && !kind.inTown) spawnable += 1
if (kind.maxHp > 0) withHealth += 1
if (kind.attack1.maxDamage > 0) withDamage += 1
const sane =
stats.id === kind.id &&
stats.name !== '' &&
Number.isInteger(stats.hp) && stats.hp >= 1 &&
Number.isInteger(stats.damage) && stats.damage >= 1 &&
Number.isInteger(stats.speed) && stats.speed >= 1 &&
stats.cooldownTicks > 0 && stats.reach > 0 && stats.aggroRadius > 0 &&
stats.xp >= 0 &&
stats.hp >= Math.min(kind.minHp, kind.maxHp) &&
stats.hp <= Math.max(1, Math.max(kind.minHp, kind.maxHp))
if (!sane) {
statFailures += 1
if (statFailures <= 5) console.log(` ${kind.id}: ${JSON.stringify(stats)}`)
}
}
check(statFailures === 0, `all ${String(kinds.size)} kinds produce finite, in-range stats (${String(statFailures)} bad)`)
console.log(` info ${String(spawnable)} spawnable, ${String(withHealth)} with health, ${String(withDamage)} with A1 damage`)
// A table read with the wrong column names still yields rows — they are just
// all identical. Distinct health ranges prove the numbers are really being read.
const healthRanges = new Set([...kinds.values()].map(kind => `${String(kind.minHp)}-${String(kind.maxHp)}`))
check(healthRanges.size > 100, `health ranges are distinct per monster, not a shared default (${String(healthRanges.size)} distinct)`)
const codes = new Set([...kinds.values()].map(kind => kind.code).filter(code => code !== ''))
check(codes.size > 100, `art codes are distinct (${String(codes.size)} distinct)`)
// ---------------------------------------------------------------------------
// Spot checks against values read by hand out of the 1.13c table.
// ---------------------------------------------------------------------------
console.log(`\n== spot checks (hand-read from the 1.13c table) ==`)
interface Expected {
readonly id: string
readonly nameKey: string
readonly code: string
readonly monType: string
readonly ai: string
readonly minGroup: number
readonly maxGroup: number
readonly velocity: number
readonly minHp: number
readonly maxHp: number
readonly experience: number
}
const EXPECTED: readonly Expected[] = [
{ id: 'fallen1', nameKey: 'Fallen', code: 'FA', monType: 'fallen', ai: 'Fallen', minGroup: 2, maxGroup: 3, velocity: 5, minHp: 21, maxHp: 61, experience: 61 },
{ id: 'zombie1', nameKey: 'Zombie', code: 'ZM', monType: 'zombie', ai: 'Zombie', minGroup: 1, maxGroup: 2, velocity: 1, minHp: 101, maxHp: 181, experience: 111 },
{ id: 'skeleton1', nameKey: 'Skeleton', code: 'SK', monType: 'skeleton', ai: 'Skeleton', minGroup: 1, maxGroup: 3, velocity: 3, minHp: 86, maxHp: 129, experience: 86 },
{ id: 'quillrat1', nameKey: 'QuillRat', code: 'SI', monType: 'quillrat', ai: 'QuillRat', minGroup: 1, maxGroup: 2, velocity: 3, minHp: 21, maxHp: 81, experience: 71 },
{ id: 'foulcrow1', nameKey: 'FoulCrow', code: 'BK', monType: 'foulcrow', ai: 'BloodHawk', minGroup: 2, maxGroup: 4, velocity: 4, minHp: 19, maxHp: 46, experience: 37 },
{ id: 'corruptrogue1', nameKey: 'DarkHunter', code: 'CR', monType: 'corruptrogue', ai: 'CorruptRogue', minGroup: 2, maxGroup: 3, velocity: 5, minHp: 58, maxHp: 101, experience: 78 },
{ id: 'hellbovine', nameKey: 'Hell Bovine', code: 'EC', monType: 'bovine', ai: 'Skeleton', minGroup: 5, maxGroup: 10, velocity: 5, minHp: 133, maxHp: 285, experience: 140 },
]
for (const want of EXPECTED) {
const got = kinds.get(want.id)
if (got === undefined) {
check(false, `${want.id} exists`)
continue
}
const mismatches: string[] = []
const compare = (field: string, a: unknown, b: unknown): void => {
if (a !== b) mismatches.push(`${field} ${String(a)} ≠ ${String(b)}`)
}
compare('nameKey', got.nameKey, want.nameKey)
compare('code', got.code, want.code)
compare('monType', got.monType, want.monType)
compare('ai', got.ai, want.ai)
compare('minGroup', got.minGroup, want.minGroup)
compare('maxGroup', got.maxGroup, want.maxGroup)
compare('velocity', got.velocity, want.velocity)
compare('minHp', got.minHp, want.minHp)
compare('maxHp', got.maxHp, want.maxHp)
compare('experience', got.experience, want.experience)
check(mismatches.length === 0, `${want.id}${mismatches.length === 0 ? '' : `: ${mismatches.join('; ')}`}`)
}
// ---------------------------------------------------------------------------
// Difficulty columns: the capitalisation trap.
// ---------------------------------------------------------------------------
//
// Normal is `minHP`; Nightmare and Hell are `MinHP(N)` and `MinHP(H)`. Reading
// the lower-case form on Nightmare returns nothing, which would show up as a
// Fallen with the same health on every difficulty.
console.log(`\n== difficulty columns are read, and are not a scaling ==`)
//
// The obvious assertion — "Nightmare health is higher" — is false in the data.
// `fallen1` is 21–61 on Normal and 25–55 on Nightmare, and 426 of the rows with
// health have a *lower* `MaxHP(N)` than `maxHP`. The difficulty columns are a
// boss-only override; ordinary monsters are scaled by `MonLvl.txt` instead.
// What can be asserted is that the columns are being read at all, rather than
// silently falling back to the Normal ones through a misspelling.
{
const normal = kinds.get('fallen1')
const nightmare = readMonsterKinds(tables.monstats, 'nightmare').get('fallen1')
const hell = readMonsterKinds(tables.monstats, 'hell').get('fallen1')
check(
normal !== undefined && nightmare !== undefined && hell !== undefined,
'fallen1 reads on all three difficulties',
)
check(
nightmare !== undefined && normal !== undefined && (nightmare.minHp !== normal.minHp || nightmare.maxHp !== normal.maxHp),
`fallen1 Nightmare health differs from Normal (${String(nightmare?.minHp)}-${String(nightmare?.maxHp)} vs ${String(normal?.minHp)}-${String(normal?.maxHp)}), so MinHP(N) is really being read`,
)
check(
nightmare !== undefined && normal !== undefined && nightmare.experience !== normal.experience,
`fallen1 Nightmare experience differs (${String(nightmare?.experience)} vs ${String(normal?.experience)})`,
)
}
// The documented oddity, asserted so that a future "fix" that quietly inverts
// it has to confront the data first.
{
let lower = 0
let total = 0
for (const row of tables.monstats.rows) {
const normalHp = Number(cell(tables.monstats, row, 'maxHP'))
const nightmareHp = Number(cell(tables.monstats, row, 'MaxHP(N)'))
if (!Number.isFinite(normalHp) || !Number.isFinite(nightmareHp) || normalHp === 0) continue
total += 1
if (nightmareHp < normalHp) lower += 1
}
check(
lower > total / 2,
`most rows have a lower MaxHP(N) than maxHP (${String(lower)} of ${String(total)}) — these columns are not a difficulty scaling`,
)
}
// ---------------------------------------------------------------------------
// Levels.txt monster columns.
// ---------------------------------------------------------------------------
console.log(`\n== Levels.txt monster plans ==`)
interface LevelExpectation {
readonly id: number
readonly name: string
readonly density: number
readonly typeCount: number
readonly pool: readonly string[]
}
const LEVELS: readonly LevelExpectation[] = [
{ id: 1, name: 'Act 1 - Town', density: 0, typeCount: 0, pool: [] },
{ id: 2, name: 'Act 1 - Wilderness 1', density: 520, typeCount: 3, pool: ['zombie1', 'fallen1', 'quillrat1'] },
{ id: 8, name: 'Act 1 - Cave 1', density: 600, typeCount: 3, pool: ['zombie1', 'brute1', 'fallenshaman1'] },
]
for (const want of LEVELS) {
const plan = readLevelMonsterPlan(tables.levels, want.id)
if (plan === null) {
check(false, `level ${String(want.id)} has a row`)
continue
}
check(plan.levelName === want.name, `level ${String(want.id)} is ${plan.levelName}`)
check(plan.density === want.density, `${plan.levelName} MonDen ${String(plan.density)} (want ${String(want.density)})`)
check(plan.typeCount === want.typeCount, `${plan.levelName} NumMon ${String(plan.typeCount)} (want ${String(want.typeCount)})`)
check(
plan.pool.join(',') === want.pool.join(','),
`${plan.levelName} pool [${plan.pool.join(', ')}]${plan.pool.join(',') === want.pool.join(',') ? '' : ` (want [${want.pool.join(', ')}])`}`,
)
}
// Every monster any level names must exist in MonStats.txt, or the populator
// would be asked to place something it cannot build.
let danglingPool = 0
let levelsWithMonsters = 0
for (const row of tables.levels.rows) {
const levelId = Number(cell(tables.levels, row, 'Id'))
if (!Number.isFinite(levelId)) continue
const plan = readLevelMonsterPlan(tables.levels, levelId)
if (plan === null) continue
if (plan.pool.length > 0) levelsWithMonsters += 1
for (const name of [...plan.pool, ...plan.nightmarePool, ...plan.elitePool]) {
if (!kinds.has(name)) {
danglingPool += 1
if (danglingPool <= 5) console.log(` ${plan.levelName}: unknown monster "${name}"`)
}
}
}
check(danglingPool === 0, `every level's monster pool resolves (${String(danglingPool)} dangling across ${String(levelsWithMonsters)} populated levels)`)
// ---------------------------------------------------------------------------
// SuperUniques.txt and MonUMod.txt.
// ---------------------------------------------------------------------------
console.log(`\n== SuperUniques.txt ==`)
const superUniques = readSuperUniques(tables.superuniques)
check(superUniques.length > 60, `${String(superUniques.length)} super uniques read`)
const danglingClass = superUniques.filter(entry => !kinds.has(entry.monsterId))
check(danglingClass.length === 0, `every super unique's Class resolves${danglingClass.length === 0 ? '' : `: ${danglingClass.map(e => `${e.id}→${e.monsterId}`).join(', ')}`}`)
const bishibosh = superUniques.find(entry => entry.id === 'Bishibosh')
check(
bishibosh !== undefined && bishibosh.monsterId === 'fallenshaman1' && bishibosh.minMinions === 2 && bishibosh.maxMinions === 2,
`Bishibosh is a fallenshaman1 with 2 minions (${String(bishibosh?.monsterId)}, ${String(bishibosh?.minMinions)}-${String(bishibosh?.maxMinions)})`,
)
const rakanishu = superUniques.find(entry => entry.id === 'Rakanishu')
check(
rakanishu !== undefined && rakanishu.monsterId === 'fallen2' && rakanishu.maxMinions === 8,
`Rakanishu is a fallen2 with 8 minions (${String(rakanishu?.monsterId)}, ${String(rakanishu?.maxMinions)})`,
)
console.log(`\n== MonUMod.txt ==`)
const modifiers = readEliteModifiers(tables.monumod)
check(modifiers.length > 20, `${String(modifiers.length)} elite modifiers read`)
const modifierIds = new Set(modifiers.map(entry => entry.id))
const danglingMod = superUniques.flatMap(entry => entry.modifiers).filter(id => !modifierIds.has(id))
check(danglingMod.length === 0, `every super unique modifier id exists (${String(danglingMod.length)} dangling)`)
// ---------------------------------------------------------------------------
// Population: density, type selection, packs.
// ---------------------------------------------------------------------------
console.log(`\n== populating Act 1 - Wilderness 1 ==`)
const bloodMoor = readLevelMonsterPlan(tables.levels, 2)
if (bloodMoor === null) {
check(false, 'Blood Moor has a plan')
} else {
/** A wilderness level is roughly 50×50 cells. */
const CELLS = 50 * 50
const budget = monsterBudget(bloodMoor, CELLS)
check(budget >= 10 && budget <= 60, `budget for a ${String(CELLS)}-cell level is ${String(budget)}, in the 10..60 range the real level holds`)
const town = readLevelMonsterPlan(tables.levels, 1)
check(town !== null && monsterBudget(town, CELLS) === 0, 'a town has a budget of zero')
const types = selectLevelTypes(bloodMoor, kinds, new Rng(2))
check(types.length === bloodMoor.typeCount, `selected ${String(types.length)} types, NumMon is ${String(bloodMoor.typeCount)}`)
check(new Set(types.map(t => t.id)).size === types.length, `selected types are distinct: ${types.map(t => t.id).join(', ')}`)
check(types.every(t => bloodMoor.pool.includes(t.id)), 'selected types all come from the level pool')
const groups = planMonsterGroups(bloodMoor, types, kinds, budget, new Rng(3))
const placed = groups.reduce((sum, group) => sum + group.count, 0)
check(placed === budget, `packs account for the whole budget (${String(placed)} of ${String(budget)})`)
check(groups.every(group => group.count >= 1), 'no empty packs')
check(
groups.every(group => group.count <= Math.max(1, group.kind.maxGroup)),
'no pack exceeds its monster\'s MaxGrp',
)
const ranks = new Map<string, number>()
for (const group of groups) ranks.set(group.rank, (ranks.get(group.rank) ?? 0) + 1)
console.log(` info ${String(groups.length)} packs: ${[...ranks].map(([rank, n]) => `${String(n)} ${rank}`).join(', ')}`)
// Determinism: the same seed must produce the same level, or a save cannot be
// reloaded and a co-op session cannot agree on what it is fighting.
const again = planMonsterGroups(bloodMoor, selectLevelTypes(bloodMoor, kinds, new Rng(2)), kinds, budget, new Rng(3))
const encode = (list: readonly { kind: MonsterKind; count: number; rank: string }[]): string =>
list.map(group => `${group.kind.id}:${String(group.count)}:${group.rank}`).join('|')
check(encode(groups) === encode(again), 'the same seed produces the same packs')
const different = planMonsterGroups(bloodMoor, selectLevelTypes(bloodMoor, kinds, new Rng(99)), kinds, budget, new Rng(98))
check(encode(groups) !== encode(different), 'a different seed produces different packs')
}
// Density ratios between levels must follow the table, since the absolute
// count is a calibration but the ratios are data.
console.log(`\n== density ratios follow MonDen ==`)
const cow = readLevelMonsterPlan(tables.levels, 39)
if (bloodMoor !== null && cow !== null) {
const ratio = cow.density / bloodMoor.density
const budgetRatio = monsterBudget(cow, 10_000) / monsterBudget(bloodMoor, 10_000)
check(
Math.abs(ratio - budgetRatio) < 0.05,
`Cow Level is ${ratio.toFixed(2)}× the Blood Moor's density and ${budgetRatio.toFixed(2)}× its budget`,
)
} else {
check(false, 'Cow Level and Blood Moor both have plans')
}
// ---------------------------------------------------------------------------
// MonStats2.txt covers the monsters that spawn.
// ---------------------------------------------------------------------------
console.log(`\n== MonStats2.txt covers spawnable monsters ==`)
const art = readMonsterArt(tables.monstats2, kinds)
const uncovered: string[] = []
let viaBaseId = 0
for (const kind of kinds.values()) {
if (!kind.enabled || !kind.isSpawn || kind.npc) continue
const entry = art.get(kind.id)
if (entry === undefined) { uncovered.push(kind.id); continue }
if (entry.rowId !== kind.id) viaBaseId += 1
}
check(uncovered.length === 0, `every spawnable monster resolves art${uncovered.length === 0 ? '' : `; missing ${uncovered.slice(0, 10).join(', ')}`}`)
check(viaBaseId > 0, `${String(viaBaseId)} spawnable monsters reach their art through BaseId, as the game does`)
const fallenArt = art.get('fallen1')
check(
fallenArt !== undefined && fallenArt.sizeX === 2 && fallenArt.sizeY === 2 && fallenArt.totalPieces === 6 && fallenArt.directions === 8,
`fallen1 art is 2×2, 6 COF pieces, 8 directions (${String(fallenArt?.sizeX)}×${String(fallenArt?.sizeY)}, ${String(fallenArt?.totalPieces)}, ${String(fallenArt?.directions)})`,
)
const variantArt = art.get('quillrat6')
check(
variantArt !== undefined && variantArt.rowId === 'quillrat1',
`quillrat6 borrows quillrat1's art (${String(variantArt?.rowId)})`,
)
const flat = [...art.values()]
check(
new Set(flat.map(entry => `${String(entry.sizeX)}x${String(entry.sizeY)}`)).size > 3,
`body sizes vary rather than defaulting (${String(new Set(flat.map(e => `${String(e.sizeX)}x${String(e.sizeY)}`)).size)} distinct)`,
)
console.log(`\n== summary ==`)
console.log(` ${String(checks - failures)}/${String(checks)} checks passed`)
const difficulties: readonly Difficulty[] = ['normal', 'nightmare', 'hell']
console.log(` difficulties read: ${difficulties.join(', ')}`)
if (failures > 0) process.exit(1)

View File

@ -90,13 +90,30 @@ export function cell(table: D2Table, row: readonly string[], column: string): st
return index === -1 ? '' : (row[index] ?? '')
}
/** The three tables a town resolution needs. */
/**
* The tables a level needs to be resolved and populated.
*
* The first five place and draw the level; the rest describe what lives in it.
* They are loaded together because every caller that wants one wants the level
* it belongs to as well, and because mounting the archives is the expensive
* part — a table read is a few hundred kilobytes off an already-open handle.
*/
export interface ActTables {
readonly levels: D2Table
readonly lvltypes: D2Table
readonly lvlprest: D2Table
readonly monstats: D2Table
readonly monpreset: D2Table
/** `MonStats2.txt` — body size, melee range, and which COF layers exist. */
readonly monstats2: D2Table
/** `MonLvl.txt` — the per-level scaling curve, read but not yet applied (M9). */
readonly monlvl: D2Table
/** `MonType.txt` — family equivalences, e.g. `lowundead` → `undead`. */
readonly montype: D2Table
/** `MonUMod.txt` — the elite modifiers and their shared constants. */
readonly monumod: D2Table
/** `SuperUniques.txt` — the named bosses, e.g. Bishibosh, Rakanishu. */
readonly superuniques: D2Table
}
/** Everything needed to place and render one level. */
@ -150,6 +167,11 @@ export async function loadActTables(archives: MountedArchives): Promise<ActTable
lvlprest: await read('lvlprest.txt'),
monstats: await read('monstats.txt'),
monpreset: await read('MonPreset.txt'),
monstats2: await read('MonStats2.txt'),
monlvl: await read('MonLvl.txt'),
montype: await read('MonType.txt'),
monumod: await read('MonUMod.txt'),
superuniques: await read('SuperUniques.txt'),
}
}

View File

@ -402,6 +402,103 @@ export function spawnMonsters(
return placed
}
/** A group of monsters that belongs together on the ground. */
export interface MonsterPack {
/**
* The members, already rolled — one entry per monster.
*
* A pack is a list rather than a definition plus a count because members are
* not interchangeable: health is rolled per monster, and a unique's pack has
* a stronger leader followed by its minions.
*/
readonly members: readonly MonsterStats[]
}
/**
* How far a pack's members sit from their camp centre, in pixels.
*
* Two cells across. A pack has to read as one thing from the player's distance
* — close enough that pulling one pulls the rest, far enough that the sprites
* do not overlap. A cell is 80 wide, and a monster body is about 20.
*/
const PACK_RADIUS_PX = 96
/**
* Place packs, keeping each pack's members together.
*
* {@link spawnMonsters} scatters its monsters on a golden-angle spiral, which
* is the right shape for spreading *unrelated* things evenly and exactly the
* wrong one for a Fallen camp: consecutive spiral points are deliberately far
* apart, so a pack placed that way arrives as a field of loners. Here the
* spiral places the *camps*, and members are clustered inside their own camp.
*
* Both loops reject blocked ground rather than nudging, for the same reason
* {@link spawnMonsters} does: a monster shoved into a wall is stuck forever.
* A camp that cannot fit its whole pack keeps whichever members found room, so
* the caller can see the shortfall in the return value.
*
* @param world - the world to populate.
* @param packs - the packs to place, in order.
* @param around - the centre to spread camps around.
* @param spread - how far camps may be from that centre, in pixels.
* @param terrain - collision predicate.
* @returns how many monsters were actually placed.
*/
export function spawnMonsterPacks(
world: CombatWorld,
packs: readonly MonsterPack[],
around: { readonly x: number; readonly y: number },
spread: number,
terrain: CombatTerrain,
): number {
if (packs.length === 0) return 0
let placed = 0
let campIndex = 0
let attempt = 0
const attemptLimit = packs.length * 24
while (campIndex < packs.length && attempt < attemptLimit) {
attempt += 1
const angle = attempt * 2.399963
const radius = spread * Math.sqrt(attempt / attemptLimit)
const campX = around.x + Math.cos(angle) * radius
const campY = around.y + Math.sin(angle) * radius
if (terrain.overlap(campX, campY) > 0) continue
const pack = packs[campIndex]!
campIndex += 1
let memberAttempt = 0
let memberPlaced = 0
while (memberPlaced < pack.members.length && memberAttempt < pack.members.length * 12) {
// The leader stands on the camp centre; the rest ring it. Offsetting the
// ring by the camp's own angle stops every camp in the level from having
// an identically oriented formation.
const memberAngle = angle + memberAttempt * 2.399963
const memberRadius = memberAttempt === 0
? 0
: PACK_RADIUS_PX * Math.sqrt(memberAttempt / (pack.members.length * 12))
memberAttempt += 1
const x = campX + Math.cos(memberAngle) * memberRadius
const y = campY + Math.sin(memberAngle) * memberRadius
if (terrain.overlap(x, y) > 0) continue
const definition = pack.members[memberPlaced]!
world.monsters.push({
index: world.monsters.length,
stats: definition,
x, y,
hp: definition.hp,
cooldown: 0,
state: 'idle',
facing: 0,
hitFlash: 0,
corpseTicks: 0,
})
memberPlaced += 1
placed += 1
}
}
return placed
}
/**
* Direction index (0 = south, turning west) for a vector.
*

754
src/game/monsters.ts Normal file
View File

@ -0,0 +1,754 @@
/**
* Real monster data: `MonStats.txt`, `Levels.txt`'s monster columns,
* `SuperUniques.txt` and `MonUMod.txt`.
*
* ## Why this is a separate module from `combat.ts`
*
* `combat.ts`'s {@link monsterStatsFromRow} reads a *fixture* table — nine
* columns named `Id/Name/HP/Damage/...` that the sandbox and the co-op tests
* write by hand. The real `MonStats.txt` has **255 columns** and shares almost
* none of those names: it is `NameStr` not `Name`, `minHP`/`maxHP` not `HP`,
* `A1MinD`/`A1MaxD` not `Damage`, `Exp` not `XP`, `Velocity` not `Speed`.
* Pointing the fixture reader at the real table does not fail — it silently
* falls back to its defaults for every column, producing 735 monsters with
* identical stats. That is worse than having no monsters, because it looks like
* it works. Hence two readers, each honest about the shape it reads.
*
* ## What is real here and what is not
*
* Read straight from the tables, no interpretation:
* `Id`, `BaseId`, `NameStr`, `Code`, `MonType`, `AI`, `Rarity`, `MinGrp`,
* `MaxGrp`, `Level`, `minHP`/`maxHP`, `AC`, `Exp`, `A1*`/`A2*`, the six
* resistances, `TreasureClass*`, and every `Levels.txt` monster column.
*
* Engine-side approximations, each marked at its definition:
* - **Difficulty scaling is not applied, and the difficulty columns are not it.**
* `MonStats.txt` has `MinHP(N)`/`MinHP(H)` columns, and they are *not* the
* Nightmare and Hell health. Measured against the 1.13c drop: `fallen1` is
* 21–61 on Normal but 25–55 on both Nightmare and Hell, 426 of the 594 rows
* with health have a **lower** `MaxHP(N)` than `maxHP`, and Duriel — a boss,
* where these columns do apply — drops from 4757 to 1295 on Hell. The real
* scaling comes from `MonLvl.txt`'s per-level multipliers applied to the
* Normal values; the difficulty columns are a boss-only override that the
* data itself uses inconsistently. They are read and kept so M9 (真实战斗数学)
* has them, and this module does not pretend they mean more than that.
* - **Speed, reach, aggro radius and attack cooldown** have no direct pixel or
* tick equivalent in the tables. They are derived from the player's own
* numbers by a documented ratio rather than invented.
* - **Density → monster count.** `MonDen` is consumed by the real level
* generator per *room*; we have no rooms until M8. The conversion here is an
* area-proportional approximation and says so.
*/
import type { D2Table } from './acts.ts'
import { cell } from './acts.ts'
import type { MonsterStats } from './combat.ts'
import { Rng } from './rng.ts'
/** Which difficulty a number is being read for. */
export type Difficulty = 'normal' | 'nightmare' | 'hell'
/**
* The suffix `MonStats.txt` and `Levels.txt` use for a difficulty's columns.
*
* @param difficulty - the difficulty.
* @returns `''`, `'(N)'` or `'(H)'`.
*/
export function difficultySuffix(difficulty: Difficulty): string {
if (difficulty === 'nightmare') return '(N)'
if (difficulty === 'hell') return '(H)'
return ''
}
/**
* A cell as a number.
*
* @param table - the table.
* @param row - the row.
* @param column - the column name.
* @param fallback - value for a missing or non-numeric cell.
* @returns the number.
*/
function num(table: D2Table, row: readonly string[], column: string, fallback = 0): number {
const raw = cell(table, row, column).trim()
if (raw === '') return fallback
const value = Number(raw)
return Number.isFinite(value) ? value : fallback
}
/**
* A cell as a flag.
*
* The tables write `1` for true and leave false blank rather than writing `0`,
* so anything non-empty and non-zero counts.
*
* @param table - the table.
* @param row - the row.
* @param column - the column name.
* @returns whether the flag is set.
*/
function flag(table: D2Table, row: readonly string[], column: string): boolean {
const raw = cell(table, row, column).trim()
return raw !== '' && raw !== '0'
}
/** One physical or special attack, as the table describes it. */
export interface MonsterAttack {
/** `A1MinD` — minimum damage. */
readonly minDamage: number
/** `A1MaxD` — maximum damage. */
readonly maxDamage: number
/** `A1TH` — attack rating, used by M9's to-hit formula. */
readonly toHit: number
}
/** The six damage types a monster resists, as percentages. */
export interface MonsterResistances {
/** `ResDm` — physical. */
readonly physical: number
/** `ResMa` — magic. */
readonly magic: number
/** `ResFi` — fire. */
readonly fire: number
/** `ResLi` — lightning. */
readonly lightning: number
/** `ResCo` — cold. */
readonly cold: number
/** `ResPo` — poison. */
readonly poison: number
}
/**
* One row of `MonStats.txt`, with the columns this project uses.
*
* Deliberately not all 255: the omitted ones are skills, sounds, overlays and
* the client-side animation hints, which belong to later milestones. Adding a
* field here is cheap; guessing what an unread column means is not.
*/
export interface MonsterKind {
/** `Id` — the key every other table refers to it by. */
readonly id: string
/** `BaseId` — the family head; `fallen2` and `fallen3` both base on `fallen1`. */
readonly baseId: string
/**
* `NameStr` — a **`string.tbl` key**, not display text.
*
* `fallen1` is `"Fallen"` and happens to read like a name, but `corruptrogue1`
* is `"DarkHunter"`, which is the key for 「黑暗猎手」. Resolving these is the
* localisation work tracked separately.
*/
readonly nameKey: string
/** `Code` — the two-letter token the art paths are built from, e.g. `FA`. */
readonly code: string
/** `MonType` — family, for immunities and skill counters (`MonType.txt`). */
readonly monType: string
/** `AI` — behaviour name (`MonAi.txt`). Not yet interpreted. */
readonly ai: string
/** `enabled` — rows that are off are development leftovers. */
readonly enabled: boolean
/** `isSpawn` — whether the level populator may place it at all. */
readonly isSpawn: boolean
/** `isMelee` — melee rather than ranged. */
readonly isMelee: boolean
/** `rangedtype` — uses a missile attack. */
readonly ranged: boolean
/** `npc` — a town character, never a wild spawn. */
readonly npc: boolean
/** `interact` — can be talked to. */
readonly interact: boolean
/** `inTown` — belongs in a town level. */
readonly inTown: boolean
/** `boss` — a boss, excluded from ordinary population. */
readonly boss: boolean
/** `killable` — some rows are scenery that merely looks alive. */
readonly killable: boolean
/**
* `Rarity` — relative weight when the level picks which types to use.
*
* Not a percentage: it is a weight within the level's own pool, so a `2`
* against a `1` is twice as likely to be chosen.
*/
readonly rarity: number
/** `MinGrp` — smallest pack this monster appears in. */
readonly minGroup: number
/** `MaxGrp` — largest pack. */
readonly maxGroup: number
/** `Level`, `Level(N)`, `Level(H)` — its level per difficulty. */
readonly level: readonly [number, number, number]
/** `Velocity` — walking speed, in the table's own units. */
readonly velocity: number
/** `Run` — running speed, same units. */
readonly runVelocity: number
/** `threat` — AI aggression weight. Almost always 10; not a radius. */
readonly threat: number
/** `aidist` — AI engagement distance, in sub-tiles. Frequently 0. */
readonly aiDistance: number
/** `minHP` — lower bound of the health roll. */
readonly minHp: number
/** `maxHP` — upper bound of the health roll. */
readonly maxHp: number
/** `AC` — defence, for M9's to-hit formula. */
readonly armour: number
/** `Exp` — experience awarded. */
readonly experience: number
/** `A1*` — the primary attack. */
readonly attack1: MonsterAttack
/** `A2*` — the secondary attack. */
readonly attack2: MonsterAttack
/** `ResDm`…`ResPo` for this difficulty. */
readonly resistances: MonsterResistances
/** `TreasureClass1..4`, blanks dropped. Consumed by M10. */
readonly treasureClasses: readonly string[]
/** `minion1`, `minion2` — what spawns alongside it, blanks dropped. */
readonly minions: readonly string[]
/** `SetBoss` — the pack leader becomes a unique. */
readonly setBoss: boolean
}
/**
* Read every row of `MonStats.txt`.
*
* Disabled rows are kept: `enabled` is exposed rather than filtered so a caller
* that wants the whole table (a verification script counting coverage) and one
* that wants spawnable monsters can both be served without a second parse.
*
* @param table - the parsed `MonStats.txt`.
* @param difficulty - which difficulty's columns to read.
* @returns the kinds, keyed by `Id`.
*/
export function readMonsterKinds(table: D2Table, difficulty: Difficulty = 'normal'): Map<string, MonsterKind> {
const d = difficultySuffix(difficulty)
const kinds = new Map<string, MonsterKind>()
for (const row of table.rows) {
const id = cell(table, row, 'Id').trim()
if (id === '') continue
// `MinHP`/`MaxHP` are capitalised differently per difficulty in the real
// file: `minHP` on normal, `MinHP(N)` and `MinHP(H)` after it. Reading the
// wrong case silently yields 0, so both spellings are tried.
const minHp = d === '' ? num(table, row, 'minHP') : num(table, row, `MinHP${d}`, num(table, row, `minHP${d}`))
const maxHp = d === '' ? num(table, row, 'maxHP') : num(table, row, `MaxHP${d}`, num(table, row, `maxHP${d}`))
kinds.set(id, {
id,
baseId: cell(table, row, 'BaseId').trim() || id,
nameKey: cell(table, row, 'NameStr').trim(),
code: cell(table, row, 'Code').trim(),
monType: cell(table, row, 'MonType').trim(),
ai: cell(table, row, 'AI').trim(),
enabled: flag(table, row, 'enabled'),
isSpawn: flag(table, row, 'isSpawn'),
isMelee: flag(table, row, 'isMelee'),
ranged: flag(table, row, 'rangedtype'),
npc: flag(table, row, 'npc'),
interact: flag(table, row, 'interact'),
inTown: flag(table, row, 'inTown'),
boss: flag(table, row, 'boss'),
killable: flag(table, row, 'killable'),
rarity: num(table, row, 'Rarity', 1),
minGroup: num(table, row, 'MinGrp', 1),
maxGroup: num(table, row, 'MaxGrp', 1),
level: [num(table, row, 'Level'), num(table, row, 'Level(N)'), num(table, row, 'Level(H)')],
velocity: num(table, row, 'Velocity'),
runVelocity: num(table, row, 'Run'),
threat: num(table, row, 'threat'),
aiDistance: num(table, row, `aidist${d}`, num(table, row, 'aidist')),
minHp,
maxHp,
armour: num(table, row, `AC${d}`),
experience: num(table, row, `Exp${d}`),
attack1: {
minDamage: num(table, row, `A1MinD${d}`),
maxDamage: num(table, row, `A1MaxD${d}`),
toHit: num(table, row, `A1TH${d}`),
},
attack2: {
minDamage: num(table, row, `A2MinD${d}`),
maxDamage: num(table, row, `A2MaxD${d}`),
toHit: num(table, row, `A2TH${d}`),
},
resistances: {
physical: num(table, row, `ResDm${d}`),
magic: num(table, row, `ResMa${d}`),
fire: num(table, row, `ResFi${d}`),
lightning: num(table, row, `ResLi${d}`),
cold: num(table, row, `ResCo${d}`),
poison: num(table, row, `ResPo${d}`),
},
treasureClasses: ['TreasureClass1', 'TreasureClass2', 'TreasureClass3', 'TreasureClass4']
.map(name => cell(table, row, `${name}${d}`).trim())
.filter(value => value !== ''),
minions: ['minion1', 'minion2']
.map(name => cell(table, row, name).trim())
.filter(value => value !== ''),
setBoss: flag(table, row, 'SetBoss'),
})
}
return kinds
}
/**
* The body and animation facts one monster's art needs, from `MonStats2.txt`.
*
* Only the fields that affect placement and drawing are read. The COF layer
* composition lives here too, but as a count rather than as the layer list —
* picking which of the sixteen layer columns are live is the art pass's job,
* not the data reader's.
*/
export interface MonsterArt {
/** Which `MonStats2.txt` row this came from; not always the monster's own id. */
readonly rowId: string
/** `SizeX`/`SizeY` — body footprint in sub-tiles. A Fallen is 2×2. */
readonly sizeX: number
readonly sizeY: number
/** `pixHeight` — sprite height in pixels, for the selection box. */
readonly pixelHeight: number
/** `MeleeRng` — melee reach in sub-tiles. Usually 0, meaning "touching". */
readonly meleeRange: number
/** `BaseW` — the default weapon class token, e.g. `hth`. */
readonly weaponClass: string
/** `TotalPieces` — how many COF layers compose this monster. */
readonly totalPieces: number
/** `dDT`…`dRN` — how many directions the death animation has; 8 for most. */
readonly directions: number
}
/**
* Read `MonStats2.txt`, resolving each monster through `BaseId` when it has no
* row of its own.
*
* 735 monsters share 610 art rows. The extras are the numbered difficulty
* variants — `quillrat6`, `slinger7`, `deathmauler6` and 57 others — which the
* game draws with their family's art: `quillrat6` has no row, `quillrat1` does,
* and `quillrat6.BaseId` is `quillrat1`. Looking up by id alone leaves those 60
* monsters invisible, which is exactly the kind of failure that shows up as an
* empty patch of ground rather than as an error.
*
* @param table - the parsed `MonStats2.txt`.
* @param kinds - the monsters, for the `BaseId` fallback.
* @returns art keyed by monster id, with an entry for every kind that resolves.
*/
export function readMonsterArt(table: D2Table, kinds: ReadonlyMap<string, MonsterKind>): Map<string, MonsterArt> {
const byRowId = new Map<string, readonly string[]>()
for (const row of table.rows) {
const id = cell(table, row, 'Id').trim()
if (id !== '') byRowId.set(id, row)
}
const art = new Map<string, MonsterArt>()
for (const kind of kinds.values()) {
const rowId = byRowId.has(kind.id) ? kind.id : (byRowId.has(kind.baseId) ? kind.baseId : '')
if (rowId === '') continue
const row = byRowId.get(rowId)!
art.set(kind.id, {
rowId,
sizeX: num(table, row, 'SizeX', 1),
sizeY: num(table, row, 'SizeY', 1),
pixelHeight: num(table, row, 'pixHeight'),
meleeRange: num(table, row, 'MeleeRng'),
weaponClass: cell(table, row, 'BaseW').trim(),
totalPieces: num(table, row, 'TotalPieces'),
// `dDT` is the death animation's direction count. Every other mode has
// its own column, but they agree in the shipped data, and death is the
// one mode every monster has.
directions: num(table, row, 'dDT', 8),
})
}
return art
}
/** What `Levels.txt` says should live on one level. */
export interface LevelMonsterPlan {
/** `Id`. */
readonly levelId: number
/** `Name`. */
readonly levelName: string
/**
* `MonDen` — spawn density.
*
* The real generator consumes this per room. Blood Moor is 520, a cave 600,
* the Cow Level 800, a town 0. Zero means "populate nothing", which is the
* one part of its meaning that needs no interpretation.
*/
readonly density: number
/** `MonUMin` — fewest elite packs. */
readonly eliteMin: number
/** `MonUMax` — most elite packs. */
readonly eliteMax: number
/** `NumMon` — how many *distinct types* are drawn from the pool. */
readonly typeCount: number
/** `mon1`…`mon10`, blanks dropped: the classic-game pool. */
readonly pool: readonly string[]
/** `nmon1`…`nmon10`: the pool on Nightmare and Hell. */
readonly nightmarePool: readonly string[]
/** `umon1`…`umon10`: the pool elite packs are drawn from. */
readonly elitePool: readonly string[]
/** `MonLvl1`/`MonLvl1Ex` etc. — the level monsters here are treated as. */
readonly monsterLevel: number
/** `MonWndr` — whether wandering monsters are allowed. */
readonly wander: boolean
}
/**
* Collect the `mon1`…`mon10`-style columns of one row.
*
* @param table - `Levels.txt`.
* @param row - the level's row.
* @param prefix - `mon`, `nmon` or `umon`.
* @returns the non-empty entries, in column order.
*/
function monsterColumns(table: D2Table, row: readonly string[], prefix: string): string[] {
const out: string[] = []
for (let slot = 1; slot <= 10; slot += 1) {
const value = cell(table, row, `${prefix}${String(slot)}`).trim()
if (value !== '') out.push(value)
}
return out
}
/**
* Read one level's monster columns.
*
* @param table - the parsed `Levels.txt`.
* @param levelId - the `Id` to look up.
* @param difficulty - which difficulty's density and elite counts to read.
* @param expansion - read the `…Ex` monster-level columns, as the expansion does.
* @returns the plan, or null when the level has no row.
*/
export function readLevelMonsterPlan(
table: D2Table,
levelId: number,
difficulty: Difficulty = 'normal',
expansion = true,
): LevelMonsterPlan | null {
const row = table.rows.find(candidate => num(table, candidate, 'Id', -1) === levelId)
if (row === undefined) return null
const d = difficultySuffix(difficulty)
const tier = difficulty === 'normal' ? 1 : difficulty === 'nightmare' ? 2 : 3
const levelColumn = `MonLvl${String(tier)}${expansion ? 'Ex' : ''}`
return {
levelId,
levelName: cell(table, row, 'Name').trim(),
density: num(table, row, `MonDen${d}`),
eliteMin: num(table, row, `MonUMin${d}`),
eliteMax: num(table, row, `MonUMax${d}`),
typeCount: num(table, row, 'NumMon'),
pool: monsterColumns(table, row, 'mon'),
nightmarePool: monsterColumns(table, row, 'nmon'),
elitePool: monsterColumns(table, row, 'umon'),
monsterLevel: num(table, row, levelColumn, num(table, row, `MonLvl${String(tier)}`)),
wander: flag(table, row, 'MonWndr'),
}
}
/** One row of `SuperUniques.txt` — a named boss with a fixed spawn. */
export interface SuperUnique {
/** `Superunique` — the key. */
readonly id: string
/** `Name` — a `string.tbl` key, like {@link MonsterKind.nameKey}. */
readonly nameKey: string
/** `Class` — which `MonStats.txt` row it is built from. */
readonly monsterId: string
/** `Mod1`…`Mod3` — `MonUMod.txt` ids, blanks and zeroes dropped. */
readonly modifiers: readonly number[]
/** `MinGrp`/`MaxGrp` — how many minions accompany it. */
readonly minMinions: number
readonly maxMinions: number
/** `EClass` — 0 normal, 1 champion-grade, 2 unique-grade. */
readonly enhancementClass: number
/** `AutoPos` — whether the generator may move it to a legal spot. */
readonly autoPosition: boolean
/** `Stacks` — several may occupy the same spot. */
readonly stacks: boolean
/** `Replaceable` — another super unique may take its place. */
readonly replaceable: boolean
/** `TC` for this difficulty. Consumed by M10. */
readonly treasureClass: string
}
/**
* Read `SuperUniques.txt`.
*
* The table carries **no level column**: where each one spawns is decided by
* the level generator, which places a "super unique" marker and then picks from
* the ones legal for that area. Reproducing that placement needs the DRLG room
* layout and is part of M8 — until then this is a lookup table, not a spawner.
*
* @param table - the parsed `SuperUniques.txt`.
* @param difficulty - which difficulty's treasure class to read.
* @returns the entries, in file order.
*/
export function readSuperUniques(table: D2Table, difficulty: Difficulty = 'normal'): SuperUnique[] {
const d = difficultySuffix(difficulty)
const out: SuperUnique[] = []
for (const row of table.rows) {
const id = cell(table, row, 'Superunique').trim()
const monsterId = cell(table, row, 'Class').trim()
// `Expansion` is a section divider: it fills in the name column and leaves
// every other column blank. Keeping it would hand the spawner an entry with
// no monster behind it.
if (id === '' || monsterId === '') continue
out.push({
id,
nameKey: cell(table, row, 'Name').trim(),
monsterId,
modifiers: ['Mod1', 'Mod2', 'Mod3']
.map(name => num(table, row, name))
.filter(value => value > 0),
minMinions: num(table, row, 'MinGrp'),
maxMinions: num(table, row, 'MaxGrp'),
enhancementClass: num(table, row, 'EClass'),
autoPosition: flag(table, row, 'AutoPos'),
stacks: flag(table, row, 'Stacks'),
replaceable: flag(table, row, 'Replaceable'),
treasureClass: cell(table, row, `TC${d}`).trim(),
})
}
return out
}
/** One row of `MonUMod.txt` — an affix an elite monster can carry. */
export interface EliteModifier {
/** `uniquemod` — the name, e.g. `strong`, `fast`, `coldenchant`. */
readonly name: string
/** `id` — the number `SuperUniques.txt` refers to it by. */
readonly id: number
/** `enabled`. */
readonly enabled: boolean
/** `champion` — may appear on champion packs, not only on uniques. */
readonly champion: boolean
/** `exclude1`/`exclude2` — modifiers it cannot be combined with. */
readonly excludes: readonly string[]
}
/**
* Read `MonUMod.txt`.
*
* @param table - the parsed `MonUMod.txt`.
* @returns the modifiers, in file order.
*/
export function readEliteModifiers(table: D2Table): EliteModifier[] {
const out: EliteModifier[] = []
for (const row of table.rows) {
const name = cell(table, row, 'uniquemod').trim()
if (name === '' || name === 'none') continue
out.push({
name,
id: num(table, row, 'id'),
enabled: flag(table, row, 'enabled'),
champion: flag(table, row, 'champion'),
excludes: ['exclude1', 'exclude2']
.map(column => cell(table, row, column).trim())
.filter(value => value !== ''),
})
}
return out
}
/** What a monster is: ordinary, or promoted by the level generator. */
export type MonsterRank = 'normal' | 'champion' | 'unique' | 'minion'
/** One pack the level populator wants placed. */
export interface MonsterGroup {
/** Which monster. */
readonly kind: MonsterKind
/** How many, from `MinGrp`…`MaxGrp`. */
readonly count: number
/** Whether this pack is an elite one. */
readonly rank: Exclude<MonsterRank, 'minion'>
}
/**
* Pick which monster types a level uses.
*
* `Levels.txt` lists up to ten candidates and a `NumMon` saying how many of
* them actually appear — that choice is made once when the level is generated,
* which is why the Blood Moor you walk into has zombies *or* quill rats rather
* than all three every time. Selection is weighted by `Rarity`.
*
* @param plan - the level's columns.
* @param kinds - every known monster.
* @param rng - seeded from the level, so one level always picks the same types.
* @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.
*/
export function selectLevelTypes(
plan: LevelMonsterPlan,
kinds: ReadonlyMap<string, MonsterKind>,
rng: Rng,
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)
const wanted = Math.min(plan.typeCount, candidates.length)
const chosen: MonsterKind[] = []
const remaining = [...candidates]
while (chosen.length < wanted && remaining.length > 0) {
const total = remaining.reduce((sum, kind) => sum + Math.max(1, kind.rarity), 0)
let ticket = rng.next() * total
let index = remaining.length - 1
for (let i = 0; i < remaining.length; i += 1) {
ticket -= Math.max(1, remaining[i]!.rarity)
if (ticket <= 0) { index = i; break }
}
chosen.push(remaining[index]!)
remaining.splice(index, 1)
}
return chosen
}
/**
* How much of a level's area one unit of `MonDen` is worth.
*
* **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.
*
* @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.
*/
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))
}
/**
* Break a level's budget into packs.
*
* Monsters in D2 come in groups, not as evenly scattered individuals:
* `MinGrp`/`MaxGrp` say a Fallen comes 2–3 at a time and a Hell Bovine 5–10.
* Spreading the budget over single monsters — which is what a round-robin over
* the type list does — produces a field of loners that behaves nothing like the
* game.
*
* Elite packs are drawn first, from `umon`, up to `MonUMax`; whatever budget is
* left goes to ordinary packs.
*
* @param plan - the level's columns.
* @param types - the types {@link selectLevelTypes} chose.
* @param kinds - every known monster, for the `umon` lookup.
* @param budget - how many monsters in total.
* @param rng - seeded from the level.
* @returns the packs to place.
*/
export function planMonsterGroups(
plan: LevelMonsterPlan,
types: readonly MonsterKind[],
kinds: ReadonlyMap<string, MonsterKind>,
budget: number,
rng: Rng,
): 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)
const elitePacks = plan.eliteMax <= 0 ? 0 : rng.int(plan.eliteMin, plan.eliteMax)
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.
groups.push({ kind, count, rank: rng.next() < 0.2 ? 'champion' : 'unique' })
remaining -= count
}
while (remaining > 0) {
const kind = types[rng.int(0, types.length - 1)]!
const count = Math.min(remaining, Math.max(1, rng.int(kind.minGroup, kind.maxGroup)))
groups.push({ kind, count, rank: 'normal' })
remaining -= count
}
return groups
}
/**
* The player's walking speed in `Velocity` units.
*
* `MonStats.txt` gives speeds in an abstract unit — a Fallen is 5, a Zombie 1,
* a Hell Bovine 5 — with no pixels anywhere in the file. Anchoring on the
* player (walk 6, run 9 in the same scale) turns them into our pixel speeds
* without inventing a constant out of nothing: a Fallen ends up slightly slower
* than the player, a Zombie six times slower, which is how they read in game.
*/
const PLAYER_WALK_VELOCITY = 6
/**
* Melee reach when `MonStats2.txt` gives none, in scene pixels.
*
* Most rows have `MeleeRng` 0, meaning "touching". A cell is 80×40, so half a
* cell's width is about as close as two bodies get.
*/
const DEFAULT_REACH_PX = 40
/**
* Ticks between monster attacks.
*
* The real value is the length of the attack animation, which lives in the COF
* files rather than in a table. 25 ticks is one second at the simulation rate —
* a placeholder that is honest about being one, to be replaced when M7's art
* pass reads the animation timings.
*/
const DEFAULT_ATTACK_COOLDOWN_TICKS = 25
/**
* Aggro radius when `aidist` is 0, in scene pixels.
*
* `threat` is 10 for nearly every row and is a weighting, not a distance;
* `aidist` is the real engagement range but is frequently blank. Roughly three
* cells is what an idle monster notices from.
*/
const DEFAULT_AGGRO_PX = 240
/** Scene pixels per sub-tile, for turning `aidist` into a radius. */
const PIXELS_PER_SUBTILE = 16
/**
* Bridge a table row to the numbers the combat simulation consumes.
*
* Health is rolled here rather than averaged: `minHP`/`maxHP` is a range, and
* collapsing it would make every Fallen in a pack die to exactly the same
* number of hits.
*
* @param kind - the table row.
* @param rng - seeded, so the same spawn rolls the same health.
* @param walkSpeedPx - the player's walking speed, the anchor for the scale.
* @returns stats the existing combat code can use unchanged.
*/
export function monsterStatsOf(kind: MonsterKind, rng: Rng, walkSpeedPx: number): MonsterStats {
const velocity = kind.velocity > 0 ? kind.velocity : 1
return {
id: kind.id,
name: kind.nameKey === '' ? kind.id : kind.nameKey,
hp: Math.max(1, rng.int(Math.min(kind.minHp, kind.maxHp), Math.max(kind.minHp, kind.maxHp))),
damage: Math.max(1, Math.round((kind.attack1.minDamage + kind.attack1.maxDamage) / 2)),
cooldownTicks: DEFAULT_ATTACK_COOLDOWN_TICKS,
reach: DEFAULT_REACH_PX,
aggroRadius: kind.aiDistance > 0 ? kind.aiDistance * PIXELS_PER_SUBTILE : DEFAULT_AGGRO_PX,
speed: Math.max(8, Math.round((velocity / PLAYER_WALK_VELOCITY) * walkSpeedPx)),
xp: Math.max(0, kind.experience),
}
}
/**
* How much an elite pack's members are strengthened.
*
* `MonUMod.txt` keeps these in its `constants` column: `champion +hp%` is 200,
* `unique +hp%` is 300. They are applied as multipliers on the rolled health.
*/
export const ELITE_HEALTH_MULTIPLIER: Readonly<Record<Exclude<MonsterRank, 'minion'>, number>> = {
normal: 1,
champion: 3,
unique: 4,
}

160
tests/monster-packs.test.ts Normal file
View File

@ -0,0 +1,160 @@
/**
* Pack-aware monster placement.
*
* The property that matters is the one the old round-robin spawner could not
* give: members of a pack must land near each other, and packs must land away
* from each other. These tests measure both distances rather than eyeballing a
* screenshot.
*/
import { describe, expect, it } from 'vitest'
import { createWorld, spawnMonsterPacks, spawnMonsters } from '../src/game/combat.ts'
import type { CombatTerrain, MonsterPack, MonsterStats } from '../src/game/combat.ts'
/** Open ground everywhere. */
const OPEN: CombatTerrain = { overlap: () => 0 }
/** A wall down the middle of the spawn area. */
const WALLED: CombatTerrain = {
overlap: (x: number) => (x > 900 && x < 1100 ? 1 : 0),
}
/**
* A monster definition.
*
* @param id - its id.
* @param hp - its health.
* @returns the stats.
*/
function stats(id: string, hp = 30): MonsterStats {
return { id, name: id, hp, damage: 5, cooldownTicks: 25, reach: 40, aggroRadius: 200, speed: 100, xp: 10 }
}
/**
* A pack of one kind.
*
* @param id - the monster id.
* @param count - how many members.
* @returns the pack.
*/
function pack(id: string, count: number): MonsterPack {
return { members: Array.from({ length: count }, (_, i) => stats(id, 30 + i)) }
}
/**
* Distance between two monsters.
*
* @param a - one.
* @param b - the other.
* @returns the distance in pixels.
*/
function distance(a: { x: number; y: number }, b: { x: number; y: number }): number {
return Math.hypot(a.x - b.x, a.y - b.y)
}
describe('spawnMonsterPacks', () => {
it('places every member of every pack on open ground', () => {
const world = createWorld(1000, 1000)
const placed = spawnMonsterPacks(world, [pack('fallen1', 4), pack('zombie1', 2)], { x: 1000, y: 1000 }, 600, OPEN)
expect(placed).toBe(6)
expect(world.monsters).toHaveLength(6)
})
it('keeps a pack together', () => {
const world = createWorld(1000, 1000)
spawnMonsterPacks(world, [pack('fallen1', 5)], { x: 1000, y: 1000 }, 600, OPEN)
const members = world.monsters
expect(members).toHaveLength(5)
// Every member is within a camp's radius of the leader.
const leader = members[0]!
for (const member of members.slice(1)) {
expect(distance(leader, member)).toBeLessThanOrEqual(96)
}
})
it('keeps packs apart', () => {
const world = createWorld(1000, 1000)
spawnMonsterPacks(world, [pack('fallen1', 3), pack('zombie1', 3), pack('quillrat1', 3)], { x: 1000, y: 1000 }, 800, OPEN)
const camps = new Map<string, { x: number; y: number }>()
for (const monster of world.monsters) {
if (!camps.has(monster.stats.id)) camps.set(monster.stats.id, { x: monster.x, y: monster.y })
}
const centres = [...camps.values()]
expect(centres).toHaveLength(3)
for (let i = 0; i < centres.length; i += 1) {
for (let j = i + 1; j < centres.length; j += 1) {
expect(distance(centres[i]!, centres[j]!)).toBeGreaterThan(96)
}
}
})
it('clusters far tighter than the flat spawner does', () => {
// This is the regression the function exists to prevent: passing the same
// monsters through `spawnMonsters` spreads them over the whole level.
const spreadOut = createWorld(1000, 1000)
spawnMonsters(spreadOut, [stats('fallen1')], 6, { x: 1000, y: 1000 }, 600, OPEN)
const clustered = createWorld(1000, 1000)
spawnMonsterPacks(clustered, [pack('fallen1', 6)], { x: 1000, y: 1000 }, 600, OPEN)
const spanOf = (monsters: readonly { x: number; y: number }[]): number => {
let max = 0
for (let i = 0; i < monsters.length; i += 1) {
for (let j = i + 1; j < monsters.length; j += 1) max = Math.max(max, distance(monsters[i]!, monsters[j]!))
}
return max
}
expect(spanOf(clustered.monsters)).toBeLessThan(spanOf(spreadOut.monsters) / 2)
})
it('gives each member its own rolled health rather than sharing one', () => {
const world = createWorld(1000, 1000)
spawnMonsterPacks(world, [pack('fallen1', 4)], { x: 1000, y: 1000 }, 600, OPEN)
expect(new Set(world.monsters.map(monster => monster.hp)).size).toBe(4)
})
it('refuses blocked ground rather than nudging monsters into it', () => {
const world = createWorld(1000, 1000)
const placed = spawnMonsterPacks(world, [pack('fallen1', 4), pack('zombie1', 4)], { x: 1000, y: 1000 }, 600, WALLED)
expect(world.monsters.every(monster => WALLED.overlap(monster.x, monster.y) === 0)).toBe(true)
expect(placed).toBe(world.monsters.length)
})
it('is deterministic', () => {
const encode = (): string => {
const world = createWorld(1000, 1000)
spawnMonsterPacks(world, [pack('fallen1', 3), pack('zombie1', 4)], { x: 1000, y: 1000 }, 500, OPEN)
return world.monsters.map(monster => `${monster.stats.id}@${monster.x.toFixed(3)},${monster.y.toFixed(3)}`).join('|')
}
expect(encode()).toBe(encode())
})
it('indexes monsters consecutively so the renderer can address them', () => {
const world = createWorld(1000, 1000)
spawnMonsterPacks(world, [pack('fallen1', 3), pack('zombie1', 2)], { x: 1000, y: 1000 }, 500, OPEN)
expect(world.monsters.map(monster => monster.index)).toEqual([0, 1, 2, 3, 4])
})
it('appends to a world that already has monsters', () => {
const world = createWorld(1000, 1000)
spawnMonsters(world, [stats('existing')], 2, { x: 1000, y: 1000 }, 200, OPEN)
spawnMonsterPacks(world, [pack('fallen1', 3)], { x: 1000, y: 1000 }, 500, OPEN)
expect(world.monsters).toHaveLength(5)
expect(world.monsters.map(monster => monster.index)).toEqual([0, 1, 2, 3, 4])
})
it('places nothing when given nothing', () => {
const world = createWorld(1000, 1000)
expect(spawnMonsterPacks(world, [], { x: 1000, y: 1000 }, 500, OPEN)).toBe(0)
expect(spawnMonsterPacks(world, [{ members: [] }], { x: 1000, y: 1000 }, 500, OPEN)).toBe(0)
})
it('starts every monster idle and undamaged', () => {
const world = createWorld(1000, 1000)
spawnMonsterPacks(world, [pack('fallen1', 3)], { x: 1000, y: 1000 }, 500, OPEN)
for (const monster of world.monsters) {
expect(monster.state).toBe('idle')
expect(monster.hp).toBe(monster.stats.hp)
expect(monster.cooldown).toBe(0)
expect(monster.corpseTicks).toBe(0)
}
})
})

514
tests/monsters.test.ts Normal file
View File

@ -0,0 +1,514 @@
/**
* 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')
})
})