diff --git a/src/game/monsters.ts b/src/game/monsters.ts index 458dcb5..b62a896 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -71,7 +71,7 @@ * case on that basis would be guessing; the act bosses get their own pass. */ import type { D2Table } from './acts.ts' -import { cell } from './acts.ts' +import { cell, parseTable } from './acts.ts' import type { MonsterPack, MonsterStats } from './combat.ts' import { Rng } from './rng.ts' @@ -1069,351 +1069,158 @@ export interface SuperUniqueLandmarkSpec { readonly minionMonsterId: string } -/** Canonical outdoor SuperUniques tied to level landmarks across Acts 1-5. */ -export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly> = { - // Act 1 - 3: { - id: 'Bishibosh', - name: 'Bishibosh', - monsterId: 'fallenshaman1', - landmark: 'Act 1 - Fallen Camp Bishibosh', - modifiers: ['magicresistant', 'fireenchant'], - minMinions: 5, - maxMinions: 7, - minionMonsterId: 'fallen1', - }, - 4: { - id: 'Rakanishu', - name: 'Rakanishu', - monsterId: 'fallen1', - landmark: 'Act 1 - Cairn Stones', - modifiers: ['lightenchant', 'fast'], - minMinions: 6, - maxMinions: 8, - minionMonsterId: 'fallen1', - }, - 5: { - id: 'Treehead WoodFist', - name: 'Treehead WoodFist', - monsterId: 'brute1', - landmark: 'Act 1 - Inifus', - modifiers: ['strong', 'fast'], - minMinions: 3, - maxMinions: 4, - minionMonsterId: 'brute1', - }, - 6: { - id: 'The Countess', - name: 'The Countess', - monsterId: 'corruptrogue5', - landmark: 'Act 1 - Tower 1', - modifiers: ['fireenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'corruptrogue5', - }, - 17: { - id: 'Blood Raven', - name: 'Blood Raven', - monsterId: 'corruptrogue1', - landmark: 'Act 1 - Graveyard', - modifiers: ['fireenchant', 'fast'], - minMinions: 6, - maxMinions: 8, - minionMonsterId: 'zombie1', - }, - 25: { - id: 'The Countess', - name: 'The Countess', - monsterId: 'corruptrogue5', - landmark: 'Act 1 - Tower 1', - modifiers: ['fireenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'corruptrogue5', - }, - // Act 2 - 41: { - id: 'Creeping Feature', - name: 'Creeping Feature', - monsterId: 'mummy1', - landmark: 'Act 2 - Desert Tomb 1', - modifiers: ['strong', 'coldenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'mummy1', - }, - 43: { - id: 'Beetleburst', - name: 'Beetleburst', - monsterId: 'beetle2', - landmark: 'Act 2 - Desert Oasis 1', - modifiers: ['magicresistant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'beetle2', - }, - 44: { - id: 'Dark Elder', - name: 'Dark Elder', - monsterId: 'plaguebearer1', - landmark: 'Act 2 - Desert Ruins Elder', - modifiers: ['fast', 'magicresistant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'plaguebearer1', - }, - 45: [ - { - id: 'Bloodwitch the Wild', - name: 'Bloodwitch the Wild', - monsterId: 'huntress2', - landmark: 'Act 2 - Desert Tomb 2', - modifiers: ['cursed', 'strong'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'huntress2', - }, - { - id: 'Fangskin', - name: 'Fangskin', - monsterId: 'clawviper3', - landmark: 'Act 2 - Desert Valley Ruin 2', - modifiers: ['lightenchant', 'fast'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'clawviper3', - }, - ], - 55: { - id: 'Creeping Feature', - name: 'Creeping Feature', - monsterId: 'mummy1', - landmark: 'Act 2 - Desert Tomb 1', - modifiers: ['strong', 'coldenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'mummy1', - }, - 59: { - id: 'Creeping Feature', - name: 'Creeping Feature', - monsterId: 'mummy1', - landmark: 'Act 2 - Desert Tomb 1', - modifiers: ['strong', 'coldenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'mummy1', - }, - 60: { - id: 'Bloodwitch the Wild', - name: 'Bloodwitch the Wild', - monsterId: 'huntress2', - landmark: 'Act 2 - Desert Tomb 2', - modifiers: ['cursed', 'strong'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'huntress2', - }, - 61: { - id: 'Fangskin', - name: 'Fangskin', - monsterId: 'clawviper3', - landmark: 'Act 2 - Desert Valley Ruin 2', - modifiers: ['lightenchant', 'fast'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'clawviper3', - }, - // Act 3 - 76: { - id: 'Sszark the Burning', - name: 'Sszark the Burning', - monsterId: 'spider2', - landmark: 'Act 3 - Spider Cavern', - modifiers: ['strong', 'cursed'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'spider2', - }, - 78: [ - { - id: 'Stormtree', - name: 'Stormtree', - monsterId: 'thornhulk3', - landmark: 'Act 3 - Flayer Jungle Entrance', - modifiers: ['lightenchant', 'fast'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'thornhulk3', - }, - { - id: 'Witch Doctor Endugu', - name: 'Witch Doctor Endugu', - monsterId: 'flayershaman4', - landmark: 'Act 3 - Flayer Dungeon', - modifiers: ['magicresistant', 'fireenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'flayer4', - }, - ], - 80: { - id: 'Battlemaid Sarina', - name: 'Battlemaid Sarina', - monsterId: 'corruptrogue8', - landmark: 'Act 3 - Burbs Temple', - modifiers: ['fast', 'spectralhit'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'corruptrogue8', - }, - 84: { - id: 'Sszark the Burning', - name: 'Sszark the Burning', - monsterId: 'spider2', - landmark: 'Act 3 - Spider Cavern', - modifiers: ['strong', 'cursed'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'spider2', - }, - 88: { - id: 'Witch Doctor Endugu', - name: 'Witch Doctor Endugu', - monsterId: 'flayershaman4', - landmark: 'Act 3 - Flayer Dungeon', - modifiers: ['magicresistant', 'fireenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'flayer4', - }, - 91: { - id: 'Witch Doctor Endugu', - name: 'Witch Doctor Endugu', - monsterId: 'flayershaman4', - landmark: 'Act 3 - Flayer Dungeon', - modifiers: ['magicresistant', 'fireenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'flayer4', - }, - 94: { - id: 'Battlemaid Sarina', - name: 'Battlemaid Sarina', - monsterId: 'corruptrogue8', - landmark: 'Act 3 - Burbs Temple', - modifiers: ['fast', 'spectralhit'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'corruptrogue8', - }, - // Act 4 - 105: { - id: 'Izual', - name: 'Izual', - monsterId: 'izual', - landmark: 'Act 4 - Plains of Despair', - modifiers: ['coldenchant'], - minMinions: 0, - maxMinions: 0, - minionMonsterId: 'izual', - }, - 106: { - id: 'Hephasto the Armorer', - name: 'Hephasto the Armorer', - monsterId: 'hephasto', - landmark: 'Act 4 - Hellforge', - modifiers: ['spectralhit', 'aura'], - minMinions: 0, - maxMinions: 0, - minionMonsterId: 'hephasto', - }, - 107: { - id: 'Hephasto the Armorer', - name: 'Hephasto the Armorer', - monsterId: 'hephasto', - landmark: 'Act 4 - Hellforge', - modifiers: ['spectralhit', 'aura'], - minMinions: 0, - maxMinions: 0, - minionMonsterId: 'hephasto', - }, - // Act 5 - 110: [ - { - id: 'Dac Farren', - name: 'Dac Farren', - monsterId: 'demonimp1', - landmark: 'Act 5 - Barricade Prison 1', - modifiers: ['coldenchant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'demonimp1', - }, - { - id: 'Shenk the Overseer', - name: 'Shenk the Overseer', - monsterId: 'overseer2', - landmark: 'Act 5 - Barricade Building', - modifiers: ['strong'], - minMinions: 15, - maxMinions: 20, - minionMonsterId: 'enslaved1', - }, - ], - 111: { - id: 'Eyeback the Unleashed', - name: 'Eyeback the Unleashed', - monsterId: 'bloodlord2', - landmark: 'Act 5 - Barricade Hell Portal N', - modifiers: ['fast', 'strong'], - minMinions: 3, - maxMinions: 5, - minionMonsterId: 'bloodlord2', - }, - 112: { - id: 'Thresh Socket', - name: 'Thresh Socket', - monsterId: 'siegebeast1', - landmark: 'Act 5 - Barricade Snow Unique', - modifiers: ['cursed'], - minMinions: 2, - maxMinions: 4, - minionMonsterId: 'siegebeast1', - }, - 114: { - id: 'Frozenstein', - name: 'Frozenstein', - monsterId: 'yeti2', - landmark: 'Act 5 - Frozen River', - modifiers: ['coldenchant', 'manahit'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'yeti2', - }, - 115: { - id: 'Bonesaw Breaker', - name: 'Bonesaw Breaker', - monsterId: 'reanimatedhorde2', - landmark: 'Act 5 - Glacial Trail', - modifiers: ['strong', 'magicresistant'], - minMinions: 4, - maxMinions: 6, - minionMonsterId: 'reanimatedhorde2', - }, - 124: { - id: 'Nihlathak', - name: 'Nihlathak', - monsterId: 'nihlathakboss', - landmark: 'Act 5 - Halls of Vaught', - modifiers: ['coldenchant'], - minMinions: 6, - maxMinions: 8, - minionMonsterId: 'iceprowler2', - }, +/** 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: 'beetle2' }, + { id: 'Dark Elder', levelIds: [44], landmark: 'Act 2 - Desert Ruins Elder', minionMonsterId: 'plaguebearer1' }, + { id: 'Bloodwitch the Wild', levelIds: [45, 60], landmark: 'Act 2 - Desert Tomb 2', minionMonsterId: 'huntress2' }, + { 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: 'spider2' }, + { 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: 'flayer4' }, + { id: 'Battlemaid Sarina', levelIds: [80, 94], landmark: 'Act 3 - Burbs Temple', minionMonsterId: 'corruptrogue8' }, + // 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: 'demonimp1' }, + { id: 'Shenk the Overseer', levelIds: [110], landmark: 'Act 5 - Barricade Building', minionMonsterId: 'enslaved1' }, + { 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: 'yeti2' }, + { id: 'Bonesaw Breaker', levelIds: [115], landmark: 'Act 5 - Glacial Trail', minionMonsterId: 'reanimatedhorde2' }, + { id: 'Nihlathak', levelIds: [124], landmark: 'Act 5 - Halls of Vaught', minionMonsterId: 'iceprowler2' }, +] + +/** 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\tbeetle2\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\tplaguebearer1\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\thuntress2\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\tspider2\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\tflayershaman4\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\tcorruptrogue8\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\tdemonimp1\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\tyeti2\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() + 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. */ @@ -1463,7 +1270,12 @@ export interface PlannedLevel { * @returns the population, empty for a town or an unknown level. */ export function planLevelMonsters( - tables: { readonly levels: D2Table; readonly monstats: D2Table; readonly monlvl: D2Table }, + tables: { + readonly levels: D2Table + readonly monstats: D2Table + readonly monlvl: D2Table + readonly superuniques?: D2Table + }, levelId: number, cells: number, seed: number, @@ -1520,7 +1332,10 @@ export function planLevelMonsters( }) // Check if this level has fixed SuperUnique boss(es) - const suRaw = CANONICAL_SUPER_UNIQUES_BY_LEVEL[levelId] + 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] : []) @@ -1530,9 +1345,9 @@ export function planLevelMonsters( for (let suIdx = 0; suIdx < suSpecs.length; suIdx += 1) { const suSpec = suSpecs[suIdx]! const suRng = new Rng(seed ^ 0x55aa ^ (suIdx * 0x3c6ef35f)) - const fallbackKind = kinds.get('fallen1') ?? kinds.values().next().value! - const bossKind = kinds.get(suSpec.monsterId) ?? fallbackKind - const minionKind = kinds.get(suSpec.minionMonsterId) ?? bossKind + 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 bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3) const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel)) const bossScaled: MonsterStats = { diff --git a/tests/wilderness-superuniques.test.ts b/tests/wilderness-superuniques.test.ts index 7d9cf42..caab34e 100644 --- a/tests/wilderness-superuniques.test.ts +++ b/tests/wilderness-superuniques.test.ts @@ -11,6 +11,9 @@ import { planLevelMonsters, CANONICAL_SUPER_UNIQUES_BY_LEVEL, CANONICAL_ELITE_MODIFIERS, + lookupSuperUnique, + buildSuperUniqueLandmarkSpecs, + CANONICAL_SUPERUNIQUES_TABLE, } from "../src/game/monsters.ts" import { parseTable } from "../src/game/acts.ts" import type { D2Table } from "../src/game/acts.ts" @@ -393,5 +396,92 @@ describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => { expect(dacPack?.members[0]?.modifiers?.includes("coldenchant")).toBe(true) expect(shenkPack?.members[0]?.modifiers?.includes("strong")).toBe(true) }) + + test("lookupSuperUnique retrieves canonical superuniques by id or name", () => { + const bishibosh = lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "Bishibosh") + expect(bishibosh).toBeDefined() + expect(bishibosh?.monsterId).toBe("fallenshaman1") + expect(bishibosh?.minMinions).toBe(5) + expect(bishibosh?.maxMinions).toBe(7) + + const caseInsensitive = lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "rakanishu") + expect(caseInsensitive).toBeDefined() + expect(caseInsensitive?.id).toBe("Rakanishu") + expect(caseInsensitive?.monsterId).toBe("fallen1") + + expect(lookupSuperUnique(CANONICAL_SUPERUNIQUES_TABLE, "nonexistent")).toBeUndefined() + }) + + test("buildSuperUniqueLandmarkSpecs builds specs from custom SuperUniques table", () => { + const customSuTable = mockTable([ + "Superunique\tName\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tAutoPos\tStacks\tReplaceable\tUtrans\tTC\tTC(N)\tTC(H)", + "Bishibosh\tBishibosh Custom\tfallenshaman2\t6\t28\t0\t8\t10\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A", + ].join("\n")) + + const specsByLevel = buildSuperUniqueLandmarkSpecs(customSuTable) + const bishiboshSpec = specsByLevel[3] + expect(bishiboshSpec).toBeDefined() + expect(Array.isArray(bishiboshSpec)).toBe(false) + const spec = bishiboshSpec as import("../src/game/monsters.ts").SuperUniqueLandmarkSpec + expect(spec.name).toBe("Bishibosh Custom") + expect(spec.monsterId).toBe("fallenshaman2") + expect(spec.minMinions).toBe(8) + expect(spec.maxMinions).toBe(10) + expect(spec.modifiers).toEqual(["fast", "stoneskin"]) + }) + + test("planLevelMonsters skips boss without falling back to fallen1 when boss monster is missing", () => { + const levelsTxt = mockTable([ + "Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3", + "4\tStony Field\t600\t2\t4\t7\tzombie1\tquillrat1\tbrute1", + ].join("\n")) + // Only define zombie1 in monstats - Rakanishu is fallen1, which is absent + const monstatsTxt = mockTable([ + "Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee", + "zombie1\tzombie1\tZombie\t7\t1\t2\t4\t6\t10\t20\t10\t20\t2\t5\t20\t1\t1\t1\t1", + ].join("\n")) + const monlvlTxt = mockTable([ + "Level\tAC\tTH\tHP\tDM\tXP", + "7\t100\t100\t100\t100\t100", + ].join("\n")) + const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt } + + const plan = planLevelMonsters(tables, 4, 6400, 12345, 3.5, "normal") + // Rakanishu should NOT be spawned as zombie1 or fallen1 (fallen1 fallback was removed) + expect(plan.superUniques?.includes("Rakanishu")).toBeFalsy() + const rakanishuPack = plan.packs.find(p => p.superUniqueId === "Rakanishu") + expect(rakanishuPack).toBeUndefined() + }) + + test("planLevelMonsters uses tables.superuniques when provided", () => { + const levelsTxt = mockTable([ + "Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3", + "3\tCold Plains\t600\t2\t4\t5\tfallenshaman1\tfallen1\tzombie1", + ].join("\n")) + const monstatsTxt = mockTable([ + "Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee", + "fallenshaman1\tfallenshaman1\tFallen Shaman\t5\t1\t1\t6\t9\t10\t20\t15\t25\t2\t4\t20\t1\t1\t1\t1", + "fallen1\tfallen1\tFallen\t5\t3\t5\t8\t12\t5\t10\t5\t10\t1\t2\t15\t1\t1\t1\t1", + ].join("\n")) + const monlvlTxt = mockTable([ + "Level\tAC\tTH\tHP\tDM\tXP", + "5\t100\t100\t100\t100\t100", + ].join("\n")) + const customSuTable = mockTable([ + "Superunique\tName\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tAutoPos\tStacks\tReplaceable\tUtrans\tTC\tTC(N)\tTC(H)", + "Bishibosh\tBishibosh Ultra\tfallenshaman1\t6\t0\t0\t3\t3\t0\t0\t0\t0\tAct 1 Super A\tAct 1 (N) Super A\tAct 1 (H) Super A", + ].join("\n")) + + const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt, superuniques: customSuTable } + const plan = planLevelMonsters(tables, 3, 6400, 54321, 3.5, "normal") + + expect(plan.superUniques).toContain("Bishibosh") + const bishiboshPack = plan.packs.find(p => p.superUniqueId === "Bishibosh") + expect(bishiboshPack).toBeDefined() + expect(bishiboshPack?.members[0]?.name).toBe("Bishibosh Ultra") + expect(bishiboshPack?.members[0]?.modifiers).toEqual(["fast"]) + // 1 boss + 3 minions = 4 members + expect(bishiboshPack?.members.length).toBe(4) + }) }) })