398 lines
16 KiB
TypeScript
398 lines
16 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
TOWN_LEVEL_IDS,
|
|
isTownLevel,
|
|
parseDensityMultiplier,
|
|
parseEliteMultiplier,
|
|
updateSelectorWarningStyle,
|
|
updateToolbarUrl,
|
|
rebudgetMonsterPacks,
|
|
} from '../src/scene/act-scene.ts'
|
|
import type { CombatTerrain, CombatWorld, MonsterPack, MonsterStats } from '../src/game/combat.ts'
|
|
import { MonsterStreamingManager } from '../src/game/monster-streaming.ts'
|
|
|
|
function createSampleMonster(id: string, rank: 'normal' | 'champion' | 'unique' | 'minion' = 'normal', hp: number = 50): MonsterStats {
|
|
return {
|
|
id,
|
|
name: id,
|
|
level: 2,
|
|
hp,
|
|
damage: 5,
|
|
cooldownTicks: 15,
|
|
reach: 48,
|
|
aggroRadius: 200,
|
|
speed: 6,
|
|
xp: 25,
|
|
rank,
|
|
modifiers: [],
|
|
}
|
|
}
|
|
|
|
function createSamplePack(id: string, memberCount: number = 3, rank: 'normal' | 'champion' | 'unique' = 'normal'): MonsterPack {
|
|
const members: MonsterStats[] = []
|
|
if (rank === 'unique') {
|
|
members.push(createSampleMonster(id, 'unique', 120))
|
|
for (let i = 1; i < memberCount; i += 1) {
|
|
members.push(createSampleMonster(id, 'minion', 60))
|
|
}
|
|
} else if (rank === 'champion') {
|
|
for (let i = 0; i < memberCount; i += 1) {
|
|
members.push(createSampleMonster(id, 'champion', 100))
|
|
}
|
|
} else {
|
|
for (let i = 0; i < memberCount; i += 1) {
|
|
members.push(createSampleMonster(id, 'normal', 50))
|
|
}
|
|
}
|
|
return {
|
|
members,
|
|
}
|
|
}
|
|
|
|
function createMockSelect(initialValue: string): HTMLSelectElement {
|
|
const classes = new Set<string>()
|
|
const style: Record<string, string> = {}
|
|
return {
|
|
value: initialValue,
|
|
classList: {
|
|
toggle: (cls: string, force?: boolean) => {
|
|
if (force === undefined) {
|
|
if (classes.has(cls)) classes.delete(cls)
|
|
else classes.add(cls)
|
|
} else if (force) {
|
|
classes.add(cls)
|
|
} else {
|
|
classes.delete(cls)
|
|
}
|
|
},
|
|
contains: (cls: string) => classes.has(cls),
|
|
add: (cls: string) => { classes.add(cls) },
|
|
remove: (cls: string) => { classes.delete(cls) },
|
|
},
|
|
style,
|
|
} as unknown as HTMLSelectElement
|
|
}
|
|
|
|
describe('Debug Toolbar: Monster Density & Elite Multipliers (Issue #450)', () => {
|
|
describe('Query parameter parsing & defaulting to 1x', () => {
|
|
it('defaults density to 1 for undefined, null, empty string, or invalid inputs', () => {
|
|
expect(parseDensityMultiplier(undefined)).toBe(1)
|
|
expect(parseDensityMultiplier(null)).toBe(1)
|
|
expect(parseDensityMultiplier('')).toBe(1)
|
|
expect(parseDensityMultiplier('invalid')).toBe(1)
|
|
expect(parseDensityMultiplier('0')).toBe(1)
|
|
expect(parseDensityMultiplier('3')).toBe(1)
|
|
expect(parseDensityMultiplier('16')).toBe(1)
|
|
expect(parseDensityMultiplier(NaN)).toBe(1)
|
|
})
|
|
|
|
it('correctly parses supported density values (0.5, 1, 2, 4, 8)', () => {
|
|
expect(parseDensityMultiplier(0.5)).toBe(0.5)
|
|
expect(parseDensityMultiplier(1)).toBe(1)
|
|
expect(parseDensityMultiplier(2)).toBe(2)
|
|
expect(parseDensityMultiplier(4)).toBe(4)
|
|
expect(parseDensityMultiplier(8)).toBe(8)
|
|
|
|
expect(parseDensityMultiplier('0.5')).toBe(0.5)
|
|
expect(parseDensityMultiplier('1')).toBe(1)
|
|
expect(parseDensityMultiplier('2')).toBe(2)
|
|
expect(parseDensityMultiplier('4')).toBe(4)
|
|
expect(parseDensityMultiplier('8')).toBe(8)
|
|
expect(parseDensityMultiplier(' 4 ')).toBe(4)
|
|
})
|
|
|
|
it('defaults elite multiplier to 1 for undefined, null, empty string, or invalid inputs', () => {
|
|
expect(parseEliteMultiplier(undefined)).toBe(1)
|
|
expect(parseEliteMultiplier(null)).toBe(1)
|
|
expect(parseEliteMultiplier('')).toBe(1)
|
|
expect(parseEliteMultiplier('foo')).toBe(1)
|
|
expect(parseEliteMultiplier('0')).toBe(1)
|
|
expect(parseEliteMultiplier('5')).toBe(1)
|
|
expect(parseEliteMultiplier(NaN)).toBe(1)
|
|
})
|
|
|
|
it('correctly parses supported elite multiplier values (0.5, 1, 2, 4, all)', () => {
|
|
expect(parseEliteMultiplier(0.5)).toBe(0.5)
|
|
expect(parseEliteMultiplier(1)).toBe(1)
|
|
expect(parseEliteMultiplier(2)).toBe(2)
|
|
expect(parseEliteMultiplier(4)).toBe(4)
|
|
expect(parseEliteMultiplier('all')).toBe('all')
|
|
expect(parseEliteMultiplier('ALL')).toBe('all')
|
|
expect(parseEliteMultiplier('All')).toBe('all')
|
|
|
|
expect(parseEliteMultiplier('0.5')).toBe(0.5)
|
|
expect(parseEliteMultiplier('1')).toBe(1)
|
|
expect(parseEliteMultiplier('2')).toBe(2)
|
|
expect(parseEliteMultiplier('4')).toBe(4)
|
|
})
|
|
})
|
|
|
|
describe('Non-default warning styling', () => {
|
|
it('applies red border and non-default class when value is not 1', () => {
|
|
const select = createMockSelect('2')
|
|
updateSelectorWarningStyle(select)
|
|
expect(select.classList.contains('non-default')).toBe(true)
|
|
expect(select.style.border).toBe('1px solid #e74c3c')
|
|
})
|
|
|
|
it('removes red border and non-default class when value is 1', () => {
|
|
const select = createMockSelect('1')
|
|
updateSelectorWarningStyle(select)
|
|
expect(select.classList.contains('non-default')).toBe(false)
|
|
expect(select.style.border).toBe('')
|
|
})
|
|
|
|
it('handles all elite non-default styling', () => {
|
|
const select = createMockSelect('all')
|
|
updateSelectorWarningStyle(select)
|
|
expect(select.classList.contains('non-default')).toBe(true)
|
|
expect(select.style.border).toBe('1px solid #e74c3c')
|
|
})
|
|
|
|
it('gracefully handles null select element', () => {
|
|
expect(() => { updateSelectorWarningStyle(null) }).not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('Bidirectional URL sync logic', () => {
|
|
it('adds non-default density and elite to URL query parameters', () => {
|
|
const result = updateToolbarUrl('?act=1&level=bloodmoor', { density: 2, elite: 4 })
|
|
expect(result).toContain('density=2')
|
|
expect(result).toContain('elite=4')
|
|
expect(result).toContain('act=1')
|
|
expect(result).toContain('level=bloodmoor')
|
|
})
|
|
|
|
it('omits density and elite from URL when they are 1 (default)', () => {
|
|
const initial = '?act=1&level=bloodmoor&density=4&elite=2'
|
|
const updated = updateToolbarUrl(initial, { density: 1, elite: 1 })
|
|
expect(updated).not.toContain('density=')
|
|
expect(updated).not.toContain('elite=')
|
|
expect(updated).toContain('act=1')
|
|
expect(updated).toContain('level=bloodmoor')
|
|
})
|
|
|
|
it('handles "all" elite in URL query', () => {
|
|
const result = updateToolbarUrl('?act=1', { elite: 'all' })
|
|
expect(result).toContain('elite=all')
|
|
})
|
|
|
|
it('supports full URL strings', () => {
|
|
const url = 'http://localhost:3000/acts.html?act=2&level=rockywaste'
|
|
const updated = updateToolbarUrl(url, { density: 8, elite: 2 })
|
|
expect(updated).toContain('http://localhost:3000/acts.html?')
|
|
expect(updated).toContain('density=8')
|
|
expect(updated).toContain('elite=2')
|
|
expect(updated).toContain('level=rockywaste')
|
|
})
|
|
})
|
|
|
|
describe('Town safe-zone invariant: strictly 0 density in town levels', () => {
|
|
it('identifies all 5 canonical town levels across Acts 1..5', () => {
|
|
expect(TOWN_LEVEL_IDS.size).toBe(5)
|
|
expect(isTownLevel(1)).toBe(true) // Act 1: Rogue Encampment
|
|
expect(isTownLevel(40)).toBe(true) // Act 2: Lut Gholein
|
|
expect(isTownLevel(75)).toBe(true) // Act 3: Kurast Docks
|
|
expect(isTownLevel(103)).toBe(true) // Act 4: The Pandemonium Fortress
|
|
expect(isTownLevel(109)).toBe(true) // Act 5: Harrogath
|
|
})
|
|
|
|
it('rejects combat levels and invalid IDs', () => {
|
|
expect(isTownLevel(2)).toBe(false) // Blood Moor
|
|
expect(isTownLevel(3)).toBe(false) // Cold Plains
|
|
expect(isTownLevel(8)).toBe(false) // Den of Evil
|
|
expect(isTownLevel(136)).toBe(false) // Throne of Destruction
|
|
expect(isTownLevel(0)).toBe(false)
|
|
expect(isTownLevel(-1)).toBe(false)
|
|
expect(isTownLevel(undefined)).toBe(false)
|
|
expect(isTownLevel(null)).toBe(false)
|
|
})
|
|
|
|
it('rebudgetMonsterPacks unconditionally returns 0 packs for all town levels under extreme multipliers', () => {
|
|
const basePacks = [
|
|
createSamplePack('zombie', 4),
|
|
createSamplePack('fallen', 5),
|
|
]
|
|
|
|
for (const townLevelId of [1, 40, 75, 103, 109]) {
|
|
// Default 1x
|
|
expect(rebudgetMonsterPacks(basePacks, { levelId: townLevelId, densityMultiplier: 1, eliteMultiplier: 1 })).toEqual([])
|
|
// Maximum 8x density, all-elite
|
|
expect(rebudgetMonsterPacks(basePacks, { levelId: townLevelId, densityMultiplier: 8, eliteMultiplier: 'all' })).toEqual([])
|
|
// 4x density, 4x elite
|
|
expect(rebudgetMonsterPacks(basePacks, { levelId: townLevelId, densityMultiplier: 4, eliteMultiplier: 4 })).toEqual([])
|
|
// 0.5x density, 0.5x elite
|
|
expect(rebudgetMonsterPacks(basePacks, { levelId: townLevelId, densityMultiplier: 0.5, eliteMultiplier: 0.5 })).toEqual([])
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('Dynamic monster re-budgeting logic', () => {
|
|
it('returns an identical deep copy matching 1.13c ground truth when multipliers are 1x', () => {
|
|
const basePacks = [
|
|
createSamplePack('zombie', 3, 'normal'),
|
|
createSamplePack('fallen', 4, 'champion'),
|
|
createSamplePack('skeleton', 5, 'unique'),
|
|
]
|
|
const rebudgeted = rebudgetMonsterPacks(basePacks, { levelId: 2, densityMultiplier: 1, eliteMultiplier: 1 })
|
|
expect(rebudgeted).toHaveLength(3)
|
|
expect(rebudgeted[0]?.members[0]?.rank).toBe('normal')
|
|
expect(rebudgeted[1]?.members[0]?.rank).toBe('champion')
|
|
expect(rebudgeted[2]?.members[0]?.rank).toBe('unique')
|
|
// Ensure deep clone (distinct object references)
|
|
expect(rebudgeted[0]).not.toBe(basePacks[0])
|
|
expect(rebudgeted[0]!.members[0]).not.toBe(basePacks[0]!.members[0])
|
|
})
|
|
|
|
it('scales pack count proportionally with density multipliers', () => {
|
|
const basePacks = [
|
|
createSamplePack('zombie', 3),
|
|
createSamplePack('fallen', 3),
|
|
createSamplePack('skeleton', 3),
|
|
createSamplePack('brute', 3),
|
|
]
|
|
|
|
// 0.5x density: 4 * 0.5 = 2 packs
|
|
const half = rebudgetMonsterPacks(basePacks, { levelId: 2, densityMultiplier: 0.5, eliteMultiplier: 1 })
|
|
expect(half).toHaveLength(2)
|
|
|
|
// 2x density: 4 * 2 = 8 packs
|
|
const double = rebudgetMonsterPacks(basePacks, { levelId: 2, densityMultiplier: 2, eliteMultiplier: 1 })
|
|
expect(double).toHaveLength(8)
|
|
|
|
// 4x density: 4 * 4 = 16 packs
|
|
const quad = rebudgetMonsterPacks(basePacks, { levelId: 2, densityMultiplier: 4, eliteMultiplier: 1 })
|
|
expect(quad).toHaveLength(16)
|
|
})
|
|
|
|
it('protects landmark and superunique packs from unwanted scaling duplication', () => {
|
|
const basePacks: MonsterPack[] = [
|
|
createSamplePack('zombie', 3),
|
|
{
|
|
superUniqueId: 'Bishibosh',
|
|
members: [createSampleMonster('bishibosh', 'unique', 200)],
|
|
},
|
|
]
|
|
|
|
const scaled = rebudgetMonsterPacks(basePacks, { levelId: 3, densityMultiplier: 4, eliteMultiplier: 1 })
|
|
// Regular pack scales 1 * 4 = 4; superunique pack remains exactly 1 -> total 5 packs
|
|
expect(scaled).toHaveLength(5)
|
|
const bishiPacks = scaled.filter(p => p.superUniqueId !== undefined)
|
|
expect(bishiPacks).toHaveLength(1)
|
|
})
|
|
|
|
it('converts 100% of regular packs into champions or uniques when eliteMultiplier is "all"', () => {
|
|
const basePacks = [
|
|
createSamplePack('zombie', 3, 'normal'),
|
|
createSamplePack('fallen', 4, 'normal'),
|
|
createSamplePack('skeleton', 3, 'normal'),
|
|
]
|
|
|
|
const allElite = rebudgetMonsterPacks(basePacks, { levelId: 2, densityMultiplier: 1, eliteMultiplier: 'all', seed: 42 })
|
|
expect(allElite).toHaveLength(3)
|
|
|
|
for (const pack of allElite) {
|
|
const leaderRank = pack.members[0]?.rank
|
|
expect(leaderRank === 'champion' || leaderRank === 'unique').toBe(true)
|
|
if (leaderRank === 'unique') {
|
|
// Check that minions have minion rank
|
|
for (let i = 1; i < pack.members.length; i += 1) {
|
|
expect(pack.members[i]?.rank).toBe('minion')
|
|
}
|
|
} else if (leaderRank === 'champion') {
|
|
// Check that all members are champions
|
|
for (const m of pack.members) {
|
|
expect(m.rank).toBe('champion')
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
it('promotes normal packs when elite multiplier > 1 even on maps with 0 base elites', () => {
|
|
const normalOnlyPacks = [
|
|
createSamplePack('zombie', 3, 'normal'),
|
|
createSamplePack('zombie', 3, 'normal'),
|
|
createSamplePack('fallen', 4, 'normal'),
|
|
createSamplePack('fallen', 4, 'normal'),
|
|
createSamplePack('skeleton', 3, 'normal'),
|
|
createSamplePack('skeleton', 3, 'normal'),
|
|
]
|
|
|
|
const promoted = rebudgetMonsterPacks(normalOnlyPacks, { levelId: 2, densityMultiplier: 1, eliteMultiplier: 4, seed: 101 })
|
|
const elitePacks = promoted.filter(p => p.members[0]?.rank === 'champion' || p.members[0]?.rank === 'unique')
|
|
expect(elitePacks.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('demotes elites toward normal when elite multiplier < 1', () => {
|
|
const eliteHeavyPacks = [
|
|
createSamplePack('zombie', 3, 'champion'),
|
|
createSamplePack('fallen', 4, 'unique'),
|
|
createSamplePack('skeleton', 3, 'champion'),
|
|
createSamplePack('brute', 4, 'unique'),
|
|
]
|
|
|
|
const reduced = rebudgetMonsterPacks(eliteHeavyPacks, { levelId: 2, densityMultiplier: 1, eliteMultiplier: 0.5, seed: 99 })
|
|
const eliteCount = reduced.filter(p => p.members[0]?.rank === 'champion' || p.members[0]?.rank === 'unique').length
|
|
expect(eliteCount).toBeLessThan(eliteHeavyPacks.length)
|
|
})
|
|
|
|
it('maintains deterministic behavior for the same seed', () => {
|
|
const basePacks = [
|
|
createSamplePack('zombie', 3, 'normal'),
|
|
createSamplePack('fallen', 4, 'normal'),
|
|
]
|
|
const run1 = rebudgetMonsterPacks(basePacks, { levelId: 2, densityMultiplier: 2, eliteMultiplier: 'all', seed: 12345 })
|
|
const run2 = rebudgetMonsterPacks(basePacks, { levelId: 2, densityMultiplier: 2, eliteMultiplier: 'all', seed: 12345 })
|
|
expect(run1).toEqual(run2)
|
|
})
|
|
})
|
|
|
|
describe('Integration with MonsterStreamingManager', () => {
|
|
it('initializes MonsterStreamingManager with scaled packs across rooms', () => {
|
|
const basePacks = [
|
|
createSamplePack('zombie', 3),
|
|
createSamplePack('fallen', 4),
|
|
]
|
|
const rebudgeted = rebudgetMonsterPacks(basePacks, { levelId: 2, densityMultiplier: 2, eliteMultiplier: 1 })
|
|
expect(rebudgeted).toHaveLength(4)
|
|
|
|
// Verify streaming manager accepts rooms constructed from rebudgeted packs
|
|
const mockTerrain: CombatTerrain = {
|
|
isWalkable: () => true,
|
|
overlap: () => 0,
|
|
bounds: { minX: 0, minY: 0, maxX: 1000, maxY: 1000 },
|
|
} as unknown as CombatTerrain
|
|
|
|
const mockWorld: CombatWorld = {
|
|
player: { x: 100, y: 100, hp: 100, maxHp: 100 },
|
|
monsters: [],
|
|
projectiles: [],
|
|
corpses: [],
|
|
} as unknown as CombatWorld
|
|
|
|
const rooms = [
|
|
{
|
|
id: 1,
|
|
bounds: { minX: 0, minY: 0, maxX: 500, maxY: 500 },
|
|
packs: rebudgeted.slice(0, 2),
|
|
},
|
|
{
|
|
id: 2,
|
|
bounds: { minX: 501, minY: 501, maxX: 1000, maxY: 1000 },
|
|
packs: rebudgeted.slice(2),
|
|
},
|
|
]
|
|
|
|
const manager = new MonsterStreamingManager(mockWorld, mockTerrain, rooms, {
|
|
activationRadius: 300,
|
|
deactivationRadius: 400,
|
|
})
|
|
|
|
expect(manager).toBeDefined()
|
|
expect(manager.rooms.length).toBe(2)
|
|
expect(manager.rooms[0]!.packs.length).toBe(2)
|
|
expect(manager.rooms[1]!.packs.length).toBe(2)
|
|
})
|
|
})
|
|
})
|