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