1601 lines
66 KiB
TypeScript
1601 lines
66 KiB
TypeScript
/**
|
||
* 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 } : {}),
|
||
}
|
||
}
|
||
|