import { DUNGEON_DEFINITIONS } from "./dungeonRegistry"; import type { DungeonDefinition, Vector3Tuple } from "./dungeonTypes"; import type { StaticMobSpawnDefinition } from "./mobPopulation"; import type { BossTestSession } from "./store"; export interface TestableBoss { readonly key: string; readonly name: string; readonly dungeonId: string; readonly dungeonTitle: string; readonly entityId: string; readonly category: "wow" | "rom"; readonly spawn: StaticMobSpawnDefinition; } export function buildBossTestingCatalog(definitions: readonly DungeonDefinition[]): readonly TestableBoss[] { return definitions.flatMap((dungeon) => Object.values(dungeon.entities).flatMap((entity): TestableBoss[] => { if (entity.kind !== "boss") return []; const spawn = dungeon.staticSpawns.find((candidate) => candidate.entityId === entity.id) ?? dungeon.encounterSpawnGroups?.flatMap((group) => group.spawns).find((candidate) => candidate.entityId === entity.id); const objective = dungeon.bosses.find((boss) => boss.id === spawn?.id || boss.name === entity.name); const pack = dungeon.roamingPacks.find((candidate) => candidate.members.some((member) => member.entityId === entity.id)); const member = pack?.members.find((candidate) => candidate.entityId === entity.id); const position = spawn?.position ?? objective?.position ?? pack?.waypoints[0]; if (!position) return []; return [{ key: `${dungeon.id}/${entity.id}`, name: entity.name, dungeonId: dungeon.id, dungeonTitle: dungeon.title, entityId: entity.id, category: dungeon.expansion === "runewaker" ? "rom" : "wow", spawn: { ...spawn, id: spawn?.id ?? (pack && member ? `${pack.id}:${member.id}` : objective?.id ?? entity.id), entityId: entity.id, position, spawnChance: 1 }, }]; })).sort((a, b) => a.name.localeCompare(b.name) || a.dungeonTitle.localeCompare(b.dungeonTitle)); } export const BOSS_TESTING_CATALOG = buildBossTestingCatalog(DUNGEON_DEFINITIONS); export function randomTestingBoss(bosses: readonly TestableBoss[], random: () => number = Math.random): TestableBoss | null { if (!bosses.length) return null; const draw = random(); const index = Math.floor((Number.isFinite(draw) ? Math.max(0, Math.min(0.999999999, draw)) : 0) * bosses.length); return bosses[index]; } function references(value: unknown, ids: ReadonlySet): boolean { if (typeof value === "string") return ids.has(value); if (Array.isArray(value)) return value.some((entry) => references(entry, ids)); return Boolean(value && typeof value === "object" && Object.values(value).some((entry) => references(entry, ids))); } function remapEncounterReferences(value: T, ids: ReadonlyMap): T { if (typeof value === "string") return (ids.get(value) ?? value) as T; if (Array.isArray(value)) return value.map((entry) => remapEncounterReferences(entry, ids)) as T; if (value && typeof value === "object") return Object.fromEntries(Object.entries(value) .map(([key, entry]) => [key, remapEncounterReferences(entry, ids)])) as T; return value; } /** Keep original mechanic coordinates and spawn IDs while replacing the environment with a clear floor. */ export function createBossTestingRoom(source: DungeonDefinition, boss: TestableBoss): { readonly definition: DungeonDefinition; readonly session: BossTestSession; } { const entity = source.entities[boss.entityId]; if (source.id !== boss.dungeonId || entity?.kind !== "boss") throw new Error("That boss is unavailable."); const id = `boss-testing:${source.id}:${boss.entityId}`; const ids = new Set([boss.entityId, boss.spawn.id]); const sourceEncounters = (source.encounters ?? []).filter((encounter) => references(encounter.rules.map((rule) => rule.trigger), ids) || references(entity.combat, new Set([encounter.id])) ); const encounterIds = new Map(sourceEncounters.map((encounter) => [encounter.id, `${id}:${encounter.id}`])); const encounters = sourceEncounters.map((encounter) => { const isolated = remapEncounterReferences(encounter, encounterIds); return { ...isolated, id: encounterIds.get(encounter.id)!, dungeonId: id, rules: isolated.rules.map((rule) => ({ ...rule, actions: rule.actions.filter((action) => action.kind !== "grant-loot" && action.kind !== "set-checkpoint" ) })), }; }); const groups = (source.encounterSpawnGroups ?? []).filter((group) => references(encounters, new Set([group.id]))); const objects = source.serverGameObjects.filter((object) => references(encounters, new Set([object.id, object.interaction?.signalId ?? ""]))); const entityIds = new Set([boss.entityId, ...groups.flatMap((group) => group.spawns.map((spawn) => spawn.entityId))]); // Summoned creature definitions are needed even though they are absent from the initial room. for (const entityId of entityIds) { for (const attack of source.entities[entityId]?.combat?.attacks ?? []) { for (const effect of attack.effect ? [attack.effect] : []) { if (effect.kind !== "summon") continue; for (const candidate of Object.values(source.entities)) { if (candidate.id === effect.entityId || candidate.combat?.serverEntry === effect.creatureEntry) entityIds.add(candidate.id); } } } } const center = boss.spawn.position; const radius = Math.max(40, ...groups.flatMap((group) => group.spawns.map((spawn) => Math.hypot(spawn.position[0] - center[0], spawn.position[2] - center[2]) + 8 )), ...objects.map((object) => Math.hypot(object.position[0] - center[0], object.position[2] - center[2]) + 8)); const spawnDistance = Math.max(14, (entity.combat?.aggroRange ?? 12) + 5); const entrance: Vector3Tuple = [center[0], center[1], center[2] + Math.min(radius - 5, spawnDistance)]; const spawn = { ...boss.spawn, spawnMask: undefined, spawnChance: 1 }; const objective = { id: spawn.id, name: boss.name, position: center }; return { session: { key: boss.key, name: boss.name, sourceDungeonId: source.id, entityId: boss.entityId, runtimeIds: [spawn.id] }, definition: { ...source, id, title: `${boss.name} — Boss Testing`, availability: "available", assets: { visual: [], collision: [] }, bounds: { min: [center[0] - radius, center[1] - 2, center[2] - radius], max: [center[0] + radius, center[1] + 24, center[2] + radius] }, entrance: { name: "Testing room", footPosition: entrance, yaw: Math.PI, forward: [0, 0, -1] }, areas: [{ id: "testing-room", name: "Boss Testing", center, radius: radius * 2 }], entities: Object.fromEntries([...entityIds].filter((entityId) => source.entities[entityId]) .map((entityId) => [entityId, remapEncounterReferences(source.entities[entityId], encounterIds)])), staticSpawns: [spawn], roamingPacks: [], bosses: [objective], objectiveOrder: [objective], navigationLinks: [], traversalRamps: [], encounters, encounterSpawnGroups: groups, serverGameObjects: objects, presentation: { ...source.presentation, location: "Boss Testing", summary: "An isolated room for testing boss mechanics.", loadingMessage: `Preparing ${boss.name}…`, background: "#111923", fog: { color: "#111923", near: radius, far: radius * 3 }, ambientLight: { color: "#ffffff", intensity: 1.2 }, hemisphereLight: { skyColor: "#c8ddf0", groundColor: "#444c57", intensity: 1.4 }, map: { width: 600, height: 600, padding: 20, contours: [] } }, }, }; }