import { DUNGEON_DEFINITIONS, dungeonDefinitionById } from "./dungeonRegistry"; import type { DungeonBossObjective, Vector3Tuple, WorldSpawn } from "./dungeonTypes"; import { runtimeStages, type ManastormCatalogLike, type ManastormEncounterDefinition, } from "./manastorm"; import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "./manastormInstalledCatalog"; export const MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION = 1; export const MANASTORM_ADMIN_CACHE_KEY = "healer-man.manastorm-admin-config.v1"; export type ManastormBossStatus = "ready" | "needs-tested" | "not-ready"; export interface ManastormGlobalOptions { readonly enabled: boolean; readonly levelingEnabled: boolean; readonly endgameEnabled: boolean; readonly affixesEnabled: boolean; readonly chaoticLinkEnabled: boolean; readonly milestoneEncountersEnabled: boolean; } export interface ManastormAdminStageConfig { readonly dungeonId: string; readonly mapId: number; readonly baseStageId?: string; readonly assetPackageId?: string; readonly bossRuntimeId: string; readonly bossEntityId: string; readonly bossName: string; readonly bossPosition: Vector3Tuple; readonly bossYaw: number; readonly groupSpawn: WorldSpawn; readonly portalPosition: Vector3Tuple; readonly linkedMobRuntimeIds: readonly string[]; readonly requiredKillCount: number; } export interface ManastormBossAdminRecord { readonly status: ManastormBossStatus; readonly stage?: ManastormAdminStageConfig; } export interface ManastormAdminConfig { readonly schemaVersion: typeof MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION; readonly global: ManastormGlobalOptions; readonly dungeonSpawns: Readonly>; readonly bosses: Readonly>; } export interface PublishedManastormAdminConfig { readonly revision: number; readonly updatedAt: number | null; readonly config: ManastormAdminConfig; } export interface UnifiedBossCatalogEntry { readonly key: string; readonly name: string; readonly dungeonId: string | null; readonly dungeonTitle: string; readonly mapId: number; readonly bossRuntimeId: string; readonly bossEntityId: string; readonly source: readonly ("dungeon" | "installed-manastorm")[]; readonly sourceIds: readonly string[]; readonly baseStage: ManastormEncounterDefinition | null; readonly objective: DungeonBossObjective | null; } export interface ResolvedBossAdminEntry extends UnifiedBossCatalogEntry { readonly status: ManastormBossStatus; readonly stage: ManastormEncounterDefinition | null; readonly runnable: boolean; readonly validationIssues: readonly string[]; readonly inPublicPool: boolean; } export const DEFAULT_MANASTORM_ADMIN_CONFIG: ManastormAdminConfig = Object.freeze({ schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION, global: Object.freeze({ enabled: true, levelingEnabled: true, endgameEnabled: true, affixesEnabled: true, chaoticLinkEnabled: true, milestoneEncountersEnabled: true, }), dungeonSpawns: Object.freeze({}), bosses: Object.freeze({}), }); function normalizedName(value: string): string { return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); } function finiteVector(value: unknown): value is Vector3Tuple { return Array.isArray(value) && value.length === 3 && value.every((coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate)); } function normalizedSpawn(value: unknown): WorldSpawn | null { if (!value || typeof value !== "object") return null; const input = value as Partial; if (!finiteVector(input.footPosition) || typeof input.yaw !== "number" || !Number.isFinite(input.yaw)) return null; return { footPosition: [...input.footPosition] as Vector3Tuple, yaw: input.yaw }; } function normalizedStage(value: unknown): ManastormAdminStageConfig | null { if (!value || typeof value !== "object") return null; const input = value as Partial; if ( typeof input.dungeonId !== "string" || !Number.isInteger(input.mapId) || typeof input.bossRuntimeId !== "string" || typeof input.bossEntityId !== "string" || typeof input.bossName !== "string" || !finiteVector(input.bossPosition) || typeof input.bossYaw !== "number" || !Number.isFinite(input.bossYaw) || !finiteVector(input.portalPosition) || !Array.isArray(input.linkedMobRuntimeIds) || !input.linkedMobRuntimeIds.every((id) => typeof id === "string" && Boolean(id)) || !Number.isInteger(input.requiredKillCount) ) return null; const groupSpawn = normalizedSpawn(input.groupSpawn); const linkedMobRuntimeIds = [...new Set(input.linkedMobRuntimeIds)]; if (!groupSpawn || input.requiredKillCount! < 0 || input.requiredKillCount! > linkedMobRuntimeIds.length) return null; return { dungeonId: input.dungeonId, mapId: input.mapId!, ...(typeof input.baseStageId === "string" && input.baseStageId ? { baseStageId: input.baseStageId } : {}), ...(typeof input.assetPackageId === "string" && input.assetPackageId ? { assetPackageId: input.assetPackageId } : {}), bossRuntimeId: input.bossRuntimeId, bossEntityId: input.bossEntityId, bossName: input.bossName, bossPosition: [...input.bossPosition] as Vector3Tuple, bossYaw: input.bossYaw, groupSpawn, portalPosition: [...input.portalPosition] as Vector3Tuple, linkedMobRuntimeIds, requiredKillCount: input.requiredKillCount!, }; } export function normalizeManastormAdminConfig(value: unknown): ManastormAdminConfig { if (!value || typeof value !== "object") return DEFAULT_MANASTORM_ADMIN_CONFIG; const input = value as Partial; if (input.schemaVersion !== MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION) return DEFAULT_MANASTORM_ADMIN_CONFIG; const globalInput = input.global as Partial | undefined; const global: ManastormGlobalOptions = { enabled: globalInput?.enabled !== false, levelingEnabled: globalInput?.levelingEnabled !== false, endgameEnabled: globalInput?.endgameEnabled !== false, affixesEnabled: globalInput?.affixesEnabled !== false, chaoticLinkEnabled: globalInput?.chaoticLinkEnabled !== false, milestoneEncountersEnabled: globalInput?.milestoneEncountersEnabled !== false, }; const dungeonSpawns = Object.fromEntries(Object.entries(input.dungeonSpawns ?? {}).flatMap(([id, value]) => { const spawn = normalizedSpawn(value); return id && spawn ? [[id, spawn]] : []; })); const bosses = Object.fromEntries(Object.entries(input.bosses ?? {}).flatMap(([key, value]) => { if (!key || !value || typeof value !== "object") return []; const candidate = value as Partial; const status: ManastormBossStatus = candidate.status === "ready" || candidate.status === "not-ready" ? candidate.status : "needs-tested"; const stage = normalizedStage(candidate.stage); return [[key, { status, ...(stage ? { stage } : {}) }]]; })); return { schemaVersion: MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION, global, dungeonSpawns, bosses }; } function dungeonBossEntityId(dungeonId: string, objectiveId: string): string { const dungeon = dungeonDefinitionById(dungeonId); const staticSpawn = dungeon?.staticSpawns.find((spawn) => spawn.id === objectiveId); if (staticSpawn) return staticSpawn.entityId; for (const pack of dungeon?.roamingPacks ?? []) { const member = pack.members.find((candidate) => `${pack.id}:${candidate.id}` === objectiveId); if (member) return member.entityId; } return objectiveId; } function entryMergeKey(mapId: number, name: string): string { return `${mapId}:${normalizedName(name)}`; } export function buildUnifiedBossCatalog(): readonly UnifiedBossCatalogEntry[] { const entries = new Map(); for (const dungeon of DUNGEON_DEFINITIONS) { for (const objective of dungeon.bosses) { const mergeKey = entryMergeKey(dungeon.mapId, objective.name); entries.set(mergeKey, { key: `dungeon:${dungeon.id}:boss:${objective.id}`, name: objective.name, dungeonId: dungeon.id, dungeonTitle: dungeon.title, mapId: dungeon.mapId, bossRuntimeId: objective.id, bossEntityId: dungeonBossEntityId(dungeon.id, objective.id), source: ["dungeon"], sourceIds: [objective.id], baseStage: null, objective, }); } } for (const stage of runtimeStages(INSTALLED_MANASTORM_RUNTIME_CATALOG)) { const mapId = stage.mapId ?? dungeonDefinitionById(stage.dungeonId)?.mapId ?? 0; const mergeKey = entryMergeKey(mapId, stage.bossName); const matchingDungeon = DUNGEON_DEFINITIONS.find((dungeon) => dungeon.mapId === mapId) ?? null; const existing = entries.get(mergeKey); const mergedStage = existing?.baseStage ? { ...stage, modeIds: [...new Set([...(existing.baseStage.modeIds ?? []), ...(stage.modeIds ?? [])])], } : stage; const key = stage.encounterId !== undefined ? `map:${mapId}:encounter:${stage.encounterId}` : existing?.key ?? `manastorm:${stage.id}`; entries.set(mergeKey, { key, name: stage.bossName, dungeonId: matchingDungeon?.id ?? existing?.dungeonId ?? null, dungeonTitle: matchingDungeon?.title ?? stage.location ?? existing?.dungeonTitle ?? `Map ${mapId}`, mapId, bossRuntimeId: existing?.bossRuntimeId ?? stage.bossRuntimeId, bossEntityId: existing?.bossEntityId ?? stage.bossEntityId, source: [...new Set([...(existing?.source ?? []), "installed-manastorm" as const])], sourceIds: [...new Set([...(existing?.sourceIds ?? []), stage.id])], baseStage: mergedStage, objective: existing?.objective ?? null, }); } return Object.freeze([...entries.values()].sort((left, right) => ( left.dungeonTitle.localeCompare(right.dungeonTitle) || left.name.localeCompare(right.name) ))); } export const UNIFIED_MANASTORM_BOSS_CATALOG = buildUnifiedBossCatalog(); function knownMobRuntimeIds(entry: UnifiedBossCatalogEntry): ReadonlySet { const ids = new Set(entry.baseStage?.trashRuntimeIds ?? []); const dungeon = entry.dungeonId ? dungeonDefinitionById(entry.dungeonId) : null; for (const spawn of dungeon?.staticSpawns ?? []) ids.add(spawn.id); for (const pack of dungeon?.roamingPacks ?? []) { for (const member of pack.members) ids.add(`${pack.id}:${member.id}`); } return ids; } export function configuredStageForBoss( entry: UnifiedBossCatalogEntry, config: ManastormAdminConfig, ): ManastormEncounterDefinition | null { const override = config.bosses[entry.key]?.stage; const base = entry.baseStage; if (!override && !base) return null; const dungeonId = override?.dungeonId ?? entry.dungeonId ?? base?.dungeonId; if (!dungeonId) return null; return { ...(base ?? {}), id: base?.id ?? `gm:stage:${entry.key}`, dungeonId, mapId: override?.mapId ?? base?.mapId ?? entry.mapId, location: base?.location ?? entry.dungeonTitle, ...(override?.assetPackageId || base?.assetPackageId ? { assetPackageId: override?.assetPackageId ?? base?.assetPackageId } : {}), bossRuntimeId: override?.bossRuntimeId ?? base?.bossRuntimeId ?? entry.bossRuntimeId, bossEntityId: override?.bossEntityId ?? base?.bossEntityId ?? entry.bossEntityId, bossName: override?.bossName ?? base?.bossName ?? entry.name, bossPosition: override?.bossPosition ?? base?.bossPosition ?? entry.objective?.position ?? [0, 0, 0], bossYaw: override?.bossYaw ?? base?.bossYaw ?? 0, playerSpawn: override?.groupSpawn ?? base?.playerSpawn ?? config.dungeonSpawns[dungeonId] ?? dungeonDefinitionById(dungeonId)?.entrance ?? { footPosition: [0, 0, 0], yaw: 0 }, portalPosition: override?.portalPosition ?? base?.portalPosition ?? config.dungeonSpawns[dungeonId]?.footPosition ?? dungeonDefinitionById(dungeonId)?.entrance.footPosition ?? [0, 0, 0], trashRuntimeIds: override?.linkedMobRuntimeIds ?? base?.trashRuntimeIds ?? [], requiredGuardianKills: override?.requiredKillCount ?? base?.requiredGuardianKills, modeIds: base?.modeIds ?? [0, 2], stageKind: base?.stageKind ?? "standard", unlockLevel: base?.unlockLevel ?? 1, minimumPartySize: base?.minimumPartySize ?? 1, maximumPartySize: base?.maximumPartySize ?? 5, }; } /** Produces the complete persisted override edited by GM tooling. */ export function authoredStageConfigForBoss( entry: UnifiedBossCatalogEntry, config: ManastormAdminConfig, ): ManastormAdminStageConfig | null { const stage = configuredStageForBoss(entry, config); if (!stage) { if (!entry.dungeonId || !entry.objective) return null; const dungeon = dungeonDefinitionById(entry.dungeonId); if (!dungeon) return null; const groupSpawn = config.dungeonSpawns[entry.dungeonId] ?? dungeon.entrance; const staticBoss = dungeon.staticSpawns.find((spawn) => spawn.id === entry.bossRuntimeId); return { dungeonId: entry.dungeonId, mapId: entry.mapId, bossRuntimeId: entry.bossRuntimeId, bossEntityId: entry.bossEntityId, bossName: entry.name, bossPosition: [...entry.objective.position] as Vector3Tuple, bossYaw: staticBoss?.yaw ?? 0, groupSpawn: { footPosition: [...groupSpawn.footPosition] as Vector3Tuple, yaw: groupSpawn.yaw }, portalPosition: [...groupSpawn.footPosition] as Vector3Tuple, linkedMobRuntimeIds: [], requiredKillCount: 0, }; } return { dungeonId: stage.dungeonId, mapId: stage.mapId ?? entry.mapId, baseStageId: stage.id, ...(stage.assetPackageId ? { assetPackageId: stage.assetPackageId } : {}), bossRuntimeId: stage.bossRuntimeId, bossEntityId: stage.bossEntityId, bossName: stage.bossName, bossPosition: [...stage.bossPosition] as Vector3Tuple, bossYaw: stage.bossYaw ?? 0, groupSpawn: { footPosition: [...stage.playerSpawn.footPosition] as Vector3Tuple, yaw: stage.playerSpawn.yaw, }, portalPosition: [...stage.portalPosition] as Vector3Tuple, linkedMobRuntimeIds: [...(stage.trashRuntimeIds ?? [])], requiredKillCount: Math.max( 0, Math.min(stage.trashRuntimeIds?.length ?? 0, stage.requiredGuardianKills ?? Math.ceil((stage.trashRuntimeIds?.length ?? 0) / 2)), ), }; } /** Materializes inherited stage data when a catalog boss is promoted to Ready. */ export function bossAdminRecordForStatus( entry: UnifiedBossCatalogEntry, config: ManastormAdminConfig, status: ManastormBossStatus, ): ManastormBossAdminRecord { const existing = config.bosses[entry.key]; const stage = status === "ready" ? (existing?.stage ?? authoredStageConfigForBoss(entry, config) ?? undefined) : existing?.stage; return { ...existing, status, ...(stage ? { stage } : {}) }; } export function resolveBossAdminEntry( entry: UnifiedBossCatalogEntry, config: ManastormAdminConfig, ): ResolvedBossAdminEntry { const status = config.bosses[entry.key]?.status ?? "needs-tested"; const stage = configuredStageForBoss(entry, config); const issues: string[] = []; if (!stage) issues.push("No runnable Manastorm stage has been configured."); if (stage && !dungeonDefinitionById(stage.dungeonId)) issues.push("The configured dungeon is not installed."); if (stage) { const dungeon = dungeonDefinitionById(stage.dungeonId); if (entry.dungeonId && stage.dungeonId !== entry.dungeonId) issues.push("The stage references a different dungeon than its boss record."); if (dungeon && stage.mapId !== dungeon.mapId) issues.push("The stage map does not match its dungeon."); const knownIds = knownMobRuntimeIds(entry); const missingIds = (stage.trashRuntimeIds ?? []).filter((id) => !knownIds.has(id)); if (missingIds.length) issues.push(`Missing linked mobs: ${missingIds.join(", ")}.`); const required = stage.requiredGuardianKills ?? Math.ceil((stage.trashRuntimeIds?.length ?? 0) / 2); if (required < 0 || required > (stage.trashRuntimeIds?.length ?? 0)) issues.push("The required kill count is outside the linked mob set."); } const runnable = Boolean(stage && issues.length === 0); return { ...entry, status, stage, runnable, validationIssues: Object.freeze(issues), inPublicPool: status === "ready" && runnable, }; } export function resolvedBossAdminCatalog(config: ManastormAdminConfig): readonly ResolvedBossAdminEntry[] { return UNIFIED_MANASTORM_BOSS_CATALOG.map((entry) => resolveBossAdminEntry(entry, config)); } export function effectiveManastormCatalog( config: ManastormAdminConfig, base: ManastormCatalogLike = INSTALLED_MANASTORM_RUNTIME_CATALOG, ): ManastormCatalogLike { const stages = resolvedBossAdminCatalog(config) .filter((entry) => entry.inPublicPool) .flatMap((entry) => entry.stage ? [entry.stage] : []); return Object.freeze({ ...base, stages: Object.freeze(stages.map((stage) => Object.freeze({ ...stage, chaoticLinkEnabled: config.global.chaoticLinkEnabled, }))), fallbackPolicy: "none" as const, runtimeOptions: Object.freeze({ ...config.global }), }); } export function isGameMasterSession(session: { readonly kind: string; readonly accessToken?: string; readonly roles?: readonly string[] } | null): boolean { return Boolean(session?.kind === "account" && session.accessToken && session.roles?.includes("gm")); } export function derivePartySpawnSlots(spawn: WorldSpawn, partySize = 5): readonly WorldSpawn[] { const size = Math.max(1, Math.min(5, Math.trunc(partySize))); const offsets = [ [0, 0], [-1.65, -1.8], [1.65, -1.8], [-3.05, -3.5], [3.05, -3.5], ] as const; const sin = Math.sin(spawn.yaw); const cos = Math.cos(spawn.yaw); return Object.freeze(offsets.slice(0, size).map(([lateral, forward]) => ({ footPosition: [ spawn.footPosition[0] + lateral * cos + forward * sin, spawn.footPosition[1], spawn.footPosition[2] - lateral * sin + forward * cos, ] as const, yaw: spawn.yaw, }))); }