feat(debug): add monster density and elite chance selectors to acts.html (Fixes #450)

This commit is contained in:
troytt 2026-09-24 14:49:41 +00:00
parent ff2d091254
commit 6f935bccf6
3 changed files with 876 additions and 2 deletions

View File

@ -24,6 +24,10 @@
#controls label { color: #9c8d78; }
select { background: #1c1813; color: #e8dcc8; border: 1px solid #33291f;
border-radius: 4px; padding: 2px 5px; font: inherit; max-width: 22vw; }
select.non-default, .non-default {
border: 1px solid #e74c3c !important;
color: #ff6b6b !important;
}
optgroup { background: #1c1813; color: #c8a15a; }
option { background: #1c1813; }
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
@ -68,6 +72,20 @@
<option value="eclipse">蚀日之咒 (幽蓝暗蚀)</option>
<option value="off">关闭光照 (全亮无光影)</option>
</select></label>
<label>密度 <select id="density">
<option value="0.5">0.5x</option>
<option value="1" selected>Density: 1x (1.13c)</option>
<option value="2">2x</option>
<option value="4">4x</option>
<option value="8">8x</option>
</select></label>
<label>精英 <select id="elite">
<option value="0.5">0.5x</option>
<option value="1" selected>Elite: 1x (1.13c)</option>
<option value="2">2x</option>
<option value="4">4x</option>
<option value="all">All Elite</option>
</select></label>
<span class="hint" style="margin:0">I背包 · A角色 · T技能 · Q任务 · V小站 · ~腰带 · R奔跑 · W换手</span>
</div>
<div id="status" class="overlay">正在加载地图…</div>

View File

@ -34,6 +34,14 @@ import { spawnMonsterPacks, damageMonster } from '../game/combat.ts'
import type { Monster, MonsterPack, MonsterStats, SafeZone } from '../game/combat.ts'
import { MonsterStreamingManager } from '../game/monster-streaming.ts'
import type { StreamingRoomDef } from '../game/monster-streaming.ts'
import {
applyEliteModifiers,
rollEliteModifiers,
ELITE_HEALTH_MULTIPLIER,
MONUMOD_CONSTANTS,
type MonsterRank,
} from '../game/monsters.ts'
import { Rng } from '../game/rng.ts'
import { allocatePacksToRooms } from '../game/monster-rooms.ts'
import type { MapRoom } from '../game/monster-rooms.ts'
import { SUB_TILES_PER_TILE, depthInsertIndex } from '../game/map.ts'
@ -340,7 +348,13 @@ export interface ActSceneState {
* `DEMO_MONSTERS` into that case, which also rendered as red boxes because
* their ids are in no art table.
*/
monstersPlanned: number
monstersPlanned: number
/** Active monster density multiplier (0.5, 1, 2, 4, 8). */
densityMultiplier?: DensityMultiplier | undefined
/** Active elite chance multiplier (0.5, 1, 2, 4, 'all'). */
eliteMultiplier?: EliteMultiplier | undefined
/** Dynamic monster re-budget and re-stream method without page reload. */
reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise<void> | void
}
declare global {
@ -352,10 +366,274 @@ declare global {
getHero: () => string
switchLighting?: (preset: string) => LightingPreset
getLighting?: () => LightingPreset
reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise<void> | void
}
}
}
/** Town levels across Acts 1..5 that must strictly remain at 0 monster density. */
export const TOWN_LEVEL_IDS = new Set<number>([1, 40, 75, 103, 109])
export function isTownLevel(levelId: number | undefined | null): boolean {
if (levelId === undefined || levelId === null || levelId <= 0) return false
return TOWN_LEVEL_IDS.has(levelId)
}
export type DensityMultiplier = 0.5 | 1 | 2 | 4 | 8
export type EliteMultiplier = 0.5 | 1 | 2 | 4 | 'all'
export function parseDensityMultiplier(raw: string | number | null | undefined): DensityMultiplier {
if (raw === null || raw === undefined) return 1
const num = typeof raw === 'number' ? raw : parseFloat(String(raw).trim())
if (num === 0.5 || num === 1 || num === 2 || num === 4 || num === 8) {
return num as DensityMultiplier
}
return 1
}
export function parseEliteMultiplier(raw: string | number | null | undefined): EliteMultiplier {
if (raw === null || raw === undefined) return 1
const str = String(raw).trim().toLowerCase()
if (str === 'all') return 'all'
const num = parseFloat(str)
if (num === 0.5 || num === 1 || num === 2 || num === 4) {
return num as EliteMultiplier
}
return 1
}
export function updateSelectorWarningStyle(select: HTMLSelectElement | null, defaultValue: string = '1'): void {
if (!select) return
const isNonDefault = select.value !== defaultValue
select.classList.toggle('non-default', isNonDefault)
if (isNonDefault) {
select.style.border = '1px solid #e74c3c'
} else {
select.style.border = ''
}
}
export function updateToolbarUrl(
currentUrlOrSearch: string | URL,
options: { density?: number | string | undefined; elite?: number | string | undefined },
): string {
const isUrl = typeof currentUrlOrSearch === 'object' || currentUrlOrSearch.startsWith('http://') || currentUrlOrSearch.startsWith('https://')
const url = isUrl
? new URL(typeof currentUrlOrSearch === 'string' ? currentUrlOrSearch : currentUrlOrSearch.href)
: new URL(`http://localhost${currentUrlOrSearch.startsWith('?') ? currentUrlOrSearch : '?' + currentUrlOrSearch}`)
if (options.density !== undefined) {
const density = parseDensityMultiplier(options.density)
if (density === 1) {
url.searchParams.delete('density')
} else {
url.searchParams.set('density', String(density))
}
}
if (options.elite !== undefined) {
const elite = parseEliteMultiplier(options.elite)
if (elite === 1) {
url.searchParams.delete('elite')
} else {
url.searchParams.set('elite', String(elite))
}
}
if (isUrl) {
return url.toString()
}
return url.search
}
function promoteToChampion(pack: MonsterPack, rng: Rng): MonsterPack {
const leaderBase = pack.members[0]
if (!leaderBase) return pack
const count = rng.int(2, 4)
const mods = rollEliteModifiers('champion', rng)
const members: MonsterStats[] = []
for (let i = 0; i < count; i += 1) {
const base = pack.members[i % pack.members.length] ?? leaderBase
const scaledBase: MonsterStats = {
...base,
hp: Math.max(1, Math.round(base.hp * ELITE_HEALTH_MULTIPLIER.champion)),
}
members.push(applyEliteModifiers(scaledBase, 'champion', mods))
}
return {
...pack,
members,
}
}
function promoteToUnique(pack: MonsterPack, rng: Rng): MonsterPack {
const leaderBase = pack.members[0]
if (!leaderBase) return pack
const minionCount = rng.int(3, 6)
const mods = rollEliteModifiers('unique', rng)
const scaledLeader: MonsterStats = {
...leaderBase,
hp: Math.max(1, Math.round(leaderBase.hp * ELITE_HEALTH_MULTIPLIER.unique)),
}
const leader = applyEliteModifiers(scaledLeader, 'unique', mods)
const members: MonsterStats[] = [leader]
for (let i = 0; i < minionCount; i += 1) {
const minionBase = pack.members[(i + 1) % pack.members.length] ?? leaderBase
const scaledMinion: MonsterStats = {
...minionBase,
hp: Math.max(1, Math.round(minionBase.hp * (1 + MONUMOD_CONSTANTS.minionHpPct / 100))),
}
members.push(applyEliteModifiers(scaledMinion, 'minion', []))
}
return {
...pack,
members,
}
}
function demoteToNormal(pack: MonsterPack): MonsterPack {
const members: MonsterStats[] = pack.members.map(m => {
let hp = m.hp
if (m.rank === 'champion') {
hp = Math.max(1, Math.round(hp / ELITE_HEALTH_MULTIPLIER.champion))
} else if (m.rank === 'unique') {
hp = Math.max(1, Math.round(hp / ELITE_HEALTH_MULTIPLIER.unique))
} else if (m.rank === 'minion') {
hp = Math.max(1, Math.round(hp / (1 + MONUMOD_CONSTANTS.minionHpPct / 100)))
}
return {
...m,
hp,
rank: 'normal',
modifiers: [],
}
})
return {
...pack,
members,
}
}
export interface RebudgetOptions {
readonly densityMultiplier?: number | string | undefined
readonly eliteMultiplier?: number | string | undefined
readonly levelId?: number | undefined
readonly seed?: number | undefined
}
export function rebudgetMonsterPacks(
basePacks: readonly MonsterPack[],
options?: RebudgetOptions,
): MonsterPack[] {
const levelId = options?.levelId
if (levelId !== undefined && isTownLevel(levelId)) {
return []
}
if (!basePacks || basePacks.length === 0) {
return []
}
const density = parseDensityMultiplier(options?.densityMultiplier)
const elite = parseEliteMultiplier(options?.eliteMultiplier)
if (density === 1 && elite === 1) {
return basePacks.map(pack => ({
...pack,
members: pack.members.map(m => ({ ...m })),
}))
}
const rng = new Rng(options?.seed ?? 0x9e37)
// Separate fixed / landmark packs from regular wild packs
const fixedPacks = basePacks.filter(p => p.superUniqueId !== undefined || p.fixedPosition !== undefined || p.landmarkTile !== undefined)
const regularPacks = basePacks.filter(p => p.superUniqueId === undefined && p.fixedPosition === undefined && p.landmarkTile === undefined)
// 1. Density scaling on regular packs
let scaledRegularPacks: MonsterPack[] = []
if (regularPacks.length > 0) {
const targetCount = Math.max(1, Math.round(regularPacks.length * density))
if (targetCount <= regularPacks.length) {
for (let i = 0; i < targetCount; i += 1) {
const idx = Math.floor((i * regularPacks.length) / targetCount)
const src = regularPacks[idx]!
scaledRegularPacks.push({
...src,
members: src.members.map(m => ({ ...m })),
})
}
} else {
for (let i = 0; i < targetCount; i += 1) {
const src = regularPacks[i % regularPacks.length]!
scaledRegularPacks.push({
...src,
members: src.members.map(m => ({ ...m })),
})
}
}
}
// 2. Elite scaling on regular packs
if (elite === 'all') {
scaledRegularPacks = scaledRegularPacks.map((pack, idx) => {
const packRng = rng.fork(`all-elite-${idx}`)
const isChampion = packRng.next() < (MONUMOD_CONSTANTS.championChance / 100)
return isChampion ? promoteToChampion(pack, packRng) : promoteToUnique(pack, packRng)
})
} else if (elite !== 1) {
const currentEliteIndices: number[] = []
const currentNormalIndices: number[] = []
for (let i = 0; i < scaledRegularPacks.length; i += 1) {
const leaderRank = scaledRegularPacks[i]?.members[0]?.rank
if (leaderRank === 'champion' || leaderRank === 'unique') {
currentEliteIndices.push(i)
} else {
currentNormalIndices.push(i)
}
}
if (currentEliteIndices.length > 0) {
const targetEliteCount = Math.min(scaledRegularPacks.length, Math.max(0, Math.round(currentEliteIndices.length * elite)))
if (targetEliteCount < currentEliteIndices.length) {
const toDemoteCount = currentEliteIndices.length - targetEliteCount
for (let i = 0; i < toDemoteCount; i += 1) {
const idx = currentEliteIndices[currentEliteIndices.length - 1 - i]!
scaledRegularPacks[idx] = demoteToNormal(scaledRegularPacks[idx]!)
}
} else if (targetEliteCount > currentEliteIndices.length) {
const toPromoteCount = Math.min(currentNormalIndices.length, targetEliteCount - currentEliteIndices.length)
for (let i = 0; i < toPromoteCount; i += 1) {
const idx = currentNormalIndices[i]!
const packRng = rng.fork(`promote-${idx}`)
const isChampion = packRng.next() < (MONUMOD_CONSTANTS.championChance / 100)
scaledRegularPacks[idx] = isChampion
? promoteToChampion(scaledRegularPacks[idx]!, packRng)
: promoteToUnique(scaledRegularPacks[idx]!, packRng)
}
}
} else if (elite > 1 && currentNormalIndices.length > 0) {
const targetEliteCount = Math.min(
scaledRegularPacks.length,
Math.max(1, Math.round(scaledRegularPacks.length * 0.05 * elite)),
)
const toPromote = Math.min(currentNormalIndices.length, targetEliteCount)
for (let i = 0; i < toPromote; i += 1) {
const idx = currentNormalIndices[i]!
const packRng = rng.fork(`promote-zero-${idx}`)
const isChampion = packRng.next() < (MONUMOD_CONSTANTS.championChance / 100)
scaledRegularPacks[idx] = isChampion
? promoteToChampion(scaledRegularPacks[idx]!, packRng)
: promoteToUnique(scaledRegularPacks[idx]!, packRng)
}
}
}
return [
...fixedPacks.map(p => ({ ...p, members: p.members.map(m => ({ ...m })) })),
...scaledRegularPacks,
]
}
export type LightingPreset =
| 'auto'
| 'noon'
@ -551,6 +829,7 @@ const state: ActSceneState = {
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0, animatedObjects: 0,
animSpeed: undefined, frameDurationMs: DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, monstersPlanned: 0,
densityMultiplier: 1, eliteMultiplier: 1,
}
if (typeof window !== 'undefined') window.__d2webAct = state
@ -843,7 +1122,8 @@ export interface MapRuntime {
* not planned one. There is no fallback: a level nothing planned for stays
* empty and says so, rather than being filled with invented monsters.
*/
readonly monsterPacks: readonly MonsterPack[]
monsterPacks: readonly MonsterPack[]
baseMonsterPacks?: readonly MonsterPack[] | undefined
/**
* The level's monster types, by `MonStats.txt` id, for the status line.
*/
@ -3706,6 +3986,7 @@ export class SceneMouseController {
* @returns the value.
*/
function param(key: string, fallback: string): string {
if (typeof location === 'undefined') return fallback
return new URLSearchParams(location.search).get(key) ?? fallback
}
@ -4189,6 +4470,7 @@ async function buildPackRuntime(
spawn,
npcs: scene.npcs ?? [],
monsterPacks: scene.monsters?.packs ?? [],
baseMonsterPacks: scene.monsters?.packs ?? [],
monsterTypes: Array.from(new Set([
...(scene.monsters?.types ?? []),
...(scene.monsters?.packs ?? []).flatMap(p => p.members.map(m => m.id)),
@ -5547,6 +5829,15 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
const actSelect = document.querySelector<HTMLSelectElement>('#act')!
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')!
const lightingSelect = document.querySelector<HTMLSelectElement>('#lighting')
const densitySelect = document.querySelector<HTMLSelectElement>('#density')
const eliteSelect = document.querySelector<HTMLSelectElement>('#elite')
let densityMultiplier: DensityMultiplier = parseDensityMultiplier(param('density', densitySelect?.value ?? '1'))
let eliteMultiplier: EliteMultiplier = parseEliteMultiplier(param('elite', eliteSelect?.value ?? '1'))
state.densityMultiplier = densityMultiplier
state.eliteMultiplier = eliteMultiplier
let reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise<void> = async () => {}
const input = new KeyboardInput()
input.attach()
@ -5685,9 +5976,11 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
getHero: () => currentHeroToken,
switchLighting,
getLighting: () => currentLightingPreset,
reloadMonsters: (opts) => reloadMonsters(opts),
}
;(state as any).switchHero = switchHero
;(state as any).switchLighting = switchLighting
state.reloadMonsters = (opts) => reloadMonsters(opts)
}
const monsterAnimators = new Map<number, ActorAnimator>()
const camera = new ViewportCamera(canvas, runtime.widthPx, runtime.heightPx)
@ -5743,6 +6036,15 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
for (const s of runtime.stashes ?? []) {
safeZones.push({ x: s.x, y: s.y, radius: 160 })
}
if (!runtime.baseMonsterPacks) {
runtime.baseMonsterPacks = runtime.monsterPacks
}
runtime.monsterPacks = rebudgetMonsterPacks(runtime.baseMonsterPacks, {
densityMultiplier,
eliteMultiplier,
levelId: runtime.levelId,
seed: WORLD_VARIANT_SEED ^ runtime.levelId,
})
const resolvedPacks: MonsterPack[] = runtime.monsterPacks.map(pack => {
if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) {
return {
@ -5834,6 +6136,142 @@ if (typeof window !== 'undefined') {
;(window as any).__d2webEngine = engine
}
reloadMonsters = async (options?: { density?: number | string; elite?: number | string }): Promise<void> => {
if (options?.density !== undefined) {
densityMultiplier = parseDensityMultiplier(options.density)
}
if (options?.elite !== undefined) {
eliteMultiplier = parseEliteMultiplier(options.elite)
}
state.densityMultiplier = densityMultiplier
state.eliteMultiplier = eliteMultiplier
if (densitySelect !== null) {
densitySelect.value = String(densityMultiplier)
updateSelectorWarningStyle(densitySelect, '1')
}
if (eliteSelect !== null) {
eliteSelect.value = String(eliteMultiplier)
updateSelectorWarningStyle(eliteSelect, '1')
}
try {
if (typeof location !== 'undefined' && typeof history !== 'undefined') {
const nextSearch = updateToolbarUrl(location.search, { density: densityMultiplier, elite: eliteMultiplier })
const newUrl = `${location.pathname}${nextSearch}${location.hash}`
history.replaceState(null, '', newUrl)
}
} catch {
// ignore in non-browser context
}
if (isTownLevel(runtime.levelId)) {
runtime.monsterPacks = []
engine.world.monsters.length = 0
engine.streamingManager = undefined
reportMonsterPlan(runtime)
return
}
const basePacks = runtime.baseMonsterPacks ?? runtime.monsterPacks
runtime.baseMonsterPacks = basePacks
runtime.monsterPacks = rebudgetMonsterPacks(basePacks, {
densityMultiplier,
eliteMultiplier,
levelId: runtime.levelId,
seed: WORLD_VARIANT_SEED ^ runtime.levelId,
})
// Clear existing monsters
engine.world.monsters.length = 0
const player = engine.world.player
const currentSafeZones: SafeZone[] = [
{ x: player.x, y: player.y, radius: 180 },
]
for (const wp of runtime.waypoints) {
const pos = subTileToScene(wp.x, wp.y, runtime.grid)
currentSafeZones.push({ x: pos.x, y: pos.y, radius: 160 })
}
for (const ent of runtime.entrances) {
const pos = subTileToScene(ent.x, ent.y, runtime.grid)
currentSafeZones.push({ x: pos.x, y: pos.y, radius: 160 })
}
for (const s of runtime.stashes ?? []) {
currentSafeZones.push({ x: s.x, y: s.y, radius: 160 })
}
const currentResolvedPacks: MonsterPack[] = runtime.monsterPacks.map(pack => {
if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) {
return {
...pack,
fixedPosition: cellCentre(runtime.grid, pack.landmarkTile.tileX, pack.landmarkTile.tileY),
}
}
return pack
})
const nextRooms = buildStreamingRooms(runtime, currentResolvedPacks, currentSafeZones)
if (nextRooms.length > 0) {
engine.streamingManager = new MonsterStreamingManager(
engine.world,
engine.terrain,
nextRooms,
{
activationRadius: ROOM_ACTIVATION_RADIUS,
deactivationRadius: ROOM_DEACTIVATION_RADIUS,
safeZones: currentSafeZones,
},
)
} else {
engine.streamingManager = undefined
if (currentResolvedPacks.length > 0) {
spawnMonsterPacks(
engine.world,
currentResolvedPacks,
{ x: player.x, y: player.y },
Math.max(runtime.widthPx, runtime.heightPx) / 4,
engine.terrain,
currentSafeZones,
)
}
}
reportMonsterPlan(runtime)
if (runtime.palette !== null) {
const packMonsterIds = runtime.monsterPacks.flatMap(p => p.members.map(m => m.id))
const activeMonsterIds = Array.from(new Set([...runtime.monsterTypes, ...packMonsterIds]))
if (activeMonsterIds.length > 0) {
const loaded = await loadMonsterArtMap(
DEFAULT_BASES,
activeMonsterIds,
runtime.palette,
renderer,
packEntityBase,
packEntityAct,
)
monsterArtMap = loaded.map
recordMonsterArtLoad(loaded)
}
}
}
if (densitySelect !== null) {
densitySelect.disabled = false
densitySelect.value = String(densityMultiplier)
updateSelectorWarningStyle(densitySelect, '1')
densitySelect.onchange = () => {
void reloadMonsters({ density: densitySelect.value })
}
}
if (eliteSelect !== null) {
eliteSelect.disabled = false
eliteSelect.value = String(eliteMultiplier)
updateSelectorWarningStyle(eliteSelect, '1')
eliteSelect.onchange = () => {
void reloadMonsters({ elite: eliteSelect.value })
}
}
/* ----------------------------------------------------------------------- *
* World connectivity
* ----------------------------------------------------------------------- */
@ -6011,6 +6449,15 @@ if (typeof window !== 'undefined') {
const pos = subTileToScene(ent.x, ent.y, next.grid)
nextSafeZones.push({ x: pos.x, y: pos.y, radius: 160 })
}
if (!next.baseMonsterPacks) {
next.baseMonsterPacks = next.monsterPacks
}
next.monsterPacks = rebudgetMonsterPacks(next.baseMonsterPacks, {
densityMultiplier,
eliteMultiplier,
levelId: next.levelId,
seed: WORLD_VARIANT_SEED ^ next.levelId,
})
const nextResolvedPacks: MonsterPack[] = next.monsterPacks.map(pack => {
if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) {
return {
@ -7636,6 +8083,18 @@ async function boot(): Promise<void> {
location.search = params.toString()
}
}
const densitySelect = document.querySelector<HTMLSelectElement>('#density')
const eliteSelect = document.querySelector<HTMLSelectElement>('#elite')
if (densitySelect !== null) {
densitySelect.disabled = false
densitySelect.value = String(parseDensityMultiplier(param('density', '1')))
updateSelectorWarningStyle(densitySelect, '1')
}
if (eliteSelect !== null) {
eliteSelect.disabled = false
eliteSelect.value = String(parseEliteMultiplier(param('elite', '1')))
updateSelectorWarningStyle(eliteSelect, '1')
}
actSelect.disabled = false
sceneSelect.disabled = true
variantSelect.disabled = true

View File

@ -0,0 +1,397 @@
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)
})
})
})