diablo2-web/tests/monster-streaming.test.ts

572 lines
18 KiB
TypeScript

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