Files
healer-man/src/game/manastormAdminConfig.ts
T
2026-08-18 16:22:19 -04:00

426 lines
18 KiB
TypeScript

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<Record<string, WorldSpawn>>;
readonly bosses: Readonly<Record<string, ManastormBossAdminRecord>>;
}
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<WorldSpawn>;
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<ManastormAdminStageConfig>;
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<ManastormAdminConfig>;
if (input.schemaVersion !== MANASTORM_ADMIN_CONFIG_SCHEMA_VERSION) return DEFAULT_MANASTORM_ADMIN_CONFIG;
const globalInput = input.global as Partial<ManastormGlobalOptions> | 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<ManastormBossAdminRecord>;
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<string, UnifiedBossCatalogEntry>();
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<string> {
const ids = new Set<string>(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,
})));
}