430 lines
22 KiB
TypeScript
430 lines
22 KiB
TypeScript
/**
|
||
* 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)
|