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 (
);
}
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.
)}
);
}