diff --git a/src/game/monsters.ts b/src/game/monsters.ts index a6154f3..d4133e4 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -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> = { +/** Canonical outdoor SuperUniques tied to level landmarks across Acts 1-5. */ +export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly> = { + // Act 1 3: { id: 'Bishibosh', name: 'Bishibosh', @@ -1030,6 +1032,16 @@ export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly 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 { diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 091e4d1..3bf3c08 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -103,6 +103,9 @@ import { stampAct5SiegeStrips, type SiegeTopographyStats, } from './wilderness-siege.ts' +import type { D2Table } from './acts.ts' +import { readSuperUniques, CANONICAL_ELITE_MODIFIERS } from './monsters.ts' +import type { SuperUnique } from './monsters.ts' export { DRLG_GenerateJungles, @@ -290,6 +293,12 @@ export interface WildernessRequest { * the tests and `verify-generators` use. */ 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. */ @@ -314,7 +323,9 @@ export interface WildernessResult { /** A single synthesized map the isometric renderer can draw unmodified. */ readonly level: Ds1 /** What the generator did, for reporting and tests. */ - readonly stats: Record + readonly stats: Record & { + readonly landmarkEntities?: LandmarkEntity[] + } } /** Canonical default open ground floor tiles per wilderness level type. */ @@ -1043,9 +1054,13 @@ export interface LandmarkEntity { readonly landmark: string readonly tileX: number readonly tileY: number + readonly subtileX?: number + readonly subtileY?: number + readonly levelId?: number readonly modifiers: readonly string[] readonly minionCount: readonly [number, number] readonly minionMonsterId: string + readonly objectId?: number } /** The report collector. */ @@ -2602,6 +2617,14 @@ export const SPECIAL_PRESETS_BY_LEVEL: Readonly { const placements: Array<{ name: string; x: number; y: number }> = [ { name: 'Act 3 - Travincal NW', x: 0, y: 0 }, @@ -2964,6 +3028,7 @@ function stampAct3Travincal( if (!ds1) continue stampDs1(canvas, ds1, item.x, item.y) 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 }) } return placed @@ -2979,6 +3044,7 @@ function stampAct3Jungle( pieces: readonly WildernessPiece[], rng: Rng, stats: WildernessStats, + placedRects?: PlacedPresetRect[], ): Set { return DRLG_GenerateJungles(canvas, levelId, pieces, rng, stats, stampDs1) } @@ -4108,11 +4174,576 @@ export function spawnOutdoorShrines( export const spawnAct12Waypoint = spawnOutdoorWaypoint export const spawnAct12Shrines = spawnOutdoorShrines +/** Specification of a landmark entity definition for data-driven spawning. */ +export interface LandmarkEntityDef { + readonly id: string + readonly name: string + 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, + levelId: number, + act1Plan: Act1OutdoorPlan | undefined, + placedRects: readonly PlacedPresetRect[], + gridWidth: number, + gridHeight: number, + ) => { tileX: number; tileY: number; landmark: string } | null +} + /** - * Anchor fixed Act 1 SuperUniques (Bishibosh, Rakanishu, Treehead WoodFist, Blood Raven) - * to their landmark presets and place Guard NPC Flavie (`rogue2`) at the Blood Moor <-> Cold Plains border gate. + * Canonical landmark SuperUniques and special NPCs across Acts 1-5. */ -function spawnLandmarkEntities( +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 cy = Math.floor(canvas.height / 2) + const targetGateLevel = levelId === 2 ? 3 : 2 + const anchor = act1Plan?.anchors.find( + a => a.toLevelId === targetGateLevel || /Cold Plains|Blood Moor/i.test(a.label), + ) + let fx: number + let fy: number + if (anchor !== undefined) { + const dx = Math.sign(cx - anchor.interiorCell.x) * 2 + const dy = Math.sign(cy - anchor.interiorCell.y) * 2 + fx = Math.max(4, Math.min(canvas.width - 5, anchor.interiorCell.x + dx)) + fy = Math.max(4, Math.min(canvas.height - 5, anchor.interiorCell.y + dy)) + } else { + const openings = getPerimeterOpenings(levelId, gridWidth, gridHeight) + let chosenKey: string | undefined + for (const k of openings) { + const [bx, by] = k.split(',').map(Number) + if (levelId === 2 && bx === gridWidth - 1) chosenKey = k + if (levelId === 3 && by === gridHeight - 1) chosenKey = k + } + if (!chosenKey) chosenKey = openings.values().next().value + if (chosenKey) { + const [bx, by] = chosenKey.split(',').map(Number) + const rawX = (bx ?? 0) * TILES_PER_BLOCK + 4 + const rawY = (by ?? 0) * TILES_PER_BLOCK + 4 + const dx = Math.sign(cx - rawX) * 3 + const dy = Math.sign(cy - rawY) * 3 + fx = Math.max(4, Math.min(canvas.width - 5, rawX + dx)) + fy = Math.max(4, Math.min(canvas.height - 5, rawY + dy)) + } else { + fx = cx + fy = cy + } + } + return { + tileX: fx, + tileY: fy, + landmark: 'Border Gate (Blood Moor <-> Cold Plains)', + } + }, + }, + { + id: 'Bishibosh', + name: 'Bishibosh', + role: 'superunique', + act: 1, + levelIds: [3], + monsterId: 'fallenshaman1', + presetMatch: /Fallen Camp Bishibosh|Bivouac/i, + defaultLandmark: 'Act 1 - Fallen Camp Bishibosh', + modifiers: ['magicresistant', 'fireenchant'], + minionCount: [5, 7], + minionMonsterId: 'fallen1', + objectId: 70, + defaultRelativePos: { x: 0.45, y: 0.45 }, + }, + { + id: 'Rakanishu', + name: 'Rakanishu', + role: 'superunique', + act: 1, + levelIds: [4], + monsterId: 'fallen1', + presetMatch: /Cairn Stones/i, + defaultLandmark: 'Act 1 - Cairn Stones', + modifiers: ['lightenchant', 'fast'], + minionCount: [6, 8], + minionMonsterId: 'fallen1', + objectId: 71, + defaultRelativePos: { x: 0.52, y: 0.48 }, + }, + { + id: 'Treehead WoodFist', + name: 'Treehead WoodFist', + role: 'superunique', + act: 1, + levelIds: [5], + monsterId: 'brute1', + presetMatch: /Inifus/i, + defaultLandmark: 'Act 1 - Inifus', + modifiers: ['strong', 'fast'], + minionCount: [3, 4], + minionMonsterId: 'brute1', + objectId: 72, + defaultRelativePos: { x: 0.48, y: 0.52 }, + }, + { + id: 'Blood Raven', + name: 'Blood Raven', + role: 'superunique', + act: 1, + levelIds: [17], + monsterId: 'corruptrogue1', + presetMatch: /Graveyard/i, + defaultLandmark: 'Act 1 - Graveyard', + modifiers: ['fireenchant', 'fast'], + minionCount: [6, 8], + 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() + 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() + 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() + 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, @@ -4120,168 +4751,61 @@ function spawnLandmarkEntities( gridWidth: number, gridHeight: number, stats: WildernessStats, + defs: readonly LandmarkEntityDef[] = CANONICAL_LANDMARK_ENTITIES, ): void { const entities: LandmarkEntity[] = [] - const cx = Math.floor(canvas.width / 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 anchor = act1Plan?.anchors.find( - a => a.toLevelId === targetGateLevel || /Cold Plains|Blood Moor/i.test(a.label), - ) - let fx: number - let fy: number - if (anchor !== undefined) { - const dx = Math.sign(cx - anchor.interiorCell.x) * 2 - const dy = Math.sign(cy - anchor.interiorCell.y) * 2 - fx = Math.max(4, Math.min(canvas.width - 5, anchor.interiorCell.x + dx)) - fy = Math.max(4, Math.min(canvas.height - 5, anchor.interiorCell.y + dy)) + 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 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 - for (const k of openings) { - const [bx, by] = k.split(',').map(Number) - if (levelId === 2 && bx === gridWidth - 1) chosenKey = k - if (levelId === 3 && by === gridHeight - 1) chosenKey = k - } - if (!chosenKey) chosenKey = openings.values().next().value - if (chosenKey) { - const [bx, by] = chosenKey.split(',').map(Number) - const rawX = (bx ?? 0) * TILES_PER_BLOCK + 4 - const rawY = (by ?? 0) * TILES_PER_BLOCK + 4 - const dx = Math.sign(cx - rawX) * 3 - const dy = Math.sign(cy - rawY) * 3 - fx = Math.max(4, Math.min(canvas.width - 5, rawX + dx)) - fy = Math.max(4, Math.min(canvas.height - 5, rawY + dy)) + 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 { - fx = cx - fy = cy + continue } } - entities.push({ - role: 'npc_guard', - id: 'Flavie', - monsterId: 'rogue2', - name: 'Flavie', - landmark: 'Border Gate (Blood Moor <-> Cold Plains)', - tileX: fx, - tileY: fy, - modifiers: [], - 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', - monsterId: 'fallenshaman1', - name: 'Bishibosh', - landmark: rect?.name ?? 'Act 1 - Fallen Camp Bishibosh', - tileX: tx, - tileY: ty, - modifiers: ['magicresistant', 'fireenchant'], - minionCount: [5, 7], - minionMonsterId: 'fallen1', - }) - canvas.objects.push({ - type: 1, - id: 70, - x: tx * SUB_TILES_PER_TILE + 2, - y: ty * SUB_TILES_PER_TILE + 2, - flags: 0, - }) - } + const subtileX = tileX * SUB_TILES_PER_TILE + 2 + const subtileY = tileY * SUB_TILES_PER_TILE + 2 - // 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', - monsterId: 'fallen1', - name: 'Rakanishu', - landmark: rect?.name ?? 'Act 1 - Cairn Stones', - tileX: tx, - tileY: ty, - modifiers: ['lightenchant', 'fast'], - minionCount: [6, 8], - minionMonsterId: 'fallen1', + 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({ - 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', - monsterId: 'brute1', - name: 'Treehead WoodFist', - landmark: rect?.name ?? 'Act 1 - Inifus', - tileX: tx, - tileY: ty, - modifiers: ['strong', 'fast'], - minionCount: [3, 4], - minionMonsterId: 'brute1', - }) canvas.objects.push({ 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', - monsterId: 'corruptrogue1', - name: 'Blood Raven', - landmark: graveyardRect?.name ?? 'Act 1 - Graveyard', - tileX: tx, - tileY: ty, - modifiers: ['fireenchant', 'fast'], - minionCount: [6, 8], - minionMonsterId: 'zombie1', - }) - canvas.objects.push({ - type: 1, - id: 73, - x: tx * SUB_TILES_PER_TILE + 2, - y: ty * SUB_TILES_PER_TILE + 2, + id: def.objectId, + x: subtileX, + y: subtileY, flags: 0, }) } @@ -4440,6 +4964,11 @@ export function generateWilderness(request: WildernessRequest): WildernessResult const canvas = createCanvas(gridWidth * TILES_PER_BLOCK, gridHeight * TILES_PER_BLOCK, wallLayers, floorLayers, substitutionLayers) const rng = new Rng(request.seed) + const landmarkDefs = buildLandmarkEntitiesFromTables( + request.superuniques, + request.monpreset, + request.customLandmarks ?? CANONICAL_LANDMARK_ENTITIES, + ) stats.groundTile = primaryGroundTile stats.groundTiles = groundTiles ?? undefined @@ -4458,19 +4987,30 @@ export function generateWilderness(request: WildernessRequest): WildernessResult request.levelId === 82 || (request.levelId >= 76 && request.levelId <= 78) ) { + const specialPlacedRects: PlacedPresetRect[] = [] let placedSpecial: Set if (request.levelId === 83) { - placedSpecial = stampAct3Travincal(canvas, request.pieces, stats) + placedSpecial = stampAct3Travincal(canvas, request.pieces, stats, specialPlacedRects) } else if (request.levelId === 82) { placedSpecial = stampAct3KurastCauseway(canvas, request.pieces, stats) } 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) const populationRng = new Rng(request.seed ^ 0x6c07) spawnOutdoorWaypoint(canvas, request.levelId, act, undefined, [], populationRng, stats, request.levelTypeName) spawnOutdoorShrines(canvas, request.levelId, act, undefined, [], populationRng, stats, request.shrineSubstitutions, 0, request.levelTypeName) } else { placedSpecial = stampAct5SiegeStrips(canvas, request.pieces, rng, stats) } + spawnLandmarkEntities( + canvas, + request.levelId, + undefined, + specialPlacedRects, + gridWidth, + gridHeight, + stats, + landmarkDefs, + ) let finalGroundCells = 0 for (let y = 0; y < canvas.height; y += 1) { const row = canvas.cells[y] @@ -4790,7 +5330,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult const populationRng = new Rng(request.seed ^ 0x6c07) spawnOutdoorWaypoint(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.levelTypeName) spawnOutdoorShrines(canvas, request.levelId, act, dirtPathGrid, allPlacedRects, populationRng, stats, request.shrineSubstitutions, themeIndex, request.levelTypeName) - spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats) + spawnLandmarkEntities(canvas, request.levelId, act1Plan, allPlacedRects, gridWidth, gridHeight, stats, landmarkDefs) for (const piece of fillPieces) { if (!placedSpecialPieces.has(piece.name)) { diff --git a/tests/wilderness-features.test.ts b/tests/wilderness-features.test.ts index 739535e..a37a7d1 100644 --- a/tests/wilderness-features.test.ts +++ b/tests/wilderness-features.test.ts @@ -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) diff --git a/tests/wilderness-superuniques.test.ts b/tests/wilderness-superuniques.test.ts new file mode 100644 index 0000000..7d9cf42 --- /dev/null +++ b/tests/wilderness-superuniques.test.ts @@ -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) + }) + }) +})