diff --git a/scripts/pack-act-assets.ts b/scripts/pack-act-assets.ts index 297cfc3..d150c1a 100644 --- a/scripts/pack-act-assets.ts +++ b/scripts/pack-act-assets.ts @@ -1154,6 +1154,7 @@ async function bakeDs1Variant( generatorLinks: { readonly entrances: readonly WildernessEntrance[] readonly mazeWarps: readonly MazeWarp[] + readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] } = { entrances: [], mazeWarps: [] }, ): Promise { const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed) @@ -1370,6 +1371,8 @@ async function bakeDs1Variant( scene.cellsX * scene.cellsY, seed, PLAYER_WALK_SPEED_PX, + 'normal', + generatorLinks.landmarks ? { landmarks: generatorLinks.landmarks } : undefined, ) const sceneLinks = buildSceneLinks( @@ -1611,7 +1614,13 @@ for (const entry of LEVELS) { await bakeDs1Variant( entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, result.level, `generated:${label}`, label, seed, - { entrances: (result.stats.entrances ?? []) as WildernessEntrance[], mazeWarps: [] }, + { + entrances: (result.stats.entrances ?? []) as WildernessEntrance[], + mazeWarps: [], + ...(Array.isArray(result.stats.landmarkEntities) + ? { landmarks: result.stats.landmarkEntities as readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] } + : {}), + }, ) } catch (err) { console.error(`failed to bake wilderness ${label}: ${(err as Error).message}`) diff --git a/src/game/combat.ts b/src/game/combat.ts index 402667a..eada4ac 100644 --- a/src/game/combat.ts +++ b/src/game/combat.ts @@ -90,6 +90,12 @@ export interface MonsterStats { readonly speed: number /** Experience awarded on death. */ readonly xp: number + /** Elite rank: normal, champion, unique, or minion. */ + readonly rank?: 'normal' | 'champion' | 'unique' | 'minion' + /** Rolled elite/superunique modifiers from MonUMod.txt. */ + readonly modifiers?: readonly string[] + /** SuperUnique identifier if this is a fixed SuperUnique boss (e.g. 'Bishibosh'). */ + readonly superUniqueId?: string } /** One monster's mutable state. */ @@ -404,6 +410,13 @@ export function spawnMonsters( return placed } +/** A circular exclusion zone where ordinary monster camps must not spawn. */ +export interface SafeZone { + readonly x: number + readonly y: number + readonly radius: number +} + /** A group of monsters that belongs together on the ground. */ export interface MonsterPack { /** @@ -414,6 +427,12 @@ export interface MonsterPack { * a stronger leader followed by its minions. */ readonly members: readonly MonsterStats[] + /** Fixed world pixel coordinates for landmark-bound SuperUnique packs. */ + readonly fixedPosition?: { readonly x: number; readonly y: number } + /** Landmark tile coordinates (tileX, tileY) if pixel coordinates are resolved at scene load. */ + readonly landmarkTile?: { readonly tileX: number; readonly tileY: number } + /** SuperUnique boss identifier if this pack belongs to a fixed SuperUnique. */ + readonly superUniqueId?: string } /** @@ -425,6 +444,14 @@ export interface MonsterPack { */ const PACK_RADIUS_PX = 96 +function isInsideSafeZone(x: number, y: number, safeZones: readonly SafeZone[] | undefined): boolean { + if (safeZones === undefined || safeZones.length === 0) return false + for (const sz of safeZones) { + if (Math.hypot(x - sz.x, y - sz.y) < sz.radius) return true + } + return false +} + /** * Place packs, keeping each pack's members together. * @@ -444,6 +471,7 @@ const PACK_RADIUS_PX = 96 * @param around - the centre to spread camps around. * @param spread - how far camps may be from that centre, in pixels. * @param terrain - collision predicate. + * @param safeZones - optional safe zones (spawn point, town bridge, waypoints) to exclude ordinary packs from. * @returns how many monsters were actually placed. */ export function spawnMonsterPacks( @@ -452,25 +480,57 @@ export function spawnMonsterPacks( around: { readonly x: number; readonly y: number }, spread: number, terrain: CombatTerrain, + safeZones?: readonly SafeZone[], ): number { if (packs.length === 0) return 0 let placed = 0 let campIndex = 0 let attempt = 0 - const attemptLimit = packs.length * 24 + const attemptLimit = packs.length * 32 while (campIndex < packs.length && attempt < attemptLimit) { - attempt += 1 - const angle = attempt * 2.399963 - const radius = spread * Math.sqrt(attempt / attemptLimit) - const campX = around.x + Math.cos(angle) * radius - const campY = around.y + Math.sin(angle) * radius - if (terrain.overlap(campX, campY) > 0) continue - const pack = packs[campIndex]! + let campX: number + let campY: number + let angle: number + + if (pack.fixedPosition !== undefined) { + // Fixed landmark SuperUnique pack: anchor camp center to the landmark position. + campX = pack.fixedPosition.x + campY = pack.fixedPosition.y + angle = campIndex * 2.399963 + if (terrain.overlap(campX, campY) > 0) { + // Search outward from landmark if exact center tile is slightly blocked + let foundClear = false + for (let searchStep = 1; searchStep <= 16; searchStep += 1) { + const sAngle = searchStep * 2.399963 + const sx = campX + Math.cos(sAngle) * (searchStep * 12) + const sy = campY + Math.sin(sAngle) * (searchStep * 12) + if (terrain.overlap(sx, sy) === 0) { + campX = sx + campY = sy + foundClear = true + break + } + } + if (!foundClear) { + campIndex += 1 + continue + } + } + } else { + attempt += 1 + angle = attempt * 2.399963 + const radius = spread * Math.sqrt(attempt / (packs.length * 24)) + campX = around.x + Math.cos(angle) * radius + campY = around.y + Math.sin(angle) * radius + if (isInsideSafeZone(campX, campY, safeZones)) continue + if (terrain.overlap(campX, campY) > 0) continue + } + campIndex += 1 let memberAttempt = 0 let memberPlaced = 0 - while (memberPlaced < pack.members.length && memberAttempt < pack.members.length * 12) { + while (memberPlaced < pack.members.length && memberAttempt < pack.members.length * 16) { // The leader stands on the camp centre; the rest ring it. Offsetting the // ring by the camp's own angle stops every camp in the level from having // an identically oriented formation. @@ -481,6 +541,7 @@ export function spawnMonsterPacks( memberAttempt += 1 const x = campX + Math.cos(memberAngle) * memberRadius const y = campY + Math.sin(memberAngle) * memberRadius + if (pack.fixedPosition === undefined && isInsideSafeZone(x, y, safeZones)) continue if (terrain.overlap(x, y) > 0) continue const definition = pack.members[memberPlaced]! world.monsters.push({ diff --git a/src/game/engine.ts b/src/game/engine.ts index 120f4e8..9a7e522 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -1,5 +1,5 @@ import { tickCombat, createWorld, spawnMonsters, spawnMonsterPacks, damageMonster, rebindPlayer } from './combat.ts' -import type { CombatWorld, CombatOptions, MonsterPack, MonsterStats } from './combat.ts' +import type { CombatWorld, CombatOptions, MonsterPack, MonsterStats, SafeZone } from './combat.ts' import { Inventory, rollDrop, goldItem, totalStat } from './items.ts' import type { Item, ItemBase, Affix } from './items.ts' import { castSkill, tickProjectiles } from './skills.ts' @@ -92,6 +92,11 @@ export interface GameEngineOptions { * to hit keep passing a count. */ monsterPacks?: readonly MonsterPack[] + /** + * Optional safe zones (e.g. around spawn point, town bridges, and waypoints) + * where ordinary monster packs must not spawn. + */ + safeZones?: readonly SafeZone[] /** * Whether to place NPCs on a synthetic ring around the spawn. * @@ -163,7 +168,7 @@ export class GameEngine { const spread = opts.monsterSpread ?? 260 const count = opts.monsterCount ?? 8 if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) { - spawnMonsterPacks(this.world, opts.monsterPacks, opts.spawn, spread, this.terrain) + spawnMonsterPacks(this.world, opts.monsterPacks, opts.spawn, spread, this.terrain, opts.safeZones) } else if (count > 0) { spawnMonsters(this.world, opts.stats, count, opts.spawn, spread, this.terrain) } diff --git a/src/game/monsters.ts b/src/game/monsters.ts index 6f076a9..a6154f3 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -684,14 +684,87 @@ export function readEliteModifiers(table: D2Table): EliteModifier[] { /** What a monster is: ordinary, or promoted by the level generator. */ export type MonsterRank = 'normal' | 'champion' | 'unique' | 'minion' +/** Canonical elite modifiers from `MonUMod.txt`. */ +export const CANONICAL_ELITE_MODIFIERS: readonly { + readonly id: number + readonly name: string + readonly label: string + readonly champion: boolean +}[] = [ + { id: 5, name: 'strong', label: 'Extra Strong', champion: true }, + { id: 6, name: 'fast', label: 'Extra Fast', champion: true }, + { id: 7, name: 'cursed', label: 'Cursed', champion: false }, + { id: 8, name: 'magicresistant', label: 'Magic Resistant', champion: false }, + { id: 9, name: 'fireenchant', label: 'Fire Enchanted', champion: false }, + { id: 17, name: 'lightenchant', label: 'Lightning Enchanted', champion: false }, + { id: 18, name: 'coldenchant', label: 'Cold Enchanted', champion: false }, + { id: 25, name: 'manahit', label: 'Mana Burn', champion: false }, + { id: 26, name: 'teleport', label: 'Teleportation', champion: false }, + { 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 }, +] + +/** + * Roll elite modifiers (`MonUMod.txt`) for a champion or unique pack. + */ +export function rollEliteModifiers( + rank: Exclude, + rng: Rng, +): readonly string[] { + if (rank === 'normal') return [] + if (rank === 'champion') { + const champs = CANONICAL_ELITE_MODIFIERS.filter(m => m.champion) + return [champs[rng.int(0, champs.length - 1)]!.name] + } + const count = rng.int(1, 2) + const chosen: string[] = [] + const pool = [...CANONICAL_ELITE_MODIFIERS] + while (chosen.length < count && pool.length > 0) { + const idx = rng.int(0, pool.length - 1) + chosen.push(pool[idx]!.name) + pool.splice(idx, 1) + } + return chosen +} + +/** + * Apply elite/superunique stat modifiers to base rolled `MonsterStats`. + */ +export function applyEliteModifiers( + base: MonsterStats, + rank: MonsterRank, + modifiers: readonly string[] = [], + superUniqueId?: string, +): MonsterStats { + let damage = base.damage + let speed = base.speed + if (modifiers.includes('strong')) { + damage = Math.max(1, Math.round(damage * 1.5)) + } + if (modifiers.includes('fast')) { + speed = Math.round(speed * 1.4 * 100) / 100 + } + return { + ...base, + damage, + speed, + rank, + ...(modifiers.length > 0 ? { modifiers } : {}), + ...(superUniqueId !== undefined ? { superUniqueId } : {}), + } +} + /** One pack the level populator wants placed. */ export interface MonsterGroup { /** Which monster. */ readonly kind: MonsterKind - /** How many, from `MinGrp`…`MaxGrp`. */ + /** How many, from MinGrp to MaxGrp. */ readonly count: number /** Whether this pack is an elite one. */ readonly rank: Exclude + /** Rolled elite modifiers (`MonUMod.txt`). */ + readonly modifiers?: readonly string[] } /** @@ -800,7 +873,10 @@ export function planMonsterGroups( const count = Math.min(remaining, Math.max(1, rng.int(kind.minGroup, kind.maxGroup))) // `MonUMod.txt`'s `champion chance` constant is 20, i.e. a fifth of elite // packs are champions rather than a unique with minions. - groups.push({ kind, count, rank: rng.next() < 0.2 ? 'champion' : 'unique' }) + const rank: Exclude = rng.next() < 0.2 ? 'champion' : 'unique' + const modSeed = ((i + 1) * 0x9e3779b9) ^ (count * 0x85ebca6b) ^ (rank === 'champion' ? 1 : 2) + const modifiers = rollEliteModifiers(rank, new Rng(modSeed)) + groups.push({ kind, count, rank, modifiers }) remaining -= count } @@ -910,6 +986,62 @@ export const ELITE_HEALTH_MULTIPLIER: Readonly> = { + 3: { + id: 'Bishibosh', + name: 'Bishibosh', + monsterId: 'fallenshaman1', + landmark: 'Act 1 - Fallen Camp Bishibosh', + modifiers: ['magicresistant', 'fireenchant'], + minMinions: 5, + maxMinions: 7, + minionMonsterId: 'fallen1', + }, + 4: { + id: 'Rakanishu', + name: 'Rakanishu', + monsterId: 'fallen1', + landmark: 'Act 1 - Cairn Stones', + modifiers: ['lightenchant', 'fast'], + minMinions: 6, + maxMinions: 8, + minionMonsterId: 'fallen1', + }, + 5: { + id: 'Treehead WoodFist', + name: 'Treehead WoodFist', + monsterId: 'brute1', + landmark: 'Act 1 - Inifus', + modifiers: ['strong', 'fast'], + minMinions: 3, + maxMinions: 4, + minionMonsterId: 'brute1', + }, + 17: { + id: 'Blood Raven', + name: 'Blood Raven', + monsterId: 'corruptrogue1', + landmark: 'Act 1 - Graveyard', + modifiers: ['fireenchant', 'fast'], + minMinions: 6, + maxMinions: 8, + minionMonsterId: 'zombie1', + }, +} + /** What one level's population came out as. */ export interface PlannedLevel { /** `Levels.txt` id. */ @@ -924,6 +1056,8 @@ export interface PlannedLevel { readonly packs: readonly MonsterPack[] /** How many packs are champion or unique. */ readonly elitePacks: number + /** Fixed SuperUnique bosses planned for this level (e.g. Bishibosh, Rakanishu). */ + readonly superUniques?: readonly string[] } /** @@ -944,6 +1078,7 @@ export interface PlannedLevel { * @param seed - the level seed. * @param walkSpeedPx - the player's walking speed, the anchor for monster speed. * @param difficulty - which difficulty's columns to read. + * @param options - optional landmark overrides with exact tile/pixel positions. * @returns the population, empty for a town or an unknown level. */ export function planLevelMonsters( @@ -953,6 +1088,14 @@ export function planLevelMonsters( seed: number, walkSpeedPx: number, difficulty: Difficulty = 'normal', + options?: { + readonly landmarks?: readonly { + readonly id: string + readonly tileX: number + readonly tileY: number + readonly fixedPosition?: { readonly x: number; readonly y: number } + }[] + }, ): PlannedLevel { const empty: PlannedLevel = { levelId, levelName: '', types: [], budget: 0, packs: [], elitePacks: 0 } const plan = readLevelMonsterPlan(tables.levels, levelId, difficulty) @@ -978,18 +1121,62 @@ export function planLevelMonsters( // rank's full bonus, the minions a smaller one, which is why a champion // pack reads as "a tough one and its friends" rather than as a wall. const scale = group.rank === 'normal' ? 1 : (i === 0 ? multiplier : 1 + (multiplier - 1) / 2) - members.push(scale === 1 ? base : { ...base, hp: Math.max(1, Math.round(base.hp * scale)) }) + const scaled = scale === 1 ? base : { ...base, hp: Math.max(1, Math.round(base.hp * scale)) } + const memberRank: MonsterRank = group.rank === 'normal' ? 'normal' : (i === 0 || group.rank === 'champion' ? group.rank : 'minion') + const memberMods = (i === 0 || group.rank === 'champion') ? (group.modifiers ?? []) : [] + members.push(applyEliteModifiers(scaled, memberRank, memberMods)) } 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] + 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)), + } + 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 } } : {}), + } + packs.unshift(suPack) + superUniques.push(suSpec.id) + } + return { levelId, levelName: plan.levelName, types: types.map(kind => kind.id), budget, packs, - elitePacks: groups.filter(group => group.rank !== 'normal').length, + elitePacks: groups.filter(group => group.rank !== 'normal').length + superUniques.length, + ...(superUniques.length > 0 ? { superUniques } : {}), } } diff --git a/src/game/wilderness.ts b/src/game/wilderness.ts index 59d6cdc..d860ad4 100644 --- a/src/game/wilderness.ts +++ b/src/game/wilderness.ts @@ -593,6 +593,20 @@ interface SubstitutionReport { readonly clusters: number } +/** A fixed SuperUnique boss or guard NPC anchored to a wilderness landmark or border gate. */ +export interface LandmarkEntity { + readonly role: 'superunique' | 'npc_guard' + readonly id: string + readonly monsterId: string + readonly name: string + readonly landmark: string + readonly tileX: number + readonly tileY: number + readonly modifiers: readonly string[] + readonly minionCount: readonly [number, number] + readonly minionMonsterId: string +} + /** The report collector. */ interface WildernessStats { readonly substitutions: SubstitutionReport[] @@ -609,6 +623,11 @@ interface WildernessStats { specialPresets: string[] /** Every way into or out of the level, in cells. See `WildernessEntrance`. */ entrances: WildernessEntrance[] + waypointsSpawned?: number + waypointTile?: { readonly x: number; readonly y: number } + shrinesSpawned?: number + interactivesSpawned?: number + landmarkEntities?: LandmarkEntity[] } /** @@ -652,7 +671,7 @@ export const DIRT_PATH_TILE_LUT: Readonly = new Uint8Array([ * reproduce. Reported verbatim in every result's `stats.unimplementedPasses` so * the gap is visible rather than implied. */ -const UNIMPLEMENTED_PASSES: readonly string[] = [ +export const UNIMPLEMENTED_PASSES: readonly string[] = [ 'DRLGVER_CreateVertices', 'DRLGOUTPLACE_CreateLevelConnections', 'DRLGOUTDESR_InitAct2OutdoorLevel', @@ -660,8 +679,6 @@ const UNIMPLEMENTED_PASSES: readonly string[] = [ 'DRLGOUTDOORS_InitAct4OutdoorLevel', 'DRLGOUTSIEGE_InitAct5OutdoorLevel', 'DRLG_GenerateJungles', - 'DRLGOUTDOORS_SpawnAct12Waypoint', - 'DRLGOUTDOORS_SpawnAct12Shrines', 'DRLGOUTDOORS_SpawnAct3Mephisto', ] @@ -2906,6 +2923,437 @@ function stampAct1InteriorStoneWalls( } } +/* ------------------------------------------------------------------------- * + * Outdoor Population Phase: Waypoints, Roadside Shrines/Chests, SuperUniques & Flavie + * (`DRLGOUTDOORS_SpawnAct12Waypoint`, `DRLGOUTDOORS_SpawnAct12Shrines`) + * ------------------------------------------------------------------------- */ + +/** Outdoor levels in Act 1 and Act 2 that carry a Waypoint (`Levels.txt` `Waypoint != 255`). */ +const OUTDOOR_WAYPOINT_LEVELS = new Set([3, 4, 5, 6, 42, 43, 44]) + +function isTileClear( + canvas: Canvas, + tx: number, + ty: number, + placedRects: readonly PlacedPresetRect[], + padding = 1, +): boolean { + if (tx < padding || ty < padding || tx >= canvas.width - padding || ty >= canvas.height - padding) { + return false + } + for (const r of placedRects) { + if (tx >= r.x - padding && tx < r.x + r.w + padding && ty >= r.y - padding && ty < r.y + r.h + padding) { + return false + } + } + for (let dy = -padding; dy <= padding; dy += 1) { + const row = canvas.cells[ty + dy] + if (!row) return false + for (let dx = -padding; dx <= padding; dx += 1) { + const cell = row[tx + dx] + if (!cell) return false + if (cell.walls.some(w => !w.hidden && w.prop1 !== 0)) return false + } + } + return true +} + +function isNearDirtRoad( + dirtPathGrid: Uint8Array | undefined, + width: number, + height: number, + tx: number, + ty: number, + minDist: number, + maxDist: number, +): boolean { + if (!dirtPathGrid) return false + let minDistFound = 999 + for (let dy = -maxDist; dy <= maxDist; dy += 1) { + const ny = ty + dy + if (ny < 0 || ny >= height) continue + for (let dx = -maxDist; dx <= maxDist; dx += 1) { + const nx = tx + dx + if (nx < 0 || nx >= width) continue + if (dirtPathGrid[ny * width + nx] !== 0) { + const d = Math.max(Math.abs(dx), Math.abs(dy)) + if (d < minDistFound) minDistFound = d + } + } + } + return minDistFound >= minDist && minDistFound <= maxDist +} + +/** + * Automatically locate and place an outdoor Waypoint (`DRLGOUTDOORS_SpawnAct12Waypoint`) + * on a flat, open area near the main dirt road when not already provided by a preset. + */ +function spawnAct12Waypoint( + canvas: Canvas, + levelId: number, + act: number, + dirtPathGrid: Uint8Array | undefined, + placedRects: readonly PlacedPresetRect[], + rng: Rng, + stats: WildernessStats, +): void { + if (!OUTDOOR_WAYPOINT_LEVELS.has(levelId)) return + + // Check if a preset already placed a Waypoint object (Act 1 WP id 37/52, Act 2 WM id 10/306) + const existingWp = canvas.objects.find( + o => o.type === 2 && (o.id === 37 || o.id === 52 || o.id === 10 || o.id === 306), + ) + if (existingWp !== undefined) { + stats.waypointsSpawned = 1 + stats.waypointTile = { + x: Math.floor(existingWp.x / SUB_TILES_PER_TILE), + y: Math.floor(existingWp.y / SUB_TILES_PER_TILE), + } + return + } + + const roadCandidates: { x: number; y: number }[] = [] + const openCandidates: { x: number; y: number }[] = [] + const margin = Math.min(16, Math.floor(Math.min(canvas.width, canvas.height) / 4)) + + for (let y = margin; y < canvas.height - margin; y += 2) { + for (let x = margin; x < canvas.width - margin; x += 2) { + if (!isTileClear(canvas, x, y, placedRects, 2)) continue + if (isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 3, 6)) { + roadCandidates.push({ x, y }) + } else { + openCandidates.push({ x, y }) + } + } + } + + const pool = roadCandidates.length > 0 ? roadCandidates : openCandidates + const chosen = pool.length > 0 + ? pool[rng.int(0, pool.length - 1)]! + : { x: Math.floor(canvas.width / 2), y: Math.floor(canvas.height / 2) } + + const wpId = act === 2 ? 10 : 37 + canvas.objects.push({ + type: 2, + id: wpId, + x: chosen.x * SUB_TILES_PER_TILE + 2, + y: chosen.y * SUB_TILES_PER_TILE + 2, + flags: 0, + }) + stats.waypointsSpawned = 1 + stats.waypointTile = { x: chosen.x, y: chosen.y } + stats.notes.push(`spawned outdoor waypoint (id=${String(wpId)}) at tile (${String(chosen.x)}, ${String(chosen.y)})`) +} + +/** + * Scatter roadside shrines, healing wells, chests, and barrels/rocks (`DRLGOUTDOORS_SpawnAct12Shrines`) + * along dirt road edges and near ruins/cottages. + */ +function spawnAct12Shrines( + canvas: Canvas, + levelId: number, + act: number, + dirtPathGrid: Uint8Array | undefined, + placedRects: readonly PlacedPresetRect[], + rng: Rng, + stats: WildernessStats, +): void { + const isAct1Wild = levelId >= 2 && levelId <= 7 + const isAct2Wild = levelId >= 41 && levelId <= 46 + if (!isAct1Wild && !isAct2Wild) return + + const margin = Math.min(10, Math.floor(Math.min(canvas.width, canvas.height) / 5)) + const candidates: { x: number; y: number }[] = [] + for (let y = margin; y < canvas.height - margin; y += 2) { + for (let x = margin; x < canvas.width - margin; x += 2) { + if (!isTileClear(canvas, x, y, placedRects, 1)) continue + // Prefer roadside (2..5 tiles from road) or near preset ruins + const nearRoad = isNearDirtRoad(dirtPathGrid, canvas.width, canvas.height, x, y, 2, 5) + if (nearRoad || dirtPathGrid === undefined) { + candidates.push({ x, y }) + } + } + } + if (candidates.length === 0) { + for (let y = margin; y < canvas.height - margin; y += 4) { + for (let x = margin; x < canvas.width - margin; x += 4) { + if (isTileClear(canvas, x, y, placedRects, 1)) candidates.push({ x, y }) + } + } + } + if (candidates.length === 0) return + + const placedCoords: { x: number; y: number }[] = canvas.objects.map(o => ({ + x: Math.floor(o.x / SUB_TILES_PER_TILE), + y: Math.floor(o.y / SUB_TILES_PER_TILE), + })) + + const pickSpot = (): { x: number; y: number } | null => { + for (let attempt = 0; attempt < 24; attempt += 1) { + const spot = candidates[rng.int(0, candidates.length - 1)]! + const tooClose = placedCoords.some(p => Math.hypot(p.x - spot.x, p.y - spot.y) < 4) + if (!tooClose) { + placedCoords.push(spot) + return spot + } + } + return null + } + + const shrinePool = act === 2 ? [54, 55, 152, 227] : [29, 82, 83, 84] + const wellId = act === 2 ? 234 : 31 + const chestPool = act === 2 ? [32, 33, 44] : [5, 89, 90, 93] + const scatterPool = act === 2 ? [48] : [17, 18, 25] + + let shrinesSpawned = 0 + let interactivesSpawned = 0 + + // 1..2 Shrines + const shrineCount = rng.int(1, 2) + for (let i = 0; i < shrineCount; i += 1) { + const spot = pickSpot() + if (!spot) break + const id = shrinePool[rng.int(0, shrinePool.length - 1)]! + canvas.objects.push({ + type: 2, + id, + x: spot.x * SUB_TILES_PER_TILE + 2, + y: spot.y * SUB_TILES_PER_TILE + 2, + flags: 0, + }) + shrinesSpawned += 1 + } + + // 1 Healing Well + const wellSpot = pickSpot() + if (wellSpot) { + canvas.objects.push({ + type: 2, + id: wellId, + x: wellSpot.x * SUB_TILES_PER_TILE + 2, + y: wellSpot.y * SUB_TILES_PER_TILE + 2, + flags: 0, + }) + shrinesSpawned += 1 + } + + // 1..3 Chests + const chestCount = rng.int(1, 3) + for (let i = 0; i < chestCount; i += 1) { + const spot = pickSpot() + if (!spot) break + const id = chestPool[rng.int(0, chestPool.length - 1)]! + canvas.objects.push({ + type: 2, + id, + x: spot.x * SUB_TILES_PER_TILE + 2, + y: spot.y * SUB_TILES_PER_TILE + 2, + flags: 0, + }) + interactivesSpawned += 1 + } + + // 2..4 Barrels / Campfires / Stash Rocks + const scatterCount = rng.int(2, 4) + for (let i = 0; i < scatterCount; i += 1) { + const spot = pickSpot() + if (!spot) break + const id = scatterPool[rng.int(0, scatterPool.length - 1)]! + canvas.objects.push({ + type: 2, + id, + x: spot.x * SUB_TILES_PER_TILE + 2, + y: spot.y * SUB_TILES_PER_TILE + 2, + flags: 0, + }) + interactivesSpawned += 1 + } + + stats.shrinesSpawned = shrinesSpawned + stats.interactivesSpawned = interactivesSpawned +} + +/** + * 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. + */ +function spawnLandmarkEntities( + canvas: Canvas, + levelId: number, + act1Plan: Act1OutdoorPlan | undefined, + placedRects: readonly PlacedPresetRect[], + gridWidth: number, + gridHeight: number, + stats: WildernessStats, +): 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)) + } 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)) + } else { + fx = cx + fy = cy + } + } + 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, + }) + } + + // 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', + }) + 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, + flags: 0, + }) + } + + stats.landmarkEntities = entities +} + export function generateWilderness(request: WildernessRequest): WildernessResult { const where = `level ${String(request.levelId)} (${request.levelName})` if (request.pieces.length === 0) throw new Error(`${where}: no LvlPrest pieces for this level type`) @@ -3149,6 +3597,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult ) // Step 4B: Stamp anchored special presets (Town Transition, Den of Evil, Cave Entrance, Cairn Stones, etc.) + const allPlacedRects: PlacedPresetRect[] = [] if (act1Plan !== undefined) { for (const preset of act1Plan.specialPresets) { const originX = Math.min(preset.bx * TILES_PER_BLOCK, Math.max(canvas.width - preset.ds1.width, 0)) @@ -3156,6 +3605,13 @@ export function generateWilderness(request: WildernessRequest): WildernessResult stampDs1(canvas, preset.ds1, originX, originY) stats.specialPresets.push(preset.name) stats.substitutions.push({ name: preset.name, role: 'object', enabled: true, clusters: 1 }) + allPlacedRects.push({ + name: preset.name, + x: originX, + y: originY, + w: preset.ds1.width, + h: preset.ds1.height, + }) } } @@ -3228,6 +3684,7 @@ export function generateWilderness(request: WildernessRequest): WildernessResult stats, reservedRoadBlocks, ) + allPlacedRects.push(...placedRects) // Step 4C: Run LvlSub substitutions with strict road and preset obstacle avoidance const themeIndex = Math.max(0, Math.min(4, Math.floor(request.subTheme))) @@ -3249,10 +3706,16 @@ export function generateWilderness(request: WildernessRequest): WildernessResult // For Act 1 outdoor wilderness maps (LevelId 2..7), lay dirt road networks and // interior stone wall divider lines across the meadow after random clutter substitutions. if (request.levelId >= 2 && request.levelId <= 7) { - stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) - stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) + stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, allPlacedRects, rng) + stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, allPlacedRects, rng) } + // Step 5: Outdoor Population Phase (Waypoints, Roadside Shrines/Chests, Landmark SuperUniques & Flavie) + const populationRng = new Rng(request.seed ^ 0x6c07) + spawnAct12Waypoint(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) + for (const piece of fillPieces) { if (!placedSpecialPieces.has(piece.name)) { stats.notes.push(`fill piece "${piece.name}" is not placed by this port`) @@ -3313,6 +3776,11 @@ export function generateWilderness(request: WildernessRequest): WildernessResult substitutions: stats.substitutions, substitutedClusters: substituted + placedSpecialPieces.size, 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, unresolved: stats.unresolved, unimplementedPasses: UNIMPLEMENTED_PASSES, diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 2bac04d..ba634ec 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -29,11 +29,11 @@ import type { Palette } from '../formats/pal.ts' import type { SpriteSheet } from '../formats/sprite.ts' import { ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH, - buildIsoMapScene, findIsoSpawn, isBlockedAt, levelSeed, + buildIsoMapScene, cellCentre, findIsoSpawn, isBlockedAt, levelSeed, } from '../game/d2map.ts' import type { CollisionGrid } from '../game/d2map.ts' import { createIsoTerrain } from '../game/iso-terrain.ts' -import type { MonsterPack, MonsterStats } from '../game/combat.ts' +import type { MonsterPack, MonsterStats, SafeZone } from '../game/combat.ts' import { SUB_TILES_PER_TILE, depthInsertIndex } from '../game/map.ts' import { GameEngine, syncEngineState } from "../game/engine.ts" import type { NpcEntity } from "../game/engine.ts" @@ -1280,6 +1280,26 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: let playerMoving = false let playerWalkFrame = 0 const npcDefs = runtime.npcs.map(n => buildNpcDef(n.token ?? '', n.id, n.name ?? '')) + const safeZones: SafeZone[] = [ + { x: runtime.spawn.x, y: runtime.spawn.y, radius: 180 }, + ] + for (const wp of runtime.waypoints) { + const pos = subTileToScene(wp.x, wp.y, runtime.grid) + safeZones.push({ x: pos.x, y: pos.y, radius: 160 }) + } + for (const ent of runtime.entrances) { + const pos = subTileToScene(ent.x, ent.y, runtime.grid) + safeZones.push({ x: pos.x, y: pos.y, radius: 160 }) + } + const resolvedPacks: MonsterPack[] = runtime.monsterPacks.map(pack => { + if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) { + return { + ...pack, + fixedPosition: cellCentre(runtime.grid, pack.landmarkTile.tileX, pack.landmarkTile.tileY), + } + } + return pack + }) const engine = new GameEngine( createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, { width: FEET_WIDTH, @@ -1310,8 +1330,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: inventoryCols: 10, inventoryRows: 4, monsterCount: runtime.monsterCount, - monsterPacks: runtime.monsterPacks, + monsterPacks: resolvedPacks, monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4, + safeZones, // This scene knows where its NPCs stand — the DS1 carries their // coordinates — so the engine must not build a ring we would discard. npcRingFallback: false, diff --git a/tests/wilderness-population.test.ts b/tests/wilderness-population.test.ts new file mode 100644 index 0000000..d0fa587 --- /dev/null +++ b/tests/wilderness-population.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, test } from "vitest" +import { + generateWilderness, + UNIMPLEMENTED_PASSES, + type WildernessPiece, +} from "../src/game/wilderness.ts" +import { + rollEliteModifiers, + applyEliteModifiers, + CANONICAL_ELITE_MODIFIERS, + planLevelMonsters, +} from "../src/game/monsters.ts" +import { spawnMonsterPacks, createWorld, type MonsterPack, type SafeZone } from "../src/game/combat.ts" +import { parseTable } from "../src/game/acts.ts" +import { Rng } from "../src/game/rng.ts" +import type { Ds1, Ds1Cell } from "../src/formats/ds1.ts" + +function makeMockDs1(width: number, height: number, hasGapInWalls = false): Ds1 { + const cells: Ds1Cell[][] = [] + for (let y = 0; y < height; y += 1) { + const row: Ds1Cell[] = [] + for (let x = 0; x < width; x += 1) { + const isGap = hasGapInWalls && x === Math.floor(width / 2) + row.push({ + walls: isGap ? [] : [{ prop1: 2, sequence: 0, style: 1, type: 0, unknown1: 0, unknown2: 0, hidden: false }], + floors: [{ prop1: 2, sequence: 0, style: 0, 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, + } +} + +function makeTestPieces(): WildernessPiece[] { + return [ + { name: "Act 1 - Wild Border 1", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] }, + { name: "Act 1 - Wild Border 2", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] }, + { name: "Act 1 - Wild Border 3", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] }, + { name: "Act 1 - Wild Border 4", border: true, levels: [makeMockDs1(8, 8, false), makeMockDs1(8, 8, true)] }, + { name: "Act 1 - Wild Border 5", border: true, levels: [makeMockDs1(8, 8, false)] }, + { name: "Act 1 - Wild Border 6", border: true, levels: [makeMockDs1(8, 8, false)] }, + { name: "Act 1 - Wild Border 7", border: true, levels: [makeMockDs1(8, 8, false)] }, + { name: "Act 1 - Wild Border 8", border: true, levels: [makeMockDs1(8, 8, false)] }, + { name: "Act 1 - Wild Border 9", border: true, levels: [makeMockDs1(8, 8, false)] }, + { name: "Act 1 - Wild Border 10", border: true, levels: [makeMockDs1(8, 8, false)] }, + { name: "Act 1 - Wild Border 11", border: true, levels: [makeMockDs1(8, 8, false)] }, + { name: "Act 1 - Wild Border 12", border: true, levels: [makeMockDs1(8, 8, false)] }, + { name: "Act 1 - Town Transition East", border: true, levels: [makeMockDs1(8, 8, true)] }, + { name: "Act 1 - Town Transition South", border: true, levels: [makeMockDs1(8, 8, true)] }, + { name: "Act 1 - Fallen Camp Bishibosh", border: false, levels: [makeMockDs1(8, 8, true)] }, + { name: "Act 1 - Cairn Stones", border: false, levels: [makeMockDs1(8, 8, true)] }, + { name: "Act 1 - Inifus", border: false, levels: [makeMockDs1(8, 8, true)] }, + ] +} + +describe("Wilderness Population Phase (Issue #36)", () => { + test("DRLGOUTDOORS_SpawnAct12Waypoint and DRLGOUTDOORS_SpawnAct12Shrines are removed from UNIMPLEMENTED_PASSES", () => { + expect(UNIMPLEMENTED_PASSES).not.toContain("DRLGOUTDOORS_SpawnAct12Waypoint") + expect(UNIMPLEMENTED_PASSES).not.toContain("DRLGOUTDOORS_SpawnAct12Shrines") + }) + + test("generateWilderness spawns outdoor waypoints on waypoint levels (Cold Plains, Stony Field, Dark Wood, Black Marsh)", () => { + const pieces = makeTestPieces() + for (const levelId of [3, 4, 5, 6]) { + const result = generateWilderness({ + levelId, + levelName: `Act 1 Outdoor ${levelId}`, + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x123456 + levelId, + pieces, + substitutions: [], + }) + + expect(result.stats.waypointsSpawned).toBe(1) + expect(result.stats.waypointTile).toBeDefined() + expect(result.stats.waypointTile).not.toBeNull() + + const wpObj = result.level.objects.find(o => o.type === 2 && o.id === 37) + expect(wpObj).toBeDefined() + } + }) + + test("generateWilderness does not spawn outdoor waypoint on Blood Moor (levelId 2)", () => { + const pieces = makeTestPieces() + const result = generateWilderness({ + levelId: 2, + levelName: "Blood Moor", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x222222, + pieces, + substitutions: [], + }) + + expect(result.stats.waypointsSpawned).toBe(0) + expect(result.stats.waypointTile).toBeNull() + }) + + test("generateWilderness scatters roadside shrines and interactive objects along dirt paths", () => { + const pieces = makeTestPieces() + const result = generateWilderness({ + levelId: 3, + levelName: "Cold Plains", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x998877, + pieces, + substitutions: [], + }) + + expect(Number(result.stats.shrinesSpawned)).toBeGreaterThan(0) + expect(Number(result.stats.interactivesSpawned)).toBeGreaterThan(0) + expect(result.level.objects.length).toBeGreaterThan(2) + }) + + test("generateWilderness binds Guard Flavie (rogue2) on Blood Moor (2) and Cold Plains (3)", () => { + const pieces = makeTestPieces() + const bloodMoor = generateWilderness({ + levelId: 2, + levelName: "Blood Moor", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x1111, + pieces, + substitutions: [], + }) + const coldPlains = generateWilderness({ + levelId: 3, + levelName: "Cold Plains", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x2222, + pieces, + substitutions: [], + }) + + const bmLandmarks = bloodMoor.stats.landmarkEntities as Array<{ id: string; monsterId: string; role: string }> + const cpLandmarks = coldPlains.stats.landmarkEntities as Array<{ id: string; monsterId: string; role: string }> + + expect(bmLandmarks.some(e => e.monsterId === "rogue2" && e.role === "npc_guard")).toBe(true) + expect(cpLandmarks.some(e => e.monsterId === "rogue2" && e.role === "npc_guard")).toBe(true) + }) + + test("generateWilderness binds canonical SuperUniques to their special presets (Bishibosh, Rakanishu, Treehead WoodFist)", () => { + const pieces = makeTestPieces() + const coldPlains = generateWilderness({ + levelId: 3, + levelName: "Cold Plains", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x3333, + pieces, + substitutions: [], + }) + const stonyField = generateWilderness({ + levelId: 4, + levelName: "Stony Field", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x4444, + pieces, + substitutions: [], + }) + const darkWood = generateWilderness({ + levelId: 5, + levelName: "Dark Wood", + levelTypeName: "Act 1 - Wilderness", + sizeX: 80, + sizeY: 80, + subType: 6, + subTheme: 0, + seed: 0x5555, + pieces, + substitutions: [], + }) + + const cpLandmarks = coldPlains.stats.landmarkEntities as Array<{ id: string; role: string }> + const sfLandmarks = stonyField.stats.landmarkEntities as Array<{ id: string; role: string }> + const dwLandmarks = darkWood.stats.landmarkEntities as Array<{ id: string; role: string }> + + expect(cpLandmarks.some(e => e.id === "Bishibosh" && e.role === "superunique")).toBe(true) + expect(sfLandmarks.some(e => e.id === "Rakanishu" && e.role === "superunique")).toBe(true) + expect(dwLandmarks.some(e => e.id === "Treehead WoodFist" && e.role === "superunique")).toBe(true) + }) + + test("rollEliteModifiers and applyEliteModifiers roll and apply MonUMod bonuses", () => { + const rng = new Rng(0xabcdef) + const mods = rollEliteModifiers("unique", rng) + expect(mods.length).toBeGreaterThanOrEqual(1) + const allowedNames = CANONICAL_ELITE_MODIFIERS.map(m => m.name) + for (const m of mods) { + expect(allowedNames).toContain(m) + } + + const baseStats = { + id: "fallen1", + name: "Fallen", + hp: 20, + damage: 4, + speed: 2, + reach: 32, + aggroRadius: 220, + cooldownTicks: 25, + xp: 10, + } + + const strongFast = applyEliteModifiers(baseStats, "unique", ["strong", "fast"], "Rakanishu") + expect(strongFast.rank).toBe("unique") + expect(strongFast.superUniqueId).toBe("Rakanishu") + expect(strongFast.modifiers).toEqual(["strong", "fast"]) + expect(strongFast.damage).toBe(6) // 4 * 1.5 + expect(strongFast.speed).toBeCloseTo(2.8) // 2 * 1.4 + }) + + test("spawnMonsterPacks enforces safeZones exclusion radius and anchors fixedPosition packs", () => { + const world = createWorld(500, 500) + const safeZones: SafeZone[] = [ + { x: 500, y: 500, radius: 200 }, + ] + const packs: MonsterPack[] = [ + { + members: [{ id: "fallen1", name: "Fallen", hp: 10, damage: 2, speed: 2, reach: 30, aggroRadius: 200, cooldownTicks: 20, xp: 5 }], + }, + { + members: [{ id: "fallen1", name: "Fallen", hp: 10, damage: 2, speed: 2, reach: 30, aggroRadius: 200, cooldownTicks: 20, xp: 5 }], + }, + { + superUniqueId: "Rakanishu", + fixedPosition: { x: 1200, y: 1200 }, + members: [{ id: "fallen1", name: "Rakanishu", hp: 80, damage: 10, speed: 3, reach: 30, aggroRadius: 250, cooldownTicks: 20, xp: 50, rank: "unique", superUniqueId: "Rakanishu" }], + }, + ] + + const placed = spawnMonsterPacks(world, packs, { x: 500, y: 500 }, 800, { overlap: () => 0 }, safeZones) + expect(placed).toBe(3) + expect(world.monsters.length).toBe(3) + + for (const monster of world.monsters) { + if (monster.stats.superUniqueId === "Rakanishu") { + // Anchored to fixedPosition (1200, 1200) + expect(Math.hypot(monster.x - 1200, monster.y - 1200)).toBeLessThan(80) + } else { + // Must respect safeZone around (500, 500) with radius 200 + const dist = Math.hypot(monster.x - 500, monster.y - 500) + expect(dist).toBeGreaterThanOrEqual(200) + } + } + }) + + test("planLevelMonsters plans fixed SuperUniques for Cold Plains (3), Stony Field (4), Dark Wood (5), and Burial Grounds (17)", () => { + const enc = new TextEncoder() + const levelsTxt = parseTable( + enc.encode( + [ + "Id\tName\tMonDen\tMonUMin\tMonUMax\tMonLvl1Ex\tm1\tm2\tm3", + "3\tCold Plains\t600\t2\t4\t2\tfallen1\tfallen2\tzombie1", + "4\tStony Field\t600\t2\t4\t4\tfallen1\tfallen2\tskeleton1", + "5\tDark Wood\t600\t2\t4\t5\tbrute1\tfallen1\tskeleton1", + "17\tBurial Grounds\t500\t1\t2\t3\tzombie1\tskeleton1\tcorruptrogue1", + ].join("\n"), + ), + ) + const monstatsTxt = parseTable( + enc.encode( + [ + "Id\tBaseId\tNameStr\tLevel\tMinGrp\tMaxGrp\tVelocity\trun\tAC\tExp\tminHP\tmaxHP\tA1MinD\tA1MaxD\tA1TH\tenabled\tissend\tisSpawn\tisMelee", + "fallen1\tfallen1\tFallen\t1\t3\t6\t8\t12\t5\t15\t8\t14\t1\t3\t20\t1\t1\t1\t1", + "fallen2\tfallen1\tCarver\t2\t3\t6\t8\t12\t8\t25\t12\t20\t2\t4\t28\t1\t1\t1\t1", + "fallenshaman1\tfallenshaman1\tFallen Shaman\t2\t1\t2\t7\t10\t10\t35\t16\t26\t3\t6\t30\t1\t1\t1\t1", + "zombie1\tzombie1\tZombie\t1\t2\t4\t5\t8\t6\t18\t14\t22\t2\t5\t22\t1\t1\t1\t1", + "skeleton1\tskeleton1\tSkeleton\t2\t3\t5\t7\t10\t8\t22\t12\t18\t2\t5\t26\t1\t1\t1\t1", + "brute1\tbrute1\tGargantuan Beast\t5\t2\t4\t7\t10\t18\t65\t30\t45\t5\t10\t45\t1\t1\t1\t1", + "corruptrogue1\tcorruptrogue1\tCorrupt Rogue\t3\t3\t5\t8\t12\t12\t30\t15\t24\t3\t6\t35\t1\t1\t1\t1", + ].join("\n"), + ), + ) + const monlvlTxt = parseTable( + enc.encode( + [ + "Level\tAC\tTH\tHP\tDM\tXP", + "1\t100\t100\t100\t100\t100", + "2\t100\t100\t100\t100\t100", + "3\t100\t100\t100\t100\t100", + "4\t100\t100\t100\t100\t100", + "5\t100\t100\t100\t100\t100", + "6\t100\t100\t100\t100\t100", + "7\t100\t100\t100\t100\t100", + "8\t100\t100\t100\t100\t100", + ].join("\n"), + ), + ) + + const tables = { levels: levelsTxt, monstats: monstatsTxt, monlvl: monlvlTxt } + + for (const [lvlId, suName] of [ + [3, "Bishibosh"], + [4, "Rakanishu"], + [5, "Treehead WoodFist"], + [17, "Blood Raven"], + ] as const) { + const planned = planLevelMonsters(tables, lvlId, 6400, 0x12345 + lvlId, 3.5, "normal", { + landmarks: [{ id: suName, tileX: 40, tileY: 40, fixedPosition: { x: 1000, y: 1000 } }], + }) + + expect(planned.superUniques).toContain(suName) + const suPack = planned.packs.find(p => p.superUniqueId === suName) + expect(suPack).toBeDefined() + expect(suPack?.fixedPosition).toEqual({ x: 1000, y: 1000 }) + expect(suPack?.landmarkTile).toEqual({ tileX: 40, tileY: 40 }) + expect(suPack?.members[0]?.rank).toBe("unique") + expect(suPack?.members[0]?.superUniqueId).toBe(suName) + expect(suPack?.members[0]?.modifiers?.length).toBeGreaterThan(0) + } + }) +})