diablo2-web/src/game/monsters.ts

1601 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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:
* - **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.
*
* ## The numbers in `MonStats.txt` are not the numbers in the game
*
* A Fallen's row says 21–61 health and 51–101 damage. A Fallen in the Blood
* Moor has about three health and hits for one. The row holds *per-mille-ish*
* figures that `MonLvl.txt` turns into real ones:
*
* ```
* final = MonStats[column] × MonLvl[monster level][matching column] ÷ 100
* ```
*
* That was not assumed, it was measured: it reconciles five independent columns
* against values the game is known to show, all at once.
*
* | monster | column | table | × MonLvl ÷ 100 | in game |
* |---|---|---|---|---|
* | fallen1 | `Exp` | 61 | 18.3 | 18 |
* | zombie1 | `Exp` | 111 | 33.3 | 33 |
* | quillrat1 | `Exp` | 71 | 21.3 | 21 |
* | fallen1 | `AC` | 84 | 5.0 | 5 |
* | fallen1 | `A1TH` | 101 | 8.1 | 8 |
*
* Five exact hits is not a coincidence, so the scaling is applied rather than
* deferred. Two consequences worth stating:
*
* - The difficulty columns `MinHP(N)`/`MinHP(H)` are *not* Nightmare and Hell
* health either. `fallen1` is 21–61 on Normal and 25–55 on both harder ones,
* 426 of the 594 rows with health have a **lower** `MaxHP(N)` than `maxHP`,
* and Duriel drops from 4757 to 1295 on Hell. Difficulty comes from the
* monster's own `Level(N)`/`Level(H)` — a Fallen is level 1, then 36, then 67
* — fed through `MonLvl`'s `HP(N)`/`HP(H)` columns. That is what makes a
* Nightmare Fallen a 131–289 health monster rather than a 25–55 one.
* - `MonLvl.txt`'s `L-` prefixed columns, the expansion set, are byte-identical
* to the classic ones throughout 1.13c, so which set is read makes no
* difference. The classic ones are used.
*
* Bosses were checked for an exemption and did not get one. The data is not
* consistent enough to justify carving one out: scaled Diablo lands on 13818
* against a remembered 13741 and scaled Andariel on 1025 against ~960, but
* Duriel's raw 4757 is the figure usually quoted for him. Inventing a special
* case on that basis would be guessing; the act bosses get their own pass.
*/
import type { D2Table } from './acts.ts'
import { cell, parseTable } from './acts.ts'
import type { MonsterPack, 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
readonly [k: string]: number | undefined
}
/**
* 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
/**
* `sparsePopulate` — percentage chance (0..100) this monster is allowed to spawn.
* Blank means 100%. Per KB 360: controls overall spawn chance.
*/
readonly sparsePopulate: number
/** `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[]
/**
* Resolve TC for given difficulty and monster type (1=Normal, 2=Champion, 3=Unique, 4=Boss/Quest).
*/
getTreasureClass?(difficulty?: Difficulty, monsterType?: number): string
/** `minion1`, `minion2` — what spawns alongside it, blanks dropped. */
readonly minions: readonly string[]
/** `SetBoss` — the pack leader becomes a unique. */
readonly setBoss: boolean
/** `MeleeRng` — melee reach in sub-tiles from `MonStats2.txt`. */
readonly meleeRange?: number
}
/**
* Resolves the TreasureClass name for a MonsterKind given difficulty and monster type.
*
* Monster types:
* - 1: Normal monster -> TreasureClass1
* - 2: Champion monster -> TreasureClass2 (fallback TreasureClass1)
* - 3: Unique monster -> TreasureClass3 (fallback TreasureClass1)
* - 4: Quest / Boss drop -> TreasureClass4 (fallback TreasureClass3, then TreasureClass1)
*
* @param kind - Monster definition.
* @param difficulty - Difficulty level ('normal' | 'nightmare' | 'hell').
* @param monsterType - Monster type code (1..4).
* @returns TreasureClass name.
*/
export function getMonsterTreasureClass(
kind: MonsterKind,
difficulty: Difficulty = 'normal',
monsterType = 1,
): string {
if (typeof kind.getTreasureClass === 'function') {
return kind.getTreasureClass(difficulty, monsterType)
}
return kind.treasureClasses[monsterType - 1] ?? kind.treasureClasses[0] ?? ''
}
/**
* 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}`))
const allTreasureClasses: Record<Difficulty, [string, string, string, string]> = {
normal: [
cell(table, row, 'TreasureClass1').trim(),
cell(table, row, 'TreasureClass2').trim(),
cell(table, row, 'TreasureClass3').trim(),
cell(table, row, 'TreasureClass4').trim(),
],
nightmare: [
cell(table, row, 'TreasureClass1(N)').trim() || cell(table, row, 'TreasureClass(N)1').trim(),
cell(table, row, 'TreasureClass2(N)').trim() || cell(table, row, 'TreasureClass(N)2').trim(),
cell(table, row, 'TreasureClass3(N)').trim() || cell(table, row, 'TreasureClass(N)3').trim(),
cell(table, row, 'TreasureClass4(N)').trim() || cell(table, row, 'TreasureClass(N)4').trim(),
],
hell: [
cell(table, row, 'TreasureClass1(H)').trim() || cell(table, row, 'TreasureClass(H)1').trim(),
cell(table, row, 'TreasureClass2(H)').trim() || cell(table, row, 'TreasureClass(H)2').trim(),
cell(table, row, 'TreasureClass3(H)').trim() || cell(table, row, 'TreasureClass(H)3').trim(),
cell(table, row, 'TreasureClass4(H)').trim() || cell(table, row, 'TreasureClass(H)4').trim(),
],
}
const getTc = (diff: Difficulty = 'normal', mType = 1): string => {
const tcForDiff = allTreasureClasses[diff] ?? allTreasureClasses.normal
const normalTc = allTreasureClasses.normal
if (mType === 4) {
return tcForDiff[3] || tcForDiff[2] || tcForDiff[0] || normalTc[3] || normalTc[2] || normalTc[0] || ''
}
if (mType === 3) {
return tcForDiff[2] || tcForDiff[0] || normalTc[2] || normalTc[0] || ''
}
if (mType === 2) {
return tcForDiff[1] || tcForDiff[0] || normalTc[1] || normalTc[0] || ''
}
return tcForDiff[0] || normalTc[0] || ''
}
const kind: MonsterKind = {
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'),
sparsePopulate: num(table, row, 'sparsePopulate', 100),
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'),
}
Object.defineProperty(kind, 'getTreasureClass', {
value: getTc,
enumerable: false,
configurable: true,
writable: true,
})
kinds.set(id, kind)
}
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
}
/**
* The five multipliers `MonLvl.txt` holds for one monster level.
*
* Each is a percentage applied to the matching `MonStats.txt` column. Level 1
* is `AC 6, TH 8, HP 7, DM 2, XP 30`, which is why a Fallen's 84 defence in the
* table is a defence of 5 on the ground.
*/
export interface MonsterScale {
/** `AC` — multiplies `MonStats.AC`. */
readonly armour: number
/** `TH` — multiplies the attacks' `TH` columns. */
readonly toHit: number
/** `HP` — multiplies `minHP`/`maxHP`. */
readonly health: number
/** `DM` — multiplies the attacks' `MinD`/`MaxD` columns. */
readonly damage: number
/** `XP` — multiplies `Exp`. */
readonly experience: number
}
/**
* Read `MonLvl.txt` for one difficulty.
*
* `MonLvl.txt` carries each multiplier twice: a plain set and an `L-` prefixed
* one. They are not two versions of the same number — per the file's own guide,
* *"all L-XX columns is used for ladder/single player and tcp-ip"*, and the
* plain set is closed Battle.net's. This project is single-player, so the `L-`
* set is the correct one to read. Throughout 1.13c the two are byte-identical,
* so nothing changes numerically today; reading the right one means nothing
* changes silently if a mod ever makes them differ.
*
* @param table - the parsed `MonLvl.txt`.
* @param difficulty - which difficulty's columns to read.
* @returns the multipliers, keyed by monster level.
*/
export function readMonsterScaling(table: D2Table, difficulty: Difficulty = 'normal'): Map<number, MonsterScale> {
const d = difficultySuffix(difficulty)
const scales = new Map<number, MonsterScale>()
/** The `L-` column, falling back to the plain one when a file lacks it. */
const single = (row: readonly string[], name: string): number =>
num(table, row, `L-${name}${d}`, num(table, row, `${name}${d}`))
for (const row of table.rows) {
const level = num(table, row, 'Level', -1)
if (level < 0) continue
scales.set(level, {
armour: single(row, 'AC'),
toHit: single(row, 'TH'),
health: single(row, 'HP'),
damage: single(row, 'DM'),
experience: single(row, 'XP'),
})
}
return scales
}
/** No scaling: every column passes through unchanged. */
export const UNSCALED: MonsterScale = { armour: 100, toHit: 100, health: 100, damage: 100, experience: 100 }
/**
* The level a monster is actually scaled at.
*
* This is the part that is easy to get wrong, because `MonStats.txt` has three
* `Level` columns and only the first of them is generally used. Per the file's
* guide: *"This setting is only used on normal. On nightmare and hell the
* monsters level is identical with the area level from Levels.txt, unless your
* monster has BOSS column set to 1, in this case its level will be always taken
* from these 3 columns."*
*
* So there are two rules, not one:
*
* - **Normal**, and **bosses on any difficulty**: the monster's own `Level`,
* `Level(N)` or `Level(H)`.
* - **Everyone else on Nightmare and Hell**: the *area's* level, which is
* {@link LevelMonsterPlan.monsterLevel} — `Levels.txt`'s `MonLvl2Ex` and
* `MonLvl3Ex`. A Fallen in the Blood Moor and a Fallen in the Catacombs are
* not the same monster on Hell, and that is where the difference lives.
*
* Reading `Level(N)` for an ordinary monster gives a Hell Fallen the level of
* the hardest place a Fallen appears no matter where it is standing, which is
* wrong everywhere except that one place.
*
* @param kind - the monster.
* @param difficulty - which difficulty.
* @param areaLevel - the level's own monster level, ignored on Normal and for bosses.
* @returns the level to look the multipliers up at.
*/
export function monsterLevelFor(kind: MonsterKind, difficulty: Difficulty, areaLevel: number): number {
const tier = difficulty === 'normal' ? 0 : difficulty === 'nightmare' ? 1 : 2
if (difficulty === 'normal' || kind.boss) return kind.level[tier]
return areaLevel > 0 ? areaLevel : kind.level[tier]
}
/**
* Whether a missing `MonLvl.txt` row is fatal.
*
* Off by default, because a hand-built fixture legitimately ships a two-row
* table. Turn it on in a bake or a verification script, where a missing row is a
* broken input rather than a deliberately small one.
*/
let scalingStrict = false
/**
* Levels already warned about, so one missing row is one line and not one line
* per monster rolled at that level.
*/
const warnedScaleLevels = new Set<number>()
/**
* Make a missing `MonLvl.txt` row throw instead of warn.
*
* @param strict - true to throw.
*/
export function setMonsterScalingStrict(strict: boolean): void {
scalingStrict = strict
if (!strict) warnedScaleLevels.clear()
}
/** The levels {@link monsterScaleFor} has had to fall back to {@link UNSCALED} for. */
export function monsterScalingMisses(): readonly number[] {
return [...warnedScaleLevels].sort((a, b) => a - b)
}
/** A scale lookup that says whether it found a row. */
export interface MonsterScaleLookup {
readonly scale: MonsterScale
/** True when the level had no `MonLvl.txt` row and {@link UNSCALED} was used. */
readonly missing: boolean
}
/**
* The multipliers for a level, with the miss reported rather than hidden.
*
* The caller needs this distinction because of what {@link UNSCALED} means: the
* formula is `raw × scale ÷ 100`, so falling back to 100 across the board uses
* the *raw table values* as game values. A Fallen then arrives with `MonStats`'
* 21–61 HP instead of the ~3 HP the level-1 row scales it down to, which reads
* as a balance problem and is in fact a missing row.
*
* @param scaling - the table, from {@link readMonsterScaling}.
* @param level - the level, from {@link monsterLevelFor}.
* @returns the scale and whether the row was missing.
*/
export function monsterScaleLookup(scaling: ReadonlyMap<number, MonsterScale>, level: number): MonsterScaleLookup {
const found = scaling.get(level)
if (found !== undefined) return { scale: found, missing: false }
return { scale: UNSCALED, missing: true }
}
/**
* The multipliers for a level.
*
* A level with no row used to return {@link UNSCALED} silently; it now warns
* once per level, and throws when {@link setMonsterScalingStrict} is on. Callers
* that need to react rather than read the console use {@link monsterScaleLookup}.
*
* @param scaling - the table, from {@link readMonsterScaling}.
* @param level - the level, from {@link monsterLevelFor}.
* @returns the scale, or {@link UNSCALED} when the level has no row.
* @throws when strict mode is on and the level has no row.
*/
export function monsterScaleFor(scaling: ReadonlyMap<number, MonsterScale>, level: number): MonsterScale {
const lookup = monsterScaleLookup(scaling, level)
if (!lookup.missing) return lookup.scale
const message = `MonLvl.txt has no row for level ${String(level)}: `
+ `monsters there keep their raw MonStats values (about 20x too strong)`
if (scalingStrict) throw new Error(message)
if (!warnedScaleLevels.has(level)) {
warnedScaleLevels.add(level)
console.warn(message)
}
return lookup.scale
}
/** 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.
*/
export function monsterColumns(table: D2Table, row: readonly string[], prefix: string): string[] {
const out: string[] = []
for (let slot = 1; slot <= 25; slot += 1) {
const col = `${prefix}${String(slot)}`
if (!table.header.includes(col)) break
const value = cell(table, row, col).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
}
/**
* Read the parameters table embedded in `MonUMod.txt`.
*
* The last two columns of `MonUMod.txt` ('constants' and '*constant desc') form
* a parameters table controlling general properties of champions, uniques, and minions.
*
* @param table - the parsed `MonUMod.txt`.
* @returns map of constant description to numeric value.
*/
export function readMonUModConstants(table: D2Table): Map<string, number> {
const constants = new Map<string, number>()
for (const row of table.rows) {
const desc = cell(table, row, '*constant desc').trim()
const val = num(table, row, 'constants', NaN)
if (desc !== '' && !Number.isNaN(val)) {
constants.set(desc, val)
}
}
return constants
}
/** Canonical constants extracted from `MonUMod.txt`. */
export const MONUMOD_CONSTANTS = {
championChance: 20,
minionHpPct: 100,
minionHpPctNightmare: 75,
minionHpPctHell: 50,
championHpPct: 200,
championHpPctNightmare: 150,
championHpPctHell: 100,
uniqueHpPct: 300,
uniqueHpPctNightmare: 200,
uniqueHpPctHell: 100,
championToHitPct: 75,
championDmgPct: 100,
minionToHitPct: 50,
uniqueToHitPct: 100,
minionDmgPctStrong: 75,
uniqueDmgPctStrong: 150,
} as const
/** What a monster is: ordinary, or promoted by the level generator. */
export type MonsterRank = 'normal' | 'champion' | 'unique' | 'minion'
/** Canonical elite modifiers from `MonUMod.txt`. */
export const CANONICAL_ELITE_MODIFIERS: readonly {
readonly id: number
readonly name: string
readonly label: string
readonly champion: boolean
}[] = [
{ id: 5, name: 'strong', label: 'Extra Strong', champion: true },
{ id: 6, name: 'fast', label: 'Extra Fast', champion: true },
{ id: 7, name: 'cursed', label: 'Cursed', champion: false },
{ id: 8, name: 'magicresistant', label: 'Magic Resistant', champion: false },
{ id: 9, name: 'fireenchant', label: 'Fire Enchanted', champion: false },
{ id: 17, name: 'lightenchant', label: 'Lightning Enchanted', champion: false },
{ id: 18, name: 'coldenchant', label: 'Cold Enchanted', champion: false },
{ id: 25, name: 'manahit', label: 'Mana Burn', champion: false },
{ id: 26, name: 'teleport', label: 'Teleportation', champion: false },
{ id: 27, name: 'spectralhit', label: 'Spectral Hit', champion: false },
{ id: 28, name: 'stoneskin', label: 'Stone Skin', champion: false },
{ id: 29, name: 'multishot', label: 'Multiple Shots', champion: false },
{ id: 30, name: 'aura', label: 'Aura Enchanted', champion: false },
]
/**
* Roll elite modifiers (`MonUMod.txt`) for a champion or unique pack.
*/
export function rollEliteModifiers(
rank: Exclude<MonsterRank, 'minion'>,
rng: Rng,
): readonly string[] {
if (rank === 'normal') return []
if (rank === 'champion') {
const champs = CANONICAL_ELITE_MODIFIERS.filter(m => m.champion)
return [champs[rng.int(0, champs.length - 1)]!.name]
}
const count = rng.int(1, 2)
const chosen: string[] = []
const pool = [...CANONICAL_ELITE_MODIFIERS]
while (chosen.length < count && pool.length > 0) {
const idx = rng.int(0, pool.length - 1)
chosen.push(pool[idx]!.name)
pool.splice(idx, 1)
}
return chosen
}
/**
* Apply elite/superunique stat modifiers to base rolled `MonsterStats`.
*/
export function applyEliteModifiers(
base: MonsterStats,
rank: MonsterRank,
modifiers: readonly string[] = [],
superUniqueId?: string,
): MonsterStats {
let hp = base.hp
let damage = base.damage
let speed = base.speed
if (modifiers.includes('strong')) {
damage = Math.max(1, Math.round(damage * (MONUMOD_CONSTANTS.uniqueDmgPctStrong / 100)))
}
if (modifiers.includes('fast')) {
speed = Math.round(speed * 1.4 * 100) / 100
}
if (modifiers.includes('stoneskin')) {
hp = Math.round(hp * 2)
}
if (modifiers.includes('cursed')) {
damage = Math.round(damage * 1.2)
}
if (modifiers.includes('coldenchant')) {
damage = Math.round(damage * 1.2)
}
if (modifiers.includes('fireenchant')) {
damage = Math.round(damage * 1.2)
}
if (modifiers.includes('lightenchant')) {
damage = Math.round(damage * 1.2)
}
if (modifiers.includes('spectralhit')) {
damage = Math.round(damage * 1.2)
}
if (modifiers.includes('teleport')) {
speed = Math.round(speed * 1.2 * 100) / 100
}
if (modifiers.includes('magicresistant')) {
hp = Math.round(hp * 1.2)
}
if (modifiers.includes('aura')) {
damage = Math.round(damage * 1.2)
speed = Math.round(speed * 1.1 * 100) / 100
}
let lightningRes = base.lightningResist ?? base.resistances?.lightning ?? 0
if (modifiers.includes('lightenchant')) {
lightningRes += 75
}
if (modifiers.includes('magicresistant')) {
lightningRes += 20
}
const updatedResistances = base.resistances
? { ...base.resistances, lightning: lightningRes }
: (lightningRes !== 0 ? { lightning: lightningRes } : undefined)
return {
...base,
hp,
damage,
speed,
rank,
...(modifiers.length > 0 ? { modifiers } : {}),
...(superUniqueId !== undefined ? { superUniqueId } : {}),
...(updatedResistances !== undefined ? { resistances: updatedResistances, resists: updatedResistances, lightningResist: lightningRes } : {}),
}
}
/** One pack the level populator wants placed. */
export interface MonsterGroup {
/** Which monster. */
readonly kind: MonsterKind
/** How many, from MinGrp to MaxGrp. */
readonly count: number
/** Whether this pack is an elite one. */
readonly rank: Exclude<MonsterRank, 'minion'>
/** Rolled elite modifiers (`MonUMod.txt`). */
readonly modifiers?: readonly string[]
}
/**
* 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 && kind.sparsePopulate > 0)
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 && kind.sparsePopulate > 0)
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.
const rank: Exclude<MonsterRank, 'minion'> = rng.next() < (MONUMOD_CONSTANTS.championChance / 100) ? 'champion' : 'unique'
const modSeed = ((i + 1) * 0x9e3779b9) ^ (count * 0x85ebca6b) ^ (rank === 'champion' ? 1 : 2)
const modifiers = rollEliteModifiers(rank, new Rng(modSeed))
groups.push({ kind, count, rank, modifiers })
remaining -= count
}
const totalRarity = types.reduce((sum, kind) => sum + Math.max(1, kind.rarity), 0)
while (remaining > 0) {
let ticket = rng.next() * totalRarity
let kind = types[types.length - 1]!
for (let j = 0; j < types.length; j += 1) {
ticket -= Math.max(1, types[j]!.rarity)
if (ticket <= 0) {
kind = types[j]!
break
}
}
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.
*/
export 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
/**
* Default AI activation radius when `aidist` is blank or 0, in sub-tiles.
*
* Per d2mods KB article 360: *"aidist, aidist(N), aidist(H): the distance in
* cells from which AI is activated. Most AI's have base hardcoded activation
* radius of 35 which stands for a distance of about 1 screen, thus leaving these
* fields blank sets this to 35 automatically."*
*
* In sub-tile pixels (16 px per sub-tile), 35 sub-tiles = 560 px.
*/
const DEFAULT_AI_DISTANCE_SUBTILES = 35
const DEFAULT_AGGRO_PX = DEFAULT_AI_DISTANCE_SUBTILES * 16
/** 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.
* @param scale - the `MonLvl.txt` multipliers; defaults to no scaling.
* @returns stats the existing combat code can use unchanged.
*/
export function monsterStatsOf(
kind: MonsterKind,
rng: Rng,
walkSpeedPx: number,
scale: MonsterScale = UNSCALED,
art?: MonsterArt | { readonly meleeRange?: number } | null,
level?: number,
): MonsterStats {
const velocity = kind.velocity > 0 ? kind.velocity : 1
// Roll first, scale second. Scaling the bounds and rolling between them
// would round the range's ends before the roll and lose most of its width at
// low levels, where the multiplier is a few percent: a Fallen's 21..61 at
// level 1 becomes 1..4, and three of those four values are unreachable if
// each end is rounded first.
const rolled = rng.int(Math.min(kind.minHp, kind.maxHp), Math.max(kind.minHp, kind.maxHp))
const averageDamage = (kind.attack1.minDamage + kind.attack1.maxDamage) / 2
const meleeRng = art?.meleeRange ?? kind.meleeRange
const reach = (meleeRng !== undefined && meleeRng > 0)
? Math.max(DEFAULT_REACH_PX, meleeRng * PIXELS_PER_SUBTILE)
: DEFAULT_REACH_PX
return {
id: kind.id,
name: kind.nameKey === '' ? kind.id : kind.nameKey,
hp: Math.max(1, Math.round((rolled * scale.health) / 100)),
damage: Math.max(1, Math.round((averageDamage * scale.damage) / 100)),
cooldownTicks: DEFAULT_ATTACK_COOLDOWN_TICKS,
reach,
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, Math.round((kind.experience * scale.experience) / 100)),
level: level ?? kind.level[0],
resistances: kind.resistances,
lightningResist: kind.resistances?.lightning,
resists: kind.resistances,
}
}
/**
* 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: 1 + MONUMOD_CONSTANTS.championHpPct / 100,
unique: 1 + MONUMOD_CONSTANTS.uniqueHpPct / 100,
}
/** Specification of a fixed Act 1 outdoor SuperUnique boss tied to a landmark. */
export interface SuperUniqueLandmarkSpec {
readonly id: string
readonly name: string
readonly monsterId: string
readonly landmark: string
readonly modifiers: readonly string[]
readonly minMinions: number
readonly maxMinions: number
readonly minionMonsterId: string
}
/** Landmark and minion mapping for canonical outdoor and landmark SuperUniques. */
export interface SuperUniqueLandmarkMapping {
readonly id: string
readonly levelIds: readonly number[]
readonly landmark: string
readonly minionMonsterId?: string
}
export const CANONICAL_SUPER_UNIQUE_LANDMARKS: readonly SuperUniqueLandmarkMapping[] = [
// Act 1
{ id: 'Bishibosh', levelIds: [3], landmark: 'Act 1 - Fallen Camp Bishibosh', minionMonsterId: 'fallen1' },
{ id: 'Rakanishu', levelIds: [4], landmark: 'Act 1 - Cairn Stones', minionMonsterId: 'fallen1' },
{ id: 'Treehead WoodFist', levelIds: [5], landmark: 'Act 1 - Inifus', minionMonsterId: 'brute1' },
{ id: 'The Countess', levelIds: [6, 25], landmark: 'Act 1 - Tower 1', minionMonsterId: 'corruptrogue5' },
{ id: 'Blood Raven', levelIds: [17], landmark: 'Act 1 - Graveyard', minionMonsterId: 'zombie1' },
// Act 2
{ id: 'Creeping Feature', levelIds: [41, 55, 59], landmark: 'Act 2 - Desert Tomb 1', minionMonsterId: 'mummy1' },
{ id: 'Beetleburst', levelIds: [43], landmark: 'Act 2 - Desert Oasis 1', minionMonsterId: 'scarab2' },
{ id: 'Dark Elder', levelIds: [44], landmark: 'Act 2 - Desert Ruins Elder', minionMonsterId: 'zombie5' },
{ id: 'Bloodwitch the Wild', levelIds: [45, 60], landmark: 'Act 2 - Desert Tomb 2', minionMonsterId: 'pantherwoman1' },
{ id: 'Fangskin', levelIds: [45, 61], landmark: 'Act 2 - Desert Valley Ruin 2', minionMonsterId: 'clawviper3' },
// Act 3
{ id: 'Sszark the Burning', levelIds: [76, 84], landmark: 'Act 3 - Spider Cavern', minionMonsterId: 'arach4' },
{ id: 'Stormtree', levelIds: [78], landmark: 'Act 3 - Flayer Jungle Entrance', minionMonsterId: 'thornhulk3' },
{ id: 'Witch Doctor Endugu', levelIds: [78, 88, 91], landmark: 'Act 3 - Flayer Dungeon', minionMonsterId: 'fetish4' },
{ id: 'Battlemaid Sarina', levelIds: [80, 94], landmark: 'Act 3 - Burbs Temple', minionMonsterId: 'corruptrogue5' },
// Act 4
{ id: 'Izual', levelIds: [105], landmark: 'Act 4 - Plains of Despair', minionMonsterId: 'izual' },
{ id: 'Hephasto the Armorer', levelIds: [106, 107], landmark: 'Act 4 - Hellforge', minionMonsterId: 'hephasto' },
// Act 5
{ id: 'Dac Farren', levelIds: [110], landmark: 'Act 5 - Barricade Prison 1', minionMonsterId: 'imp3' },
{ id: 'Shenk the Overseer', levelIds: [110], landmark: 'Act 5 - Barricade Building', minionMonsterId: 'minion1' },
{ id: 'Eyeback the Unleashed', levelIds: [111], landmark: 'Act 5 - Barricade Hell Portal N', minionMonsterId: 'bloodlord2' },
{ id: 'Thresh Socket', levelIds: [112], landmark: 'Act 5 - Barricade Snow Unique', minionMonsterId: 'siegebeast1' },
{ id: 'Frozenstein', levelIds: [114], landmark: 'Act 5 - Frozen River', minionMonsterId: 'snowyeti4' },
{ id: 'Bonesaw Breaker', levelIds: [115], landmark: 'Act 5 - Glacial Trail', minionMonsterId: 'reanimatedhorde2' },
{ id: 'Nihlathak', levelIds: [124], landmark: 'Act 5 - Halls of Vaught', minionMonsterId: 'minion6' },
]
/** Canonical SuperUniques TSV extracted from game data for landmark bosses. */
export const CANONICAL_SUPERUNIQUES_TSV = `Superunique\tName\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tAutoPos\tStacks\tReplaceable\tUtrans\tTC\tTC(N)\tTC(H)
Bishibosh\tBishibosh\tfallenshaman1\t8\t9\t0\t5\t7\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A
Rakanishu\tRakanishu\tfallen1\t17\t6\t0\t6\t8\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A
Treehead WoodFist\tTreehead WoodFist\tbrute1\t5\t6\t0\t3\t4\t0\t0\t0\t0\tAct 1 Super B\tAct 1 (N) Super B\tAct 1 (H) Super B
The Countess\tThe Countess\tcorruptrogue5\t9\t0\t0\t4\t6\t0\t0\t0\t0\tCountess\tCountess (N)\tCountess (H)
Blood Raven\tBlood Raven\tcorruptrogue1\t9\t6\t0\t6\t8\t0\t0\t0\t0\tBlood Raven\tBlood Raven (N)\tBlood Raven (H)
Creeping Feature\tCreeping Feature\tmummy1\t5\t18\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super A\tAct 2 (N) Super A\tAct 2 (H) Super A
Beetleburst\tBeetleburst\tscarab2\t8\t0\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super A\tAct 2 (N) Super A\tAct 2 (H) Super A
Dark Elder\tDark Elder\tzombie5\t6\t8\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super B\tAct 2 (N) Super B\tAct 2 (H) Super B
Bloodwitch the Wild\tBloodwitch the Wild\tpantherwoman1\t7\t5\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super B\tAct 2 (N) Super B\tAct 2 (H) Super B
Fangskin\tFangskin\tclawviper3\t17\t6\t0\t4\t6\t0\t0\t0\t0\tAct 2 Super C\tAct 2 (N) Super C\tAct 2 (H) Super C
Sszark the Burning\tSszark the Burning\tarach4\t5\t7\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super A\tAct 3 (N) Super A\tAct 3 (H) Super A
Stormtree\tStormtree\tthornhulk3\t17\t6\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super B\tAct 3 (N) Super B\tAct 3 (H) Super B
Witch Doctor Endugu\tWitch Doctor Endugu\tfetishshaman4\t8\t9\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super B\tAct 3 (N) Super B\tAct 3 (H) Super B
Battlemaid Sarina\tBattlemaid Sarina\tcorruptrogue5\t6\t27\t0\t4\t6\t0\t0\t0\t0\tAct 3 Super C\tAct 3 (N) Super C\tAct 3 (H) Super C
Izual\tIzual\tizual\t18\t0\t0\t0\t0\t0\t0\t0\t0\tIzual\tIzual (N)\tIzual (H)
Hephasto the Armorer\tHephasto the Armorer\thephasto\t27\t30\t0\t0\t0\t0\t0\t0\t0\tHephasto\tHephasto (N)\tHephasto (H)
Dac Farren\tDac Farren\timp3\t18\t0\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super A\tAct 5 (N) Super A\tAct 5 (H) Super A
Shenk the Overseer\tShenk the Overseer\toverseer2\t5\t0\t0\t15\t20\t0\t0\t0\t0\tShenk\tShenk (N)\tShenk (H)
Eyeback the Unleashed\tEyeback the Unleashed\tbloodlord2\t6\t5\t0\t3\t5\t0\t0\t0\t0\tAct 5 Super A\tAct 5 (N) Super A\tAct 5 (H) Super A
Thresh Socket\tThresh Socket\tsiegebeast1\t7\t0\t0\t2\t4\t0\t0\t0\t0\tAct 5 Super B\tAct 5 (N) Super B\tAct 5 (H) Super B
Frozenstein\tFrozenstein\tsnowyeti4\t18\t25\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super B\tAct 5 (N) Super B\tAct 5 (H) Super B
Bonesaw Breaker\tBonesaw Breaker\treanimatedhorde2\t5\t8\t0\t4\t6\t0\t0\t0\t0\tAct 5 Super C\tAct 5 (N) Super C\tAct 5 (H) Super C
Nihlathak\tNihlathak\tnihlathakboss\t18\t0\t0\t6\t8\t0\t0\t0\t0\tNihlathak\tNihlathak (N)\tNihlathak (H)`
/** Canonical SuperUniques table parsed from canonical game TSV. */
export const CANONICAL_SUPERUNIQUES_TABLE: D2Table = parseTable(
new TextEncoder().encode(CANONICAL_SUPERUNIQUES_TSV.trim()),
)
/**
* Look up a SuperUnique boss by id or name from a parsed `SuperUniques.txt` table.
*
* @param table - the parsed `SuperUniques.txt`.
* @param id - the superunique ID or name (case-insensitive).
* @param difficulty - which difficulty's treasure class to read.
* @returns the matched SuperUnique, or undefined if not found.
*/
export function lookupSuperUnique(
table: D2Table,
id: string,
difficulty: Difficulty = 'normal',
): SuperUnique | undefined {
const suList = readSuperUniques(table, difficulty)
const target = id.toLowerCase()
return suList.find(su => su.id.toLowerCase() === target || su.nameKey.toLowerCase() === target)
}
/**
* Build landmark specifications for canonical SuperUniques from a `SuperUniques.txt` table.
*
* @param table - the parsed `SuperUniques.txt` (defaults to `CANONICAL_SUPERUNIQUES_TABLE`).
* @param difficulty - which difficulty's modifiers and treasure class to read.
* @returns map of levelId to SuperUniqueLandmarkSpec or array of specs.
*/
export function buildSuperUniqueLandmarkSpecs(
table: D2Table = CANONICAL_SUPERUNIQUES_TABLE,
difficulty: Difficulty = 'normal',
): Record<number, SuperUniqueLandmarkSpec | readonly SuperUniqueLandmarkSpec[]> {
const suList = readSuperUniques(table, difficulty)
const suMap = new Map<string, SuperUnique>()
for (const su of suList) {
suMap.set(su.id.toLowerCase(), su)
if (su.nameKey) {
suMap.set(su.nameKey.toLowerCase(), su)
}
}
const modNameById = new Map<number, string>()
for (const m of CANONICAL_ELITE_MODIFIERS) {
modNameById.set(m.id, m.name)
}
const byLevel: Record<number, SuperUniqueLandmarkSpec[]> = {}
for (const mapping of CANONICAL_SUPER_UNIQUE_LANDMARKS) {
const su = suMap.get(mapping.id.toLowerCase())
if (!su) continue
const modifiers = su.modifiers
.map(id => modNameById.get(id))
.filter((name): name is string => name !== undefined)
const spec: SuperUniqueLandmarkSpec = {
id: su.id,
name: su.nameKey || su.id,
monsterId: su.monsterId,
landmark: mapping.landmark,
modifiers,
minMinions: su.minMinions,
maxMinions: su.maxMinions,
minionMonsterId: mapping.minionMonsterId ?? su.monsterId,
}
for (const levelId of mapping.levelIds) {
if (!byLevel[levelId]) {
byLevel[levelId] = []
}
byLevel[levelId].push(spec)
}
}
const result: Record<number, SuperUniqueLandmarkSpec | readonly SuperUniqueLandmarkSpec[]> = {}
for (const [lvlStr, specs] of Object.entries(byLevel)) {
const lvl = Number(lvlStr)
result[lvl] = specs.length === 1 ? specs[0]! : specs
}
return result
}
/** Canonical outdoor SuperUniques tied to level landmarks across Acts 1-5. */
export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<
Record<number, SuperUniqueLandmarkSpec | readonly SuperUniqueLandmarkSpec[]>
> = buildSuperUniqueLandmarkSpecs()
/** What one level's population came out as. */
export interface PlannedLevel {
/** `Levels.txt` id. */
readonly levelId: number
/** `Levels.txt` name, for reporting. */
readonly levelName: string
/** The monster ids the level drew, in the order they were drawn. */
readonly types: readonly string[]
/** How many monsters the density asked for. */
readonly budget: number
/** The packs, ready to hand to `spawnMonsterPacks`. */
readonly packs: readonly MonsterPack[]
/** How many packs are champion or unique. */
readonly elitePacks: number
/**
* Levels whose `MonLvl.txt` row was missing while rolling this population.
*
* Non-empty means some of {@link PlannedLevel.packs} carry raw `MonStats.txt`
* numbers rather than scaled ones — roughly 20x too strong — because
* {@link monsterScaleFor} had to fall back to {@link UNSCALED}. Empty is the
* healthy case, and the only one a bake should accept.
*/
readonly missingScalingLevels: readonly number[]
/** Fixed SuperUnique bosses planned for this level (e.g. Bishibosh, Rakanishu). */
readonly superUniques?: readonly string[]
}
/**
* Go from a level id to packs of rolled monsters in one call.
*
* Two callers need this and they must not disagree: the browser reading the
* archives directly, and the pack baker writing the level out as JSON. Keeping
* the chain — plan, select types, budget, group, roll stats — in one function
* is what makes "the baked level matches the live one" a property rather than
* a coincidence.
*
* Every random choice comes from `seed`, so the same level is the same level on
* a reload, in a replay, and on a second machine.
*
* @param tables - `Levels.txt`, `MonStats.txt` and `MonLvl.txt`.
* @param levelId - which level.
* @param cells - the level's area in cells, for the density conversion.
* @param seed - the level seed.
* @param walkSpeedPx - the player's walking speed, the anchor for monster speed.
* @param difficulty - which difficulty's columns to read.
* @param options - optional landmark overrides with exact tile/pixel positions.
* @returns the population, empty for a town or an unknown level.
*/
export function planLevelMonsters(
tables: {
readonly levels: D2Table
readonly monstats: D2Table
readonly monlvl: D2Table
readonly monstats2?: D2Table
readonly superuniques?: D2Table
},
levelId: number,
cells: number,
seed: number,
walkSpeedPx: number,
difficulty: Difficulty = 'normal',
options?: {
readonly landmarks?: readonly {
readonly id: string
readonly tileX: number
readonly tileY: number
readonly fixedPosition?: { readonly x: number; readonly y: number }
}[]
},
): PlannedLevel {
const empty: PlannedLevel = {
levelId, levelName: '', types: [], budget: 0, packs: [], elitePacks: 0, missingScalingLevels: [],
}
const plan = readLevelMonsterPlan(tables.levels, levelId, difficulty)
if (plan === null) return empty
const kinds = readMonsterKinds(tables.monstats, difficulty)
const artMap = tables.monstats2 ? readMonsterArt(tables.monstats2, kinds) : undefined
const scaling = readMonsterScaling(tables.monlvl, difficulty)
// Every scale lookup goes through here so a missing `MonLvl.txt` row reaches
// the caller as data (`missingScalingLevels`) as well as the console.
const missingScaling = new Set<number>()
const scaleAt = (level: number): MonsterScale => {
if (monsterScaleLookup(scaling, level).missing) missingScaling.add(level)
return monsterScaleFor(scaling, level)
}
// Three independent streams off the one seed. Sharing a single stream would
// make the type choice depend on how many monsters the density happened to
// ask for, so a level would change its monsters when its size changed.
const types = selectLevelTypes(plan, kinds, new Rng(seed ^ 0x7b10), difficulty)
const budget = monsterBudget(plan, cells)
const groups = planMonsterGroups(plan, types, kinds, budget, new Rng(seed ^ 0x9e37))
const rollRng = new Rng(seed ^ 0x2545)
const packs: MonsterPack[] = groups.map(group => {
const multiplier = ELITE_HEALTH_MULTIPLIER[group.rank]
const members: MonsterStats[] = []
const groupArt = artMap?.get(group.kind.id)
for (let i = 0; i < group.count; i += 1) {
const level = monsterLevelFor(group.kind, difficulty, plan.monsterLevel)
const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, scaleAt(level), groupArt, level)
// An elite pack is one leader plus its minions: the leader carries the
// rank's full bonus, the minions a smaller one, which is why a champion
// pack reads as "a tough one and its friends" rather than as a wall.
const scale = group.rank === 'normal' ? 1 : (i === 0 ? multiplier : 1 + (multiplier - 1) / 2)
const scaled = scale === 1 ? base : { ...base, hp: Math.max(1, Math.round(base.hp * scale)) }
const memberRank: MonsterRank = group.rank === 'normal' ? 'normal' : (i === 0 || group.rank === 'champion' ? group.rank : 'minion')
const memberMods = (i === 0 || group.rank === 'champion') ? (group.modifiers ?? []) : []
members.push(applyEliteModifiers(scaled, memberRank, memberMods))
}
return { members }
})
// Check if this level has fixed SuperUnique boss(es)
const suMap = tables.superuniques
? buildSuperUniqueLandmarkSpecs(tables.superuniques, difficulty)
: CANONICAL_SUPER_UNIQUES_BY_LEVEL
const suRaw = suMap[levelId]
const suSpecs: readonly SuperUniqueLandmarkSpec[] = Array.isArray(suRaw)
? suRaw
: (suRaw !== undefined ? [suRaw] : [])
const superUniques: string[] = []
if (suSpecs.length > 0 && kinds.size > 0) {
const suPacks: MonsterPack[] = []
for (let suIdx = 0; suIdx < suSpecs.length; suIdx += 1) {
const suSpec = suSpecs[suIdx]!
const suRng = new Rng(seed ^ 0x55aa ^ (suIdx * 0x3c6ef35f))
const bossKind = kinds.get(suSpec.monsterId) ?? kinds.get(suSpec.monsterId.replace(/\d+$/, '1'))
if (!bossKind) continue
const minionKind = kinds.get(suSpec.minionMonsterId) ?? kinds.get(suSpec.minionMonsterId.replace(/\d+$/, '1')) ?? bossKind
const bossArt = artMap?.get(bossKind.id)
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3)
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel), bossArt, bossLevel)
const bossScaled: MonsterStats = {
...bossBase,
id: suSpec.monsterId,
name: suSpec.name,
hp: Math.max(1, Math.round(bossBase.hp * ELITE_HEALTH_MULTIPLIER.unique)),
}
const bossStats = applyEliteModifiers(bossScaled, 'unique', suSpec.modifiers, suSpec.id)
const minionCount = suRng.int(suSpec.minMinions, suSpec.maxMinions)
const suMembers: MonsterStats[] = [bossStats]
const minionArt = artMap?.get(minionKind.id)
for (let m = 0; m < minionCount; m += 1) {
const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel)
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, scaleAt(mLevel), minionArt, mLevel)
const mScaled: MonsterStats = {
...mBase,
id: suSpec.minionMonsterId,
hp: Math.max(1, Math.round(mBase.hp * (1 + MONUMOD_CONSTANTS.minionHpPct / 100))),
}
suMembers.push(applyEliteModifiers(mScaled, 'minion', []))
}
const matchedLandmark = options?.landmarks?.find(l => l.id === suSpec.id)
const suPack: MonsterPack = {
members: suMembers,
superUniqueId: suSpec.id,
...(matchedLandmark?.fixedPosition !== undefined ? { fixedPosition: matchedLandmark.fixedPosition } : {}),
...(matchedLandmark !== undefined ? { landmarkTile: { tileX: matchedLandmark.tileX, tileY: matchedLandmark.tileY } } : {}),
}
suPacks.push(suPack)
superUniques.push(suSpec.id)
}
packs.unshift(...suPacks)
}
const allMonsterTypes = [...types.map(kind => kind.id)]
for (const pack of packs) {
for (const member of pack.members) {
if (!allMonsterTypes.includes(member.id)) {
allMonsterTypes.push(member.id)
}
}
}
for (const suSpec of suSpecs) {
if (!allMonsterTypes.includes(suSpec.monsterId)) {
allMonsterTypes.push(suSpec.monsterId)
}
if (!allMonsterTypes.includes(suSpec.minionMonsterId)) {
allMonsterTypes.push(suSpec.minionMonsterId)
}
}
return {
levelId,
levelName: plan.levelName,
types: allMonsterTypes,
budget,
packs,
elitePacks: groups.filter(group => group.rank !== 'normal').length + superUniques.length,
missingScalingLevels: [...missingScaling].sort((a, b) => a - b),
...(superUniques.length > 0 ? { superUniques } : {}),
}
}