fix(monsters): derive superunique specs from SuperUniques table and eliminate fallen1 fallback
- Replace static CANONICAL_SUPER_UNIQUES_BY_LEVEL mapping with buildSuperUniqueLandmarkSpecs - Parse canonical SuperUniques from game data table CANONICAL_SUPERUNIQUES_TABLE - Export lookupSuperUnique helper to search SuperUniques by id or nameKey - Support optional tables.superuniques in planLevelMonsters - Remove dangerous fallback to fallen1/first monster, gracefully skipping missing boss kinds TAG=agy CONV=2a1934de-30ef-464e-b3f3-fcbcdbbc49f1
This commit is contained in:
parent
2e73d7b02f
commit
fd0f48c391
|
|
@ -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<Record<number, SuperUniqueLandmarkSpec | readonly SuperUniqueLandmarkSpec[]>> = {
|
||||
// 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<number, SuperUniqueLandmarkSpec | readonly SuperUniqueLandmarkSpec[]> {
|
||||
const suList = readSuperUniques(table, difficulty)
|
||||
const suMap = new Map<string, SuperUnique>()
|
||||
for (const su of suList) {
|
||||
suMap.set(su.id.toLowerCase(), su)
|
||||
if (su.nameKey) {
|
||||
suMap.set(su.nameKey.toLowerCase(), su)
|
||||
}
|
||||
}
|
||||
|
||||
const modNameById = new Map<number, string>()
|
||||
for (const m of CANONICAL_ELITE_MODIFIERS) {
|
||||
modNameById.set(m.id, m.name)
|
||||
}
|
||||
|
||||
const byLevel: Record<number, SuperUniqueLandmarkSpec[]> = {}
|
||||
|
||||
for (const mapping of CANONICAL_SUPER_UNIQUE_LANDMARKS) {
|
||||
const su = suMap.get(mapping.id.toLowerCase())
|
||||
if (!su) continue
|
||||
const modifiers = su.modifiers
|
||||
.map(id => modNameById.get(id))
|
||||
.filter((name): name is string => name !== undefined)
|
||||
const spec: SuperUniqueLandmarkSpec = {
|
||||
id: su.id,
|
||||
name: su.nameKey || su.id,
|
||||
monsterId: su.monsterId,
|
||||
landmark: mapping.landmark,
|
||||
modifiers,
|
||||
minMinions: su.minMinions,
|
||||
maxMinions: su.maxMinions,
|
||||
minionMonsterId: mapping.minionMonsterId ?? su.monsterId,
|
||||
}
|
||||
for (const levelId of mapping.levelIds) {
|
||||
if (!byLevel[levelId]) {
|
||||
byLevel[levelId] = []
|
||||
}
|
||||
byLevel[levelId].push(spec)
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<number, SuperUniqueLandmarkSpec | readonly SuperUniqueLandmarkSpec[]> = {}
|
||||
for (const [lvlStr, specs] of Object.entries(byLevel)) {
|
||||
const lvl = Number(lvlStr)
|
||||
result[lvl] = specs.length === 1 ? specs[0]! : specs
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Canonical outdoor SuperUniques tied to level landmarks across Acts 1-5. */
|
||||
export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<
|
||||
Record<number, SuperUniqueLandmarkSpec | readonly SuperUniqueLandmarkSpec[]>
|
||||
> = buildSuperUniqueLandmarkSpecs()
|
||||
|
||||
/** What one level's population came out as. */
|
||||
export interface PlannedLevel {
|
||||
/** `Levels.txt` id. */
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue