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:
parent
ae94755401
commit
70b299569e
|
|
@ -21,6 +21,7 @@
|
||||||
"verify:renderer": "tsx scripts/verify-renderer-lifecycle.ts",
|
"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: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:acts": "tsx scripts/verify-acts.ts",
|
||||||
|
"verify:monsters": "tsx scripts/verify-monsters.ts",
|
||||||
"verify:generators": "tsx scripts/verify-generators.ts samples/d2",
|
"verify:generators": "tsx scripts/verify-generators.ts samples/d2",
|
||||||
"build:game": "vite build --base=/diablo2/ --outDir dist-game",
|
"build:game": "vite build --base=/diablo2/ --outDir dist-game",
|
||||||
"pack:data": "tsx scripts/pack-act-assets.ts",
|
"pack:data": "tsx scripts/pack-act-assets.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<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)
|
||||||
|
|
@ -90,13 +90,30 @@ export function cell(table: D2Table, row: readonly string[], column: string): st
|
||||||
return index === -1 ? '' : (row[index] ?? '')
|
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 {
|
export interface ActTables {
|
||||||
readonly levels: D2Table
|
readonly levels: D2Table
|
||||||
readonly lvltypes: D2Table
|
readonly lvltypes: D2Table
|
||||||
readonly lvlprest: D2Table
|
readonly lvlprest: D2Table
|
||||||
readonly monstats: D2Table
|
readonly monstats: D2Table
|
||||||
readonly monpreset: 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. */
|
/** 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'),
|
lvlprest: await read('lvlprest.txt'),
|
||||||
monstats: await read('monstats.txt'),
|
monstats: await read('monstats.txt'),
|
||||||
monpreset: await read('MonPreset.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'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -402,6 +402,103 @@ export function spawnMonsters(
|
||||||
return placed
|
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.
|
* Direction index (0 = south, turning west) for a vector.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue