diablo2-web/tests/debug-toolbar-density-elite...

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