feat(monsters): room-local pack allocation and clustering placement

This commit is contained in:
troytt 2026-09-17 08:35:11 +00:00
parent 1fa3f8b7a2
commit 8e46b64156
3 changed files with 968 additions and 0 deletions

View File

@ -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 {
@ -481,7 +497,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

544
src/game/monster-rooms.ts Normal file
View File

@ -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<MapRoom, MonsterPack[]> {
const allocation = new Map<MapRoom, MonsterPack[]>()
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
}

404
tests/monster-rooms.test.ts Normal file
View File

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