feat(map-gen/wilderness): data-driven wilderness SuperUniques and landmark entities from SuperUniques.txt (fixes #57)
This commit is contained in:
parent
1fa3f8b7a2
commit
92d6731dae
|
|
@ -703,6 +703,7 @@ export const CANONICAL_ELITE_MODIFIERS: readonly {
|
||||||
{ id: 27, name: 'spectralhit', label: 'Spectral Hit', champion: false },
|
{ id: 27, name: 'spectralhit', label: 'Spectral Hit', champion: false },
|
||||||
{ id: 28, name: 'stoneskin', label: 'Stone Skin', champion: false },
|
{ id: 28, name: 'stoneskin', label: 'Stone Skin', champion: false },
|
||||||
{ id: 29, name: 'multishot', label: 'Multiple Shots', champion: false },
|
{ id: 29, name: 'multishot', label: 'Multiple Shots', champion: false },
|
||||||
|
{ id: 30, name: 'aura', label: 'Aura Enchanted', champion: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -998,8 +999,9 @@ export interface SuperUniqueLandmarkSpec {
|
||||||
readonly minionMonsterId: string
|
readonly minionMonsterId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Canonical Act 1 outdoor SuperUniques tied to level landmarks. */
|
/** Canonical outdoor SuperUniques tied to level landmarks across Acts 1-5. */
|
||||||
export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniqueLandmarkSpec>> = {
|
export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniqueLandmarkSpec | readonly SuperUniqueLandmarkSpec[]>> = {
|
||||||
|
// Act 1
|
||||||
3: {
|
3: {
|
||||||
id: 'Bishibosh',
|
id: 'Bishibosh',
|
||||||
name: 'Bishibosh',
|
name: 'Bishibosh',
|
||||||
|
|
@ -1030,6 +1032,16 @@ export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniq
|
||||||
maxMinions: 4,
|
maxMinions: 4,
|
||||||
minionMonsterId: 'brute1',
|
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: {
|
17: {
|
||||||
id: 'Blood Raven',
|
id: 'Blood Raven',
|
||||||
name: 'Blood Raven',
|
name: 'Blood Raven',
|
||||||
|
|
@ -1040,6 +1052,296 @@ export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniq
|
||||||
maxMinions: 8,
|
maxMinions: 8,
|
||||||
minionMonsterId: 'zombie1',
|
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',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What one level's population came out as. */
|
/** What one level's population came out as. */
|
||||||
|
|
@ -1129,11 +1431,17 @@ export function planLevelMonsters(
|
||||||
return { members }
|
return { members }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check if this level has a fixed SuperUnique boss (Bishibosh, Rakanishu, Treehead WoodFist, Blood Raven)
|
// Check if this level has fixed SuperUnique boss(es)
|
||||||
const suSpec = CANONICAL_SUPER_UNIQUES_BY_LEVEL[levelId]
|
const suRaw = CANONICAL_SUPER_UNIQUES_BY_LEVEL[levelId]
|
||||||
|
const suSpecs: readonly SuperUniqueLandmarkSpec[] = Array.isArray(suRaw)
|
||||||
|
? suRaw
|
||||||
|
: (suRaw !== undefined ? [suRaw] : [])
|
||||||
const superUniques: string[] = []
|
const superUniques: string[] = []
|
||||||
if (suSpec !== undefined && kinds.size > 0) {
|
if (suSpecs.length > 0 && kinds.size > 0) {
|
||||||
const suRng = new Rng(seed ^ 0x55aa)
|
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 fallbackKind = kinds.get('fallen1') ?? kinds.values().next().value!
|
const fallbackKind = kinds.get('fallen1') ?? kinds.values().next().value!
|
||||||
const bossKind = kinds.get(suSpec.monsterId) ?? fallbackKind
|
const bossKind = kinds.get(suSpec.monsterId) ?? fallbackKind
|
||||||
const minionKind = kinds.get(suSpec.minionMonsterId) ?? bossKind
|
const minionKind = kinds.get(suSpec.minionMonsterId) ?? bossKind
|
||||||
|
|
@ -1165,9 +1473,11 @@ export function planLevelMonsters(
|
||||||
...(matchedLandmark?.fixedPosition !== undefined ? { fixedPosition: matchedLandmark.fixedPosition } : {}),
|
...(matchedLandmark?.fixedPosition !== undefined ? { fixedPosition: matchedLandmark.fixedPosition } : {}),
|
||||||
...(matchedLandmark !== undefined ? { landmarkTile: { tileX: matchedLandmark.tileX, tileY: matchedLandmark.tileY } } : {}),
|
...(matchedLandmark !== undefined ? { landmarkTile: { tileX: matchedLandmark.tileX, tileY: matchedLandmark.tileY } } : {}),
|
||||||
}
|
}
|
||||||
packs.unshift(suPack)
|
suPacks.push(suPack)
|
||||||
superUniques.push(suSpec.id)
|
superUniques.push(suSpec.id)
|
||||||
}
|
}
|
||||||
|
packs.unshift(...suPacks)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
levelId,
|
levelId,
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,9 @@ import type { Ds1, Ds1Cell, Ds1Floor, Ds1Object, Ds1Wall, Ds1Substitution } from
|
||||||
import { Rng } from './rng.ts'
|
import { Rng } from './rng.ts'
|
||||||
import { SUB_TILES_PER_TILE } from './map.ts'
|
import { SUB_TILES_PER_TILE } from './map.ts'
|
||||||
import type { Side } from './world-graph.ts'
|
import type { Side } from './world-graph.ts'
|
||||||
|
import type { D2Table } from './acts.ts'
|
||||||
|
import { readSuperUniques, CANONICAL_ELITE_MODIFIERS } from './monsters.ts'
|
||||||
|
import type { SuperUnique } from './monsters.ts'
|
||||||
|
|
||||||
/** One generator block, in cells. `DRLGOUTDOORS_GenerateLevel` divides by 8. */
|
/** One generator block, in cells. `DRLGOUTDOORS_GenerateLevel` divides by 8. */
|
||||||
export const TILES_PER_BLOCK = 8
|
export const TILES_PER_BLOCK = 8
|
||||||
|
|
@ -245,6 +248,12 @@ export interface WildernessRequest {
|
||||||
* the tests and `verify-generators` use.
|
* the tests and `verify-generators` use.
|
||||||
*/
|
*/
|
||||||
readonly gates?: readonly PlannedGate[]
|
readonly gates?: readonly PlannedGate[]
|
||||||
|
/** Optional `SuperUniques.txt` table for data-driven outdoor boss and landmark generation. */
|
||||||
|
readonly superuniques?: D2Table
|
||||||
|
/** Optional `MonPreset.txt` table for data-driven DS1 spawner object IDs. */
|
||||||
|
readonly monpreset?: D2Table
|
||||||
|
/** Optional custom landmark entity definitions overriding CANONICAL_LANDMARK_ENTITIES. */
|
||||||
|
readonly customLandmarks?: readonly LandmarkEntityDef[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One cross-level opening, as decided by the act layout. */
|
/** One cross-level opening, as decided by the act layout. */
|
||||||
|
|
@ -269,7 +278,9 @@ export interface WildernessResult {
|
||||||
/** A single synthesized map the isometric renderer can draw unmodified. */
|
/** A single synthesized map the isometric renderer can draw unmodified. */
|
||||||
readonly level: Ds1
|
readonly level: Ds1
|
||||||
/** What the generator did, for reporting and tests. */
|
/** What the generator did, for reporting and tests. */
|
||||||
readonly stats: Record<string, unknown>
|
readonly stats: Record<string, unknown> & {
|
||||||
|
readonly landmarkEntities?: LandmarkEntity[]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Canonical default open ground floor tiles per wilderness level type. */
|
/** Canonical default open ground floor tiles per wilderness level type. */
|
||||||
|
|
@ -602,9 +613,13 @@ export interface LandmarkEntity {
|
||||||
readonly landmark: string
|
readonly landmark: string
|
||||||
readonly tileX: number
|
readonly tileX: number
|
||||||
readonly tileY: number
|
readonly tileY: number
|
||||||
|
readonly subtileX?: number
|
||||||
|
readonly subtileY?: number
|
||||||
|
readonly levelId?: number
|
||||||
readonly modifiers: readonly string[]
|
readonly modifiers: readonly string[]
|
||||||
readonly minionCount: readonly [number, number]
|
readonly minionCount: readonly [number, number]
|
||||||
readonly minionMonsterId: string
|
readonly minionMonsterId: string
|
||||||
|
readonly objectId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The report collector. */
|
/** The report collector. */
|
||||||
|
|
@ -2144,6 +2159,14 @@ export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]
|
||||||
'Act 2 - Desert Cliff Right King Tomb',
|
'Act 2 - Desert Cliff Right King Tomb',
|
||||||
'Act 2 - Desert Fill Mesa 1',
|
'Act 2 - Desert Fill Mesa 1',
|
||||||
],
|
],
|
||||||
|
76: [
|
||||||
|
'Act 3 - Spider Cavern',
|
||||||
|
'Act 3 - Spider Temple Entrance',
|
||||||
|
],
|
||||||
|
78: [
|
||||||
|
'Act 3 - Flayer Jungle Entrance',
|
||||||
|
'Act 3 - Flayer Dungeon',
|
||||||
|
],
|
||||||
79: [
|
79: [
|
||||||
'Act 3 - Slums 16x16',
|
'Act 3 - Slums 16x16',
|
||||||
'Act 3 - Slums 16x16',
|
'Act 3 - Slums 16x16',
|
||||||
|
|
@ -2166,6 +2189,35 @@ export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]
|
||||||
'Act 3 - Metro 16x16',
|
'Act 3 - Metro 16x16',
|
||||||
'Act 3 - Metro 16x08',
|
'Act 3 - Metro 16x08',
|
||||||
],
|
],
|
||||||
|
88: [
|
||||||
|
'Act 3 - Flayer Dungeon',
|
||||||
|
],
|
||||||
|
91: [
|
||||||
|
'Act 3 - Flayer Dungeon',
|
||||||
|
],
|
||||||
|
94: [
|
||||||
|
'Act 3 - Ruined Temple',
|
||||||
|
],
|
||||||
|
105: [
|
||||||
|
'Act 4 - Plains of Despair',
|
||||||
|
'Act 4 - Izual',
|
||||||
|
],
|
||||||
|
106: [
|
||||||
|
'Act 4 - Hellforge',
|
||||||
|
'Act 4 - Chaos Sanctuary Entrance',
|
||||||
|
],
|
||||||
|
107: [
|
||||||
|
'Act 4 - Hellforge',
|
||||||
|
'Act 4 - Chaos Sanctuary Entrance',
|
||||||
|
],
|
||||||
|
108: [
|
||||||
|
'Act 4 - Chaos Sanctuary Entrance',
|
||||||
|
'Act 4 - Hadriel',
|
||||||
|
],
|
||||||
|
110: [
|
||||||
|
'Act 5 - Barricade Prison 1',
|
||||||
|
'Act 5 - Barricade Building',
|
||||||
|
],
|
||||||
111: [
|
111: [
|
||||||
'Act 5 - Barricade Waypoint Dirt',
|
'Act 5 - Barricade Waypoint Dirt',
|
||||||
'Act 5 - Barricade Hell Portal N',
|
'Act 5 - Barricade Hell Portal N',
|
||||||
|
|
@ -2194,6 +2246,14 @@ export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]
|
||||||
'Act 5 - Barricade Ruins N Treasure',
|
'Act 5 - Barricade Ruins N Treasure',
|
||||||
'Act 5 - Barricade Ruins W Treasure',
|
'Act 5 - Barricade Ruins W Treasure',
|
||||||
],
|
],
|
||||||
|
114: [
|
||||||
|
'Act 5 - Frozen River',
|
||||||
|
'Act 5 - Anya',
|
||||||
|
],
|
||||||
|
115: [
|
||||||
|
'Act 5 - Glacial Trail',
|
||||||
|
'Act 5 - Drifter Cavern',
|
||||||
|
],
|
||||||
117: [
|
117: [
|
||||||
'Act 5 - Barricade To Cave 32x16 Snow',
|
'Act 5 - Barricade To Cave 32x16 Snow',
|
||||||
'Act 5 - Barricade Hell Portal N',
|
'Act 5 - Barricade Hell Portal N',
|
||||||
|
|
@ -2203,6 +2263,10 @@ export const SPECIAL_PRESETS_BY_LEVEL: Readonly<Record<number, readonly string[]
|
||||||
'Act 5 - Barricade 3 Snow',
|
'Act 5 - Barricade 3 Snow',
|
||||||
'Act 5 - Barricade Snow Lake 1',
|
'Act 5 - Barricade Snow Lake 1',
|
||||||
],
|
],
|
||||||
|
124: [
|
||||||
|
'Act 5 - Halls of Vaught',
|
||||||
|
'Act 5 - Nihlathak',
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -2488,6 +2552,7 @@ function stampAct5SiegeStrips(
|
||||||
canvas: Canvas,
|
canvas: Canvas,
|
||||||
pieces: readonly WildernessPiece[],
|
pieces: readonly WildernessPiece[],
|
||||||
stats: WildernessStats,
|
stats: WildernessStats,
|
||||||
|
placedRects?: PlacedPresetRect[],
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
const stripOrder = [
|
const stripOrder = [
|
||||||
'Act 5 - Siege To Barricade',
|
'Act 5 - Siege To Barricade',
|
||||||
|
|
@ -2514,6 +2579,7 @@ function stampAct5SiegeStrips(
|
||||||
if (!ds1) continue
|
if (!ds1) continue
|
||||||
stampDs1(canvas, ds1, i * 16, 0)
|
stampDs1(canvas, ds1, i * 16, 0)
|
||||||
placed.add(name)
|
placed.add(name)
|
||||||
|
placedRects?.push({ name, x: i * 16, y: 0, w: ds1.width, h: ds1.height })
|
||||||
stats.substitutions.push({ name, role: 'object', enabled: true, clusters: 1 })
|
stats.substitutions.push({ name, role: 'object', enabled: true, clusters: 1 })
|
||||||
}
|
}
|
||||||
return placed
|
return placed
|
||||||
|
|
@ -2526,6 +2592,7 @@ function stampAct4ChaosSanctuary(
|
||||||
canvas: Canvas,
|
canvas: Canvas,
|
||||||
pieces: readonly WildernessPiece[],
|
pieces: readonly WildernessPiece[],
|
||||||
stats: WildernessStats,
|
stats: WildernessStats,
|
||||||
|
placedRects?: PlacedPresetRect[],
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
const placements: Array<{ name: string; x: number; y: number }> = [
|
const placements: Array<{ name: string; x: number; y: number }> = [
|
||||||
{ name: 'Act 4 - Diablo Arm N', x: 48, y: 0 },
|
{ name: 'Act 4 - Diablo Arm N', x: 48, y: 0 },
|
||||||
|
|
@ -2543,6 +2610,7 @@ function stampAct4ChaosSanctuary(
|
||||||
if (!ds1) continue
|
if (!ds1) continue
|
||||||
stampDs1(canvas, ds1, item.x, item.y)
|
stampDs1(canvas, ds1, item.x, item.y)
|
||||||
placed.add(item.name)
|
placed.add(item.name)
|
||||||
|
placedRects?.push({ name: item.name, x: item.x, y: item.y, w: ds1.width, h: ds1.height })
|
||||||
stats.substitutions.push({ name: item.name, role: 'object', enabled: true, clusters: 1 })
|
stats.substitutions.push({ name: item.name, role: 'object', enabled: true, clusters: 1 })
|
||||||
}
|
}
|
||||||
return placed
|
return placed
|
||||||
|
|
@ -2555,6 +2623,7 @@ function stampAct3Travincal(
|
||||||
canvas: Canvas,
|
canvas: Canvas,
|
||||||
pieces: readonly WildernessPiece[],
|
pieces: readonly WildernessPiece[],
|
||||||
stats: WildernessStats,
|
stats: WildernessStats,
|
||||||
|
placedRects?: PlacedPresetRect[],
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
const placements: Array<{ name: string; x: number; y: number }> = [
|
const placements: Array<{ name: string; x: number; y: number }> = [
|
||||||
{ name: 'Act 3 - Travincal NW', x: 0, y: 0 },
|
{ name: 'Act 3 - Travincal NW', x: 0, y: 0 },
|
||||||
|
|
@ -2571,6 +2640,7 @@ function stampAct3Travincal(
|
||||||
if (!ds1) continue
|
if (!ds1) continue
|
||||||
stampDs1(canvas, ds1, item.x, item.y)
|
stampDs1(canvas, ds1, item.x, item.y)
|
||||||
placed.add(item.name)
|
placed.add(item.name)
|
||||||
|
placedRects?.push({ name: item.name, x: item.x, y: item.y, w: ds1.width, h: ds1.height })
|
||||||
stats.substitutions.push({ name: item.name, role: 'object', enabled: true, clusters: 1 })
|
stats.substitutions.push({ name: item.name, role: 'object', enabled: true, clusters: 1 })
|
||||||
}
|
}
|
||||||
return placed
|
return placed
|
||||||
|
|
@ -2611,6 +2681,7 @@ function stampAct3Jungle(
|
||||||
pieces: readonly WildernessPiece[],
|
pieces: readonly WildernessPiece[],
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
stats: WildernessStats,
|
stats: WildernessStats,
|
||||||
|
placedRects?: PlacedPresetRect[],
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
const placed = new Set<string>()
|
const placed = new Set<string>()
|
||||||
const tailPiece = pieces.find(p => p.name === 'Act 3 - Jungle Tail')
|
const tailPiece = pieces.find(p => p.name === 'Act 3 - Jungle Tail')
|
||||||
|
|
@ -2618,11 +2689,13 @@ function stampAct3Jungle(
|
||||||
if (tailPiece?.levels[0]) {
|
if (tailPiece?.levels[0]) {
|
||||||
stampDs1(canvas, tailPiece.levels[0], 0, 0)
|
stampDs1(canvas, tailPiece.levels[0], 0, 0)
|
||||||
placed.add(tailPiece.name)
|
placed.add(tailPiece.name)
|
||||||
|
placedRects?.push({ name: tailPiece.name, x: 0, y: 0, w: tailPiece.levels[0].width, h: tailPiece.levels[0].height })
|
||||||
stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 })
|
stats.substitutions.push({ name: tailPiece.name, role: 'object', enabled: true, clusters: 1 })
|
||||||
}
|
}
|
||||||
if (headPiece?.levels[0]) {
|
if (headPiece?.levels[0]) {
|
||||||
stampDs1(canvas, headPiece.levels[0], 0, 160)
|
stampDs1(canvas, headPiece.levels[0], 0, 160)
|
||||||
placed.add(headPiece.name)
|
placed.add(headPiece.name)
|
||||||
|
placedRects?.push({ name: headPiece.name, x: 0, y: 160, w: headPiece.levels[0].width, h: headPiece.levels[0].height })
|
||||||
stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 })
|
stats.substitutions.push({ name: headPiece.name, role: 'object', enabled: true, clusters: 1 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2656,6 +2729,7 @@ function stampAct3Jungle(
|
||||||
const ds1 = chosenClearing.levels[rng.int(0, chosenClearing.levels.length - 1)]!
|
const ds1 = chosenClearing.levels[rng.int(0, chosenClearing.levels.length - 1)]!
|
||||||
stampDs1(canvas, ds1, 0, y)
|
stampDs1(canvas, ds1, 0, y)
|
||||||
placed.add(chosenClearing.name)
|
placed.add(chosenClearing.name)
|
||||||
|
placedRects?.push({ name: chosenClearing.name, x: 0, y, w: ds1.width, h: ds1.height })
|
||||||
stats.substitutions.push({ name: chosenClearing.name, role: 'object', enabled: true, clusters: 1 })
|
stats.substitutions.push({ name: chosenClearing.name, role: 'object', enabled: true, clusters: 1 })
|
||||||
}
|
}
|
||||||
// East column (x = 32): Continuous North-South River channel
|
// East column (x = 32): Continuous North-South River channel
|
||||||
|
|
@ -2664,6 +2738,7 @@ function stampAct3Jungle(
|
||||||
const ds1 = chosenRiver.levels[rng.int(0, chosenRiver.levels.length - 1)]!
|
const ds1 = chosenRiver.levels[rng.int(0, chosenRiver.levels.length - 1)]!
|
||||||
stampDs1(canvas, ds1, 32, y)
|
stampDs1(canvas, ds1, 32, y)
|
||||||
placed.add(chosenRiver.name)
|
placed.add(chosenRiver.name)
|
||||||
|
placedRects?.push({ name: chosenRiver.name, x: 32, y, w: ds1.width, h: ds1.height })
|
||||||
stats.substitutions.push({ name: chosenRiver.name, role: 'object', enabled: true, clusters: 1 })
|
stats.substitutions.push({ name: chosenRiver.name, role: 'object', enabled: true, clusters: 1 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3173,25 +3248,52 @@ function spawnAct12Shrines(
|
||||||
stats.interactivesSpawned = interactivesSpawned
|
stats.interactivesSpawned = interactivesSpawned
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Specification of a landmark entity definition for data-driven spawning. */
|
||||||
* Anchor fixed Act 1 SuperUniques (Bishibosh, Rakanishu, Treehead WoodFist, Blood Raven)
|
export interface LandmarkEntityDef {
|
||||||
* to their landmark presets and place Guard NPC Flavie (`rogue2`) at the Blood Moor <-> Cold Plains border gate.
|
readonly id: string
|
||||||
*/
|
readonly name: string
|
||||||
function spawnLandmarkEntities(
|
readonly role: 'superunique' | 'npc_guard'
|
||||||
|
readonly act: number
|
||||||
|
readonly levelIds: readonly number[]
|
||||||
|
readonly monsterId: string
|
||||||
|
readonly presetMatch?: RegExp
|
||||||
|
readonly defaultLandmark: string
|
||||||
|
readonly modifiers: readonly string[]
|
||||||
|
readonly minionCount: readonly [number, number]
|
||||||
|
readonly minionMonsterId: string
|
||||||
|
readonly objectId: number
|
||||||
|
readonly defaultRelativePos?: { readonly x: number; readonly y: number }
|
||||||
|
readonly resolvePosition?: (
|
||||||
canvas: Canvas,
|
canvas: Canvas,
|
||||||
levelId: number,
|
levelId: number,
|
||||||
act1Plan: Act1OutdoorPlan | undefined,
|
act1Plan: Act1OutdoorPlan | undefined,
|
||||||
placedRects: readonly PlacedPresetRect[],
|
placedRects: readonly PlacedPresetRect[],
|
||||||
gridWidth: number,
|
gridWidth: number,
|
||||||
gridHeight: number,
|
gridHeight: number,
|
||||||
stats: WildernessStats,
|
) => { tileX: number; tileY: number; landmark: string } | null
|
||||||
): void {
|
}
|
||||||
const entities: LandmarkEntity[] = []
|
|
||||||
|
/**
|
||||||
|
* Canonical landmark SuperUniques and special NPCs across Acts 1-5.
|
||||||
|
*/
|
||||||
|
export const CANONICAL_LANDMARK_ENTITIES: readonly LandmarkEntityDef[] = [
|
||||||
|
// Act 1
|
||||||
|
{
|
||||||
|
id: 'Flavie',
|
||||||
|
name: 'Flavie',
|
||||||
|
role: 'npc_guard',
|
||||||
|
act: 1,
|
||||||
|
levelIds: [2, 3],
|
||||||
|
monsterId: 'rogue2',
|
||||||
|
defaultLandmark: 'Border Gate (Blood Moor <-> Cold Plains)',
|
||||||
|
modifiers: [],
|
||||||
|
minionCount: [0, 0],
|
||||||
|
minionMonsterId: '',
|
||||||
|
objectId: 52,
|
||||||
|
resolvePosition: (canvas, levelId, act1Plan, _placedRects, gridWidth, gridHeight) => {
|
||||||
|
if (levelId !== 2 && levelId !== 3) return null
|
||||||
const cx = Math.floor(canvas.width / 2)
|
const cx = Math.floor(canvas.width / 2)
|
||||||
const cy = Math.floor(canvas.height / 2)
|
const cy = Math.floor(canvas.height / 2)
|
||||||
|
|
||||||
// 1. Guard Flavie (rogue2) at Blood Moor (levelId 2) <-> Cold Plains (levelId 3) open border gate
|
|
||||||
if (levelId === 2 || levelId === 3) {
|
|
||||||
const targetGateLevel = levelId === 2 ? 3 : 2
|
const targetGateLevel = levelId === 2 ? 3 : 2
|
||||||
const anchor = act1Plan?.anchors.find(
|
const anchor = act1Plan?.anchors.find(
|
||||||
a => a.toLevelId === targetGateLevel || /Cold Plains|Blood Moor/i.test(a.label),
|
a => a.toLevelId === targetGateLevel || /Cold Plains|Blood Moor/i.test(a.label),
|
||||||
|
|
@ -3205,7 +3307,6 @@ function spawnLandmarkEntities(
|
||||||
fy = Math.max(4, Math.min(canvas.height - 5, anchor.interiorCell.y + dy))
|
fy = Math.max(4, Math.min(canvas.height - 5, anchor.interiorCell.y + dy))
|
||||||
} else {
|
} else {
|
||||||
const openings = getPerimeterOpenings(levelId, gridWidth, gridHeight)
|
const openings = getPerimeterOpenings(levelId, gridWidth, gridHeight)
|
||||||
// Level 2 has exit at (9,5) or east/north border; Level 3 has entrance at south/west border
|
|
||||||
let chosenKey: string | undefined
|
let chosenKey: string | undefined
|
||||||
for (const k of openings) {
|
for (const k of openings) {
|
||||||
const [bx, by] = k.split(',').map(Number)
|
const [bx, by] = k.split(',').map(Number)
|
||||||
|
|
@ -3226,127 +3327,559 @@ function spawnLandmarkEntities(
|
||||||
fy = cy
|
fy = cy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entities.push({
|
return {
|
||||||
role: 'npc_guard',
|
|
||||||
id: 'Flavie',
|
|
||||||
monsterId: 'rogue2',
|
|
||||||
name: 'Flavie',
|
|
||||||
landmark: 'Border Gate (Blood Moor <-> Cold Plains)',
|
|
||||||
tileX: fx,
|
tileX: fx,
|
||||||
tileY: fy,
|
tileY: fy,
|
||||||
modifiers: [],
|
landmark: 'Border Gate (Blood Moor <-> Cold Plains)',
|
||||||
minionCount: [0, 0],
|
|
||||||
minionMonsterId: '',
|
|
||||||
})
|
|
||||||
canvas.objects.push({
|
|
||||||
type: 1,
|
|
||||||
id: 52,
|
|
||||||
x: fx * SUB_TILES_PER_TILE + 2,
|
|
||||||
y: fy * SUB_TILES_PER_TILE + 2,
|
|
||||||
flags: 0,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
// 2. Bishibosh (Cold Plains - Level 3 - Act 1 - Fallen Camp Bishibosh)
|
},
|
||||||
if (levelId === 3) {
|
{
|
||||||
const rect = placedRects.find(r => /Fallen Camp Bishibosh|Bivouac/i.test(r.name))
|
|
||||||
const tx = rect ? Math.floor(rect.x + rect.w / 2) : Math.floor(canvas.width * 0.45)
|
|
||||||
const ty = rect ? Math.floor(rect.y + rect.h / 2) : Math.floor(canvas.height * 0.45)
|
|
||||||
entities.push({
|
|
||||||
role: 'superunique',
|
|
||||||
id: 'Bishibosh',
|
id: 'Bishibosh',
|
||||||
monsterId: 'fallenshaman1',
|
|
||||||
name: 'Bishibosh',
|
name: 'Bishibosh',
|
||||||
landmark: rect?.name ?? 'Act 1 - Fallen Camp Bishibosh',
|
role: 'superunique',
|
||||||
tileX: tx,
|
act: 1,
|
||||||
tileY: ty,
|
levelIds: [3],
|
||||||
|
monsterId: 'fallenshaman1',
|
||||||
|
presetMatch: /Fallen Camp Bishibosh|Bivouac/i,
|
||||||
|
defaultLandmark: 'Act 1 - Fallen Camp Bishibosh',
|
||||||
modifiers: ['magicresistant', 'fireenchant'],
|
modifiers: ['magicresistant', 'fireenchant'],
|
||||||
minionCount: [5, 7],
|
minionCount: [5, 7],
|
||||||
minionMonsterId: 'fallen1',
|
minionMonsterId: 'fallen1',
|
||||||
})
|
objectId: 70,
|
||||||
canvas.objects.push({
|
defaultRelativePos: { x: 0.45, y: 0.45 },
|
||||||
type: 1,
|
},
|
||||||
id: 70,
|
{
|
||||||
x: tx * SUB_TILES_PER_TILE + 2,
|
|
||||||
y: ty * SUB_TILES_PER_TILE + 2,
|
|
||||||
flags: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Rakanishu (Stony Field - Level 4 - Act 1 - Cairn Stones)
|
|
||||||
if (levelId === 4) {
|
|
||||||
const rect = placedRects.find(r => /Cairn Stones/i.test(r.name))
|
|
||||||
const tx = rect ? Math.floor(rect.x + rect.w / 2) : Math.floor(canvas.width * 0.52)
|
|
||||||
const ty = rect ? Math.floor(rect.y + rect.h / 2) : Math.floor(canvas.height * 0.48)
|
|
||||||
entities.push({
|
|
||||||
role: 'superunique',
|
|
||||||
id: 'Rakanishu',
|
id: 'Rakanishu',
|
||||||
monsterId: 'fallen1',
|
|
||||||
name: 'Rakanishu',
|
name: 'Rakanishu',
|
||||||
landmark: rect?.name ?? 'Act 1 - Cairn Stones',
|
role: 'superunique',
|
||||||
tileX: tx,
|
act: 1,
|
||||||
tileY: ty,
|
levelIds: [4],
|
||||||
|
monsterId: 'fallen1',
|
||||||
|
presetMatch: /Cairn Stones/i,
|
||||||
|
defaultLandmark: 'Act 1 - Cairn Stones',
|
||||||
modifiers: ['lightenchant', 'fast'],
|
modifiers: ['lightenchant', 'fast'],
|
||||||
minionCount: [6, 8],
|
minionCount: [6, 8],
|
||||||
minionMonsterId: 'fallen1',
|
minionMonsterId: 'fallen1',
|
||||||
})
|
objectId: 71,
|
||||||
canvas.objects.push({
|
defaultRelativePos: { x: 0.52, y: 0.48 },
|
||||||
type: 1,
|
},
|
||||||
id: 71,
|
{
|
||||||
x: tx * SUB_TILES_PER_TILE + 2,
|
|
||||||
y: ty * SUB_TILES_PER_TILE + 2,
|
|
||||||
flags: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Treehead WoodFist (Dark Wood - Level 5 - Act 1 - Inifus)
|
|
||||||
if (levelId === 5) {
|
|
||||||
const rect = placedRects.find(r => /Inifus/i.test(r.name))
|
|
||||||
const tx = rect ? Math.floor(rect.x + rect.w / 2) : Math.floor(canvas.width * 0.48)
|
|
||||||
const ty = rect ? Math.floor(rect.y + rect.h / 2) : Math.floor(canvas.height * 0.52)
|
|
||||||
entities.push({
|
|
||||||
role: 'superunique',
|
|
||||||
id: 'Treehead WoodFist',
|
id: 'Treehead WoodFist',
|
||||||
monsterId: 'brute1',
|
|
||||||
name: 'Treehead WoodFist',
|
name: 'Treehead WoodFist',
|
||||||
landmark: rect?.name ?? 'Act 1 - Inifus',
|
role: 'superunique',
|
||||||
tileX: tx,
|
act: 1,
|
||||||
tileY: ty,
|
levelIds: [5],
|
||||||
|
monsterId: 'brute1',
|
||||||
|
presetMatch: /Inifus/i,
|
||||||
|
defaultLandmark: 'Act 1 - Inifus',
|
||||||
modifiers: ['strong', 'fast'],
|
modifiers: ['strong', 'fast'],
|
||||||
minionCount: [3, 4],
|
minionCount: [3, 4],
|
||||||
minionMonsterId: 'brute1',
|
minionMonsterId: 'brute1',
|
||||||
})
|
objectId: 72,
|
||||||
canvas.objects.push({
|
defaultRelativePos: { x: 0.48, y: 0.52 },
|
||||||
type: 1,
|
},
|
||||||
id: 72,
|
{
|
||||||
x: tx * SUB_TILES_PER_TILE + 2,
|
|
||||||
y: ty * SUB_TILES_PER_TILE + 2,
|
|
||||||
flags: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Blood Raven (Burial Grounds - Level 17 or Graveyard preset)
|
|
||||||
const graveyardRect = placedRects.find(r => /Graveyard/i.test(r.name))
|
|
||||||
if (levelId === 17 || graveyardRect !== undefined) {
|
|
||||||
const tx = graveyardRect ? Math.floor(graveyardRect.x + graveyardRect.w / 2) : cx
|
|
||||||
const ty = graveyardRect ? Math.floor(graveyardRect.y + graveyardRect.h / 2) : cy
|
|
||||||
entities.push({
|
|
||||||
role: 'superunique',
|
|
||||||
id: 'Blood Raven',
|
id: 'Blood Raven',
|
||||||
monsterId: 'corruptrogue1',
|
|
||||||
name: 'Blood Raven',
|
name: 'Blood Raven',
|
||||||
landmark: graveyardRect?.name ?? 'Act 1 - Graveyard',
|
role: 'superunique',
|
||||||
tileX: tx,
|
act: 1,
|
||||||
tileY: ty,
|
levelIds: [17],
|
||||||
|
monsterId: 'corruptrogue1',
|
||||||
|
presetMatch: /Graveyard/i,
|
||||||
|
defaultLandmark: 'Act 1 - Graveyard',
|
||||||
modifiers: ['fireenchant', 'fast'],
|
modifiers: ['fireenchant', 'fast'],
|
||||||
minionCount: [6, 8],
|
minionCount: [6, 8],
|
||||||
minionMonsterId: 'zombie1',
|
minionMonsterId: 'zombie1',
|
||||||
|
objectId: 73,
|
||||||
|
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'The Countess',
|
||||||
|
name: 'The Countess',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 1,
|
||||||
|
levelIds: [6, 25],
|
||||||
|
monsterId: 'corruptrogue5',
|
||||||
|
presetMatch: /Tower/i,
|
||||||
|
defaultLandmark: 'Act 1 - Tower 1',
|
||||||
|
modifiers: ['fireenchant'],
|
||||||
|
minionCount: [4, 6],
|
||||||
|
minionMonsterId: 'corruptrogue5',
|
||||||
|
objectId: 74,
|
||||||
|
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||||||
|
},
|
||||||
|
// Act 2
|
||||||
|
{
|
||||||
|
id: 'Creeping Feature',
|
||||||
|
name: 'Creeping Feature',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 2,
|
||||||
|
levelIds: [41, 55, 59],
|
||||||
|
monsterId: 'mummy1',
|
||||||
|
presetMatch: /Desert Tomb 1|Stony Tomb/i,
|
||||||
|
defaultLandmark: 'Act 2 - Desert Tomb 1',
|
||||||
|
modifiers: ['strong', 'coldenchant'],
|
||||||
|
minionCount: [4, 6],
|
||||||
|
minionMonsterId: 'mummy1',
|
||||||
|
objectId: 75,
|
||||||
|
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Bloodwitch the Wild',
|
||||||
|
name: 'Bloodwitch the Wild',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 2,
|
||||||
|
levelIds: [45, 58, 60],
|
||||||
|
monsterId: 'huntress2',
|
||||||
|
presetMatch: /Desert Tomb 2|Claw Viper|Valley Ruin 1/i,
|
||||||
|
defaultLandmark: 'Act 2 - Desert Tomb 2',
|
||||||
|
modifiers: ['cursed', 'strong'],
|
||||||
|
minionCount: [4, 6],
|
||||||
|
minionMonsterId: 'huntress2',
|
||||||
|
objectId: 76,
|
||||||
|
defaultRelativePos: { x: 0.48, y: 0.52 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Beetleburst',
|
||||||
|
name: 'Beetleburst',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 2,
|
||||||
|
levelIds: [43],
|
||||||
|
monsterId: 'beetle2',
|
||||||
|
presetMatch: /Desert Oasis|Desert Lair/i,
|
||||||
|
defaultLandmark: 'Act 2 - Desert Oasis 1',
|
||||||
|
modifiers: ['magicresistant'],
|
||||||
|
minionCount: [4, 6],
|
||||||
|
minionMonsterId: 'beetle2',
|
||||||
|
objectId: 77,
|
||||||
|
defaultRelativePos: { x: 0.52, y: 0.48 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Dark Elder',
|
||||||
|
name: 'Dark Elder',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 2,
|
||||||
|
levelIds: [44],
|
||||||
|
monsterId: 'plaguebearer1',
|
||||||
|
presetMatch: /Desert Ruins Elder/i,
|
||||||
|
defaultLandmark: 'Act 2 - Desert Ruins Elder',
|
||||||
|
modifiers: ['fast', 'magicresistant'],
|
||||||
|
minionCount: [4, 6],
|
||||||
|
minionMonsterId: 'plaguebearer1',
|
||||||
|
objectId: 78,
|
||||||
|
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Fangskin',
|
||||||
|
name: 'Fangskin',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 2,
|
||||||
|
levelIds: [45, 61],
|
||||||
|
monsterId: 'clawviper3',
|
||||||
|
presetMatch: /Desert Valley Ruin 2|Valley Warp|Claw Viper Altar/i,
|
||||||
|
defaultLandmark: 'Act 2 - Desert Valley Ruin 2',
|
||||||
|
modifiers: ['lightenchant', 'fast'],
|
||||||
|
minionCount: [4, 6],
|
||||||
|
minionMonsterId: 'clawviper3',
|
||||||
|
objectId: 79,
|
||||||
|
defaultRelativePos: { x: 0.55, y: 0.45 },
|
||||||
|
},
|
||||||
|
// Act 3
|
||||||
|
{
|
||||||
|
id: 'Stormtree',
|
||||||
|
name: 'Stormtree',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 3,
|
||||||
|
levelIds: [78],
|
||||||
|
monsterId: 'thornhulk3',
|
||||||
|
presetMatch: /Flayer Jungle Entrance|Jungle Gate|Flayer Entrance/i,
|
||||||
|
defaultLandmark: 'Act 3 - Flayer Jungle Entrance',
|
||||||
|
modifiers: ['lightenchant', 'fast'],
|
||||||
|
minionCount: [4, 6],
|
||||||
|
minionMonsterId: 'thornhulk3',
|
||||||
|
objectId: 80,
|
||||||
|
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Sszark the Burning',
|
||||||
|
name: 'Sszark the Burning',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 3,
|
||||||
|
levelIds: [76, 84],
|
||||||
|
monsterId: 'spider2',
|
||||||
|
presetMatch: /Spider Cavern|Spider/i,
|
||||||
|
defaultLandmark: 'Act 3 - Spider Cavern',
|
||||||
|
modifiers: ['strong', 'cursed'],
|
||||||
|
minionCount: [4, 6],
|
||||||
|
minionMonsterId: 'spider2',
|
||||||
|
objectId: 81,
|
||||||
|
defaultRelativePos: { x: 0.48, y: 0.52 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Witch Doctor Endugu',
|
||||||
|
name: 'Witch Doctor Endugu',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 3,
|
||||||
|
levelIds: [78, 88, 91],
|
||||||
|
monsterId: 'flayershaman4',
|
||||||
|
presetMatch: /Flayer Dungeon/i,
|
||||||
|
defaultLandmark: 'Act 3 - Flayer Dungeon',
|
||||||
|
modifiers: ['magicresistant', 'fireenchant'],
|
||||||
|
minionCount: [4, 6],
|
||||||
|
minionMonsterId: 'flayer4',
|
||||||
|
objectId: 82,
|
||||||
|
defaultRelativePos: { x: 0.52, y: 0.48 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Battlemaid Sarina',
|
||||||
|
name: 'Battlemaid Sarina',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 3,
|
||||||
|
levelIds: [80, 94],
|
||||||
|
monsterId: 'corruptrogue8',
|
||||||
|
presetMatch: /Burbs Temple|Ruined Temple|MetroTemple/i,
|
||||||
|
defaultLandmark: 'Act 3 - Burbs Temple',
|
||||||
|
modifiers: ['fast', 'spectralhit'],
|
||||||
|
minionCount: [4, 6],
|
||||||
|
minionMonsterId: 'corruptrogue8',
|
||||||
|
objectId: 83,
|
||||||
|
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||||||
|
},
|
||||||
|
// Act 4
|
||||||
|
{
|
||||||
|
id: 'Izual',
|
||||||
|
name: 'Izual',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 4,
|
||||||
|
levelIds: [105],
|
||||||
|
monsterId: 'izual',
|
||||||
|
presetMatch: /Plains of Despair|Izual/i,
|
||||||
|
defaultLandmark: 'Act 4 - Plains of Despair',
|
||||||
|
modifiers: ['coldenchant'],
|
||||||
|
minionCount: [0, 0],
|
||||||
|
minionMonsterId: 'izual',
|
||||||
|
objectId: 84,
|
||||||
|
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Hephasto the Armorer',
|
||||||
|
name: 'Hephasto the Armorer',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 4,
|
||||||
|
levelIds: [106, 107],
|
||||||
|
monsterId: 'hephasto',
|
||||||
|
presetMatch: /Hellforge/i,
|
||||||
|
defaultLandmark: 'Act 4 - Hellforge',
|
||||||
|
modifiers: ['spectralhit', 'aura'],
|
||||||
|
minionCount: [0, 0],
|
||||||
|
minionMonsterId: 'hephasto',
|
||||||
|
objectId: 85,
|
||||||
|
defaultRelativePos: { x: 0.52, y: 0.48 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Hadriel',
|
||||||
|
name: 'Hadriel',
|
||||||
|
role: 'npc_guard',
|
||||||
|
act: 4,
|
||||||
|
levelIds: [106, 107, 108],
|
||||||
|
monsterId: 'hadriel',
|
||||||
|
presetMatch: /Chaos Sanctuary Entrance|Hadriel/i,
|
||||||
|
defaultLandmark: 'Act 4 - Chaos Sanctuary Entrance',
|
||||||
|
modifiers: [],
|
||||||
|
minionCount: [0, 0],
|
||||||
|
minionMonsterId: '',
|
||||||
|
objectId: 86,
|
||||||
|
defaultRelativePos: { x: 0.48, y: 0.52 },
|
||||||
|
},
|
||||||
|
// Act 5
|
||||||
|
{
|
||||||
|
id: 'Dac Farren',
|
||||||
|
name: 'Dac Farren',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 5,
|
||||||
|
levelIds: [110],
|
||||||
|
monsterId: 'demonimp1',
|
||||||
|
presetMatch: /Barricade Prison 1|Barricade Ruins/i,
|
||||||
|
defaultLandmark: 'Act 5 - Barricade Prison 1',
|
||||||
|
modifiers: ['coldenchant'],
|
||||||
|
minionCount: [4, 6],
|
||||||
|
minionMonsterId: 'demonimp1',
|
||||||
|
objectId: 87,
|
||||||
|
defaultRelativePos: { x: 0.35, y: 0.5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Shenk the Overseer',
|
||||||
|
name: 'Shenk the Overseer',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 5,
|
||||||
|
levelIds: [110, 111],
|
||||||
|
monsterId: 'overseer2',
|
||||||
|
presetMatch: /Barricade Building|Shenk|Siege Strip 15|Siege To Barricade/i,
|
||||||
|
defaultLandmark: 'Act 5 - Barricade Building',
|
||||||
|
modifiers: ['strong'],
|
||||||
|
minionCount: [15, 20],
|
||||||
|
minionMonsterId: 'enslaved1',
|
||||||
|
objectId: 88,
|
||||||
|
defaultRelativePos: { x: 0.75, y: 0.5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Eyeback the Unleashed',
|
||||||
|
name: 'Eyeback the Unleashed',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 5,
|
||||||
|
levelIds: [111],
|
||||||
|
monsterId: 'bloodlord2',
|
||||||
|
presetMatch: /Barricade Hell Portal N|Barricade 1/i,
|
||||||
|
defaultLandmark: 'Act 5 - Barricade Hell Portal N',
|
||||||
|
modifiers: ['fast', 'strong'],
|
||||||
|
minionCount: [3, 5],
|
||||||
|
minionMonsterId: 'bloodlord2',
|
||||||
|
objectId: 89,
|
||||||
|
defaultRelativePos: { x: 0.45, y: 0.55 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Thresh Socket',
|
||||||
|
name: 'Thresh Socket',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 5,
|
||||||
|
levelIds: [112],
|
||||||
|
monsterId: 'siegebeast1',
|
||||||
|
presetMatch: /Barricade Snow Unique/i,
|
||||||
|
defaultLandmark: 'Act 5 - Barricade Snow Unique',
|
||||||
|
modifiers: ['cursed'],
|
||||||
|
minionCount: [2, 4],
|
||||||
|
minionMonsterId: 'siegebeast1',
|
||||||
|
objectId: 90,
|
||||||
|
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Bonesaw Breaker',
|
||||||
|
name: 'Bonesaw Breaker',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 5,
|
||||||
|
levelIds: [115],
|
||||||
|
monsterId: 'reanimatedhorde2',
|
||||||
|
presetMatch: /Glacial Trail|Drifter Cavern/i,
|
||||||
|
defaultLandmark: 'Act 5 - Glacial Trail',
|
||||||
|
modifiers: ['strong', 'magicresistant'],
|
||||||
|
minionCount: [4, 6],
|
||||||
|
minionMonsterId: 'reanimatedhorde2',
|
||||||
|
objectId: 91,
|
||||||
|
defaultRelativePos: { x: 0.52, y: 0.48 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Frozenstein',
|
||||||
|
name: 'Frozenstein',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 5,
|
||||||
|
levelIds: [114],
|
||||||
|
monsterId: 'yeti2',
|
||||||
|
presetMatch: /Frozen River|Anya/i,
|
||||||
|
defaultLandmark: 'Act 5 - Frozen River',
|
||||||
|
modifiers: ['coldenchant', 'manahit'],
|
||||||
|
minionCount: [4, 6],
|
||||||
|
minionMonsterId: 'yeti2',
|
||||||
|
objectId: 92,
|
||||||
|
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Nihlathak',
|
||||||
|
name: 'Nihlathak',
|
||||||
|
role: 'superunique',
|
||||||
|
act: 5,
|
||||||
|
levelIds: [124],
|
||||||
|
monsterId: 'nihlathakboss',
|
||||||
|
presetMatch: /Halls of Vaught|Nihlathak/i,
|
||||||
|
defaultLandmark: 'Act 5 - Halls of Vaught',
|
||||||
|
modifiers: ['coldenchant'],
|
||||||
|
minionCount: [6, 8],
|
||||||
|
minionMonsterId: 'iceprowler2',
|
||||||
|
objectId: 93,
|
||||||
|
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refactor landmark and outdoor boss generation into a table/data-driven pipeline
|
||||||
|
* based on `SuperUniques.txt` and `MonPreset.txt`.
|
||||||
|
*/
|
||||||
|
export function buildLandmarkEntitiesFromTables(
|
||||||
|
superuniques?: D2Table,
|
||||||
|
monpreset?: D2Table,
|
||||||
|
baseDefs: readonly LandmarkEntityDef[] = CANONICAL_LANDMARK_ENTITIES,
|
||||||
|
): LandmarkEntityDef[] {
|
||||||
|
if (!superuniques && !monpreset) {
|
||||||
|
return [...baseDefs]
|
||||||
|
}
|
||||||
|
|
||||||
|
const suMap = new Map<string, SuperUnique>()
|
||||||
|
if (superuniques) {
|
||||||
|
const suList = readSuperUniques(superuniques)
|
||||||
|
for (const su of suList) {
|
||||||
|
suMap.set(su.id.toLowerCase(), su)
|
||||||
|
if (su.nameKey) {
|
||||||
|
suMap.set(su.nameKey.toLowerCase(), su)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actPlaceIndex = new Map<string, number>()
|
||||||
|
if (monpreset) {
|
||||||
|
const actIdx = monpreset.header.findIndex(h => /^act$/i.test(h.trim()))
|
||||||
|
const placeIdx = monpreset.header.findIndex(h => /^place$/i.test(h.trim()))
|
||||||
|
const objectIdIdx = monpreset.header.findIndex(h => /^(objectid|id|object)$/i.test(h.trim()))
|
||||||
|
if (actIdx !== -1 && placeIdx !== -1) {
|
||||||
|
const perActCount = new Map<number, number>()
|
||||||
|
for (const row of monpreset.rows) {
|
||||||
|
const actVal = Number(row[actIdx])
|
||||||
|
const placeVal = row[placeIdx]?.trim()
|
||||||
|
if (Number.isFinite(actVal) && placeVal) {
|
||||||
|
const currentIdx = perActCount.get(actVal) ?? 0
|
||||||
|
perActCount.set(actVal, currentIdx + 1)
|
||||||
|
const customId = objectIdIdx !== -1 ? Number(row[objectIdIdx]) : currentIdx
|
||||||
|
actPlaceIndex.set(
|
||||||
|
`${actVal}:${placeVal.toLowerCase()}`,
|
||||||
|
Number.isFinite(customId) ? customId : currentIdx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseDefs.map(def => {
|
||||||
|
let monsterId = def.monsterId
|
||||||
|
let name = def.name
|
||||||
|
let modifiers = def.modifiers
|
||||||
|
let minionCount = def.minionCount
|
||||||
|
let minionMonsterId = def.minionMonsterId
|
||||||
|
let objectId = def.objectId
|
||||||
|
|
||||||
|
if (superuniques) {
|
||||||
|
const su = suMap.get(def.id.toLowerCase()) ?? suMap.get(def.name.toLowerCase())
|
||||||
|
if (su) {
|
||||||
|
if (su.monsterId) {
|
||||||
|
monsterId = su.monsterId
|
||||||
|
if (!minionMonsterId) {
|
||||||
|
minionMonsterId = su.monsterId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (su.nameKey && su.nameKey.trim() !== '') {
|
||||||
|
name = su.nameKey.trim()
|
||||||
|
}
|
||||||
|
if (su.modifiers.length > 0) {
|
||||||
|
const mappedMods: string[] = []
|
||||||
|
for (const modId of su.modifiers) {
|
||||||
|
const found = CANONICAL_ELITE_MODIFIERS.find(m => m.id === modId)
|
||||||
|
if (found) mappedMods.push(found.name)
|
||||||
|
}
|
||||||
|
if (mappedMods.length > 0) {
|
||||||
|
modifiers = mappedMods
|
||||||
|
}
|
||||||
|
}
|
||||||
|
minionCount = [su.minMinions, su.maxMinions]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monpreset) {
|
||||||
|
const candidates = [
|
||||||
|
def.id.toLowerCase(),
|
||||||
|
def.name.toLowerCase(),
|
||||||
|
def.monsterId.toLowerCase(),
|
||||||
|
def.defaultLandmark.toLowerCase(),
|
||||||
|
]
|
||||||
|
for (const [key, id] of actPlaceIndex) {
|
||||||
|
const [actStr, placeName] = key.split(':')
|
||||||
|
if (Number(actStr) === def.act) {
|
||||||
|
if (
|
||||||
|
candidates.some(c => c.includes(placeName) || placeName.includes(c)) ||
|
||||||
|
(def.presetMatch && def.presetMatch.test(placeName))
|
||||||
|
) {
|
||||||
|
objectId = id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...def,
|
||||||
|
monsterId,
|
||||||
|
name,
|
||||||
|
modifiers,
|
||||||
|
minionCount,
|
||||||
|
minionMonsterId,
|
||||||
|
objectId,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anchor outdoor SuperUniques and special NPCs to their landmark presets or border gates.
|
||||||
|
*/
|
||||||
|
export function spawnLandmarkEntities(
|
||||||
|
canvas: Canvas,
|
||||||
|
levelId: number,
|
||||||
|
act1Plan: Act1OutdoorPlan | undefined,
|
||||||
|
placedRects: readonly PlacedPresetRect[],
|
||||||
|
gridWidth: number,
|
||||||
|
gridHeight: number,
|
||||||
|
stats: WildernessStats,
|
||||||
|
defs: readonly LandmarkEntityDef[] = CANONICAL_LANDMARK_ENTITIES,
|
||||||
|
): void {
|
||||||
|
const entities: LandmarkEntity[] = []
|
||||||
|
|
||||||
|
for (const def of defs) {
|
||||||
|
let tileX: number
|
||||||
|
let tileY: number
|
||||||
|
let landmark: string
|
||||||
|
|
||||||
|
if (def.resolvePosition) {
|
||||||
|
const resolved = def.resolvePosition(canvas, levelId, act1Plan, placedRects, gridWidth, gridHeight)
|
||||||
|
if (!resolved) continue
|
||||||
|
tileX = resolved.tileX
|
||||||
|
tileY = resolved.tileY
|
||||||
|
landmark = resolved.landmark
|
||||||
|
} else {
|
||||||
|
const rect = def.presetMatch ? placedRects.find(r => def.presetMatch!.test(r.name)) : undefined
|
||||||
|
if (rect !== undefined) {
|
||||||
|
tileX = Math.floor(rect.x + rect.w / 2)
|
||||||
|
tileY = Math.floor(rect.y + rect.h / 2)
|
||||||
|
landmark = rect.name
|
||||||
|
} else if (def.levelIds.includes(levelId)) {
|
||||||
|
tileX = Math.floor(canvas.width * (def.defaultRelativePos?.x ?? 0.5))
|
||||||
|
tileY = Math.floor(canvas.height * (def.defaultRelativePos?.y ?? 0.5))
|
||||||
|
landmark = def.defaultLandmark
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtileX = tileX * SUB_TILES_PER_TILE + 2
|
||||||
|
const subtileY = tileY * SUB_TILES_PER_TILE + 2
|
||||||
|
|
||||||
|
entities.push({
|
||||||
|
role: def.role,
|
||||||
|
id: def.id,
|
||||||
|
monsterId: def.monsterId,
|
||||||
|
name: def.name,
|
||||||
|
landmark,
|
||||||
|
tileX,
|
||||||
|
tileY,
|
||||||
|
subtileX,
|
||||||
|
subtileY,
|
||||||
|
levelId,
|
||||||
|
modifiers: def.modifiers,
|
||||||
|
minionCount: def.minionCount,
|
||||||
|
minionMonsterId: def.minionMonsterId,
|
||||||
|
objectId: def.objectId,
|
||||||
|
})
|
||||||
|
|
||||||
canvas.objects.push({
|
canvas.objects.push({
|
||||||
type: 1,
|
type: 1,
|
||||||
id: 73,
|
id: def.objectId,
|
||||||
x: tx * SUB_TILES_PER_TILE + 2,
|
x: subtileX,
|
||||||
y: ty * SUB_TILES_PER_TILE + 2,
|
y: subtileY,
|
||||||
flags: 0,
|
flags: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -3456,6 +3989,11 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
|
|
||||||
const canvas = createCanvas(gridWidth * TILES_PER_BLOCK, gridHeight * TILES_PER_BLOCK, wallLayers, floorLayers, substitutionLayers)
|
const canvas = createCanvas(gridWidth * TILES_PER_BLOCK, gridHeight * TILES_PER_BLOCK, wallLayers, floorLayers, substitutionLayers)
|
||||||
const rng = new Rng(request.seed)
|
const rng = new Rng(request.seed)
|
||||||
|
const landmarkDefs = buildLandmarkEntitiesFromTables(
|
||||||
|
request.superuniques,
|
||||||
|
request.monpreset,
|
||||||
|
request.customLandmarks ?? CANONICAL_LANDMARK_ENTITIES,
|
||||||
|
)
|
||||||
|
|
||||||
stats.groundTile = groundTile
|
stats.groundTile = groundTile
|
||||||
if (groundTile === null) {
|
if (groundTile === null) {
|
||||||
|
|
@ -3473,16 +4011,27 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
request.levelId === 83 ||
|
request.levelId === 83 ||
|
||||||
(request.levelId >= 76 && request.levelId <= 78)
|
(request.levelId >= 76 && request.levelId <= 78)
|
||||||
) {
|
) {
|
||||||
|
const specialPlacedRects: PlacedPresetRect[] = []
|
||||||
let placedSpecial: Set<string>
|
let placedSpecial: Set<string>
|
||||||
if (request.levelId === 108) {
|
if (request.levelId === 108) {
|
||||||
placedSpecial = stampAct4ChaosSanctuary(canvas, request.pieces, stats)
|
placedSpecial = stampAct4ChaosSanctuary(canvas, request.pieces, stats, specialPlacedRects)
|
||||||
} else if (request.levelId === 83) {
|
} else if (request.levelId === 83) {
|
||||||
placedSpecial = stampAct3Travincal(canvas, request.pieces, stats)
|
placedSpecial = stampAct3Travincal(canvas, request.pieces, stats, specialPlacedRects)
|
||||||
} else if (request.levelId >= 76 && request.levelId <= 78) {
|
} else if (request.levelId >= 76 && request.levelId <= 78) {
|
||||||
placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats)
|
placedSpecial = stampAct3Jungle(canvas, request.levelId, request.pieces, rng, stats, specialPlacedRects)
|
||||||
} else {
|
} else {
|
||||||
placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, stats)
|
placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, stats, specialPlacedRects)
|
||||||
}
|
}
|
||||||
|
spawnLandmarkEntities(
|
||||||
|
canvas,
|
||||||
|
request.levelId,
|
||||||
|
undefined,
|
||||||
|
specialPlacedRects,
|
||||||
|
gridWidth,
|
||||||
|
gridHeight,
|
||||||
|
stats,
|
||||||
|
landmarkDefs,
|
||||||
|
)
|
||||||
let finalGroundCells = 0
|
let finalGroundCells = 0
|
||||||
for (let y = 0; y < canvas.height; y += 1) {
|
for (let y = 0; y < canvas.height; y += 1) {
|
||||||
const row = canvas.cells[y]
|
const row = canvas.cells[y]
|
||||||
|
|
@ -3532,6 +4081,11 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
substitutions: stats.substitutions,
|
substitutions: stats.substitutions,
|
||||||
substitutedClusters: placedSpecial.size,
|
substitutedClusters: placedSpecial.size,
|
||||||
objects: canvas.objects.length,
|
objects: canvas.objects.length,
|
||||||
|
waypointsSpawned: stats.waypointsSpawned ?? 0,
|
||||||
|
waypointTile: stats.waypointTile ?? null,
|
||||||
|
shrinesSpawned: stats.shrinesSpawned ?? 0,
|
||||||
|
interactivesSpawned: stats.interactivesSpawned ?? 0,
|
||||||
|
landmarkEntities: stats.landmarkEntities ?? [],
|
||||||
cells: canvas.width * canvas.height,
|
cells: canvas.width * canvas.height,
|
||||||
unresolved: stats.unresolved,
|
unresolved: stats.unresolved,
|
||||||
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
unimplementedPasses: UNIMPLEMENTED_PASSES,
|
||||||
|
|
@ -3714,7 +4268,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult
|
||||||
const populationRng = new Rng(request.seed ^ 0x6c07)
|
const populationRng = new Rng(request.seed ^ 0x6c07)
|
||||||
spawnAct12Waypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats)
|
spawnAct12Waypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats)
|
||||||
spawnAct12Shrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats)
|
spawnAct12Shrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats)
|
||||||
spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats)
|
spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats, landmarkDefs)
|
||||||
|
|
||||||
for (const piece of fillPieces) {
|
for (const piece of fillPieces) {
|
||||||
if (!placedSpecialPieces.has(piece.name)) {
|
if (!placedSpecialPieces.has(piece.name)) {
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,8 @@ describe("Wilderness Features & Border Rules", () => {
|
||||||
expect(result.stats.substitutedClusters).toBe(15)
|
expect(result.stats.substitutedClusters).toBe(15)
|
||||||
expect(result.level.width).toBe(240)
|
expect(result.level.width).toBe(240)
|
||||||
expect(result.level.height).toBe(48)
|
expect(result.level.height).toBe(48)
|
||||||
expect(result.level.objects.length).toBe(15)
|
expect(result.level.objects.filter(o => o.type === 2).length).toBe(15)
|
||||||
|
expect(result.level.objects.length).toBe(17)
|
||||||
|
|
||||||
// Verify strip 0 (Siege To Barricade) is at x=0 and strip 14 (Siege To Town) is at x=224
|
// Verify strip 0 (Siege To Barricade) is at x=0 and strip 14 (Siege To Town) is at x=224
|
||||||
expect(result.level.cells[0][0].floors[0].sequence).toBe(10)
|
expect(result.level.cells[0][0].floors[0].sequence).toBe(10)
|
||||||
|
|
@ -286,7 +287,8 @@ describe("Wilderness Features & Border Rules", () => {
|
||||||
expect(result.level.width).toBe(120)
|
expect(result.level.width).toBe(120)
|
||||||
expect(result.level.height).toBe(120)
|
expect(result.level.height).toBe(120)
|
||||||
expect(result.stats.substitutedClusters).toBe(7)
|
expect(result.stats.substitutedClusters).toBe(7)
|
||||||
expect(result.level.objects.length).toBe(7)
|
expect(result.level.objects.filter(o => o.type === 2).length).toBe(7)
|
||||||
|
expect(result.level.objects.length).toBe(8)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("Act 3 Travincal (Level 83) stamps all 6 temple complex pieces even with trailing spaces", () => {
|
test("Act 3 Travincal (Level 83) stamps all 6 temple complex pieces even with trailing spaces", () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,397 @@
|
||||||
|
import { describe, expect, test } from "vitest"
|
||||||
|
import {
|
||||||
|
generateWilderness,
|
||||||
|
buildLandmarkEntitiesFromTables,
|
||||||
|
spawnLandmarkEntities,
|
||||||
|
CANONICAL_LANDMARK_ENTITIES,
|
||||||
|
type LandmarkEntityDef,
|
||||||
|
type WildernessPiece,
|
||||||
|
} from "../src/game/wilderness.ts"
|
||||||
|
import {
|
||||||
|
planLevelMonsters,
|
||||||
|
CANONICAL_SUPER_UNIQUES_BY_LEVEL,
|
||||||
|
CANONICAL_ELITE_MODIFIERS,
|
||||||
|
} from "../src/game/monsters.ts"
|
||||||
|
import { parseTable } from "../src/game/acts.ts"
|
||||||
|
import type { D2Table } from "../src/game/acts.ts"
|
||||||
|
import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts"
|
||||||
|
|
||||||
|
function mockTable(tsv: string): D2Table {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
return parseTable(encoder.encode(tsv.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockDs1(width: number, height: number): Ds1 {
|
||||||
|
const cells: Ds1Cell[][] = []
|
||||||
|
for (let y = 0; y < height; y += 1) {
|
||||||
|
const row: Ds1Cell[] = []
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
row.push({
|
||||||
|
walls: [],
|
||||||
|
floors: [{ prop1: 1, sequence: 0, style: 1, unknown1: 0, unknown2: 0, hidden: false }],
|
||||||
|
shadows: [],
|
||||||
|
substitutions: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
cells.push(row)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: 18,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
act: 1,
|
||||||
|
substitutionType: 0,
|
||||||
|
wallLayers: 1,
|
||||||
|
floorLayers: 1,
|
||||||
|
cells,
|
||||||
|
objects: [],
|
||||||
|
npcPathOffset: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Wilderness SuperUniques & Landmark Entities (Issue #57)", () => {
|
||||||
|
describe("CANONICAL_LANDMARK_ENTITIES Catalog", () => {
|
||||||
|
test("contains all 25 canonical landmark entities across Acts 1-5", () => {
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.length).toBe(25)
|
||||||
|
|
||||||
|
// Act 1 (6 entities: 5 SuperUniques + 1 NPC)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Flavie" && e.levelIds.includes(3))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bishibosh" && e.levelIds.includes(3))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Rakanishu" && e.levelIds.includes(4))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Treehead WoodFist" && e.levelIds.includes(5))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Blood Raven" && e.levelIds.includes(17))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "The Countess" && e.levelIds.includes(25))).toBe(true)
|
||||||
|
|
||||||
|
// Act 2 (5 entities)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Beetleburst" && e.levelIds.includes(43))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Dark Elder" && e.levelIds.includes(44))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Creeping Feature" && e.levelIds.includes(59))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bloodwitch the Wild" && e.levelIds.includes(60))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Fangskin" && e.levelIds.includes(61))).toBe(true)
|
||||||
|
|
||||||
|
// Act 3 (4 entities)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Stormtree" && e.levelIds.includes(78))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Sszark the Burning" && e.levelIds.includes(84))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Witch Doctor Endugu" && e.levelIds.includes(91))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Battlemaid Sarina" && e.levelIds.includes(94))).toBe(true)
|
||||||
|
|
||||||
|
// Act 4 (3 entities: 2 SuperUniques + 1 NPC)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Izual" && e.levelIds.includes(105))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Hephasto the Armorer" && e.levelIds.includes(107))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Hadriel" && e.levelIds.includes(108))).toBe(true)
|
||||||
|
|
||||||
|
// Act 5 (7 entities)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Dac Farren" && e.levelIds.includes(110))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Shenk the Overseer" && e.levelIds.includes(110))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Eyeback the Unleashed" && e.levelIds.includes(111))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Thresh Socket" && e.levelIds.includes(112))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Frozenstein" && e.levelIds.includes(114))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Bonesaw Breaker" && e.levelIds.includes(115))).toBe(true)
|
||||||
|
expect(CANONICAL_LANDMARK_ENTITIES.some(e => e.name === "Nihlathak" && e.levelIds.includes(124))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("buildLandmarkEntitiesFromTables", () => {
|
||||||
|
test("returns canonical definitions when no tables are supplied", () => {
|
||||||
|
const entities = buildLandmarkEntitiesFromTables()
|
||||||
|
expect(entities.length).toBe(CANONICAL_LANDMARK_ENTITIES.length)
|
||||||
|
expect(entities[0].name).toBe(CANONICAL_LANDMARK_ENTITIES[0].name)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("overrides boss modifiers and properties from SuperUniques.txt", () => {
|
||||||
|
// Mod id 8 is 'magicresistant', 9 is 'fireenchant', 17 is 'lightenchant', 6 is 'fast'
|
||||||
|
const superUniquesTsv = [
|
||||||
|
"Superunique\tClass\tMod1\tMod2\tMod3\tMinGrp\tMaxGrp\tEClass\tAutoPos\tStacks\tUtrans\tTC\tTC(N)\tTC(H)",
|
||||||
|
"Bishibosh\tfallen2\t8\t9\t0\t2\t5\tfallen1\t0\t0\t0\tAct 1 Super A\tAct 1 Super A\tAct 1 Super A",
|
||||||
|
"Rakanishu\tfallen3\t17\t6\t0\t3\t6\tfallen1\t0\t0\t0\tAct 1 Super A\tAct 1 Super A\tAct 1 Super A",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const suTable = mockTable(superUniquesTsv)
|
||||||
|
const entities = buildLandmarkEntitiesFromTables(suTable)
|
||||||
|
|
||||||
|
const bishibosh = entities.find(e => e.id === "Bishibosh")
|
||||||
|
expect(bishibosh).toBeDefined()
|
||||||
|
expect(bishibosh?.monsterId).toBe("fallen2")
|
||||||
|
expect(bishibosh?.modifiers).toEqual(["magicresistant", "fireenchant"])
|
||||||
|
expect(bishibosh?.minionCount).toEqual([2, 5])
|
||||||
|
|
||||||
|
const rakanishu = entities.find(e => e.id === "Rakanishu")
|
||||||
|
expect(rakanishu).toBeDefined()
|
||||||
|
expect(rakanishu?.monsterId).toBe("fallen3")
|
||||||
|
expect(rakanishu?.modifiers).toEqual(["lightenchant", "fast"])
|
||||||
|
expect(rakanishu?.minionCount).toEqual([3, 6])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolves objectId from MonPreset.txt", () => {
|
||||||
|
const monPresetTsv = [
|
||||||
|
"Act\tPlace\tComponent\tObjectId",
|
||||||
|
"1\tCairn Stones\t1\t105",
|
||||||
|
"4\tChaos Sanctuary\t1\t350",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const mpTable = mockTable(monPresetTsv)
|
||||||
|
const entities = buildLandmarkEntitiesFromTables(undefined, mpTable)
|
||||||
|
|
||||||
|
const rakanishu = entities.find(e => e.name === "Rakanishu")
|
||||||
|
expect(rakanishu?.objectId).toBe(105)
|
||||||
|
|
||||||
|
const hadriel = entities.find(e => e.name === "Hadriel")
|
||||||
|
expect(hadriel?.objectId).toBe(350)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("spawnLandmarkEntities in Wilderness Generator", () => {
|
||||||
|
test("places Rakanishu centered on Cairn Stones preset piece", () => {
|
||||||
|
const cairnDs1 = makeMockDs1(16, 16)
|
||||||
|
const borderDs1 = makeMockDs1(8, 8)
|
||||||
|
const pieces: WildernessPiece[] = [
|
||||||
|
{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1] },
|
||||||
|
{ name: "Act 1 - Cairn Stones", border: false, levels: [cairnDs1] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = generateWilderness({
|
||||||
|
levelId: 4,
|
||||||
|
levelName: "Stony Field",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 42,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
specialPresets: ["Act 1 - Cairn Stones"],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.stats.landmarkEntities).toBeDefined()
|
||||||
|
const rakanishu = result.stats.landmarkEntities?.find(e => e.name === "Rakanishu")
|
||||||
|
expect(rakanishu).toBeDefined()
|
||||||
|
expect(rakanishu?.levelId).toBe(4)
|
||||||
|
|
||||||
|
// Verify subtile coordinates: tileX * 5 + 2
|
||||||
|
expect(rakanishu?.subtileX).toBe(rakanishu!.tileX * 5 + 2)
|
||||||
|
expect(rakanishu?.subtileY).toBe(rakanishu!.tileY * 5 + 2)
|
||||||
|
|
||||||
|
// Verify canvas.objects contains the Type 1 entity
|
||||||
|
const obj = result.level.objects.find(
|
||||||
|
o => o.type === 1 && o.x === rakanishu!.subtileX && o.y === rakanishu!.subtileY
|
||||||
|
)
|
||||||
|
expect(obj).toBeDefined()
|
||||||
|
expect(obj?.id).toBe(rakanishu?.objectId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("places multiple SuperUniques on Level 110 (Bloody Foothills: Dac Farren & Shenk)", () => {
|
||||||
|
const stripDs1 = makeMockDs1(16, 48)
|
||||||
|
const pieces: WildernessPiece[] = [
|
||||||
|
{ name: "Act 5 - Siege To Barricade", border: false, levels: [stripDs1] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = generateWilderness({
|
||||||
|
levelId: 110,
|
||||||
|
levelName: "Bloody Foothills",
|
||||||
|
levelTypeName: "Act 5 - Siege",
|
||||||
|
sizeX: 240,
|
||||||
|
sizeY: 48,
|
||||||
|
subType: -1,
|
||||||
|
subTheme: -1,
|
||||||
|
seed: 110,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const landmarks = result.stats.landmarkEntities ?? []
|
||||||
|
const dacFarren = landmarks.find(e => e.name === "Dac Farren")
|
||||||
|
const shenk = landmarks.find(e => e.name === "Shenk the Overseer")
|
||||||
|
|
||||||
|
expect(dacFarren).toBeDefined()
|
||||||
|
expect(shenk).toBeDefined()
|
||||||
|
expect(dacFarren?.objectId).toBe(87)
|
||||||
|
expect(shenk?.objectId).toBe(88)
|
||||||
|
|
||||||
|
// Both must be recorded in canvas.objects as type 1
|
||||||
|
const dacObj = result.level.objects.find(
|
||||||
|
o => o.type === 1 && o.x === dacFarren!.subtileX && o.y === dacFarren!.subtileY
|
||||||
|
)
|
||||||
|
const shenkObj = result.level.objects.find(
|
||||||
|
o => o.type === 1 && o.x === shenk!.subtileX && o.y === shenk!.subtileY
|
||||||
|
)
|
||||||
|
expect(dacObj).toBeDefined()
|
||||||
|
expect(shenkObj).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("places Act 4 Hadriel on Chaos Sanctuary (Level 108)", () => {
|
||||||
|
const csDs1 = makeMockDs1(25, 25)
|
||||||
|
const pieces: WildernessPiece[] = [
|
||||||
|
{ name: "Act 4 - Diablo Entry", border: false, levels: [csDs1] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = generateWilderness({
|
||||||
|
levelId: 108,
|
||||||
|
levelName: "Chaos Sanctuary",
|
||||||
|
levelTypeName: "Act 4 - Lava",
|
||||||
|
sizeX: 120,
|
||||||
|
sizeY: 120,
|
||||||
|
subType: -1,
|
||||||
|
subTheme: -1,
|
||||||
|
seed: 108,
|
||||||
|
pieces,
|
||||||
|
substitutions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const hadriel = result.stats.landmarkEntities?.find(e => e.name === "Hadriel")
|
||||||
|
expect(hadriel).toBeDefined()
|
||||||
|
expect(hadriel?.objectId).toBe(86)
|
||||||
|
expect(hadriel?.role).toBe("npc_guard")
|
||||||
|
|
||||||
|
const obj = result.level.objects.find(
|
||||||
|
o => o.type === 1 && o.x === hadriel!.subtileX && o.y === hadriel!.subtileY
|
||||||
|
)
|
||||||
|
expect(obj).toBeDefined()
|
||||||
|
expect(obj?.id).toBe(86)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("supports customLandmarks override in WildernessRequest", () => {
|
||||||
|
const borderDs1 = makeMockDs1(8, 8)
|
||||||
|
const customLandmarks: LandmarkEntityDef[] = [
|
||||||
|
{
|
||||||
|
id: "CustomBoss",
|
||||||
|
name: "Test Custom Boss",
|
||||||
|
role: "superunique",
|
||||||
|
act: 1,
|
||||||
|
levelIds: [4],
|
||||||
|
monsterId: "fallen1",
|
||||||
|
defaultLandmark: "Custom Landmark",
|
||||||
|
modifiers: ["lightenchant", "fast"],
|
||||||
|
minionCount: [4, 6],
|
||||||
|
minionMonsterId: "fallen1",
|
||||||
|
objectId: 999,
|
||||||
|
defaultRelativePos: { x: 0.5, y: 0.5 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = generateWilderness({
|
||||||
|
levelId: 4,
|
||||||
|
levelName: "Stony Field",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 80,
|
||||||
|
sizeY: 80,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 123,
|
||||||
|
pieces: [{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1] }],
|
||||||
|
substitutions: [],
|
||||||
|
customLandmarks,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.stats.landmarkEntities?.length).toBe(1)
|
||||||
|
const custom = result.stats.landmarkEntities?.[0]
|
||||||
|
expect(custom?.name).toBe("Test Custom Boss")
|
||||||
|
expect(custom?.tileX).toBe(40)
|
||||||
|
expect(custom?.tileY).toBe(40)
|
||||||
|
expect(custom?.objectId).toBe(999)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls back to relative canvas coordinates when preset pieces are absent", () => {
|
||||||
|
const borderDs1 = makeMockDs1(8, 8)
|
||||||
|
const result = generateWilderness({
|
||||||
|
levelId: 5, // Dark Wood (Treehead WoodFist)
|
||||||
|
levelName: "Dark Wood",
|
||||||
|
levelTypeName: "Act 1 - Wilderness",
|
||||||
|
sizeX: 100,
|
||||||
|
sizeY: 100,
|
||||||
|
subType: 6,
|
||||||
|
subTheme: 0,
|
||||||
|
seed: 99,
|
||||||
|
pieces: [{ name: "Act 1 - Wild Border 1", border: true, levels: [borderDs1] }],
|
||||||
|
substitutions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const treehead = result.stats.landmarkEntities?.find(e => e.name === "Treehead WoodFist")
|
||||||
|
expect(treehead).toBeDefined()
|
||||||
|
expect(treehead?.tileX).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(treehead?.tileX).toBeLessThan(99)
|
||||||
|
expect(treehead?.tileY).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(treehead?.tileY).toBeLessThan(99)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Integration with planLevelMonsters (src/game/monsters.ts)", () => {
|
||||||
|
test("Aura Enchanted modifier exists in CANONICAL_ELITE_MODIFIERS", () => {
|
||||||
|
const aura = CANONICAL_ELITE_MODIFIERS.find(m => m.name === "aura")
|
||||||
|
expect(aura).toBeDefined()
|
||||||
|
expect(aura?.label).toBe("Aura Enchanted")
|
||||||
|
expect(aura?.id).toBe(30)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("CANONICAL_SUPER_UNIQUES_BY_LEVEL maps all 5 Acts correctly", () => {
|
||||||
|
// Act 1
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[3]).toBeDefined() // Bishibosh
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[4]).toBeDefined() // Rakanishu
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[5]).toBeDefined() // Treehead WoodFist
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[17]).toBeDefined() // Blood Raven
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[25]).toBeDefined() // Countess
|
||||||
|
|
||||||
|
// Act 2
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[43]).toBeDefined() // Beetleburst
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[44]).toBeDefined() // Dark Elder
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[59]).toBeDefined() // Creeping Feature
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[60]).toBeDefined() // Bloodwitch
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[61]).toBeDefined() // Fangskin
|
||||||
|
|
||||||
|
// Act 3
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[76]).toBeDefined() // Sszark
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[78]).toBeDefined() // Stormtree & Endugu
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[84]).toBeDefined() // Sszark
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[88]).toBeDefined() // Endugu
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[91]).toBeDefined() // Endugu
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[94]).toBeDefined() // Sarina
|
||||||
|
|
||||||
|
// Act 4
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[105]).toBeDefined() // Izual
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[107]).toBeDefined() // Hephasto
|
||||||
|
|
||||||
|
// Act 5
|
||||||
|
const act5Bloody = CANONICAL_SUPER_UNIQUES_BY_LEVEL[110] // Dac Farren & Shenk
|
||||||
|
expect(Array.isArray(act5Bloody)).toBe(true)
|
||||||
|
expect((act5Bloody as readonly unknown[]).length).toBe(2)
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[111]).toBeDefined() // Eyeback
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[112]).toBeDefined() // Thresh Socket
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[114]).toBeDefined() // Frozenstein
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[115]).toBeDefined() // Bonesaw
|
||||||
|
expect(CANONICAL_SUPER_UNIQUES_BY_LEVEL[124]).toBeDefined() // Nihlathak
|
||||||
|
})
|
||||||
|
|
||||||
|
test("planLevelMonsters creates deterministic boss packs for multi-boss level 110", () => {
|
||||||
|
const levelsTxt = mockTable([
|
||||||
|
"Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3",
|
||||||
|
"110\tBloody Foothills\t600\t2\t4\t35\tsiegebeast1\tdemonimp1\tenflayer1",
|
||||||
|
].join("\n"))
|
||||||
|
const monstatsTxt = mockTable([
|
||||||
|
"Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee",
|
||||||
|
"demonimp1\tdemonimp1\tDemon Imp\t35\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1",
|
||||||
|
"overseer1\toverseer1\tOverseer\t35\t1\t2\t7\t10\t10\t35\t16\t26\t3\t6\t30\t1\t1\t1\t1",
|
||||||
|
"siegebeast1\tsiegebeast1\tSiege Beast\t35\t1\t2\t6\t8\t15\t50\t20\t35\t4\t8\t30\t1\t1\t1\t1",
|
||||||
|
].join("\n"))
|
||||||
|
const monlvlTxt = mockTable([
|
||||||
|
"Level\tAC\tTH\tHP\tDM\tXP",
|
||||||
|
"35\t100\t100\t100\t100\t100",
|
||||||
|
].join("\n"))
|
||||||
|
const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt }
|
||||||
|
|
||||||
|
const plan = planLevelMonsters(tables, 110, 240 * 48, 11042, 3.5, "normal")
|
||||||
|
|
||||||
|
expect(plan.superUniques).toContain("Dac Farren")
|
||||||
|
expect(plan.superUniques).toContain("Shenk the Overseer")
|
||||||
|
|
||||||
|
const dacPack = plan.packs.find(p => p.superUniqueId === "Dac Farren")
|
||||||
|
const shenkPack = plan.packs.find(p => p.superUniqueId === "Shenk the Overseer")
|
||||||
|
|
||||||
|
expect(dacPack).toBeDefined()
|
||||||
|
expect(shenkPack).toBeDefined()
|
||||||
|
expect(dacPack?.members[0]?.rank).toBe("unique")
|
||||||
|
expect(shenkPack?.members[0]?.rank).toBe("unique")
|
||||||
|
expect(dacPack?.members[0]?.modifiers?.includes("coldenchant")).toBe(true)
|
||||||
|
expect(shenkPack?.members[0]?.modifiers?.includes("strong")).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue