1945 lines
80 KiB
TypeScript
1945 lines
80 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'
|
||
import { getTblLang, lookupTbl, type TblLang } from '../i18n/lang.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
|
||
/** `DescStr` — a `string.tbl` key for special monster ability subtext (e.g. `"raises Fallen"`), or `""`. */
|
||
readonly descKey?: 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
|
||
/** `lUndead` or `hUndead` — whether this monster is Undead (`0x6FD62B50`). */
|
||
readonly isUndead?: boolean
|
||
/** `demon` — whether this monster is a Demon (`0x6FD62BA0`). */
|
||
readonly isDemon?: boolean
|
||
/** `boss` — a boss, excluded from ordinary population (`isBoss` alias included for parity). */
|
||
readonly boss: boolean
|
||
readonly isBoss?: boolean
|
||
/** `noRatio` — summons/pets/traps, excluded from ratio scaling and TC upgrading. */
|
||
readonly noRatio?: boolean
|
||
/** `killable` — some 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 isBoss = flag(table, row, 'boss')
|
||
const isUndead = flag(table, row, 'lUndead') || flag(table, row, 'hUndead')
|
||
const isDemon = flag(table, row, 'demon')
|
||
const rawDescStr = cell(table, row, 'DescStr').trim()
|
||
const descKey = rawDescStr.toLowerCase() === 'dummy' ? '' : rawDescStr
|
||
|
||
const kind: MonsterKind = {
|
||
id,
|
||
baseId: cell(table, row, 'BaseId').trim() || id,
|
||
nameKey: cell(table, row, 'NameStr').trim(),
|
||
descKey,
|
||
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'),
|
||
isUndead,
|
||
isDemon,
|
||
boss: isBoss,
|
||
isBoss,
|
||
...(flag(table, row, 'noRatio') ? { noRatio: true } : {}),
|
||
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 || kind.noRatio) {
|
||
return Math.min(99, Math.max(1, kind.level[tier]))
|
||
}
|
||
const baseLevel = areaLevel > 0 ? areaLevel : kind.level[tier]
|
||
return Math.min(99, Math.max(1, baseLevel))
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
/**
|
||
* Difficulty-aware TreasureClass resolution for SuperUniques.
|
||
*/
|
||
getTreasureClass?(difficulty?: Difficulty): 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
|
||
const tcsByDiff: Record<Difficulty, string> = {
|
||
normal: cell(table, row, 'TC').trim(),
|
||
nightmare: cell(table, row, 'TC(N)').trim() || cell(table, row, 'TC').trim(),
|
||
hell: cell(table, row, 'TC(H)').trim() || cell(table, row, 'TC(N)').trim() || cell(table, row, 'TC').trim(),
|
||
}
|
||
const getTc = (diff: Difficulty = 'normal'): string => tcsByDiff[diff] ?? tcsByDiff.normal
|
||
const su: SuperUnique = {
|
||
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() || tcsByDiff[difficulty] || '',
|
||
}
|
||
Object.defineProperty(su, 'getTreasureClass', {
|
||
value: getTc,
|
||
enumerable: false,
|
||
configurable: true,
|
||
writable: true,
|
||
})
|
||
out.push(su)
|
||
}
|
||
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' | 'boss'
|
||
|
||
/** Canonical elite modifiers from `MonUMod.txt` with 1.13c `.tbl` keys (`3205..3218`). */
|
||
export const CANONICAL_ELITE_MODIFIERS: readonly {
|
||
readonly id: number
|
||
readonly name: string
|
||
readonly tblKey: string
|
||
readonly label: string
|
||
readonly labelZh: string
|
||
readonly champion: boolean
|
||
}[] = [
|
||
{ id: 5, name: 'strong', tblKey: 'uniquextrastrong', label: 'Extra Strong', labelZh: '特别强壮', champion: true },
|
||
{ id: 6, name: 'fast', tblKey: 'uniqueextrafast', label: 'Extra Fast', labelZh: '特别快速', champion: true },
|
||
{ id: 7, name: 'cursed', tblKey: 'uniquecursed', label: 'Cursed', labelZh: '特别诅咒', champion: false },
|
||
{ id: 8, name: 'magicresistant', tblKey: 'uniquemagicresistance', label: 'Magic Resistant', labelZh: '魔法抵抗', champion: false },
|
||
{ id: 9, name: 'fireenchant', tblKey: 'uniquefireenchanted', label: 'Fire Enchanted', labelZh: '火焰强化', champion: false },
|
||
{ id: 17, name: 'lightenchant', tblKey: 'monsteruniqueprop2', label: 'Lightning Enchanted', labelZh: '闪电强化', champion: false },
|
||
{ id: 18, name: 'coldenchant', tblKey: 'monsteruniqueprop1', label: 'Cold Enchanted', labelZh: '冰冷强化', champion: false },
|
||
{ id: 25, name: 'manahit', tblKey: 'monsteruniqueprop3', label: 'Mana Burn', labelZh: '法力燃烧', champion: false },
|
||
{ id: 26, name: 'teleport', tblKey: 'monsteruniqueprop5', label: 'Teleportation', labelZh: '传送', champion: false },
|
||
{ id: 27, name: 'spectralhit', tblKey: 'monsteruniqueprop4', label: 'Spectral Hit', labelZh: '幽灵一击', champion: false },
|
||
{ id: 28, name: 'stoneskin', tblKey: 'monsteruniqueprop6', label: 'Stone Skin', labelZh: '石头皮肤', champion: false },
|
||
{ id: 29, name: 'multishot', tblKey: 'monsteruniqueprop7', label: 'Multiple Shots', labelZh: '多重射击', champion: false },
|
||
{ id: 30, name: 'aura', tblKey: 'monsteruniqueprop9', label: 'Aura Enchanted', labelZh: '灵气强化', champion: false },
|
||
]
|
||
|
||
/**
|
||
* Formats an elite modifier code or label (`strong`, `Extra Strong`, etc.) in the active language.
|
||
*/
|
||
export function formatEliteAffixLabel(modNameOrLabel: string, lang: TblLang = getTblLang()): string {
|
||
const lower = modNameOrLabel.trim().toLowerCase()
|
||
const entry = CANONICAL_ELITE_MODIFIERS.find(
|
||
m => m.name.toLowerCase() === lower || m.label.toLowerCase() === lower || m.labelZh === modNameOrLabel.trim(),
|
||
)
|
||
if (entry) {
|
||
return lang === 'ENG' ? entry.label : entry.labelZh
|
||
}
|
||
const tblVal = lookupTbl(modNameOrLabel.trim(), lang)
|
||
if (tblVal) return tblVal
|
||
return modNameOrLabel
|
||
}
|
||
|
||
export const affixDisplayName = formatEliteAffixLabel
|
||
|
||
|
||
const SUPERUNIQUE_TBL_ALIASES: Readonly<
|
||
Record<string, { readonly tblKey?: string; readonly zh: string; readonly en: string }>
|
||
> = {
|
||
'Blood Raven': { tblKey: 'Bloodraven', zh: '血鳥', en: 'Blood Raven' },
|
||
'Creeping Feature': { tblKey: 'The Feature Creep', zh: '蠕蟲特徵', en: 'Creeping Feature' },
|
||
'Sszark the Burning': { zh: '火焰之河斯扎克', en: 'Sszark the Burning' },
|
||
'Battlemaid Sarina': { tblKey: 'Sarina the Battlemaid', zh: '戰場處女沙莉娜', en: 'Battlemaid Sarina' },
|
||
'Hephasto the Armorer': { tblKey: 'Hephasto The Armorer', zh: '盔甲製造者海法斯特', en: 'Hephasto The Armorer' },
|
||
'Eyeback the Unleashed': { tblKey: 'Eyeback Unleashed', zh: '狂暴者艾巴克', en: 'Eyeback the Unleashed' },
|
||
'Thresh Socket': { zh: '剝殼凹槽', en: 'Thresh Socket' },
|
||
}
|
||
|
||
/**
|
||
* Resolves a monster's localized display name from `.tbl` (`MonStats.txt` `NameStr` / `SuperUniques.txt` `Name`).
|
||
*/
|
||
export function monsterDisplayName(
|
||
statsOrName: { readonly id?: string; readonly name?: string; readonly nameZh?: string; readonly superUniqueId?: string } | string,
|
||
lang: TblLang = getTblLang(),
|
||
): string {
|
||
if (typeof statsOrName === 'string') {
|
||
const trimmed = statsOrName.trim()
|
||
const fromTbl = lookupTbl(trimmed, lang)
|
||
if (fromTbl) return fromTbl
|
||
const alias = SUPERUNIQUE_TBL_ALIASES[trimmed]
|
||
if (alias) {
|
||
if (alias.tblKey) {
|
||
const fromAlias = lookupTbl(alias.tblKey, lang)
|
||
if (fromAlias) return fromAlias
|
||
}
|
||
return lang === 'ENG' ? alias.en : alias.zh
|
||
}
|
||
const strippedNum = trimmed.replace(/\d+$/, '')
|
||
if (strippedNum && strippedNum !== trimmed) {
|
||
const fromStripped = lookupTbl(strippedNum, lang)
|
||
if (fromStripped) return fromStripped
|
||
}
|
||
return trimmed
|
||
}
|
||
if (lang === 'CHI' && statsOrName.nameZh) {
|
||
return statsOrName.nameZh
|
||
}
|
||
const candidates = [
|
||
statsOrName.superUniqueId,
|
||
statsOrName.name,
|
||
statsOrName.id,
|
||
statsOrName.id?.replace(/\d+$/, ''),
|
||
].filter((c): c is string => Boolean(c && c.trim().length > 0))
|
||
for (const key of candidates) {
|
||
const trimmed = key.trim()
|
||
const val = lookupTbl(trimmed, lang)
|
||
if (val) return val
|
||
const alias = SUPERUNIQUE_TBL_ALIASES[trimmed]
|
||
if (alias) {
|
||
if (alias.tblKey) {
|
||
const fromAlias = lookupTbl(alias.tblKey, lang)
|
||
if (fromAlias) return fromAlias
|
||
}
|
||
return lang === 'ENG' ? alias.en : alias.zh
|
||
}
|
||
}
|
||
return statsOrName.name || statsOrName.id || 'Monster'
|
||
}
|
||
|
||
/**
|
||
* 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' || rank === 'boss') 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
|
||
let level = base.level ?? 1
|
||
if (rank === 'champion') {
|
||
level += 2
|
||
} else if (rank === 'unique' || rank === 'minion') {
|
||
level += 3
|
||
}
|
||
level = Math.min(99, Math.max(1, level))
|
||
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 physicalRes = base.resistances?.physical ?? 0
|
||
let magicRes = base.resistances?.magic ?? 0
|
||
let fireRes = base.resistances?.fire ?? 0
|
||
let lightningRes = base.lightningResist ?? base.resistances?.lightning ?? 0
|
||
let coldRes = base.resistances?.cold ?? 0
|
||
let poisonRes = base.resistances?.poison ?? 0
|
||
|
||
if (modifiers.includes('stoneskin')) {
|
||
physicalRes += 50
|
||
}
|
||
if (modifiers.includes('manahit')) {
|
||
magicRes += 20
|
||
}
|
||
if (modifiers.includes('fireenchant')) {
|
||
fireRes += 75
|
||
}
|
||
if (modifiers.includes('coldenchant')) {
|
||
coldRes += 75
|
||
}
|
||
if (modifiers.includes('lightenchant')) {
|
||
lightningRes += 75
|
||
}
|
||
if (modifiers.includes('poisonenchant')) {
|
||
poisonRes += 75
|
||
}
|
||
if (modifiers.includes('magicresistant')) {
|
||
fireRes += 40
|
||
coldRes += 40
|
||
lightningRes += 20
|
||
}
|
||
if (modifiers.includes('spectralhit')) {
|
||
fireRes += 20
|
||
coldRes += 20
|
||
lightningRes += 20
|
||
}
|
||
|
||
const hasAnyResMod =
|
||
physicalRes !== 0 ||
|
||
magicRes !== 0 ||
|
||
fireRes !== 0 ||
|
||
lightningRes !== 0 ||
|
||
coldRes !== 0 ||
|
||
poisonRes !== 0
|
||
const updatedResistances = base.resistances
|
||
? {
|
||
...base.resistances,
|
||
physical: physicalRes,
|
||
magic: magicRes,
|
||
fire: fireRes,
|
||
lightning: lightningRes,
|
||
cold: coldRes,
|
||
poison: poisonRes,
|
||
}
|
||
: hasAnyResMod
|
||
? {
|
||
...(physicalRes !== 0 ? { physical: physicalRes } : {}),
|
||
...(magicRes !== 0 ? { magic: magicRes } : {}),
|
||
...(fireRes !== 0 ? { fire: fireRes } : {}),
|
||
...(lightningRes !== 0 ? { lightning: lightningRes } : {}),
|
||
...(coldRes !== 0 ? { cold: coldRes } : {}),
|
||
...(poisonRes !== 0 ? { poison: poisonRes } : {}),
|
||
}
|
||
: undefined
|
||
|
||
return {
|
||
...base,
|
||
hp,
|
||
damage,
|
||
speed,
|
||
rank,
|
||
level,
|
||
...(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.
|
||
*/
|
||
/**
|
||
* Filter candidate monsters for level population per 1.13c rules (MonsterRegion.cpp:713).
|
||
*
|
||
* In 1.13c, !nSparsePopulate (sparsePopulate === 0 or undefined) means unconstrained (100% chance).
|
||
* Monsters are valid candidates if enabled, isSpawn, and not a boss (unless allowBosses is true).
|
||
*/
|
||
export function filterCandidateKinds(
|
||
candidates: readonly (MonsterKind | undefined)[],
|
||
allowBosses = false,
|
||
): MonsterKind[] {
|
||
return candidates.filter(
|
||
(kind): kind is MonsterKind =>
|
||
kind !== undefined &&
|
||
kind.enabled &&
|
||
kind.isSpawn &&
|
||
(kind.sparsePopulate === undefined || kind.sparsePopulate === 0 || kind.sparsePopulate > 0) &&
|
||
(!kind.boss || allowBosses),
|
||
)
|
||
}
|
||
|
||
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 = filterCandidateKinds(names.map(name => kinds.get(name)), false)
|
||
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
|
||
}
|
||
|
||
/**
|
||
* Turn a level's density into a monster budget using the authentic 1.13c DRLG room density formula.
|
||
*
|
||
* In Diablo II v1.13c (`MonsterRegion.cpp:645-656`):
|
||
* attempts = Math.floor((cells * 25) / 9)
|
||
* expectedPacks = attempts * (Math.min(10000, density) / 100000)
|
||
* monsterBudget = Math.max(1, Math.round(expectedPacks * 3))
|
||
*
|
||
* @param plan - the level's columns, or cells count if density is passed directly.
|
||
* @param cells - the level's area, in cells (or density if plan is number).
|
||
* @returns how many monsters to place; 0 when the level is a town or density is 0.
|
||
*/
|
||
export function monsterBudget(plan: LevelMonsterPlan, cells: number): number
|
||
export function monsterBudget(cells: number, density: number): number
|
||
export function monsterBudget(planOrCells: LevelMonsterPlan | number, cellsOrDensity: number): number {
|
||
let density: number
|
||
let cells: number
|
||
if (typeof planOrCells === 'number') {
|
||
cells = planOrCells
|
||
density = cellsOrDensity
|
||
} else {
|
||
density = planOrCells.density
|
||
cells = cellsOrDensity
|
||
}
|
||
if (density <= 0 || cells <= 0) return 0
|
||
const attempts = Math.floor((cells * 25) / 9)
|
||
const expectedPacks = attempts * (Math.min(10000, density) / 100000)
|
||
return Math.max(1, Math.round(expectedPacks * 3))
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
* @param options - optional elite chance multiplier for debugging or overrides.
|
||
* @returns the packs to place.
|
||
*/
|
||
export function planMonsterGroups(
|
||
plan: LevelMonsterPlan,
|
||
types: readonly MonsterKind[],
|
||
kinds: ReadonlyMap<string, MonsterKind>,
|
||
budget: number,
|
||
rng: Rng,
|
||
options?: {
|
||
readonly eliteMultiplier?: number | 'all' | undefined
|
||
},
|
||
): MonsterGroup[] {
|
||
if (types.length === 0 || budget <= 0) return []
|
||
const groups: MonsterGroup[] = []
|
||
let remaining = budget
|
||
|
||
let eliteCandidates = filterCandidateKinds(plan.elitePool.map(name => kinds.get(name)), true)
|
||
if (eliteCandidates.length === 0) {
|
||
eliteCandidates = types.length > 0
|
||
? [...types]
|
||
: filterCandidateKinds(plan.pool.map(name => kinds.get(name)), true)
|
||
}
|
||
|
||
let minElites = plan.eliteMin
|
||
let maxElites = plan.eliteMax
|
||
if (options?.eliteMultiplier !== undefined) {
|
||
if (options.eliteMultiplier === 'all') {
|
||
minElites = 999
|
||
maxElites = 999
|
||
} else if (typeof options.eliteMultiplier === 'number') {
|
||
minElites = Math.round(minElites * options.eliteMultiplier)
|
||
maxElites = Math.round(maxElites * options.eliteMultiplier)
|
||
}
|
||
}
|
||
|
||
const elitePacks = maxElites <= 0 ? 0 : rng.int(minElites, maxElites)
|
||
for (let i = 0; i < elitePacks && remaining > 0 && eliteCandidates.length > 0; i += 1) {
|
||
const kind = eliteCandidates[rng.int(0, eliteCandidates.length - 1)]!
|
||
// 1.13c MonUMod.txt: 20% Champions, 80% Uniques + Minions
|
||
const isChampion = rng.next() < (MONUMOD_CONSTANTS.championChance / 100)
|
||
const rank: Exclude<MonsterRank, 'minion'> = isChampion ? 'champion' : 'unique'
|
||
// 1.13c: Champions spawn in groups of 2 to 4 (2 + rand() % 3).
|
||
// Uniques spawn with 1 boss + 3 to 6 minions (1 + (3 + rand() % 4)).
|
||
const count = rank === 'champion'
|
||
? Math.min(remaining, rng.int(2, 4))
|
||
: Math.min(remaining, 1 + rng.int(3, 6))
|
||
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 minGrp = Math.max(1, kind.minGroup)
|
||
const maxGrp = Math.max(minGrp, kind.maxGroup)
|
||
const count = Math.min(remaining, rng.int(minGrp, maxGrp))
|
||
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,
|
||
rank?: MonsterRank,
|
||
): 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,
|
||
nameKey: kind.nameKey === '' ? kind.id : kind.nameKey,
|
||
...(kind.descKey !== undefined && kind.descKey !== '' && kind.descKey.toLowerCase() !== 'dummy' ? { descKey: kind.descKey } : {}),
|
||
isUndead: kind.isUndead,
|
||
isDemon: kind.isDemon,
|
||
isBoss: kind.boss,
|
||
killable: kind.killable,
|
||
inTown: kind.inTown,
|
||
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],
|
||
rank: kind.boss ? 'boss' : (rank ?? 'normal'),
|
||
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,
|
||
boss: 1,
|
||
}
|
||
|
||
/** 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>([
|
||
[24, 'thief'],
|
||
])
|
||
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 }
|
||
}[]
|
||
readonly densityMultiplier?: number | undefined
|
||
readonly eliteMultiplier?: number | 'all' | undefined
|
||
},
|
||
): 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 density = options?.densityMultiplier !== undefined
|
||
? Math.round(plan.density * options.densityMultiplier)
|
||
: plan.density
|
||
const budget = monsterBudget(cells, density)
|
||
const groups = planMonsterGroups(
|
||
plan,
|
||
types,
|
||
kinds,
|
||
budget,
|
||
new Rng(seed ^ 0x9e37),
|
||
options?.eliteMultiplier !== undefined ? { eliteMultiplier: options.eliteMultiplier } : undefined,
|
||
)
|
||
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)
|
||
// Elite health scaling:
|
||
// Champions: all members receive champion bonus (3x HP in normal).
|
||
// Uniques: leader receives unique bonus (4x HP in normal); minions receive minion bonus (2x HP in normal).
|
||
const scale = group.rank === 'normal'
|
||
? 1
|
||
: group.rank === 'champion'
|
||
? multiplier
|
||
: (i === 0 ? multiplier : 1 + (MONUMOD_CONSTANTS.minionHpPct / 100))
|
||
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)
|
||
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel), bossArt, bossLevel)
|
||
const bossScaled: MonsterStats = {
|
||
...bossBase,
|
||
id: suSpec.monsterId,
|
||
name: suSpec.name,
|
||
nameKey: 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', [], suSpec.id))
|
||
}
|
||
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 } : {}),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Enrich a `MonsterStats` record with canonical `MonStats.txt` and `SuperUniques.txt` metadata
|
||
* (`nameKey`, `descKey`, `isUndead`, `isDemon`, `isBoss`, `killable`, `inTown`, `resistances`)
|
||
* so pre-baked `scene.json` packs carry full 1.13c hover lifebar and subtext attributes.
|
||
*/
|
||
export function enrichMonsterStats(
|
||
stats: MonsterStats,
|
||
kinds: ReadonlyMap<string, MonsterKind>,
|
||
superUniques?: ReadonlyMap<string, SuperUnique>,
|
||
): MonsterStats {
|
||
const kind = kinds.get(stats.id) ?? kinds.get(stats.id.toLowerCase()) ?? kinds.get(stats.id.replace(/\d+$/, '1'))
|
||
if (!kind) return stats
|
||
const isSuperUniqueLeader = stats.superUniqueId !== undefined && stats.rank === 'unique'
|
||
const su = isSuperUniqueLeader && superUniques
|
||
? (superUniques.get(stats.superUniqueId!.toLowerCase()) ?? superUniques.get(stats.superUniqueId!))
|
||
: undefined
|
||
const nameKey = stats.nameKey ?? (isSuperUniqueLeader ? (su?.nameKey || stats.name || stats.superUniqueId) : (kind.nameKey || stats.name))
|
||
const rawDesc = stats.descKey ?? (kind.descKey !== '' ? kind.descKey : undefined)
|
||
const descKey = rawDesc !== undefined && rawDesc !== '' && rawDesc.toLowerCase() !== 'dummy' ? rawDesc : undefined
|
||
const kindWithMods = stats.modifiers && stats.modifiers.length > 0
|
||
? applyEliteModifiers(
|
||
{
|
||
...stats,
|
||
resistances: kind.resistances,
|
||
lightningResist: kind.resistances.lightning,
|
||
},
|
||
stats.rank ?? 'normal',
|
||
stats.modifiers,
|
||
stats.superUniqueId,
|
||
).resistances ?? kind.resistances
|
||
: kind.resistances
|
||
const mergedRes: MonsterResistances = stats.modifiers && stats.modifiers.length > 0
|
||
? {
|
||
physical: Math.max(kindWithMods.physical ?? 0, stats.resistances?.physical ?? kindWithMods.physical ?? 0),
|
||
magic: Math.max(kindWithMods.magic ?? 0, stats.resistances?.magic ?? kindWithMods.magic ?? 0),
|
||
fire: Math.max(kindWithMods.fire ?? 0, stats.resistances?.fire ?? kindWithMods.fire ?? 0),
|
||
lightning: Math.max(kindWithMods.lightning ?? 0, stats.resistances?.lightning ?? kindWithMods.lightning ?? 0),
|
||
cold: Math.max(kindWithMods.cold ?? 0, stats.resistances?.cold ?? kindWithMods.cold ?? 0),
|
||
poison: Math.max(kindWithMods.poison ?? 0, stats.resistances?.poison ?? kindWithMods.poison ?? 0),
|
||
}
|
||
: (stats.resistances ? { ...kind.resistances, ...stats.resistances } : kind.resistances)
|
||
return {
|
||
...stats,
|
||
nameKey,
|
||
...(descKey !== undefined ? { descKey } : {}),
|
||
isUndead: stats.isUndead ?? kind.isUndead,
|
||
isDemon: stats.isDemon ?? kind.isDemon,
|
||
isBoss: stats.isBoss ?? kind.boss,
|
||
killable: stats.killable ?? kind.killable,
|
||
inTown: stats.inTown ?? kind.inTown,
|
||
resistances: mergedRes,
|
||
resists: stats.modifiers && stats.modifiers.length > 0
|
||
? mergedRes
|
||
: (stats.resists ? { ...mergedRes, ...stats.resists } : mergedRes),
|
||
...(mergedRes.lightning !== undefined ? { lightningResist: mergedRes.lightning } : {}),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Enrich an array of `MonsterPack`s with canonical `MonStats.txt` and `SuperUniques.txt` metadata.
|
||
*/
|
||
export function enrichMonsterPacks(
|
||
packs: readonly MonsterPack[],
|
||
kinds: ReadonlyMap<string, MonsterKind>,
|
||
superUniques?: ReadonlyMap<string, SuperUnique>,
|
||
): MonsterPack[] {
|
||
return packs.map(pack => ({
|
||
...pack,
|
||
members: pack.members.map(member =>
|
||
enrichMonsterStats(
|
||
pack.superUniqueId !== undefined && member.superUniqueId === undefined
|
||
? { ...member, superUniqueId: pack.superUniqueId }
|
||
: member,
|
||
kinds,
|
||
superUniques,
|
||
),
|
||
),
|
||
}))
|
||
}
|
||
|
||
|