/** * 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 { const d = difficultySuffix(difficulty) const kinds = new Map() 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 = { 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): Map { const byRowId = new Map() for (const row of table.rows) { const id = cell(table, row, 'Id').trim() if (id !== '') byRowId.set(id, row) } const art = new Map() 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 { const d = difficultySuffix(difficulty) const scales = new Map() /** 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() /** * 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, 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, 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 = { 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 { const constants = new Map() 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 > = { '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, 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 /** 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, 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, 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 = 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, 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 { const suList = readSuperUniques(table, difficulty) const suMap = new Map() for (const su of suList) { suMap.set(su.id.toLowerCase(), su) if (su.nameKey) { suMap.set(su.nameKey.toLowerCase(), su) } } const modNameById = new Map([ [24, 'thief'], ]) for (const m of CANONICAL_ELITE_MODIFIERS) { modNameById.set(m.id, m.name) } const byLevel: Record = {} 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 = {} 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 > = 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() 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, superUniques?: ReadonlyMap, ): 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, superUniques?: ReadonlyMap, ): 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, ), ), })) }