426 lines
18 KiB
TypeScript
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,
|
|
})));
|
|
}
|