feat(debug): add monster density and elite chance selectors to acts.html (Fixes #450)
This commit is contained in:
parent
ff2d091254
commit
6f935bccf6
18
acts.html
18
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 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -341,6 +349,12 @@ export interface ActSceneState {
|
|||
* their ids are in no art table.
|
||||
*/
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue