import { useMemo, type ReactNode } from "react"; import { useShellStore } from "../app/shellStore"; import { useCombatStore } from "../game/combatStore"; import { requireDungeonDefinition } from "../game/dungeonRegistry"; import type { Vector3Tuple, WorldSpawn } from "../game/dungeonTypes"; import { authoredStageConfigForBoss, isGameMasterSession, resolvedBossAdminCatalog, type ManastormAdminStageConfig, type ResolvedBossAdminEntry, } from "../game/manastormAdminConfig"; import { useManastormAdminStore } from "../game/manastormAdminStore"; import { getMobRuntimeTransform } from "../game/mobRuntimeRegistry"; import { useGameStore } from "../game/store"; const AXES = ["X", "Y", "Z"] as const; function formatCoordinate(value: number): string { return Number.isFinite(value) ? value.toFixed(2) : "—"; } function VectorEditor(props: { readonly label: string; readonly value: Vector3Tuple; readonly onChange: (value: Vector3Tuple) => void; readonly extra?: ReactNode; }) { const setAxis = (axis: number, raw: number) => { if (!Number.isFinite(raw)) return; const next = [...props.value] as [number, number, number]; next[axis] = raw; props.onChange(next); }; const nudge = (axis: number, amount: number) => setAxis(axis, props.value[axis] + amount); return (
{props.label}
{AXES.map((axis, index) => ( ))}
{props.extra}
); } function targetSummary(target: ReturnType["mobs"][string] | null, id: string | null): string { if (!target || !id) return "No hostile target selected"; return `${target.name} · ${target.boss ? "Boss" : "Mob"} · ${id}`; } export function GmWorldEditor() { const overlay = useGameStore((state) => state.overlay); const activeDungeonId = useGameStore((state) => state.activeDungeonId); const playerPosition = useGameStore((state) => state.playerPosition); const playerGrounded = useGameStore((state) => state.playerGrounded); const cameraYaw = useGameStore((state) => state.cameraYaw); const closeOverlay = useGameStore((state) => state.closeOverlay); const session = useShellStore((state) => state.session); const selectedTargetId = useCombatStore((state) => state.selectedTargetId); const selectedTarget = useCombatStore((state) => state.selectedTargetId ? state.mobs[state.selectedTargetId] ?? null : null); const draft = useManastormAdminStore((state) => state.draft); const dirty = useManastormAdminStore((state) => state.dirty); const saving = useManastormAdminStore((state) => state.saving); const error = useManastormAdminStore((state) => state.error); const editingBossKey = useManastormAdminStore((state) => state.editingBossKey); const replaceDraft = useManastormAdminStore((state) => state.replaceDraft); const publish = useManastormAdminStore((state) => state.publish); const setEditingBossKey = useManastormAdminStore((state) => state.setEditingBossKey); const catalog = useMemo(() => resolvedBossAdminCatalog(draft), [draft]); const dungeon = requireDungeonDefinition(activeDungeonId); const dungeonBosses = catalog.filter((entry) => entry.dungeonId === activeDungeonId); const entry = catalog.find((candidate) => candidate.key === editingBossKey && candidate.dungeonId === activeDungeonId) ?? null; const stage = entry ? authoredStageConfigForBoss(entry, draft) : null; const targetTransform = selectedTargetId ? getMobRuntimeTransform(selectedTargetId) : null; if (overlay !== "gm" || !isGameMasterSession(session)) return null; const updateStage = (activeEntry: ResolvedBossAdminEntry, nextStage: ManastormAdminStageConfig) => { const previous = draft.bosses[activeEntry.key]; replaceDraft({ ...draft, bosses: { ...draft.bosses, [activeEntry.key]: { status: previous?.status === "not-ready" ? "not-ready" : "needs-tested", stage: nextStage, }, }, }); }; const captureDungeonAnchor = () => { if (!playerGrounded) return; replaceDraft({ ...draft, dungeonSpawns: { ...draft.dungeonSpawns, [activeDungeonId]: { footPosition: [...playerPosition] as Vector3Tuple, yaw: cameraYaw }, }, }); }; const captureBoss = () => { if (!selectedTarget?.boss || !selectedTargetId || !targetTransform) return; const matching = dungeonBosses.find((candidate) => candidate.bossRuntimeId === selectedTargetId) ?? dungeonBosses.find((candidate) => candidate.name.toLowerCase() === selectedTarget.name.toLowerCase()); if (!matching) return; const targetStage = authoredStageConfigForBoss(matching, draft); if (!targetStage) return; setEditingBossKey(matching.key); updateStage(matching, { ...targetStage, bossRuntimeId: selectedTargetId, bossPosition: [...targetTransform.position] as Vector3Tuple, bossYaw: targetTransform.yaw, }); }; const setGroupSpawn = (spawn: WorldSpawn) => { if (!entry || !stage) return; updateStage(entry, { ...stage, groupSpawn: spawn }); }; const setLinked = (ids: readonly string[]) => { if (!entry || !stage) return; const unique = [...new Set(ids)].filter((id) => id !== stage.bossRuntimeId); updateStage(entry, { ...stage, linkedMobRuntimeIds: unique, requiredKillCount: Math.min(stage.requiredKillCount, unique.length), }); }; const canAddTarget = Boolean( entry && stage && selectedTargetId && selectedTarget && !selectedTarget.boss && !stage.linkedMobRuntimeIds.includes(selectedTargetId), ); return (

Live world authoring

{dungeon.title}

{dirty ? "Unsaved changes" : "Published revision loaded"}
Player {playerPosition.map(formatCoordinate).join(", ")} Yaw {formatCoordinate(cameraYaw)} Grounded {playerGrounded ? "Yes" : "No"} Target {targetSummary(selectedTarget, selectedTargetId)}
{entry && stage ? (

{entry.name}

{entry.status === "not-ready" ? "Not ready" : "Edits return this boss to Needs tested"} · Map {stage.mapId}

updateStage(entry, { ...stage, bossPosition })} extra={( )} /> setGroupSpawn({ ...stage.groupSpawn, footPosition })} extra={(
)} /> updateStage(entry, { ...stage, portalPosition })} extra={} />

Chaotic Link prerequisites

Target an exact non-boss mob, then add its runtime ID.

{stage.linkedMobRuntimeIds.map((id) => (
{id}
))} {!stage.linkedMobRuntimeIds.length ?

No prerequisite mobs selected. The boss begins unempowered.

: null}
) : (

Select a boss record, or target a live boss and capture it.

)}
{error ?

{error}

:

Markers: party · boss · portal · linked

}
); }