From 70b299569e0f94683889a7e1f67367e8b75b861d Mon Sep 17 00:00:00 2001 From: taodao Date: Tue, 15 Sep 2026 08:06:52 +0000 Subject: [PATCH] =?UTF-8?q?M7=20=E7=AC=AC=E4=B8=80=E6=AD=A5=EF=BC=9A?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E6=80=AA=E7=89=A9=E6=95=B0=E6=8D=AE=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 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 --- package.json | 1 + scripts/verify-monsters.ts | 429 ++++++++++++++++++++ src/game/acts.ts | 24 +- src/game/combat.ts | 97 +++++ src/game/monsters.ts | 754 ++++++++++++++++++++++++++++++++++++ tests/monster-packs.test.ts | 160 ++++++++ tests/monsters.test.ts | 514 ++++++++++++++++++++++++ 7 files changed, 1978 insertions(+), 1 deletion(-) create mode 100644 scripts/verify-monsters.ts create mode 100644 src/game/monsters.ts create mode 100644 tests/monster-packs.test.ts create mode 100644 tests/monsters.test.ts diff --git a/package.json b/package.json index c0e9657..8e6677d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/verify-monsters.ts b/scripts/verify-monsters.ts new file mode 100644 index 0000000..9f3262c --- /dev/null +++ b/scripts/verify-monsters.ts @@ -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() +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() + 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) diff --git a/src/game/acts.ts b/src/game/acts.ts index c766050..18d0227 100644 --- a/src/game/acts.ts +++ b/src/game/acts.ts @@ -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 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. * diff --git a/src/game/monsters.ts b/src/game/monsters.ts new file mode 100644 index 0000000..9ad03bb --- /dev/null +++ b/src/game/monsters.ts @@ -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 { + const d = difficultySuffix(difficulty) + const kinds = new Map() + 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): Map { + const byRowId = new Map() + for (const row of table.rows) { + const id = cell(table, row, 'Id').trim() + if (id !== '') byRowId.set(id, row) + } + const art = new Map() + 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 +} + +/** + * 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, + 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, + 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, number>> = { + normal: 1, + champion: 3, + unique: 4, +} diff --git a/tests/monster-packs.test.ts b/tests/monster-packs.test.ts new file mode 100644 index 0000000..73d4fc2 --- /dev/null +++ b/tests/monster-packs.test.ts @@ -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() + 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) + } + }) +}) diff --git a/tests/monsters.test.ts b/tests/monsters.test.ts new file mode 100644 index 0000000..262acca --- /dev/null +++ b/tests/monsters.test.ts @@ -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') + }) +})