diff --git a/src/game/combat.ts b/src/game/combat.ts index eada4ac..7ee4a60 100644 --- a/src/game/combat.ts +++ b/src/game/combat.ts @@ -22,6 +22,22 @@ */ import { numberCell, textCell } from './tables.ts' import type { DataTable } from './tables.ts' +import { + allocatePacksToRooms, + partitionMapIntoRooms, + spawnMonsterPacksByRooms, + spawnPacksInRoom, +} from './monster-rooms.ts' +import type { MapRoom } from './monster-rooms.ts' + +export { + allocatePacksToRooms, + partitionMapIntoRooms, + spawnMonsterPacksByRooms, + spawnPacksInRoom, + type MapRoom, +} + /** Movement request and intent for one tick. */ export interface CombatInput { @@ -120,6 +136,8 @@ export interface Monster { hitFlash: number /** Ticks left of the death animation. */ corpseTicks: number + /** Whether the monster is sleeping (inactive off-screen room). */ + sleeping?: boolean | undefined } /** The player's combat-relevant state. */ @@ -481,7 +499,11 @@ export function spawnMonsterPacks( spread: number, terrain: CombatTerrain, safeZones?: readonly SafeZone[], + rooms?: readonly MapRoom[], ): number { + if (rooms !== undefined && rooms.length > 0) { + return spawnMonsterPacksByRooms(world, packs, rooms, terrain, safeZones !== undefined ? { safeZones } : undefined) + } if (packs.length === 0) return 0 let placed = 0 let campIndex = 0 @@ -787,6 +809,7 @@ function tickPlayer( */ function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: CombatTerrain, targets: readonly CombatPlayer[]): void { for (const monster of world.monsters) { + if (monster.sleeping) continue if (monster.state === 'dead') { if (monster.corpseTicks > 0) monster.corpseTicks -= 1 continue diff --git a/src/game/engine.ts b/src/game/engine.ts index 9a7e522..1eccaa9 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -8,6 +8,8 @@ import { QuestLog, npcDialog } from './quests.ts' import type { NpcDef, QuestDef } from './quests.ts' import { Rng } from './rng.ts' import { captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot } from './save.ts' +import { MonsterStreamingManager } from './monster-streaming.ts' +import type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts' export interface EngineInput { readonly movement: { readonly x: number; readonly y: number } @@ -115,6 +117,24 @@ export interface GameEngineOptions { * itself. */ snapshotStore?: SnapshotStore + /** + * Optional pre-constructed MonsterStreamingManager for lazy room population + * and active/sleeping monster streaming. + */ + streamingManager?: MonsterStreamingManager | undefined + /** + * Pre-planned rooms for monster streaming. When configured without an explicit + * streamingManager, a MonsterStreamingManager is initialized automatically. + */ + roomPacks?: readonly StreamingRoomDef[] | undefined + /** + * Alias for roomPacks. + */ + streamingRooms?: readonly StreamingRoomDef[] | undefined + /** + * Tuning options for MonsterStreamingManager when created from roomPacks. + */ + streamingOptions?: MonsterStreamingOptions | undefined } export interface GameEngineMetrics { @@ -145,6 +165,7 @@ export class GameEngine { selectedSkill = 0 npcEntities: NpcEntity[] = [] dialog: string[] = [] + streamingManager?: MonsterStreamingManager /** * Whether the save input was held on the previous tick. * @@ -164,10 +185,27 @@ export class GameEngine { } constructor(public terrain: WorldMapProvider, public opts: GameEngineOptions) { - this.world = createWorld(opts.spawn.x, opts.spawn.y) + const rooms = opts.roomPacks ?? opts.streamingRooms + if (opts.streamingManager !== undefined) { + this.streamingManager = opts.streamingManager + this.world = opts.streamingManager.world + } else { + this.world = createWorld(opts.spawn.x, opts.spawn.y) + if (rooms !== undefined && rooms.length > 0) { + this.streamingManager = new MonsterStreamingManager( + this.world, + this.terrain, + rooms, + opts.streamingOptions, + ) + } + } + const spread = opts.monsterSpread ?? 260 const count = opts.monsterCount ?? 8 - if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) { + if (this.streamingManager !== undefined) { + // Monsters are lazily populated by streamingManager; avoid frame-0 eager population. + } else if (opts.monsterPacks !== undefined && opts.monsterPacks.length > 0) { 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) @@ -198,6 +236,8 @@ export class GameEngine { const beforeX = player.x const beforeY = player.y + this.streamingManager?.update(player.x, player.y) + if (this.world.tick % 25 === 0) player.mana = Math.min(player.maxMana, player.mana + 1) const wanted = this.opts.skills[this.selectedSkill] @@ -379,6 +419,9 @@ export class GameEngine { this.questLog = pieces.quests this.ground = pieces.ground this.projectiles = [] + if (this.streamingManager !== undefined) { + this.streamingManager.world = this.world + } } } @@ -484,3 +527,6 @@ export function syncEngineState(engine: GameEngine, state: EngineViewState): voi state.y = engine.world.player.y state.bagItems = engine.bag.contents.length } + +export { MonsterStreamingManager } from './monster-streaming.ts' +export type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts' diff --git a/src/game/monster-rooms.ts b/src/game/monster-rooms.ts new file mode 100644 index 0000000..1a31a21 --- /dev/null +++ b/src/game/monster-rooms.ts @@ -0,0 +1,544 @@ +/** + * Room-Local Monster Clustering and Placement. + * + * Replaces global spiral monster placement with authentic Diablo II room-based + * spatial clustering and allocation. In Diablo II, monsters are partitioned into + * rooms (sectors of ~20-30 cells outdoors, or discrete maze preset pieces indoors), + * avoiding player safe zones (towns, waypoint clearings, level entrances), with + * landmark SuperUniques fixed to their specific room and minions clustered tightly + * in camp rings around their leader. + */ +import type { CombatTerrain, CombatWorld, MonsterPack, SafeZone } from './combat.ts' +import { Rng } from './rng.ts' + +/** + * A discrete spatial room or sector in a level. + */ +export interface MapRoom { + readonly id: string | number + readonly x: number // world/scene pixels + readonly y: number + readonly width: number + readonly height: number + readonly cellX: number // grid cell coordinates + readonly cellY: number + readonly cellsX: number + readonly cellsY: number + readonly isSafeZone?: boolean | undefined + readonly isPreset?: boolean | undefined +} + +/** + * Options for partitioning a level map into rooms. + */ +export interface PartitionOptions { + /** Room cell size for outdoor grid sector partitioning (default ~24 cells). */ + readonly roomCellSize?: number | undefined + /** Discrete maze rooms from the dungeon generator (e.g. from maze.ts). */ + readonly mazeRooms?: readonly { + readonly x: number + readonly y: number + readonly width: number + readonly height: number + readonly id?: string | number | undefined + readonly isPreset?: boolean | undefined + readonly isSafeZone?: boolean | undefined + }[] | undefined + /** Circular exclusion zones where ordinary monster camps must not spawn. */ + readonly safeZones?: readonly { + readonly x: number + readonly y: number + readonly radius: number + }[] | undefined + /** Cell pixel width (default 80 px). */ + readonly cellPixelWidth?: number | undefined + /** Cell pixel height (default 80 px). */ + readonly cellPixelHeight?: number | undefined + /** Uniform cell pixel size shorthand (default 80 px). */ + readonly cellPixelSize?: number | undefined + /** Origin X offset in pixels (default 0). */ + readonly originX?: number | undefined + /** Origin Y offset in pixels (default 0). */ + readonly originY?: number | undefined +} + +/** Default camp radius for room-local monster clustering (48~64 pixels). */ +export const PACK_RADIUS_PX = 64 + +/** + * Check if a room boundary overlaps any safe zone circle. + * Tests in both pixel space and cell space for maximum compatibility. + */ +function checkSafeZoneOverlap( + x: number, + y: number, + width: number, + height: number, + cellX: number, + cellY: number, + cellsX: number, + cellsY: number, + safeZones?: readonly { readonly x: number; readonly y: number; readonly radius: number }[], +): boolean { + if (safeZones === undefined || safeZones.length === 0) return false + for (const sz of safeZones) { + // Check in world pixel space + const clampPxX = Math.max(x, Math.min(sz.x, x + width)) + const clampPxY = Math.max(y, Math.min(sz.y, y + height)) + const pxDistSq = (sz.x - clampPxX) ** 2 + (sz.y - clampPxY) ** 2 + if (pxDistSq <= sz.radius * sz.radius) return true + + // Check in cell space (in case safeZone coordinates were given in cells) + const clampCellX = Math.max(cellX, Math.min(sz.x, cellX + cellsX)) + const clampCellY = Math.max(cellY, Math.min(sz.y, cellY + cellsY)) + const cellDistSq = (sz.x - clampCellX) ** 2 + (sz.y - clampCellY) ** 2 + if (cellDistSq <= sz.radius * sz.radius) return true + } + return false +} + +/** + * Partition a map into rooms. + * + * If `mazeRooms` are supplied (from dungeon generator), map them directly into `MapRoom`s. + * Otherwise (wilderness / outdoors), partition the level into authentic ~20-30 cell rooms + * (e.g. 24x24 cells = 120x120 subtiles), matching D2's room grid. + * Automatically flag rooms overlapping `safeZones` as `isSafeZone = true`. + * + * @param cellsX - total level width in cells. + * @param cellsY - total level height in cells. + * @param options - partitioning options. + * @returns partitioned MapRooms. + */ +export function partitionMapIntoRooms( + cellsX: number, + cellsY: number, + options?: PartitionOptions, +): MapRoom[] { + const cellWidth = options?.cellPixelWidth ?? options?.cellPixelSize ?? 80 + const cellHeight = options?.cellPixelHeight ?? options?.cellPixelSize ?? 80 + const originX = options?.originX ?? 0 + const originY = options?.originY ?? 0 + + if (options?.mazeRooms !== undefined && options.mazeRooms.length > 0) { + return options.mazeRooms.map((mr, index) => { + const cellX = mr.x + const cellY = mr.y + const rCellsX = mr.width + const rCellsY = mr.height + const x = cellX * cellWidth + originX + const y = cellY * cellHeight + originY + const width = rCellsX * cellWidth + const height = rCellsY * cellHeight + const id = mr.id ?? index + const isPreset = mr.isPreset ?? true + const isSafeZone = mr.isSafeZone ?? checkSafeZoneOverlap( + x, y, width, height, cellX, cellY, rCellsX, rCellsY, options.safeZones, + ) + + return { + id, + x, + y, + width, + height, + cellX, + cellY, + cellsX: rCellsX, + cellsY: rCellsY, + isPreset, + ...(isSafeZone ? { isSafeZone: true } : {}), + } + }) + } + + // Wilderness / outdoors: grid partition into ~24x24 cell rooms + const roomCellSize = options?.roomCellSize ?? 24 + const rooms: MapRoom[] = [] + let index = 0 + + for (let cy = 0; cy < cellsY; cy += roomCellSize) { + const rCellsY = Math.min(roomCellSize, cellsY - cy) + for (let cx = 0; cx < cellsX; cx += roomCellSize) { + const rCellsX = Math.min(roomCellSize, cellsX - cx) + const x = cx * cellWidth + originX + const y = cy * cellHeight + originY + const width = rCellsX * cellWidth + const height = rCellsY * cellHeight + const isSafeZone = checkSafeZoneOverlap( + x, y, width, height, cx, cy, rCellsX, rCellsY, options?.safeZones, + ) + + rooms.push({ + id: index++, + x, + y, + width, + height, + cellX: cx, + cellY: cy, + cellsX: rCellsX, + cellsY: rCellsY, + isPreset: false, + ...(isSafeZone ? { isSafeZone: true } : {}), + }) + } + } + + return rooms +} + +/** + * Allocate monster packs to rooms according to Diablo II rules: + * - Distributes non-landmark packs across non-safe rooms (avoiding town, stair buffers). + * - Fixed landmark packs (SuperUniques with `landmarkTile` or `fixedPosition`) route directly + * to the room covering their coordinates. + * - Avoids over-crowding by balancing pack counts across candidate rooms based on room area. + * + * @param packs - list of monster packs to distribute. + * @param rooms - candidate rooms. + * @param seed - optional random seed for deterministic distribution. + * @returns Map from each MapRoom to its allocated MonsterPack array. + */ +export function allocatePacksToRooms( + packs: readonly MonsterPack[], + rooms: readonly MapRoom[], + seed?: number, +): Map { + const allocation = new Map() + for (const room of rooms) { + allocation.set(room, []) + } + if (rooms.length === 0 || packs.length === 0) { + return allocation + } + + const landmarkPacks: MonsterPack[] = [] + const normalPacks: MonsterPack[] = [] + + for (const pack of packs) { + if (pack.fixedPosition !== undefined || pack.landmarkTile !== undefined) { + landmarkPacks.push(pack) + } else { + normalPacks.push(pack) + } + } + + // 1. Fixed landmark SuperUniques route directly to their covering room + for (const pack of landmarkPacks) { + let targetRoom: MapRoom | undefined + + if (pack.fixedPosition !== undefined) { + const fx = pack.fixedPosition.x + const fy = pack.fixedPosition.y + targetRoom = rooms.find(r => fx >= r.x && fx <= r.x + r.width && fy >= r.y && fy <= r.y + r.height) + if (targetRoom === undefined) { + // Fallback: nearest room center + let minDist = Infinity + for (const r of rooms) { + const cx = r.x + r.width / 2 + const cy = r.y + r.height / 2 + const d = Math.hypot(fx - cx, fy - cy) + if (d < minDist) { + minDist = d + targetRoom = r + } + } + } + } else if (pack.landmarkTile !== undefined) { + const tx = pack.landmarkTile.tileX + const ty = pack.landmarkTile.tileY + targetRoom = rooms.find(r => tx >= r.cellX && tx < r.cellX + r.cellsX && ty >= r.cellY && ty < r.cellY + r.cellsY) + if (targetRoom === undefined) { + targetRoom = rooms.find(r => tx >= r.cellX && tx <= r.cellX + r.cellsX && ty >= r.cellY && ty <= r.cellY + r.cellsY) + } + if (targetRoom === undefined) { + // Fallback: nearest room by cell center + let minDist = Infinity + for (const r of rooms) { + const cx = r.cellX + r.cellsX / 2 + const cy = r.cellY + r.cellsY / 2 + const d = Math.hypot(tx - cx, ty - cy) + if (d < minDist) { + minDist = d + targetRoom = r + } + } + } + } + + if (targetRoom !== undefined && allocation.has(targetRoom)) { + allocation.get(targetRoom)!.push(pack) + } + } + + // 2. Normal packs: distribute to non-safe rooms, balancing by room area + let candidateRooms = rooms.filter(r => !r.isSafeZone) + if (candidateRooms.length === 0) { + // Fallback if all rooms are marked safe + candidateRooms = rooms.slice() + } + + const rng = new Rng(seed ?? 0x5eed) + + for (const pack of normalPacks) { + // Score based on pack density: (currentCount + 1) / area + let minScore = Infinity + for (const r of candidateRooms) { + const count = allocation.get(r)!.length + const area = Math.max(1, r.cellsX * r.cellsY) + const score = (count + 1) / area + if (score < minScore) { + minScore = score + } + } + + const bestRooms: MapRoom[] = [] + for (const r of candidateRooms) { + const count = allocation.get(r)!.length + const area = Math.max(1, r.cellsX * r.cellsY) + const score = (count + 1) / area + if (Math.abs(score - minScore) < 1e-9) { + bestRooms.push(r) + } + } + + const picked = bestRooms[rng.int(0, bestRooms.length - 1)]! + allocation.get(picked)!.push(pack) + } + + return allocation +} + +/** + * Spawns a single monster pack inside a given room. + */ +function spawnSinglePackInRoom( + world: CombatWorld, + pack: MonsterPack, + room: MapRoom, + terrain: CombatTerrain, +): number { + if (pack.members.length === 0) return 0 + + const margin = Math.min(16, Math.max(2, room.width * 0.05), Math.max(2, room.height * 0.05)) + const minSpawnX = room.x + margin + const maxSpawnX = room.x + room.width - margin + const minSpawnY = room.y + margin + const maxSpawnY = room.y + room.height - margin + + // 1. Leader placement near room center or fixed landmark position + const startX = pack.fixedPosition !== undefined + ? pack.fixedPosition.x + : room.x + room.width / 2 + const startY = pack.fixedPosition !== undefined + ? pack.fixedPosition.y + : room.y + room.height / 2 + + let leaderX = Math.max(minSpawnX, Math.min(maxSpawnX, startX)) + let leaderY = Math.max(minSpawnY, Math.min(maxSpawnY, startY)) + let leaderFound = false + + // Check initial point + if (terrain.overlap(leaderX, leaderY) === 0) { + leaderFound = true + } else { + // Spiral search outward + const maxRadius = Math.max(room.width, room.height) / 2 + for (let step = 1; step <= 64; step += 1) { + const angle = step * 2.399963 + const radius = Math.min(maxRadius, step * 8) + const sx = Math.max(minSpawnX, Math.min(maxSpawnX, startX + Math.cos(angle) * radius)) + const sy = Math.max(minSpawnY, Math.min(maxSpawnY, startY + Math.sin(angle) * radius)) + if (terrain.overlap(sx, sy) === 0) { + leaderX = sx + leaderY = sy + leaderFound = true + break + } + } + } + + // Grid fallback if spiral failed + if (!leaderFound) { + const stepX = Math.max(8, (maxSpawnX - minSpawnX) / 16) + const stepY = Math.max(8, (maxSpawnY - minSpawnY) / 16) + for (let gy = minSpawnY; gy <= maxSpawnY; gy += stepY) { + for (let gx = minSpawnX; gx <= maxSpawnX; gx += stepX) { + if (terrain.overlap(gx, gy) === 0) { + leaderX = gx + leaderY = gy + leaderFound = true + break + } + } + if (leaderFound) break + } + } + + if (!leaderFound) return 0 + + const leaderDef = pack.members[0]! + world.monsters.push({ + index: world.monsters.length, + stats: leaderDef, + x: leaderX, + y: leaderY, + hp: leaderDef.hp, + cooldown: 0, + state: 'idle', + facing: 0, + hitFlash: 0, + corpseTicks: 0, + }) + let placed = 1 + + // 2. Minions placed in tight camp ring (PACK_RADIUS_PX = 48~64) around leader + const minions = pack.members.slice(1) + let minionPlaced = 0 + let attempt = 0 + const maxAttempts = minions.length * 32 + const baseAngle = typeof room.id === 'number' ? room.id * 1.5 : 0.8 + const effectiveRadius = Math.min(PACK_RADIUS_PX, Math.max(16, Math.min(room.width, room.height) / 2 - margin)) + + while (minionPlaced < minions.length && attempt < maxAttempts) { + attempt += 1 + const angle = baseAngle + attempt * 2.399963 + const distFrac = Math.sqrt(attempt / (minions.length * 12)) + const radius = Math.min(effectiveRadius, 16 + (effectiveRadius - 16) * distFrac) + let mx = leaderX + Math.cos(angle) * radius + let my = leaderY + Math.sin(angle) * radius + + // Clamps within room bounding boxes + mx = Math.max(minSpawnX, Math.min(maxSpawnX, mx)) + my = Math.max(minSpawnY, Math.min(maxSpawnY, my)) + + // Respects terrain collisions + if (terrain.overlap(mx, my) > 0) continue + + const minionDef = minions[minionPlaced]! + world.monsters.push({ + index: world.monsters.length, + stats: minionDef, + x: mx, + y: my, + hp: minionDef.hp, + cooldown: 0, + state: 'idle', + facing: 0, + hitFlash: 0, + corpseTicks: 0, + }) + minionPlaced += 1 + placed += 1 + } + + return placed +} + +/** + * Spawns monster pack(s) inside the boundaries of the specified `room`. + * + * Leader is positioned at a clear open sub-tile near the room's interior center; + * minions are placed in a tight camp ring (PACK_RADIUS_PX = 48~64) around the leader. + * Respects terrain collisions (terrain.overlap(x, y) === 0) and clamps within room + * bounding boxes (x in [minX + margin, maxX - margin]). + * + * @param world - the world to populate. + * @param pack - the pack or packs to spawn. + * @param room - the room boundary to spawn within. + * @param terrain - collision predicate. + * @returns how many monsters were placed. + */ +export function spawnPacksInRoom( + world: CombatWorld, + pack: MonsterPack | readonly MonsterPack[], + room: MapRoom, + terrain: CombatTerrain, +): number { + if (Array.isArray(pack)) { + let count = 0 + for (const p of pack) { + count += spawnSinglePackInRoom(world, p, room, terrain) + } + return count + } + return spawnSinglePackInRoom(world, pack as MonsterPack, room, terrain) +} + +/** Alias for `spawnPacksInRoom` with singular naming. */ +export const spawnPackInRoom = spawnPacksInRoom + +/** + * Options for `spawnMonsterPacksByRooms`. + */ +export interface SpawnMonsterPacksByRoomsOptions { + readonly seed?: number | undefined + readonly safeZones?: readonly SafeZone[] | undefined + readonly spread?: number | undefined +} + +/** + * Modern, clean replacement for `spawnMonsterPacks`, allocating packs across rooms + * and clustering members tightly within their assigned room boundaries. + * + * Also maintains backwards compatibility if plain packs with a center point are passed. + * + * @param world - world to populate. + * @param packs - monster packs to spawn. + * @param roomsOrAround - room array or fallback center point. + * @param terrain - collision predicate. + * @param options - allocation options or safe zones. + * @returns total monsters placed. + */ +export function spawnMonsterPacksByRooms( + world: CombatWorld, + packs: readonly MonsterPack[], + roomsOrAround: readonly MapRoom[] | { readonly x: number; readonly y: number }, + terrain: CombatTerrain, + options?: SpawnMonsterPacksByRoomsOptions | readonly SafeZone[], +): number { + if (packs.length === 0) return 0 + + let safeZones: readonly SafeZone[] | undefined + let seed: number | undefined + let spread = 600 + + if (Array.isArray(options)) { + safeZones = options + } else if (options !== undefined) { + const opts = options as SpawnMonsterPacksByRoomsOptions + safeZones = opts.safeZones + seed = opts.seed + if (opts.spread !== undefined) { + spread = opts.spread + } + } + + if (!Array.isArray(roomsOrAround)) { + const around = roomsOrAround as { readonly x: number; readonly y: number } + const syntheticRooms = partitionMapIntoRooms(64, 64, { + roomCellSize: 16, + cellPixelSize: 80, + originX: around.x - spread, + originY: around.y - spread, + safeZones, + }) + return spawnMonsterPacksByRooms(world, packs, syntheticRooms, terrain, { + safeZones, + seed, + }) + } + + const rooms = roomsOrAround as readonly MapRoom[] + if (rooms.length === 0) return 0 + + const allocation = allocatePacksToRooms(packs, rooms, seed) + let totalPlaced = 0 + for (const [room, roomPacks] of allocation) { + for (const pack of roomPacks) { + totalPlaced += spawnPacksInRoom(world, pack, room, terrain) + } + } + return totalPlaced +} diff --git a/src/game/monster-streaming.ts b/src/game/monster-streaming.ts new file mode 100644 index 0000000..b3c5719 --- /dev/null +++ b/src/game/monster-streaming.ts @@ -0,0 +1,171 @@ +/** + * Diablo II D2Game.dll style Active/Sleeping Room Streaming and Lazy Room Population. + * + * Rather than eagerly instantiating every monster pack on frame 0, rooms begin + * unpopulated (`bPopulated = false`). When the player approaches within + * `activationRadius`, monsters are instantiated into `world.monsters` once and + * only once (idempotent). + * + * When the player moves far away (> `deactivationRadius`), the room transitions to + * sleeping (`active = false`) and all living monsters have `monster.sleeping = true`, + * skipping all AI, aggro, cooldown calculations, and pathfinding in `tickMonsters`. + * + * When the player returns, monsters wake up (`monster.sleeping = false`) with their + * exact positions, health, and cooldowns preserved. Dead monsters remain dead. + */ +import { spawnMonsterPacks } from './combat.ts' +import type { CombatTerrain, CombatWorld, MonsterPack, SafeZone } from './combat.ts' + +/** Definition of a room holding unspawned monster pack blueprints. */ +export interface StreamingRoomDef { + readonly id: string | number + readonly bounds: { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number } + readonly packs: readonly MonsterPack[] +} + +/** Runtime state of a streaming room. */ +export interface StreamingRoomState extends StreamingRoomDef { + bPopulated: boolean + active: boolean + monsterIndices: number[] +} + +/** Configuration options for the streaming manager. */ +export interface MonsterStreamingOptions { + /** Distance in pixels to populate and activate a room (default: 1600px ~ 1.5-2 screens). */ + activationRadius?: number + /** Distance in pixels to deactivate and put a room to sleep (default: 2400px ~ 2.5-3 screens). */ + deactivationRadius?: number + /** Optional callback invoked whenever a room is populated for the first time. */ + onSpawnRoom?: (room: StreamingRoomState) => void + /** Optional safe zones to avoid when placing monsters inside rooms. */ + safeZones?: readonly SafeZone[] +} + +/** + * Calculates Euclidean distance from a point to an axis-aligned room bounding box. + * Returns 0 if the point is within the bounding box. + * + * @param px - point x coordinate. + * @param py - point y coordinate. + * @param bounds - room axis-aligned bounds. + * @returns Euclidean distance to the nearest point on the room boundary (or 0 if inside). + */ +export function distanceToRoom( + px: number, + py: number, + bounds: { readonly minX: number; readonly minY: number; readonly maxX: number; readonly maxY: number }, +): number { + const minX = Math.min(bounds.minX, bounds.maxX) + const maxX = Math.max(bounds.minX, bounds.maxX) + const minY = Math.min(bounds.minY, bounds.maxY) + const maxY = Math.max(bounds.minY, bounds.maxY) + const dx = Math.max(minX - px, 0, px - maxX) + const dy = Math.max(minY - py, 0, py - maxY) + return Math.hypot(dx, dy) +} + +/** + * Manages lazy room population (`bPopulated`) and active/sleeping room streaming. + */ +export class MonsterStreamingManager { + public world: CombatWorld + public terrain: CombatTerrain + public rooms: StreamingRoomState[] + public activationRadius: number + public deactivationRadius: number + public options?: MonsterStreamingOptions | undefined + + constructor( + world: CombatWorld, + terrain: CombatTerrain, + rooms: readonly StreamingRoomDef[], + options?: MonsterStreamingOptions, + ) { + this.world = world + this.terrain = terrain + this.activationRadius = options?.activationRadius ?? 1600 + this.deactivationRadius = options?.deactivationRadius ?? 2400 + this.options = options + this.rooms = rooms.map(r => ({ + id: r.id, + bounds: r.bounds, + packs: r.packs, + bPopulated: false, + active: false, + monsterIndices: [], + })) + + // On initialization, only the room containing the player spawn and adjacent rooms + // within activationRadius are populated. Distant rooms remain unpopulated. + this.update(this.world.player.x, this.world.player.y) + } + + /** + * Evaluates player distance to all rooms, populating unpopulated rooms within + * activationRadius, waking active rooms, and putting distant rooms to sleep. + * + * @param playerX - player world x position. + * @param playerY - player world y position. + */ + update(playerX: number = this.world.player.x, playerY: number = this.world.player.y): void { + for (const room of this.rooms) { + const dist = distanceToRoom(playerX, playerY, room.bounds) + + if (dist <= this.activationRadius) { + if (!room.bPopulated) { + this.populateRoom(room) + } + room.active = true + for (const idx of room.monsterIndices) { + const monster = this.world.monsters[idx] + if (monster) { + monster.sleeping = false + } + } + } else if (dist > this.deactivationRadius) { + if (room.active) { + room.active = false + for (const idx of room.monsterIndices) { + const monster = this.world.monsters[idx] + if (monster && monster.state !== 'dead') { + monster.sleeping = true + } + } + } + } + } + } + + /** + * Lazily instantiates a room's monster packs into `world.monsters`. + * Sets `bPopulated = true` to guarantee idempotency. + */ + private populateRoom(room: StreamingRoomState): void { + if (room.bPopulated) return + + const startIndex = this.world.monsters.length + const centerX = (room.bounds.minX + room.bounds.maxX) / 2 + const centerY = (room.bounds.minY + room.bounds.maxY) / 2 + const width = Math.abs(room.bounds.maxX - room.bounds.minX) + const height = Math.abs(room.bounds.maxY - room.bounds.minY) + const spread = Math.max(width, height) / 2 || 100 + + spawnMonsterPacks( + this.world, + room.packs, + { x: centerX, y: centerY }, + spread, + this.terrain, + this.options?.safeZones, + ) + + const endIndex = this.world.monsters.length + for (let i = startIndex; i < endIndex; i += 1) { + room.monsterIndices.push(i) + } + + room.bPopulated = true + this.options?.onSpawnRoom?.(room) + } +} diff --git a/tests/monster-rooms.test.ts b/tests/monster-rooms.test.ts new file mode 100644 index 0000000..4eca57b --- /dev/null +++ b/tests/monster-rooms.test.ts @@ -0,0 +1,404 @@ +import { describe, expect, it } from 'vitest' +import { + allocatePacksToRooms, + partitionMapIntoRooms, + spawnMonsterPacksByRooms, + spawnPacksInRoom, + PACK_RADIUS_PX, + type MapRoom, +} from '../src/game/monster-rooms.ts' +import { createWorld } from '../src/game/combat.ts' +import type { CombatTerrain, MonsterPack, MonsterStats } from '../src/game/combat.ts' + +/** Flat open ground everywhere. */ +const OPEN: CombatTerrain = { overlap: () => 0 } + +/** + * Creates a simple monster stat record. + */ +function stats(id: string, hp = 30): MonsterStats { + return { + id, + name: id, + hp, + damage: 5, + cooldownTicks: 25, + reach: 40, + aggroRadius: 200, + speed: 100, + xp: 10, + } +} + +/** + * Creates a test monster pack. + */ +function pack(id: string, count: number, extra?: Partial): MonsterPack { + return { + members: Array.from({ length: count }, (_, i) => stats(id, 30 + i)), + ...extra, + } +} + +describe('Room-Local Monster Clustering and Placement', () => { + describe('partitionMapIntoRooms', () => { + it('partitions outdoor grid sectors into authentic ~20-30 cell rooms', () => { + // 72x48 cells partitioned with roomCellSize = 24 -> 3 x 2 = 6 rooms + const rooms = partitionMapIntoRooms(72, 48, { roomCellSize: 24, cellPixelSize: 80 }) + expect(rooms).toHaveLength(6) + + // All rooms should be 24x24 cells and 1920x1920 pixels + for (const room of rooms) { + expect(room.cellsX).toBe(24) + expect(room.cellsY).toBe(24) + expect(room.width).toBe(1920) + expect(room.height).toBe(1920) + expect(room.isPreset).toBe(false) + } + + // Check cell grid coordinates + expect(rooms[0]?.cellX).toBe(0) + expect(rooms[0]?.cellY).toBe(0) + expect(rooms[1]?.cellX).toBe(24) + expect(rooms[1]?.cellY).toBe(0) + expect(rooms[2]?.cellX).toBe(48) + expect(rooms[2]?.cellY).toBe(0) + expect(rooms[3]?.cellX).toBe(0) + expect(rooms[3]?.cellY).toBe(24) + }) + + it('handles map dimensions that do not divide evenly by roomCellSize', () => { + // 50x30 cells with roomCellSize = 24 -> 3 cols (24, 24, 2) x 2 rows (24, 6) = 6 rooms + const rooms = partitionMapIntoRooms(50, 30, { roomCellSize: 24, cellPixelSize: 80 }) + expect(rooms).toHaveLength(6) + + const col2Row0 = rooms.find(r => r.cellX === 48 && r.cellY === 0) + expect(col2Row0).toBeDefined() + expect(col2Row0?.cellsX).toBe(2) + expect(col2Row0?.cellsY).toBe(24) + expect(col2Row0?.width).toBe(160) + + const col0Row1 = rooms.find(r => r.cellX === 0 && r.cellY === 24) + expect(col0Row1).toBeDefined() + expect(col0Row1?.cellsX).toBe(24) + expect(col0Row1?.cellsY).toBe(6) + expect(col0Row1?.height).toBe(480) + }) + + it('maps discrete maze rooms directly into MapRooms', () => { + const mazeRooms = [ + { x: 0, y: 0, width: 24, height: 24 }, + { x: 24, y: 0, width: 24, height: 24 }, + { x: 24, y: 24, width: 12, height: 12 }, + ] + const rooms = partitionMapIntoRooms(48, 48, { mazeRooms, cellPixelSize: 80 }) + expect(rooms).toHaveLength(3) + + expect(rooms[0]?.isPreset).toBe(true) + expect(rooms[0]?.cellX).toBe(0) + expect(rooms[0]?.cellY).toBe(0) + expect(rooms[0]?.width).toBe(1920) + + expect(rooms[2]?.cellsX).toBe(12) + expect(rooms[2]?.cellsY).toBe(12) + expect(rooms[2]?.width).toBe(960) + expect(rooms[2]?.height).toBe(960) + }) + + it('automatically flags rooms overlapping safe zones as isSafeZone', () => { + const safeZones = [ + { x: 500, y: 500, radius: 160 }, + ] + const rooms = partitionMapIntoRooms(72, 48, { roomCellSize: 24, cellPixelSize: 80, safeZones }) + expect(rooms).toHaveLength(6) + + // Room 0 covers [0, 1920] x [0, 1920], overlaps (500, 500) + expect(rooms[0]?.isSafeZone).toBe(true) + + // Distant rooms should not be safe zones + const distantRoom = rooms.find(r => r.cellX === 48 && r.cellY === 24) + expect(distantRoom?.isSafeZone).toBeUndefined() + }) + }) + + describe('allocatePacksToRooms', () => { + it('distributes normal packs to non-safe rooms and avoids safe rooms', () => { + const rooms: MapRoom[] = [ + { id: 0, x: 0, y: 0, width: 1000, height: 1000, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10, isSafeZone: true }, + { id: 1, x: 1000, y: 0, width: 1000, height: 1000, cellX: 10, cellY: 0, cellsX: 10, cellsY: 10 }, + { id: 2, x: 2000, y: 0, width: 1000, height: 1000, cellX: 20, cellY: 0, cellsX: 10, cellsY: 10 }, + ] + const packs = [ + pack('fallen', 3), + pack('zombie', 4), + pack('skeleton', 3), + pack('quillrat', 2), + ] + + const allocation = allocatePacksToRooms(packs, rooms, 12345) + + // Safe room (id: 0) must receive NO normal packs + expect(allocation.get(rooms[0]!)).toHaveLength(0) + + // Remaining packs must be distributed between room 1 and room 2 + const room1Count = allocation.get(rooms[1]!)?.length ?? 0 + const room2Count = allocation.get(rooms[2]!)?.length ?? 0 + expect(room1Count + room2Count).toBe(4) + expect(room1Count).toBeGreaterThan(0) + expect(room2Count).toBeGreaterThan(0) + }) + + it('balances pack counts across candidate rooms based on room area', () => { + // Room Small: 10x10 cells (area 100) + // Room Large: 20x20 cells (area 400 - 4x larger) + const roomSmall: MapRoom = { id: 'small', x: 0, y: 0, width: 800, height: 800, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10 } + const roomLarge: MapRoom = { id: 'large', x: 800, y: 0, width: 1600, height: 1600, cellX: 10, cellY: 0, cellsX: 20, cellsY: 20 } + const rooms = [roomSmall, roomLarge] + + const packs = Array.from({ length: 10 }, (_, i) => pack(`mob_${String(i)}`, 3)) + const allocation = allocatePacksToRooms(packs, rooms, 42) + + const smallCount = allocation.get(roomSmall)?.length ?? 0 + const largeCount = allocation.get(roomLarge)?.length ?? 0 + + expect(smallCount + largeCount).toBe(10) + // Large room has 4x area, so it should receive significantly more packs without overcrowding small room + expect(largeCount).toBeGreaterThan(smallCount * 2) + }) + + it('routes landmark SuperUnique packs with landmarkTile to covering room', () => { + const roomA: MapRoom = { id: 'A', x: 0, y: 0, width: 1920, height: 1920, cellX: 0, cellY: 0, cellsX: 24, cellsY: 24 } + const roomB: MapRoom = { id: 'B', x: 1920, y: 0, width: 1920, height: 1920, cellX: 24, cellY: 0, cellsX: 24, cellsY: 24 } + const rooms = [roomA, roomB] + + // Bishibosh at landmark tile (tileX: 30, tileY: 10) falls inside room B (cellX in [24, 48)) + const bishiboshPack = pack('bishibosh', 5, { + superUniqueId: 'Bishibosh', + landmarkTile: { tileX: 30, tileY: 10 }, + }) + const normalPack = pack('fallen', 3) + + const allocation = allocatePacksToRooms([bishiboshPack, normalPack], rooms, 100) + + const roomBPacks = allocation.get(roomB) ?? [] + expect(roomBPacks).toContain(bishiboshPack) + }) + + it('routes landmark SuperUnique packs with fixedPosition to covering room', () => { + const roomA: MapRoom = { id: 'A', x: 0, y: 0, width: 1000, height: 1000, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10 } + const roomB: MapRoom = { id: 'B', x: 1000, y: 0, width: 1000, height: 1000, cellX: 10, cellY: 0, cellsX: 10, cellsY: 10 } + const rooms = [roomA, roomB] + + // Rakanishu fixed at world pixel coordinates (1500, 500) inside room B + const rakanishuPack = pack('rakanishu', 4, { + superUniqueId: 'Rakanishu', + fixedPosition: { x: 1500, y: 500 }, + }) + + const allocation = allocatePacksToRooms([rakanishuPack], rooms) + expect(allocation.get(roomB)).toContain(rakanishuPack) + expect(allocation.get(roomA)).toHaveLength(0) + }) + + it('is deterministic with a given seed', () => { + const rooms: MapRoom[] = [ + { id: 1, x: 0, y: 0, width: 800, height: 800, cellX: 0, cellY: 0, cellsX: 10, cellsY: 10 }, + { id: 2, x: 800, y: 0, width: 800, height: 800, cellX: 10, cellY: 0, cellsX: 10, cellsY: 10 }, + { id: 3, x: 1600, y: 0, width: 800, height: 800, cellX: 20, cellY: 0, cellsX: 10, cellsY: 10 }, + ] + const packs = Array.from({ length: 9 }, (_, i) => pack(`mob_${String(i)}`, 2)) + + const run1 = allocatePacksToRooms(packs, rooms, 9999) + const run2 = allocatePacksToRooms(packs, rooms, 9999) + + for (const room of rooms) { + const p1 = run1.get(room)!.map(p => p.members[0]?.id) + const p2 = run2.get(room)!.map(p => p.members[0]?.id) + expect(p1).toEqual(p2) + } + }) + }) + + describe('spawnPacksInRoom', () => { + it('places monster pack strictly within room boundaries', () => { + const world = createWorld(0, 0) + const room: MapRoom = { + id: 'test_room', + x: 1000, + y: 1000, + width: 400, + height: 400, + cellX: 10, + cellY: 10, + cellsX: 5, + cellsY: 5, + } + const testPack = pack('fallen', 6) + + const placed = spawnPacksInRoom(world, testPack, room, OPEN) + expect(placed).toBe(6) + expect(world.monsters).toHaveLength(6) + + // Verify every monster is strictly inside room bounds + for (const monster of world.monsters) { + expect(monster.x).toBeGreaterThanOrEqual(room.x) + expect(monster.x).toBeLessThanOrEqual(room.x + room.width) + expect(monster.y).toBeGreaterThanOrEqual(room.y) + expect(monster.y).toBeLessThanOrEqual(room.y + room.height) + } + }) + + it('respects terrain collisions and does not spill through walls', () => { + const world = createWorld(0, 0) + const room: MapRoom = { + id: 'walled_room', + x: 500, + y: 500, + width: 400, + height: 400, + cellX: 5, + cellY: 5, + cellsX: 5, + cellsY: 5, + } + // Vertical solid wall slicing through x in [680, 750] + const walledTerrain: CombatTerrain = { + overlap: (x: number) => (x >= 680 && x <= 750 ? 1 : 0), + } + const testPack = pack('zombie', 8) + + const placed = spawnPacksInRoom(world, testPack, room, walledTerrain) + expect(placed).toBeGreaterThan(0) + + for (const monster of world.monsters) { + // Must never land on solid wall + expect(walledTerrain.overlap(monster.x, monster.y)).toBe(0) + expect(monster.x < 680 || monster.x > 750).toBe(true) + // Must stay inside room + expect(monster.x).toBeGreaterThanOrEqual(room.x) + expect(monster.x).toBeLessThanOrEqual(room.x + room.width) + expect(monster.y).toBeGreaterThanOrEqual(room.y) + expect(monster.y).toBeLessThanOrEqual(room.y + room.height) + } + }) + + it('clusters minions tightly around leader within PACK_RADIUS_PX', () => { + const world = createWorld(0, 0) + const room: MapRoom = { + id: 'cluster_room', + x: 2000, + y: 2000, + width: 800, + height: 800, + cellX: 20, + cellY: 20, + cellsX: 10, + cellsY: 10, + } + const testPack = pack('fallen', 7) + + spawnPacksInRoom(world, testPack, room, OPEN) + expect(world.monsters).toHaveLength(7) + + const leader = world.monsters[0]! + const minions = world.monsters.slice(1) + + for (const minion of minions) { + const dist = Math.hypot(minion.x - leader.x, minion.y - leader.y) + expect(dist).toBeLessThanOrEqual(PACK_RADIUS_PX) + } + }) + + it('positions leader near interior center of room on clear ground', () => { + const world = createWorld(0, 0) + const room: MapRoom = { + id: 'center_test', + x: 1000, + y: 1000, + width: 400, + height: 400, + cellX: 10, + cellY: 10, + cellsX: 5, + cellsY: 5, + } + const testPack = pack('boss', 1) + + spawnPacksInRoom(world, testPack, room, OPEN) + expect(world.monsters).toHaveLength(1) + + const leader = world.monsters[0]! + const roomCenterX = room.x + room.width / 2 // 1200 + const roomCenterY = room.y + room.height / 2 // 1200 + + // Leader should land right at or very close to center + expect(Math.abs(leader.x - roomCenterX)).toBeLessThan(16) + expect(Math.abs(leader.y - roomCenterY)).toBeLessThan(16) + }) + + it('places fixedPosition SuperUnique at or near designated landmark coordinates', () => { + const world = createWorld(0, 0) + const room: MapRoom = { + id: 'landmark_room', + x: 0, + y: 0, + width: 1000, + height: 1000, + cellX: 0, + cellY: 0, + cellsX: 10, + cellsY: 10, + } + const testPack = pack('treehead', 1, { + superUniqueId: 'Treehead WoodFist', + fixedPosition: { x: 350, y: 450 }, + }) + + spawnPacksInRoom(world, testPack, room, OPEN) + expect(world.monsters).toHaveLength(1) + + const leader = world.monsters[0]! + expect(leader.x).toBe(350) + expect(leader.y).toBe(450) + }) + }) + + describe('spawnMonsterPacksByRooms', () => { + it('populates world with all packs distributed across partitioned rooms', () => { + const world = createWorld(0, 0) + const rooms = partitionMapIntoRooms(48, 48, { roomCellSize: 24, cellPixelSize: 80 }) + const packs = [ + pack('fallen', 4), + pack('zombie', 3), + pack('skeleton', 5), + ] + + const placed = spawnMonsterPacksByRooms(world, packs, rooms, OPEN, { seed: 101 }) + expect(placed).toBe(12) + expect(world.monsters).toHaveLength(12) + + // Verify every monster lies within at least one room's bounding box + for (const m of world.monsters) { + const inAnyRoom = rooms.some( + r => m.x >= r.x && m.x <= r.x + r.width && m.y >= r.y && m.y <= r.y + r.height, + ) + expect(inAnyRoom).toBe(true) + } + }) + + it('maintains backwards compatibility when fallback around coordinates are passed', () => { + const world = createWorld(0, 0) + const packs = [pack('fallen', 3)] + + const placed = spawnMonsterPacksByRooms( + world, + packs, + { x: 500, y: 500 }, + OPEN, + { spread: 300 }, + ) + expect(placed).toBe(3) + expect(world.monsters).toHaveLength(3) + }) + }) +}) diff --git a/tests/monster-streaming.test.ts b/tests/monster-streaming.test.ts new file mode 100644 index 0000000..9a06a38 --- /dev/null +++ b/tests/monster-streaming.test.ts @@ -0,0 +1,571 @@ +import { describe, expect, it, vi } from 'vitest' +import { createWorld, damageMonster, tickCombat } from '../src/game/combat.ts' +import type { CombatOptions, CombatTerrain, MonsterPack, MonsterStats } from '../src/game/combat.ts' +import { GameEngine } from '../src/game/engine.ts' +import type { GameEngineOptions, WorldMapProvider } from '../src/game/engine.ts' +import { MonsterStreamingManager, distanceToRoom } from '../src/game/monster-streaming.ts' +import type { StreamingRoomDef, StreamingRoomState } from '../src/game/monster-streaming.ts' + +/** Open ground everywhere. */ +const OPEN: CombatTerrain = { overlap: () => 0 } + +/** Minimal map provider for engine testing. */ +const OPEN_MAP: WorldMapProvider = { + widthPx: 10000, + heightPx: 10000, + overlap: () => 0, +} + +/** Helper to construct test monster stats. */ +function stats(id: string, hp = 30, damage = 5): MonsterStats { + return { + id, + name: id, + hp, + damage, + cooldownTicks: 25, + reach: 40, + aggroRadius: 200, + speed: 100, + xp: 10, + } +} + +/** Helper to construct a test monster pack. */ +function pack(id: string, count: number, hp = 30): MonsterPack { + return { + members: Array.from({ length: count }, (_, i) => stats(id, hp + i)), + } +} + +/** Default combat options for simulation. */ +const DEFAULT_COMBAT_OPTIONS: CombatOptions = { + playerSpeed: 100, + playerReach: 40, + playerCooldownTicks: 25, + playerDamage: 10, + playerManaPerAttack: 0, + respawnTicks: 100, +} + +describe('distanceToRoom', () => { + const bounds = { minX: 1000, minY: 1000, maxX: 2000, maxY: 2000 } + + it('returns 0 when point is inside room bounds', () => { + expect(distanceToRoom(1500, 1500, bounds)).toBe(0) + expect(distanceToRoom(1000, 1000, bounds)).toBe(0) + expect(distanceToRoom(2000, 2000, bounds)).toBe(0) + }) + + it('calculates orthogonal distance correctly', () => { + expect(distanceToRoom(500, 1500, bounds)).toBe(500) + expect(distanceToRoom(2600, 1500, bounds)).toBe(600) + expect(distanceToRoom(1500, 300, bounds)).toBe(700) + expect(distanceToRoom(1500, 2800, bounds)).toBe(800) + }) + + it('calculates diagonal distance to closest corner', () => { + // Distance from (700, 600) to (1000, 1000) is hypot(300, 400) = 500 + expect(distanceToRoom(700, 600, bounds)).toBe(500) + }) +}) + +describe('MonsterStreamingManager - Lazy Population (bPopulated)', () => { + it('populates only spawn neighborhood rooms on initialization, leaving distant rooms unpopulated', () => { + const world = createWorld(100, 100) + const rooms: StreamingRoomDef[] = [ + { + id: 'spawn_room', + bounds: { minX: 0, minY: 0, maxX: 400, maxY: 400 }, + packs: [pack('fallen1', 3)], + }, + { + id: 'adjacent_room', + bounds: { minX: 600, minY: 0, maxX: 1000, maxY: 400 }, // distance = 500 <= 1600 + packs: [pack('zombie1', 2)], + }, + { + id: 'distant_room_1', + bounds: { minX: 3000, minY: 3000, maxX: 3500, maxY: 3500 }, // distance ~4100 > 1600 + packs: [pack('quillrat1', 4)], + }, + { + id: 'distant_room_2', + bounds: { minX: 6000, minY: 6000, maxX: 6500, maxY: 6500 }, // distance ~8300 > 1600 + packs: [pack('skeleton1', 3)], + }, + ] + + const mgr = new MonsterStreamingManager(world, OPEN, rooms, { + activationRadius: 1600, + deactivationRadius: 2400, + }) + + // Only spawn_room (3) and adjacent_room (2) are populated + expect(world.monsters).toHaveLength(5) + + const [spawnRoom, adjRoom, distRoom1, distRoom2] = mgr.rooms as [ + StreamingRoomState, + StreamingRoomState, + StreamingRoomState, + StreamingRoomState, + ] + + expect(spawnRoom.bPopulated).toBe(true) + expect(spawnRoom.active).toBe(true) + expect(spawnRoom.monsterIndices).toEqual([0, 1, 2]) + + expect(adjRoom.bPopulated).toBe(true) + expect(adjRoom.active).toBe(true) + expect(adjRoom.monsterIndices).toEqual([3, 4]) + + // Distant rooms must NOT be populated and must have 0 monster indices + expect(distRoom1.bPopulated).toBe(false) + expect(distRoom1.active).toBe(false) + expect(distRoom1.monsterIndices).toHaveLength(0) + + expect(distRoom2.bPopulated).toBe(false) + expect(distRoom2.active).toBe(false) + expect(distRoom2.monsterIndices).toHaveLength(0) + + // Active monsters are awake + expect(world.monsters.every(m => !m.sleeping)).toBe(true) + }) + + it('triggers room activation and population when player approaches', () => { + const world = createWorld(100, 100) + const rooms: StreamingRoomDef[] = [ + { + id: 'spawn_room', + bounds: { minX: 0, minY: 0, maxX: 400, maxY: 400 }, + packs: [pack('fallen1', 2)], + }, + { + id: 'distant_room', + bounds: { minX: 3000, minY: 3000, maxX: 3500, maxY: 3500 }, + packs: [pack('zombie1', 3)], + }, + ] + + const mgr = new MonsterStreamingManager(world, OPEN, rooms, { + activationRadius: 1600, + deactivationRadius: 2400, + }) + + expect(world.monsters).toHaveLength(2) + expect(mgr.rooms[1]!.bPopulated).toBe(false) + + // Move player close to distant room (distance to [3000, 3000] is ~282px <= 1600) + world.player.x = 2800 + world.player.y = 2800 + mgr.update(world.player.x, world.player.y) + + const distantRoom = mgr.rooms[1]! + expect(distantRoom.bPopulated).toBe(true) + expect(distantRoom.active).toBe(true) + expect(distantRoom.monsterIndices).toEqual([2, 3, 4]) + expect(world.monsters).toHaveLength(5) + + // Distant monsters are active and not sleeping + for (const idx of distantRoom.monsterIndices) { + expect(world.monsters[idx]!.sleeping).toBe(false) + } + }) + + it('invokes onSpawnRoom callback exactly once when a room is populated', () => { + const world = createWorld(100, 100) + const onSpawnRoom = vi.fn() + const rooms: StreamingRoomDef[] = [ + { + id: 'room_a', + bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 }, + packs: [pack('fallen1', 2)], + }, + { + id: 'room_b', + bounds: { minX: 3000, minY: 3000, maxX: 3300, maxY: 3300 }, + packs: [pack('zombie1', 2)], + }, + ] + + const mgr = new MonsterStreamingManager(world, OPEN, rooms, { + activationRadius: 1600, + deactivationRadius: 2400, + onSpawnRoom, + }) + + // room_a spawned on init + expect(onSpawnRoom).toHaveBeenCalledTimes(1) + expect(onSpawnRoom).toHaveBeenCalledWith(expect.objectContaining({ id: 'room_a', bPopulated: true })) + + // Move closer to room_b + mgr.update(3000, 3000) + expect(onSpawnRoom).toHaveBeenCalledTimes(2) + expect(onSpawnRoom).toHaveBeenCalledWith(expect.objectContaining({ id: 'room_b', bPopulated: true })) + + // Repeated updates do NOT trigger onSpawnRoom again + mgr.update(3000, 3000) + mgr.update(0, 0) + mgr.update(3000, 3000) + expect(onSpawnRoom).toHaveBeenCalledTimes(2) + }) +}) + +describe('MonsterStreamingManager - Active / Sleeping Room Streaming', () => { + it('marks living monsters in distant rooms as sleeping and skips their AI in tickMonsters', () => { + const world = createWorld(100, 100) + const rooms: StreamingRoomDef[] = [ + { + id: 'room_spawn', + bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 }, + packs: [pack('fallen1', 2)], + }, + { + id: 'room_far', + bounds: { minX: 3000, minY: 3000, maxX: 3300, maxY: 3300 }, + packs: [pack('zombie1', 2)], + }, + ] + + const mgr = new MonsterStreamingManager(world, OPEN, rooms, { + activationRadius: 1600, + deactivationRadius: 2400, + }) + + // Populate room_far + mgr.update(3100, 3100) + expect(mgr.rooms[0]!.active).toBe(false) + expect(mgr.rooms[1]!.active).toBe(true) + + // Monsters in room_spawn (indices 0, 1) are now sleeping + const sleepingMonster = world.monsters[0]! + expect(sleepingMonster.sleeping).toBe(true) + + // Set cooldown and hitFlash on sleeping monster to verify tickMonsters skips it completely + sleepingMonster.cooldown = 15 + sleepingMonster.hitFlash = 3 + const initialMonsterX = sleepingMonster.x + const initialMonsterY = sleepingMonster.y + + // Advance simulation tick: sleeping monster must skip all AI and timers + tickCombat( + world, + { movement: { x: 0, y: 0 }, attack: false }, + DEFAULT_COMBAT_OPTIONS, + OPEN, + [0, 100, 200], + ) + + expect(sleepingMonster.cooldown).toBe(15) // Cooldown preserved without decrementing + expect(sleepingMonster.hitFlash).toBe(3) // hitFlash preserved without decrementing + expect(sleepingMonster.x).toBe(initialMonsterX) + expect(sleepingMonster.y).toBe(initialMonsterY) + + // Active monsters in room_far (e.g. index 2) are not sleeping + const activeMonster = world.monsters[2]! + expect(activeMonster.sleeping).toBe(false) + }) + + it('wakes sleeping monsters when player re-enters activationRadius', () => { + const world = createWorld(100, 100) + const rooms: StreamingRoomDef[] = [ + { + id: 'room_spawn', + bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 }, + packs: [pack('fallen1', 2)], + }, + { + id: 'room_far', + bounds: { minX: 3000, minY: 3000, maxX: 3300, maxY: 3300 }, + packs: [pack('zombie1', 2)], + }, + ] + + const mgr = new MonsterStreamingManager(world, OPEN, rooms, { + activationRadius: 1600, + deactivationRadius: 2400, + }) + + // Move player far away: room_spawn deactivates + mgr.update(4000, 4000) + expect(mgr.rooms[0]!.active).toBe(false) + expect(world.monsters[0]!.sleeping).toBe(true) + + // Move player back: room_spawn wakes up + mgr.update(100, 100) + expect(mgr.rooms[0]!.active).toBe(true) + expect(world.monsters[0]!.sleeping).toBe(false) + expect(world.monsters[1]!.sleeping).toBe(false) + }) + + it('preserves monster damage, cooldowns, and positions across sleeping and wake cycles', () => { + const world = createWorld(100, 100) + const rooms: StreamingRoomDef[] = [ + { + id: 'room_spawn', + bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 }, + packs: [pack('fallen1', 2, 50)], + }, + ] + + const mgr = new MonsterStreamingManager(world, OPEN, rooms, { + activationRadius: 1600, + deactivationRadius: 2400, + }) + + const target = world.monsters[0]! + const initialHp = target.hp + damageMonster(world, 0, 18) // HP becomes initialHp - 18 + target.cooldown = 14 + const preservedX = target.x + const preservedY = target.y + + // Deactivate room + mgr.update(4000, 4000) + expect(mgr.rooms[0]!.active).toBe(false) + expect(target.sleeping).toBe(true) + + // Simulate several ticks while monster is sleeping + for (let i = 0; i < 5; i += 1) { + tickCombat( + world, + { movement: { x: 0, y: 0 }, attack: false }, + DEFAULT_COMBAT_OPTIONS, + OPEN, + [0, 100, 200], + ) + } + + // Wake room back up + mgr.update(100, 100) + expect(mgr.rooms[0]!.active).toBe(true) + expect(target.sleeping).toBe(false) + expect(target.hp).toBe(initialHp - 18) + expect(target.cooldown).toBe(14) + expect(target.x).toBe(preservedX) + expect(target.y).toBe(preservedY) + }) + + it('respects hysteresis between activationRadius and deactivationRadius', () => { + const world = createWorld(100, 100) + const rooms: StreamingRoomDef[] = [ + { + id: 'room_spawn', + bounds: { minX: 0, minY: 0, maxX: 300, maxY: 300 }, + packs: [pack('fallen1', 1)], + }, + { + id: 'room_mid', + bounds: { minX: 2000, minY: 0, maxX: 2200, maxY: 300 }, // distance from (100, 100) is 1900 + packs: [pack('zombie1', 1)], + }, + ] + + const mgr = new MonsterStreamingManager(world, OPEN, rooms, { + activationRadius: 1600, + deactivationRadius: 2400, + }) + + const roomSpawn = mgr.rooms[0]! + const roomMid = mgr.rooms[1]! + + // Initial state: player at 100, 100 + // roomSpawn is active (dist = 0 <= 1600) + // roomMid is at dist 1900 (between 1600 and 2400), so it stays unpopulated & inactive + expect(roomSpawn.active).toBe(true) + expect(roomMid.bPopulated).toBe(false) + expect(roomMid.active).toBe(false) + + // Player moves to dist 2000 from roomSpawn: + // roomSpawn was already active, so it STAYS active (dist 2000 <= 2400) + mgr.update(2100, 100) + // Now player is inside roomMid! (x: 2100 is between 2000 and 2200) + expect(roomMid.bPopulated).toBe(true) + expect(roomMid.active).toBe(true) + // roomSpawn distance is 2100 - 300 = 1800 <= 2400, so it remains active + expect(roomSpawn.active).toBe(true) + + // Player moves farther to x: 2800 (distance from roomSpawn: 2500 > 2400) + mgr.update(2800, 100) + expect(roomSpawn.active).toBe(false) + + // Player steps back to x: 2300 (distance from roomSpawn: 2000, between 1600 and 2400) + // roomSpawn was sleeping, so it STAYS sleeping until <= 1600! + mgr.update(2300, 100) + expect(roomSpawn.active).toBe(false) + + // Player steps closer to x: 1800 (distance from roomSpawn: 1500 <= 1600) + // roomSpawn wakes up! + mgr.update(1800, 100) + expect(roomSpawn.active).toBe(true) + }) +}) + +describe('MonsterStreamingManager - Idempotency & Non-Respawn', () => { + it('never re-populates a room once bPopulated is true: dead monsters stay dead when returning', () => { + const world = createWorld(100, 100) + const rooms: StreamingRoomDef[] = [ + { + id: 'room_a', + bounds: { minX: 0, minY: 0, maxX: 400, maxY: 400 }, + packs: [pack('fallen1', 3)], + }, + { + id: 'room_b', + bounds: { minX: 3000, minY: 3000, maxX: 3400, maxY: 3400 }, + packs: [pack('zombie1', 3)], + }, + ] + + const mgr = new MonsterStreamingManager(world, OPEN, rooms, { + activationRadius: 1600, + deactivationRadius: 2400, + }) + + expect(world.monsters).toHaveLength(3) + + // Approach room_b to populate it + mgr.update(3200, 3200) + expect(world.monsters).toHaveLength(6) + const roomB = mgr.rooms[1]! + expect(roomB.bPopulated).toBe(true) + + // Kill all monsters in room_b + for (const idx of roomB.monsterIndices) { + damageMonster(world, idx, 9999) + expect(world.monsters[idx]!.state).toBe('dead') + } + + const totalMonstersBeforeLeave = world.monsters.length + expect(totalMonstersBeforeLeave).toBe(6) + + // Player leaves room_b far away + mgr.update(100, 100) + expect(roomB.active).toBe(false) + + // Player returns to room_b + mgr.update(3200, 3200) + expect(roomB.active).toBe(true) + expect(roomB.bPopulated).toBe(true) + + // Verification: NO new monsters are spawned + expect(world.monsters).toHaveLength(totalMonstersBeforeLeave) + for (const idx of roomB.monsterIndices) { + expect(world.monsters[idx]!.state).toBe('dead') + } + }) +}) + +describe('GameEngine Integration with MonsterStreamingManager', () => { + const dummyOpts: GameEngineOptions = { + spawn: { x: 100, y: 100 }, + stats: [stats('fallen1'), stats('zombie1')], + xpTable: [0, 0, 10, 30, 60], + itemBases: [], + prefixAffixes: [], + suffixAffixes: [], + skills: [], + npcDefs: [], + questDefs: [], + combatOptions: DEFAULT_COMBAT_OPTIONS, + talkRadius: 50, + pickupRadius: 50, + inventoryCols: 10, + inventoryRows: 4, + monsterCount: 0, + } + + it('automatically initializes MonsterStreamingManager when roomPacks are provided', () => { + const rooms: StreamingRoomDef[] = [ + { + id: 'spawn_room', + bounds: { minX: 0, minY: 0, maxX: 500, maxY: 500 }, + packs: [pack('fallen1', 2)], + }, + { + id: 'distant_room', + bounds: { minX: 3000, minY: 3000, maxX: 3500, maxY: 3500 }, + packs: [pack('zombie1', 3)], + }, + ] + + const engine = new GameEngine(OPEN_MAP, { + ...dummyOpts, + roomPacks: rooms, + }) + + expect(engine.streamingManager).toBeDefined() + expect(engine.streamingManager?.rooms).toHaveLength(2) + // Only spawn room monsters are created initially + expect(engine.world.monsters).toHaveLength(2) + expect(engine.streamingManager?.rooms[0]!.bPopulated).toBe(true) + expect(engine.streamingManager?.rooms[1]!.bPopulated).toBe(false) + }) + + it('calls streamingManager.update in GameEngine.tick to stream rooms as player moves', () => { + const rooms: StreamingRoomDef[] = [ + { + id: 'spawn_room', + bounds: { minX: 0, minY: 0, maxX: 500, maxY: 500 }, + packs: [pack('fallen1', 2)], + }, + { + id: 'distant_room', + bounds: { minX: 1200, minY: 100, maxX: 1500, maxY: 500 }, // distance ~700 from (500, 100) + packs: [pack('zombie1', 3)], + }, + ] + + const engine = new GameEngine(OPEN_MAP, { + ...dummyOpts, + roomPacks: rooms, + streamingOptions: { + activationRadius: 800, + deactivationRadius: 1400, + }, + }) + + // Initially at (100, 100), distance to distant_room is 1100 > 800 -> unpopulated + expect(engine.world.monsters).toHaveLength(2) + expect(engine.streamingManager?.rooms[1]!.bPopulated).toBe(false) + + // Teleport player closer to distant room + engine.world.player.x = 800 + engine.world.player.y = 100 + + // Next engine.tick should update streaming manager and populate distant room + engine.tick({ + movement: { x: 0, y: 0 }, + attacking: false, + pickingUp: false, + talking: false, + digits: [], + saving: false, + loading: false, + }) + + expect(engine.streamingManager?.rooms[1]!.bPopulated).toBe(true) + expect(engine.world.monsters).toHaveLength(5) + }) + + it('accepts pre-constructed streamingManager in GameEngineOptions', () => { + const world = createWorld(100, 100) + const rooms: StreamingRoomDef[] = [ + { + id: 'room_1', + bounds: { minX: 0, minY: 0, maxX: 500, maxY: 500 }, + packs: [pack('fallen1', 2)], + }, + ] + const streamingManager = new MonsterStreamingManager(world, OPEN, rooms) + + const engine = new GameEngine(OPEN_MAP, { + ...dummyOpts, + streamingManager, + }) + + expect(engine.streamingManager).toBe(streamingManager) + expect(engine.world).toBe(world) + expect(engine.world.monsters).toHaveLength(2) + }) +})