feat(map-gen/population): implement wilderness population phase, waypoints, shrines, superuniques & safe zones (fixes #36)

This commit is contained in:
troytt 2026-09-17 03:44:12 +00:00
parent 93a854d9ad
commit e9f2ceec9b
7 changed files with 1125 additions and 24 deletions

View File

@ -1154,6 +1154,7 @@ async function bakeDs1Variant(
generatorLinks: { generatorLinks: {
readonly entrances: readonly WildernessEntrance[] readonly entrances: readonly WildernessEntrance[]
readonly mazeWarps: readonly MazeWarp[] readonly mazeWarps: readonly MazeWarp[]
readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[]
} = { entrances: [], mazeWarps: [] }, } = { entrances: [], mazeWarps: [] },
): Promise<void> { ): Promise<void> {
const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed) const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed)
@ -1370,6 +1371,8 @@ async function bakeDs1Variant(
scene.cellsX * scene.cellsY, scene.cellsX * scene.cellsY,
seed, seed,
PLAYER_WALK_SPEED_PX, PLAYER_WALK_SPEED_PX,
'normal',
generatorLinks.landmarks ? { landmarks: generatorLinks.landmarks } : undefined,
) )
const sceneLinks = buildSceneLinks( const sceneLinks = buildSceneLinks(
@ -1611,7 +1614,13 @@ for (const entry of LEVELS) {
await bakeDs1Variant( await bakeDs1Variant(
entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries,
result.level, `generated:${label}`, label, seed, 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) { } catch (err) {
console.error(`failed to bake wilderness ${label}: ${(err as Error).message}`) console.error(`failed to bake wilderness ${label}: ${(err as Error).message}`)

View File

@ -90,6 +90,12 @@ export interface MonsterStats {
readonly speed: number readonly speed: number
/** Experience awarded on death. */ /** Experience awarded on death. */
readonly xp: number 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. */ /** One monster's mutable state. */
@ -404,6 +410,13 @@ export function spawnMonsters(
return placed 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. */ /** A group of monsters that belongs together on the ground. */
export interface MonsterPack { export interface MonsterPack {
/** /**
@ -414,6 +427,12 @@ export interface MonsterPack {
* a stronger leader followed by its minions. * a stronger leader followed by its minions.
*/ */
readonly members: readonly MonsterStats[] 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 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. * 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 around - the centre to spread camps around.
* @param spread - how far camps may be from that centre, in pixels. * @param spread - how far camps may be from that centre, in pixels.
* @param terrain - collision predicate. * @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. * @returns how many monsters were actually placed.
*/ */
export function spawnMonsterPacks( export function spawnMonsterPacks(
@ -452,25 +480,57 @@ export function spawnMonsterPacks(
around: { readonly x: number; readonly y: number }, around: { readonly x: number; readonly y: number },
spread: number, spread: number,
terrain: CombatTerrain, terrain: CombatTerrain,
safeZones?: readonly SafeZone[],
): number { ): number {
if (packs.length === 0) return 0 if (packs.length === 0) return 0
let placed = 0 let placed = 0
let campIndex = 0 let campIndex = 0
let attempt = 0 let attempt = 0
const attemptLimit = packs.length * 24 const attemptLimit = packs.length * 32
while (campIndex < packs.length && attempt < attemptLimit) { 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]! 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 campIndex += 1
let memberAttempt = 0 let memberAttempt = 0
let memberPlaced = 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 // 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 // ring by the camp's own angle stops every camp in the level from having
// an identically oriented formation. // an identically oriented formation.
@ -481,6 +541,7 @@ export function spawnMonsterPacks(
memberAttempt += 1 memberAttempt += 1
const x = campX + Math.cos(memberAngle) * memberRadius const x = campX + Math.cos(memberAngle) * memberRadius
const y = campY + Math.sin(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 if (terrain.overlap(x, y) > 0) continue
const definition = pack.members[memberPlaced]! const definition = pack.members[memberPlaced]!
world.monsters.push({ world.monsters.push({

View File

@ -1,5 +1,5 @@
import { tickCombat, createWorld, spawnMonsters, spawnMonsterPacks, damageMonster, rebindPlayer } from './combat.ts' 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 { Inventory, rollDrop, goldItem, totalStat } from './items.ts'
import type { Item, ItemBase, Affix } from './items.ts' import type { Item, ItemBase, Affix } from './items.ts'
import { castSkill, tickProjectiles } from './skills.ts' import { castSkill, tickProjectiles } from './skills.ts'
@ -92,6 +92,11 @@ export interface GameEngineOptions {
* to hit keep passing a count. * to hit keep passing a count.
*/ */
monsterPacks?: readonly MonsterPack[] 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. * Whether to place NPCs on a synthetic ring around the spawn.
* *
@ -163,7 +168,7 @@ export class GameEngine {
const spread = opts.monsterSpread ?? 260 const spread = opts.monsterSpread ?? 260
const count = opts.monsterCount ?? 8 const count = opts.monsterCount ?? 8
if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) { 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) { } else if (count > 0) {
spawnMonsters(this.world, opts.stats, count, opts.spawn, spread, this.terrain) spawnMonsters(this.world, opts.stats, count, opts.spawn, spread, this.terrain)
} }

View File

@ -684,14 +684,87 @@ export function readEliteModifiers(table: D2Table): EliteModifier[] {
/** What a monster is: ordinary, or promoted by the level generator. */ /** What a monster is: ordinary, or promoted by the level generator. */
export type MonsterRank = 'normal' | 'champion' | 'unique' | 'minion' 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<MonsterRank, 'minion'>,
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. */ /** One pack the level populator wants placed. */
export interface MonsterGroup { export interface MonsterGroup {
/** Which monster. */ /** Which monster. */
readonly kind: MonsterKind readonly kind: MonsterKind
/** How many, from `MinGrp`…`MaxGrp`. */ /** How many, from MinGrp to MaxGrp. */
readonly count: number readonly count: number
/** Whether this pack is an elite one. */ /** Whether this pack is an elite one. */
readonly rank: Exclude<MonsterRank, 'minion'> readonly rank: Exclude<MonsterRank, 'minion'>
/** 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))) 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 // `MonUMod.txt`'s `champion chance` constant is 20, i.e. a fifth of elite
// packs are champions rather than a unique with minions. // packs are champions rather than a unique with minions.
groups.push({ kind, count, rank: rng.next() < 0.2 ? 'champion' : 'unique' }) const rank: Exclude<MonsterRank, 'minion'> = 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 remaining -= count
} }
@ -910,6 +986,62 @@ export const ELITE_HEALTH_MULTIPLIER: Readonly<Record<Exclude<MonsterRank, 'mini
unique: 4, unique: 4,
} }
/** Specification of a fixed Act 1 outdoor SuperUnique boss tied to a landmark. */
export interface SuperUniqueLandmarkSpec {
readonly id: string
readonly name: string
readonly monsterId: string
readonly landmark: string
readonly modifiers: readonly string[]
readonly minMinions: number
readonly maxMinions: number
readonly minionMonsterId: string
}
/** Canonical Act 1 outdoor SuperUniques tied to level landmarks. */
export const CANONICAL_SUPER_UNIQUES_BY_LEVEL: Readonly<Record<number, SuperUniqueLandmarkSpec>> = {
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. */ /** What one level's population came out as. */
export interface PlannedLevel { export interface PlannedLevel {
/** `Levels.txt` id. */ /** `Levels.txt` id. */
@ -924,6 +1056,8 @@ export interface PlannedLevel {
readonly packs: readonly MonsterPack[] readonly packs: readonly MonsterPack[]
/** How many packs are champion or unique. */ /** How many packs are champion or unique. */
readonly elitePacks: number 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 seed - the level seed.
* @param walkSpeedPx - the player's walking speed, the anchor for monster speed. * @param walkSpeedPx - the player's walking speed, the anchor for monster speed.
* @param difficulty - which difficulty's columns to read. * @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. * @returns the population, empty for a town or an unknown level.
*/ */
export function planLevelMonsters( export function planLevelMonsters(
@ -953,6 +1088,14 @@ export function planLevelMonsters(
seed: number, seed: number,
walkSpeedPx: number, walkSpeedPx: number,
difficulty: Difficulty = 'normal', 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 { ): PlannedLevel {
const empty: PlannedLevel = { levelId, levelName: '', types: [], budget: 0, packs: [], elitePacks: 0 } const empty: PlannedLevel = { levelId, levelName: '', types: [], budget: 0, packs: [], elitePacks: 0 }
const plan = readLevelMonsterPlan(tables.levels, levelId, difficulty) 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 // 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. // 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) 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 } 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 { return {
levelId, levelId,
levelName: plan.levelName, levelName: plan.levelName,
types: types.map(kind => kind.id), types: types.map(kind => kind.id),
budget, budget,
packs, packs,
elitePacks: groups.filter(group => group.rank !== 'normal').length, elitePacks: groups.filter(group => group.rank !== 'normal').length + superUniques.length,
...(superUniques.length > 0 ? { superUniques } : {}),
} }
} }

View File

@ -593,6 +593,20 @@ interface SubstitutionReport {
readonly clusters: number 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. */ /** The report collector. */
interface WildernessStats { interface WildernessStats {
readonly substitutions: SubstitutionReport[] readonly substitutions: SubstitutionReport[]
@ -609,6 +623,11 @@ interface WildernessStats {
specialPresets: string[] specialPresets: string[]
/** Every way into or out of the level, in cells. See `WildernessEntrance`. */ /** Every way into or out of the level, in cells. See `WildernessEntrance`. */
entrances: 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<Uint8Array> = new Uint8Array([
* reproduce. Reported verbatim in every result's `stats.unimplementedPasses` so * reproduce. Reported verbatim in every result's `stats.unimplementedPasses` so
* the gap is visible rather than implied. * the gap is visible rather than implied.
*/ */
const UNIMPLEMENTED_PASSES: readonly string[] = [ export const UNIMPLEMENTED_PASSES: readonly string[] = [
'DRLGVER_CreateVertices', 'DRLGVER_CreateVertices',
'DRLGOUTPLACE_CreateLevelConnections', 'DRLGOUTPLACE_CreateLevelConnections',
'DRLGOUTDESR_InitAct2OutdoorLevel', 'DRLGOUTDESR_InitAct2OutdoorLevel',
@ -660,8 +679,6 @@ const UNIMPLEMENTED_PASSES: readonly string[] = [
'DRLGOUTDOORS_InitAct4OutdoorLevel', 'DRLGOUTDOORS_InitAct4OutdoorLevel',
'DRLGOUTSIEGE_InitAct5OutdoorLevel', 'DRLGOUTSIEGE_InitAct5OutdoorLevel',
'DRLG_GenerateJungles', 'DRLG_GenerateJungles',
'DRLGOUTDOORS_SpawnAct12Waypoint',
'DRLGOUTDOORS_SpawnAct12Shrines',
'DRLGOUTDOORS_SpawnAct3Mephisto', '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 { export function generateWilderness(request: WildernessRequest): WildernessResult {
const where = `level ${String(request.levelId)} (${request.levelName})` const where = `level ${String(request.levelId)} (${request.levelName})`
if (request.pieces.length === 0) throw new Error(`${where}: no LvlPrest pieces for this level type`) 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.) // Step 4B: Stamp anchored special presets (Town Transition, Den of Evil, Cave Entrance, Cairn Stones, etc.)
const allPlacedRects: PlacedPresetRect[] = []
if (act1Plan !== undefined) { if (act1Plan !== undefined) {
for (const preset of act1Plan.specialPresets) { for (const preset of act1Plan.specialPresets) {
const originX = Math.min(preset.bx * TILES_PER_BLOCK, Math.max(canvas.width - preset.ds1.width, 0)) 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) stampDs1(canvas, preset.ds1, originX, originY)
stats.specialPresets.push(preset.name) stats.specialPresets.push(preset.name)
stats.substitutions.push({ name: preset.name, role: 'object', enabled: true, clusters: 1 }) 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, stats,
reservedRoadBlocks, reservedRoadBlocks,
) )
allPlacedRects.push(...placedRects)
// Step 4C: Run LvlSub substitutions with strict road and preset obstacle avoidance // Step 4C: Run LvlSub substitutions with strict road and preset obstacle avoidance
const themeIndex = Math.max(0, Math.min(4, Math.floor(request.subTheme))) 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 // 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. // interior stone wall divider lines across the meadow after random clutter substitutions.
if (request.levelId >= 2 && request.levelId <= 7) { if (request.levelId >= 2 && request.levelId <= 7) {
stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, placedRects, rng) stampAct1DirtRoads(canvas, gridWidth, gridHeight, inX, outY, allPlacedRects, rng)
stampAct1InteriorStoneWalls(canvas, gridWidth, gridHeight, inX, outY, placedRects, 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) { for (const piece of fillPieces) {
if (!placedSpecialPieces.has(piece.name)) { if (!placedSpecialPieces.has(piece.name)) {
stats.notes.push(`fill piece "${piece.name}" is not placed by this port`) 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, substitutions: stats.substitutions,
substitutedClusters: substituted + placedSpecialPieces.size, substitutedClusters: substituted + placedSpecialPieces.size,
objects: canvas.objects.length, objects: canvas.objects.length,
waypointsSpawned: stats.waypointsSpawned ?? 0,
waypointTile: stats.waypointTile ?? null,
shrinesSpawned: stats.shrinesSpawned ?? 0,
interactivesSpawned: stats.interactivesSpawned ?? 0,
landmarkEntities: stats.landmarkEntities ?? [],
cells: canvas.width * canvas.height, cells: canvas.width * canvas.height,
unresolved: stats.unresolved, unresolved: stats.unresolved,
unimplementedPasses: UNIMPLEMENTED_PASSES, unimplementedPasses: UNIMPLEMENTED_PASSES,

View File

@ -29,11 +29,11 @@ import type { Palette } from '../formats/pal.ts'
import type { SpriteSheet } from '../formats/sprite.ts' import type { SpriteSheet } from '../formats/sprite.ts'
import { import {
ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH, 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' } from '../game/d2map.ts'
import type { CollisionGrid } from '../game/d2map.ts' import type { CollisionGrid } from '../game/d2map.ts'
import { createIsoTerrain } from '../game/iso-terrain.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 { SUB_TILES_PER_TILE, depthInsertIndex } from '../game/map.ts'
import { GameEngine, syncEngineState } from "../game/engine.ts" import { GameEngine, syncEngineState } from "../game/engine.ts"
import type { NpcEntity } 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 playerMoving = false
let playerWalkFrame = 0 let playerWalkFrame = 0
const npcDefs = runtime.npcs.map(n => buildNpcDef(n.token ?? '', n.id, n.name ?? '')) 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( const engine = new GameEngine(
createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, { createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, {
width: FEET_WIDTH, width: FEET_WIDTH,
@ -1310,8 +1330,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
inventoryCols: 10, inventoryCols: 10,
inventoryRows: 4, inventoryRows: 4,
monsterCount: runtime.monsterCount, monsterCount: runtime.monsterCount,
monsterPacks: runtime.monsterPacks, monsterPacks: resolvedPacks,
monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4, monsterSpread: Math.max(runtime.widthPx, runtime.heightPx) / 4,
safeZones,
// This scene knows where its NPCs stand — the DS1 carries their // This scene knows where its NPCs stand — the DS1 carries their
// coordinates — so the engine must not build a ring we would discard. // coordinates — so the engine must not build a ring we would discard.
npcRingFallback: false, npcRingFallback: false,

View File

@ -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)
}
})
})