diff --git a/acts.html b/acts.html
index 943f86f..3ab2cc8 100644
--- a/acts.html
+++ b/acts.html
@@ -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 @@
+
+
I背包 · A角色 · T技能 · Q任务 · V小站 · ~腰带 · R奔跑 · W换手
正在加载地图…
diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts
index e7b3e6f..37b1e13 100644
--- a/src/scene/act-scene.ts
+++ b/src/scene/act-scene.ts
@@ -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
}
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
}
}
}
+/** Town levels across Acts 1..5 that must strictly remain at 0 monster density. */
+export const TOWN_LEVEL_IDS = new Set([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('#act')!
const variantSelect = document.querySelector('#variant')!
const lightingSelect = document.querySelector('#lighting')
+ const densitySelect = document.querySelector('#density')
+ const eliteSelect = document.querySelector('#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 = 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()
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 => {
+ 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 {
location.search = params.toString()
}
}
+ const densitySelect = document.querySelector('#density')
+ const eliteSelect = document.querySelector('#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
diff --git a/tests/debug-toolbar-density-elite.test.ts b/tests/debug-toolbar-density-elite.test.ts
new file mode 100644
index 0000000..2c6ae4c
--- /dev/null
+++ b/tests/debug-toolbar-density-elite.test.ts
@@ -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()
+ const style: Record = {}
+ 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)
+ })
+ })
+})