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: 28, name: 'stoneskin', label: 'Stone Skin', 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
|
||||
}
|
||||
|
||||
/** Canonical Act 1 outdoor SuperUniques tied to level landmarks. */
|
||||
export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniqueLandmarkSpec>> = {
|
||||
/** 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',
|
||||
|
|
@ -1030,6 +1032,16 @@ export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniq
|
|||
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',
|
||||
|
|
@ -1040,6 +1052,296 @@ export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniq
|
|||
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',
|
||||
},
|
||||
}
|
||||
|
||||
/** What one level's population came out as. */
|
||||
|
|
@ -1129,44 +1431,52 @@ export function planLevelMonsters(
|
|||
return { members }
|
||||
})
|
||||
|
||||
// Check if this level has a fixed SuperUnique boss (Bishibosh, Rakanishu, Treehead WoodFist, Blood Raven)
|
||||
const suSpec = CANONICAL_SUPER_UNIQUES_BY_LEVEL[levelId]
|
||||
// Check if this level has fixed SuperUnique boss(es)
|
||||
const suRaw = CANONICAL_SUPER_UNIQUES_BY_LEVEL[levelId]
|
||||
const suSpecs: readonly SuperUniqueLandmarkSpec[] = Array.isArray(suRaw)
|
||||
? suRaw
|
||||
: (suRaw !== undefined ? [suRaw] : [])
|
||||
const superUniques: string[] = []
|
||||
if (suSpec !== undefined && kinds.size > 0) {
|
||||
const suRng = new Rng(seed ^ 0x55aa)
|
||||
const fallbackKind = kinds.get('fallen1') ?? kinds.values().next().value!
|
||||
const bossKind = kinds.get(suSpec.monsterId) ?? fallbackKind
|
||||
const minionKind = kinds.get(suSpec.minionMonsterId) ?? bossKind
|
||||
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3)
|
||||
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, monsterScaleFor(scaling, bossLevel))
|
||||
const bossScaled: MonsterStats = {
|
||||
...bossBase,
|
||||
id: suSpec.monsterId,
|
||||
name: suSpec.name,
|
||||
hp: Math.max(1, Math.round(bossBase.hp * ELITE_HEALTH_MULTIPLIER.unique)),
|
||||
}
|
||||
const bossStats = applyEliteModifiers(bossScaled, 'unique', suSpec.modifiers, suSpec.id)
|
||||
const minionCount = suRng.int(suSpec.minMinions, suSpec.maxMinions)
|
||||
const suMembers: MonsterStats[] = [bossStats]
|
||||
for (let m = 0; m < minionCount; m += 1) {
|
||||
const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel)
|
||||
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, monsterScaleFor(scaling, mLevel))
|
||||
const mScaled: MonsterStats = {
|
||||
...mBase,
|
||||
id: suSpec.minionMonsterId,
|
||||
hp: Math.max(1, Math.round(mBase.hp * 2)),
|
||||
if (suSpecs.length > 0 && kinds.size > 0) {
|
||||
const suPacks: MonsterPack[] = []
|
||||
for (let suIdx = 0; suIdx < suSpecs.length; suIdx += 1) {
|
||||
const suSpec = suSpecs[suIdx]!
|
||||
const suRng = new Rng(seed ^ 0x55aa ^ (suIdx * 0x3c6ef35f))
|
||||
const fallbackKind = kinds.get('fallen1') ?? kinds.values().next().value!
|
||||
const bossKind = kinds.get(suSpec.monsterId) ?? fallbackKind
|
||||
const minionKind = kinds.get(suSpec.minionMonsterId) ?? bossKind
|
||||
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3)
|
||||
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, monsterScaleFor(scaling, bossLevel))
|
||||
const bossScaled: MonsterStats = {
|
||||
...bossBase,
|
||||
id: suSpec.monsterId,
|
||||
name: suSpec.name,
|
||||
hp: Math.max(1, Math.round(bossBase.hp * ELITE_HEALTH_MULTIPLIER.unique)),
|
||||
}
|
||||
suMembers.push(applyEliteModifiers(mScaled, 'minion', []))
|
||||
const bossStats = applyEliteModifiers(bossScaled, 'unique', suSpec.modifiers, suSpec.id)
|
||||
const minionCount = suRng.int(suSpec.minMinions, suSpec.maxMinions)
|
||||
const suMembers: MonsterStats[] = [bossStats]
|
||||
for (let m = 0; m < minionCount; m += 1) {
|
||||
const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel)
|
||||
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, monsterScaleFor(scaling, mLevel))
|
||||
const mScaled: MonsterStats = {
|
||||
...mBase,
|
||||
id: suSpec.minionMonsterId,
|
||||
hp: Math.max(1, Math.round(mBase.hp * 2)),
|
||||
}
|
||||
suMembers.push(applyEliteModifiers(mScaled, 'minion', []))
|
||||
}
|
||||
const matchedLandmark = options?.landmarks?.find(l => l.id === suSpec.id)
|
||||
const suPack: MonsterPack = {
|
||||
members: suMembers,
|
||||
superUniqueId: suSpec.id,
|
||||
...(matchedLandmark?.fixedPosition !== undefined ? { fixedPosition: matchedLandmark.fixedPosition } : {}),
|
||||
...(matchedLandmark !== undefined ? { landmarkTile: { tileX: matchedLandmark.tileX, tileY: matchedLandmark.tileY } } : {}),
|
||||
}
|
||||
suPacks.push(suPack)
|
||||
superUniques.push(suSpec.id)
|
||||
}
|
||||
const matchedLandmark = options?.landmarks?.find(l => l.id === suSpec.id)
|
||||
const suPack: MonsterPack = {
|
||||
members: suMembers,
|
||||
superUniqueId: suSpec.id,
|
||||
...(matchedLandmark?.fixedPosition !== undefined ? { fixedPosition: matchedLandmark.fixedPosition } : {}),
|
||||
...(matchedLandmark !== undefined ? { landmarkTile: { tileX: matchedLandmark.tileX, tileY: matchedLandmark.tileY } } : {}),
|
||||
}
|
||||
packs.unshift(suPack)
|
||||
superUniques.push(suSpec.id)
|
||||
packs.unshift(...suPacks)
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -244,7 +244,8 @@ describe("Wilderness Features & Border Rules", () => {
|
|||
expect(result.stats.substitutedClusters).toBe(15)
|
||||
expect(result.level.width).toBe(240)
|
||||
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
|
||||
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.height).toBe(120)
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -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