import { useEffect, useMemo, useRef, useState } from "react"; import ContentSection from "./components/ContentSection"; import ConfigSection from "./components/ConfigSection"; import EditorToolbar from "./components/EditorToolbar"; import StatusFooter from "./components/StatusFooter"; import TopNavTabs from "./components/TopNavTabs"; import { openWorldshaperStudioWindow } from "./shared/windowing"; import { CONFIG_TAB_TO_KEY, DIALOGUE_NODE_FIELD_ORDER, DIALOGUE_REACTION_TYPES, EDIT_TABS_BY_TYPE, EDIT_TAB_FIELDS_BY_TYPE, FALLBACK_CONDITION_TYPES, FIELD_ORDER_BY_TYPE, FLOW_KIND_LABELS, ROOT_KEY_BY_TYPE, TYPE_LABELS, fetchJsonOrThrow, formatTypeLabel, isPlainObject, buildDefaultRecord, getRecordLabel, normalizeHexColor, getSpritePalette, buildSpritePreviewDataUrl, getSpriteEditorSize, getSpriteCellSymbol, paintSpriteCell, toFieldLabel, normalizeStringList, parseCsv, getCatalogEntryIdValue, normalizeCatalogEntryIdentity, normalizeCatalogMetaIdentity, createDefaultSystemActionEntries, createDefaultColorEntries, setUnifiedColorEntries, getConditionBaseType, getDialogueNodes, normalizeDialogueNodesForSave, normalizeNpcRecordForLoad, normalizeNpcPayloadForLoad, normalizeNpcRecordForSave, normalizeNpcPayloadForSave, normalizeImagesPayloadForSave, normalizeSpritePayloadForSave, normalizeTileRecordForSave, normalizeTilesPayloadForSave, createFlowStep, getPlainObjectArray, normalizePlainObjectArray, nodeToFlowSteps, flowStepsToNode, cloneSandbox, parseItemValue, resolveNodeConditionTextAndNext, getVisibleChoices, applyReactionToSandbox, applyNodeReactionsToSandbox, } from "./editorCore"; import type { CatalogEntry, CatalogMeta, ConfigCatalogKey, ConfigTabLabel, DialogueChoice, DialogueFlowStep, DialogueSandbox, JsonObject, JsonValue, } from "./editorCore"; type ValidationWorkerResponse = { requestId: number; issues: string[]; }; const LAST_ACTIVE_TYPE_STORAGE_KEY = "worldshaper:lastActiveType"; const DEFAULT_EDITOR_WORLD_ID_FALLBACK = "overworld"; function getContentRecordsForType(contentDataByType: Record, type: string): JsonObject[] { const payload = contentDataByType[type]; const root = ROOT_KEY_BY_TYPE[type]; const raw = root ? payload?.[root] : null; if (!Array.isArray(raw)) { return []; } return raw.filter((entry) => isPlainObject(entry)); } function App() { const [types, setTypes] = useState([]); const [activeType, setActiveType] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); const [selectedDialogueNodeIndex, setSelectedDialogueNodeIndex] = useState(0); const [collapsedStepIds, setCollapsedStepIds] = useState>({}); const [draggingStepId, setDraggingStepId] = useState(""); const [dropTargetStepId, setDropTargetStepId] = useState(""); const [activeSection, setActiveSection] = useState<"content" | "config">("content"); const [activeConfigTab, setActiveConfigTab] = useState("Conditions"); const [selectedConfigIndexByTab, setSelectedConfigIndexByTab] = useState>({}); const [activeEditTabByType, setActiveEditTabByType] = useState>({}); const [catalogMeta, setCatalogMeta] = useState({ conditions: [], itemActions: [], systemActions: [], effects: [], colors: createDefaultColorEntries(0), }); const [contentDataByType, setContentDataByType] = useState>({}); const [simSandbox, setSimSandbox] = useState({ questStarted: [], questCompleted: [], inventory: { copper_ore: 0 } }); const [simCurrentNodeId, setSimCurrentNodeId] = useState(""); const [simText, setSimText] = useState(""); const [simChoices, setSimChoices] = useState([]); const [simFallbackNextId, setSimFallbackNextId] = useState(""); const [simEnded, setSimEnded] = useState(true); const [jsonText, setJsonText] = useState(""); const [savedContentJsonText, setSavedContentJsonText] = useState(""); const [savedCatalogMetaJsonText, setSavedCatalogMetaJsonText] = useState(""); const [recordsDraft, setRecordsDraft] = useState(null); const [recordJsonDraft, setRecordJsonDraft] = useState(""); const [recordDraftPending, setRecordDraftPending] = useState(false); const [npcSpriteSearchQuery, setNpcSpriteSearchQuery] = useState(""); const [activeSpritePaintSymbol, setActiveSpritePaintSymbol] = useState("0"); const [status, setStatus] = useState("Loading available content types..."); const [error, setError] = useState(""); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [validationIssues, setValidationIssues] = useState([]); const validationWorkerRef = useRef(null); const validationRequestIdRef = useRef(0); const contentTypes = useMemo(() => types.filter((type) => Object.prototype.hasOwnProperty.call(ROOT_KEY_BY_TYPE, type) && type !== "npcs"), [types]); const configTabLabels: ConfigTabLabel[] = ["Conditions", "Item Actions", "System Actions", "Effects", "Colors"]; const activeConfigKey = useMemo(() => CONFIG_TAB_TO_KEY[activeConfigTab], [activeConfigTab]); function readLastActiveType(): string { try { return String(window.localStorage.getItem(LAST_ACTIVE_TYPE_STORAGE_KEY) || "").trim(); } catch { return ""; } } function requestActiveType(nextType: string): void { const normalizedType = String(nextType || "").trim(); if (!normalizedType) { return; } setError(""); setIsLoading(true); setStatus(`Loading ${formatTypeLabel(normalizedType)}...`); setActiveType(normalizedType); } useEffect(() => { fetchJsonOrThrow<{ types?: string[] }>("/api/types") .then((payload) => { const nextTypes = Array.isArray(payload.types) ? payload.types : []; setTypes(nextTypes); if (nextTypes.length > 0) { const visibleContentTypes = nextTypes.filter((type) => type !== "npcs" && Object.prototype.hasOwnProperty.call(ROOT_KEY_BY_TYPE, type)); const storedRawType = readLastActiveType(); const storedType = storedRawType === "npcs" ? "npc_templates" : storedRawType; const preferredType = storedType && visibleContentTypes.includes(storedType) ? storedType : (visibleContentTypes[0] || nextTypes[0]); requestActiveType(preferredType); } fetchJsonOrThrow("/api/catalog-meta") .then((meta) => { const normalizedMeta = meta && typeof meta === "object" ? normalizeCatalogMetaIdentity(meta) : { conditions: [], itemActions: [], systemActions: [], effects: [], colors: createDefaultColorEntries(0), }; if (!Array.isArray(normalizedMeta.colors) || normalizedMeta.colors.length === 0) { normalizedMeta.colors = createDefaultColorEntries(0); } setCatalogMeta(normalizedMeta); setSavedCatalogMetaJsonText(JSON.stringify(normalizedMeta)); }) .catch(() => { const emptyMeta = { conditions: [], itemActions: [], systemActions: [], effects: [], colors: createDefaultColorEntries(0), }; setCatalogMeta(emptyMeta); setSavedCatalogMetaJsonText(JSON.stringify(emptyMeta)); }); if (nextTypes.length > 0) { Promise.all(nextTypes.map((type) => fetchJsonOrThrow(`/api/content/${type}`))) .then((payloads) => { const nextByType: Record = {}; nextTypes.forEach((type, index) => { const payload = payloads[index]; const root = ROOT_KEY_BY_TYPE[type]; // Normalize NPC records on load to flatten position data if (type === "npcs" && isPlainObject(payload) && Array.isArray(payload[root])) { payload[root] = (payload[root] as JsonObject[]).map((rec) => normalizeNpcRecordForLoad(rec)); } if (type === "tiles") { const normalizedTiles = normalizeTilesPayloadForSave(payload as JsonValue); if (isPlainObject(normalizedTiles)) { nextByType[type] = normalizedTiles; return; } } if (type === "images") { const normalizedImages = normalizeImagesPayloadForSave(payload as JsonValue); if (isPlainObject(normalizedImages)) { nextByType[type] = normalizedImages; return; } } nextByType[type] = payload; }); setContentDataByType(nextByType); }) .catch(() => { setContentDataByType({}); }); } setStatus(nextTypes.length > 0 ? "Select a type to begin editing." : "No content types available."); }) .catch((err: unknown) => { setError(String(err)); setStatus("Failed to load content types."); }); }, []); useEffect(() => { if (!activeType) { return; } try { const persistType = activeType === "npcs" ? "npc_templates" : activeType; window.localStorage.setItem(LAST_ACTIVE_TYPE_STORAGE_KEY, persistType); } catch { // Ignore storage failures (private browsing / blocked storage). } }, [activeType]); useEffect(() => { if (!activeType) { return; } fetchJsonOrThrow(`/api/content/${activeType}`) .then((payload) => { let normalized = payload; if (activeType === "npcs") { normalized = normalizeNpcPayloadForLoad(normalized); normalized = normalizeNpcPayloadForSave(normalized); } else if (activeType === "images") { normalized = normalizeImagesPayloadForSave(normalized); } else if (activeType === "sprites") { normalized = normalizeSpritePayloadForSave(normalized); } else if (activeType === "tiles") { normalized = normalizeTilesPayloadForSave(normalized); } const rootKeyForType = ROOT_KEY_BY_TYPE[activeType] || ""; let nextSelectedIndex = -1; if (rootKeyForType && normalized && typeof normalized === "object" && !Array.isArray(normalized)) { const list = (normalized as JsonObject)[rootKeyForType]; if (Array.isArray(list) && list.length > 0) { nextSelectedIndex = 0; } } const normalizedText = JSON.stringify(normalized, null, 2); setJsonText(normalizedText); setSavedContentJsonText(normalizedText); setRecordsDraft(null); if (nextSelectedIndex >= 0 && rootKeyForType) { const nextRecords = (normalized as JsonObject)[rootKeyForType]; const nextSelectedRecord = Array.isArray(nextRecords) ? nextRecords[nextSelectedIndex] : null; setRecordJsonDraft(nextSelectedRecord ? JSON.stringify(nextSelectedRecord, null, 2) : ""); } else { setRecordJsonDraft(""); } setRecordDraftPending(false); setSelectedIndex(nextSelectedIndex); setStatus(`Loaded ${formatTypeLabel(activeType)}.`); }) .catch((err: unknown) => { setError(String(err)); setStatus(`Failed to load ${formatTypeLabel(activeType)}.`); }) .finally(() => { setIsLoading(false); }); }, [activeType]); const parsedJsonError = useMemo(() => { if (!jsonText.trim()) { return "JSON is empty."; } try { JSON.parse(jsonText); return ""; } catch (err: unknown) { return String(err); } }, [jsonText]); const parsedPayload = useMemo(() => { if (parsedJsonError) { return null; } try { return JSON.parse(jsonText) as JsonObject; } catch { return null; } }, [jsonText, parsedJsonError]); const rootKey = ROOT_KEY_BY_TYPE[activeType] || ""; const committedRecords = useMemo(() => { if (!parsedPayload || !rootKey) { return [] as JsonObject[]; } const list = parsedPayload[rootKey]; if (!Array.isArray(list)) { return [] as JsonObject[]; } const filtered = list.filter((entry) => isPlainObject(entry)); // Normalize NPC records on load to flatten position data if (activeType === "npcs") { return filtered.map((entry) => normalizeNpcRecordForLoad(entry)); } return filtered; }, [parsedPayload, rootKey, activeType]); const records = useMemo(() => (recordsDraft ?? committedRecords), [recordsDraft, committedRecords]); const selectedRecordIndex = useMemo(() => { if (records.length === 0) { return -1; } if (!Number.isInteger(selectedIndex) || selectedIndex < 0) { return -1; } return Math.min(selectedIndex, records.length - 1); }, [records.length, selectedIndex]); const isAllRecordsSelected = selectedRecordIndex < 0; const baseSelectedRecord = records[selectedRecordIndex] || null; const selectedRecordJson = useMemo(() => (baseSelectedRecord ? JSON.stringify(baseSelectedRecord, null, 2) : ""), [baseSelectedRecord]); const selectedRecord = useMemo(() => { if (isAllRecordsSelected || !baseSelectedRecord) { return baseSelectedRecord; } try { const parsed = JSON.parse(recordJsonDraft) as JsonValue; if (isPlainObject(parsed)) { return activeType === "npcs" ? normalizeNpcRecordForLoad(parsed) : parsed; } } catch { // Keep using the last committed record while draft text is invalid JSON. } return baseSelectedRecord; }, [activeType, isAllRecordsSelected, baseSelectedRecord, recordJsonDraft]); const isRecordDraftDirty = useMemo(() => { if (activeSection !== "content" || isAllRecordsSelected || !baseSelectedRecord) { return false; } return recordJsonDraft !== selectedRecordJson; }, [activeSection, isAllRecordsSelected, recordJsonDraft, baseSelectedRecord, selectedRecordJson]); const hasPendingRecordChanges = activeSection === "content" && (Boolean(recordsDraft) || recordDraftPending); const hasUnsavedContentChanges = useMemo(() => { if (activeSection !== "content") { return false; } return hasPendingRecordChanges || jsonText !== savedContentJsonText; }, [activeSection, hasPendingRecordChanges, jsonText, savedContentJsonText]); const recordDraftError = useMemo(() => { if (activeSection !== "content" || isAllRecordsSelected || !baseSelectedRecord || !isRecordDraftDirty) { return ""; } try { const parsed = JSON.parse(recordJsonDraft) as JsonValue; if (!isPlainObject(parsed)) { return "Record draft must be a JSON object."; } return ""; } catch (err: unknown) { return String(err); } }, [activeSection, isAllRecordsSelected, baseSelectedRecord, isRecordDraftDirty, recordJsonDraft]); const dialogueNodes = useMemo(() => (activeType === "dialogues" ? getDialogueNodes(selectedRecord) : []), [activeType, selectedRecord]); const activeDialogueNodeIndex = useMemo(() => { if (dialogueNodes.length === 0) { return 0; } if (!Number.isInteger(selectedDialogueNodeIndex) || selectedDialogueNodeIndex < 0) { return 0; } return Math.min(selectedDialogueNodeIndex, dialogueNodes.length - 1); }, [dialogueNodes.length, selectedDialogueNodeIndex]); const selectedDialogueNode = dialogueNodes[activeDialogueNodeIndex] || null; const dialogueNodeById = useMemo(() => { const map = new Map(); dialogueNodes.forEach((node) => { const id = String(node.id || "").trim(); if (id) { map.set(id, node); } }); return map; }, [dialogueNodes]); const conditionCatalogMap = useMemo(() => { const map = new Map(); (Array.isArray(catalogMeta.conditions) ? catalogMeta.conditions : []).forEach((entry) => { const key = String(entry?.key || "").trim(); if (key) { map.set(key, entry); } }); return map; }, [catalogMeta.conditions]); const conditionTypeOptions = useMemo(() => { const keys = (Array.isArray(catalogMeta.conditions) ? catalogMeta.conditions : []) .map((entry) => String(entry?.key || "").trim()) .filter(Boolean); return keys.length > 0 ? keys : FALLBACK_CONDITION_TYPES; }, [catalogMeta.conditions]); const activeConfigEntries = useMemo(() => { const next = catalogMeta[activeConfigKey]; return Array.isArray(next) ? next : []; }, [activeConfigKey, catalogMeta]); const selectedConfigIndex = useMemo(() => { const candidate = Number(selectedConfigIndexByTab[activeConfigKey] ?? 0); if (!Number.isInteger(candidate) || candidate < 0) { return 0; } if (candidate >= activeConfigEntries.length) { return Math.max(0, activeConfigEntries.length - 1); } return candidate; }, [activeConfigEntries.length, activeConfigKey, selectedConfigIndexByTab]); const selectedConfigEntry = activeConfigEntries[selectedConfigIndex] || null; const hasUnsavedConfigChanges = useMemo(() => { return JSON.stringify(catalogMeta) !== savedCatalogMetaJsonText; }, [catalogMeta, savedCatalogMetaJsonText]); const selectedDialogueFieldEntries = useMemo(() => { if (!selectedDialogueNode) { return [] as Array<[string, JsonValue]>; } const entryMap = new Map(Object.entries(selectedDialogueNode)); const ordered: Array<[string, JsonValue]> = []; DIALOGUE_NODE_FIELD_ORDER.forEach((key) => { if (!entryMap.has(key)) { return; } ordered.push([key, entryMap.get(key) as JsonValue]); entryMap.delete(key); }); const rest = Array.from(entryMap.entries()).sort((a, b) => a[0].localeCompare(b[0])); return ordered.concat(rest); }, [selectedDialogueNode]); const selectedFlowSteps = useMemo(() => { if (!selectedDialogueNode) { return [] as DialogueFlowStep[]; } return nodeToFlowSteps(selectedDialogueNode); }, [selectedDialogueNode]); const selectedItemActions = useMemo(() => (activeType === "items" ? getItemActions(selectedRecord) : []), [activeType, selectedRecord]); const selectedQuestSteps = useMemo(() => (activeType === "quests" ? getQuestSteps(selectedRecord) : []), [activeType, selectedRecord]); const selectedSpriteEditorSize = useMemo(() => { if ((activeType !== "sprites" && activeType !== "tiles") || !selectedRecord) { return { width: 1, height: 1 }; } return getSpriteEditorSize(selectedRecord); }, [activeType, selectedRecord]); const npcSpriteOptions = useMemo(() => getContentRecordsForType(contentDataByType, "sprites"), [contentDataByType]); const allNpcRecords = useMemo(() => getContentRecordsForType(contentDataByType, "npcs"), [contentDataByType]); const allNpcTemplateRecords = useMemo(() => getContentRecordsForType(contentDataByType, "npc_templates"), [contentDataByType]); const npcTownOptions = useMemo(() => { const values = new Set(); getContentRecordsForType(contentDataByType, "npcs").forEach((record) => { const townId = String(record.mapId || "").trim(); if (townId) { values.add(townId); } }); return Array.from(values).sort((a, b) => a.localeCompare(b)); }, [contentDataByType]); const npcFactionOptions = useMemo(() => { return getContentRecordsForType(contentDataByType, "factions") .map((record) => { const id = String(record.id || "").trim(); const name = String(record.name || "").trim(); return id ? { id, name } : null; }) .filter((entry): entry is { id: string; name: string } => entry !== null) .sort((a, b) => a.id.localeCompare(b.id)); }, [contentDataByType]); const filteredNpcSpriteOptions = useMemo(() => { const query = npcSpriteSearchQuery.trim().toLowerCase(); if (!query) { return npcSpriteOptions; } return npcSpriteOptions.filter((spriteRecord) => { const id = String(spriteRecord.id || "").toLowerCase(); const name = String(spriteRecord.name || "").toLowerCase(); return id.includes(query) || name.includes(query); }); }, [npcSpriteOptions, npcSpriteSearchQuery]); const dialogueNodeIds = useMemo(() => { return dialogueNodes .map((node) => String(node.id || "").trim()) .filter(Boolean); }, [dialogueNodes]); const resolvedActiveSpritePaintSymbol = useMemo(() => { if ((activeType !== "sprites" && activeType !== "tiles") || !selectedRecord) { return activeSpritePaintSymbol; } const symbols = (Array.isArray(catalogMeta.colors) ? catalogMeta.colors : []) .map((entry) => String(entry.key || entry.sourceKey || entry.originalName || "").trim().charAt(0)) .filter(Boolean); if (!symbols.includes(".")) { symbols.push("."); } if (symbols.length === 0) { return "0"; } return symbols.includes(activeSpritePaintSymbol) ? activeSpritePaintSymbol : symbols[0]; }, [activeType, selectedRecord, catalogMeta.colors, activeSpritePaintSymbol]); useEffect(() => { setUnifiedColorEntries(Array.isArray(catalogMeta.colors) ? catalogMeta.colors : []); }, [catalogMeta.colors]); useEffect(() => { const worker = new Worker(new URL("./workers/validationWorker.ts", import.meta.url), { type: "module" }); validationWorkerRef.current = worker; worker.onmessage = (event: MessageEvent) => { const { requestId, issues } = event.data; if (requestId !== validationRequestIdRef.current) { return; } setValidationIssues(Array.isArray(issues) ? issues : []); }; return () => { worker.terminate(); validationWorkerRef.current = null; }; }, []); useEffect(() => { if (!parsedPayload || !rootKey) { validationRequestIdRef.current += 1; return; } const worker = validationWorkerRef.current; if (!worker) { return; } const requestId = validationRequestIdRef.current + 1; validationRequestIdRef.current = requestId; worker.postMessage({ requestId, activeType, rootKey, parsedPayload, records, }); }, [activeType, parsedPayload, records, rootKey]); const visibleValidationIssues = parsedPayload && rootKey ? validationIssues : []; const selectedFieldEntries = useMemo(() => { if (!selectedRecord) { return [] as Array<[string, JsonValue]>; } const priority = FIELD_ORDER_BY_TYPE[activeType] || []; const entryMap = new Map(Object.entries(selectedRecord)); const ordered: Array<[string, JsonValue]> = []; priority.forEach((key) => { if (!entryMap.has(key)) { return; } ordered.push([key, entryMap.get(key) as JsonValue]); entryMap.delete(key); }); const rest = Array.from(entryMap.entries()).sort((a, b) => a[0].localeCompare(b[0])); const merged = ordered.concat(rest); if (activeType === "npcs") { const seenCanonical = new Set(); return merged.filter(([key]) => { const lower = String(key || "").toLowerCase(); if (lower === "x" || lower === "y" || lower === "role" || lower === "mapid" || lower === "townid") { return false; } const canonical = lower === "position" ? "position" : lower === "townid" ? "mapId" : lower === "mapid" ? "mapId" : lower === "nameoverride" ? "name" : lower === "factionoverride" ? "faction" : lower === "spriteidoverride" ? "spriteId" : lower === "dialogueidoverride" ? "dialogueId" : key; if (seenCanonical.has(canonical)) { return false; } seenCanonical.add(canonical); return true; }); } return merged; }, [activeType, selectedRecord]); const activeEditTabs = useMemo(() => { const tabs = EDIT_TABS_BY_TYPE[activeType]; return Array.isArray(tabs) && tabs.length > 0 ? tabs : ["General"]; }, [activeType]); const activeEditTab = useMemo(() => { const candidate = String(activeEditTabByType[activeType] || ""); return activeEditTabs.includes(candidate) ? candidate : activeEditTabs[0]; }, [activeEditTabByType, activeEditTabs, activeType]); const selectedFieldEntriesForTab = useMemo(() => { const tabFields = EDIT_TAB_FIELDS_BY_TYPE[activeType]?.[activeEditTab]; if (!Array.isArray(tabFields) || tabFields.length === 0) { return selectedFieldEntries; } const allow = new Set(tabFields); let filtered = selectedFieldEntries.filter(([key]) => allow.has(key)); if (activeType === "factions" && activeEditTab === "General") { const hasColorField = filtered.some(([key]) => String(key || "").toLowerCase() === "color"); if (!hasColorField) { filtered = [...filtered, ["color", normalizeHexColor(selectedRecord?.color)]]; } } if (activeType === "items" && activeEditTab === "Actions") { return filtered.filter(([key]) => !["actionsList", "effects", "triggers"].includes(key)); } if (activeType === "quests" && activeEditTab === "Conditions") { return filtered.filter(([key]) => key !== "requirements"); } if (activeType === "quests" && activeEditTab === "Steps") { return filtered.filter(([key]) => key !== "steps"); } if (activeType === "npcs" && activeEditTab === "General") { return filtered.filter(([key]) => { const lower = String(key || "").toLowerCase(); return lower !== "x" && lower !== "y"; }); } return filtered; }, [activeType, activeEditTab, selectedFieldEntries, selectedRecord]); function patchCatalogEntries( key: ConfigCatalogKey, updater: (entries: CatalogEntry[]) => CatalogEntry[], ): void { setCatalogMeta((prev) => { const current = Array.isArray(prev[key]) ? prev[key] : []; const next = updater(current).map((entry, index) => normalizeCatalogEntryIdentity(entry, `${key}_${index + 1}`)); return { ...prev, schemaVersion: 1, [key]: next, }; }); } function patchActiveConfigEntries(updater: (entries: CatalogEntry[]) => CatalogEntry[]): void { patchCatalogEntries(activeConfigKey, updater); } function updateActiveConfigEntry(index: number, patch: Partial): void { if (activeConfigKey === "colors") { setStatus("Colors are read-only in this editor."); return; } patchActiveConfigEntries((entries) => entries.map((entry, idx) => (idx === index ? { ...entry, ...patch } : entry))); } function addConfigEntry(): void { if (activeConfigKey === "colors") { setStatus("Colors are read-only in this editor."); return; } if (activeConfigKey === "systemActions" && activeConfigEntries.length === 0) { const seed = Date.now(); patchActiveConfigEntries((entries) => (entries.length > 0 ? entries : createDefaultSystemActionEntries(seed))); setSelectedConfigIndexByTab((prev) => ({ ...prev, [activeConfigKey]: 0, })); setStatus("Added starter System Actions."); return; } const slug = `${activeConfigKey}_${Date.now()}`; patchActiveConfigEntries((entries) => ([ ...entries, { entryId: `${activeConfigKey}-${Date.now()}`, sourceKey: slug, key: slug, originalName: slug, description: "", sublistType: "", displayKeys: [], passKeys: [], }, ])); setSelectedConfigIndexByTab((prev) => ({ ...prev, [activeConfigKey]: activeConfigEntries.length, })); setStatus(`Added ${activeConfigTab.slice(0, -1)} entry.`); } function deleteActiveConfigEntry(): void { if (activeConfigKey === "colors") { setStatus("Colors are read-only in this editor."); return; } if (!selectedConfigEntry) { return; } patchActiveConfigEntries((entries) => entries.filter((_, idx) => idx !== selectedConfigIndex)); setSelectedConfigIndexByTab((prev) => ({ ...prev, [activeConfigKey]: Math.max(0, selectedConfigIndex - 1), })); setStatus(`Deleted ${activeConfigTab.slice(0, -1)} entry.`); } function moveConfigEntry(direction: -1 | 1): void { if (activeConfigKey === "colors") { setStatus("Colors are read-only in this editor."); return; } patchActiveConfigEntries((entries) => { const target = selectedConfigIndex + direction; if (selectedConfigIndex < 0 || target < 0 || target >= entries.length) { return entries; } const next = [...entries]; const [moved] = next.splice(selectedConfigIndex, 1); next.splice(target, 0, moved); setSelectedConfigIndexByTab((prev) => ({ ...prev, [activeConfigKey]: target })); return next; }); } async function handleSaveConfig(): Promise { if (activeConfigKey === "colors") { setStatus("Colors are read-only in this editor."); return; } if (!hasUnsavedConfigChanges) { setError("No config changes detected."); setStatus(`No changes to save for ${activeConfigTab}.`); return; } setIsSaving(true); setError(""); setStatus(`Saving ${activeConfigTab}...`); try { const payload: CatalogMeta = { schemaVersion: 1, conditions: Array.isArray(catalogMeta.conditions) ? catalogMeta.conditions : [], itemActions: Array.isArray(catalogMeta.itemActions) ? catalogMeta.itemActions : [], systemActions: Array.isArray(catalogMeta.systemActions) ? catalogMeta.systemActions : [], effects: Array.isArray(catalogMeta.effects) ? catalogMeta.effects : [], colors: Array.isArray(catalogMeta.colors) ? catalogMeta.colors : [], }; const res = await fetch("/api/catalog-meta", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) { const body = await res.text(); throw new Error(`Catalog save failed (${res.status}): ${body.slice(0, 180)}`); } const refreshed = await fetchJsonOrThrow("/api/catalog-meta"); const normalizedRefreshed = refreshed && typeof refreshed === "object" ? normalizeCatalogMetaIdentity(refreshed) : { conditions: [], itemActions: [], systemActions: [], effects: [], colors: createDefaultColorEntries(0), }; if (!Array.isArray(normalizedRefreshed.colors) || normalizedRefreshed.colors.length === 0) { normalizedRefreshed.colors = createDefaultColorEntries(0); } setCatalogMeta(normalizedRefreshed); setSavedCatalogMetaJsonText(JSON.stringify(normalizedRefreshed)); setStatus(`Saved ${activeConfigTab}.`); } catch (err: unknown) { setError(String(err)); setStatus(`Save failed for ${activeConfigTab}.`); } finally { setIsSaving(false); } } function handleRawJsonEditorChange(nextRaw: string): void { if (activeSection !== "content" || isAllRecordsSelected || !baseSelectedRecord) { setJsonText(nextRaw); return; } setRecordJsonDraft(nextRaw); setRecordDraftPending(true); } function commitPendingRecordChanges(): void { if (!hasPendingRecordChanges) { return; } let nextRecords = records; if (isRecordDraftDirty && !isAllRecordsSelected && selectedRecordIndex >= 0) { let parsedRecord: JsonValue; try { parsedRecord = JSON.parse(recordJsonDraft) as JsonValue; } catch (err: unknown) { setError(`Record JSON error: ${String(err)}`); return; } if (!isPlainObject(parsedRecord)) { setError("Record JSON error: Record draft must be a JSON object."); return; } if (activeType === "npcs") { parsedRecord = normalizeNpcRecordForLoad(parsedRecord); } nextRecords = records.map((entry, index) => (index === selectedRecordIndex ? parsedRecord : entry)); } setError(""); commitRecords(nextRecords); setRecordsDraft(null); setRecordDraftPending(false); const nextSelectedIndex = nextRecords.length === 0 ? -1 : (selectedRecordIndex < 0 ? -1 : Math.min(selectedRecordIndex, nextRecords.length - 1)); setSelectedIndex(nextSelectedIndex); if (nextSelectedIndex >= 0 && nextSelectedIndex < nextRecords.length) { setRecordJsonDraft(JSON.stringify(nextRecords[nextSelectedIndex], null, 2)); } else { setRecordJsonDraft(""); } setStatus("Record changes committed."); } function revertPendingRecordChanges(): void { setRecordsDraft(null); setRecordDraftPending(false); const nextSelectedIndex = committedRecords.length === 0 ? -1 : (selectedRecordIndex < 0 ? -1 : Math.min(selectedRecordIndex, committedRecords.length - 1)); setSelectedIndex(nextSelectedIndex); if (nextSelectedIndex >= 0 && nextSelectedIndex < committedRecords.length) { setRecordJsonDraft(JSON.stringify(committedRecords[nextSelectedIndex], null, 2)); } else { setRecordJsonDraft(""); } setError(""); setStatus("Record changes reverted."); } function selectRecordIndex(nextIndex: number): void { if (activeSection === "content" && hasPendingRecordChanges && nextIndex !== selectedRecordIndex) { setError("Pending changes are unsaved"); return; } setError(""); setSelectedIndex(nextIndex); if (nextIndex >= 0 && nextIndex < records.length) { setRecordJsonDraft(JSON.stringify(records[nextIndex], null, 2)); } else { setRecordJsonDraft(""); } setRecordDraftPending(false); } function commitRecords(nextRecords: JsonObject[]): void { if (!parsedPayload || !rootKey) { return; } const normalizedRecords = activeType === "npcs" ? nextRecords.map((record) => normalizeNpcRecordForSave(record)) : activeType === "tiles" ? nextRecords.map((record) => normalizeTileRecordForSave(record)) : nextRecords; const nextPayload: JsonObject = { ...parsedPayload, [rootKey]: normalizedRecords, }; setJsonText(JSON.stringify(nextPayload, null, 2)); } function commitSelectedRecord(nextRecord: JsonObject): void { setRecordJsonDraft(JSON.stringify(nextRecord, null, 2)); setRecordDraftPending(true); } function handlePrimitiveFieldChange(key: string, nextRaw: string): void { if (!selectedRecord) { return; } const currentValue = selectedRecord[key]; let nextValue: JsonValue = nextRaw; if (typeof currentValue === "number") { const parsed = Number(nextRaw); nextValue = Number.isFinite(parsed) ? parsed : 0; } else if (typeof currentValue === "boolean") { nextValue = nextRaw === "true"; } else if (currentValue === null) { nextValue = nextRaw.trim().length === 0 ? null : nextRaw; } commitSelectedRecord({ ...selectedRecord, [key]: nextValue, }); } function handleNpcPositionFieldChange(axis: "x" | "y", nextRaw: string): void { if (!selectedRecord) { return; } const parsed = Number(nextRaw); const nextNumber = Number.isFinite(parsed) ? parsed : 0; const position = isPlainObject(selectedRecord.position) ? (selectedRecord.position as JsonObject) : {}; commitSelectedRecord({ ...selectedRecord, position: { ...position, [axis]: nextNumber, }, }); } function addRecord(): void { const baseRecord = buildDefaultRecord(activeType, records); const nextRecord = activeType === "npcs" ? normalizeNpcRecordForLoad(baseRecord) : baseRecord; const nextRecords = records.concat(nextRecord); setRecordsDraft(nextRecords); setRecordDraftPending(false); setSelectedIndex(nextRecords.length - 1); setRecordJsonDraft(JSON.stringify(nextRecord, null, 2)); setError(""); setStatus(`Added new ${formatTypeLabel(activeType)} record. Pending commit.`); } function deleteSelectedRecord(): void { if (!selectedRecord) { return; } const confirmed = window.confirm("Delete selected record?"); if (!confirmed) { return; } const nextRecords = records.filter((_, index) => index !== selectedRecordIndex); setRecordsDraft(nextRecords); setRecordDraftPending(false); const nextSelectedIndex = nextRecords.length > 0 ? Math.max(0, selectedRecordIndex - 1) : -1; setSelectedIndex(nextSelectedIndex); setRecordJsonDraft(nextSelectedIndex >= 0 ? JSON.stringify(nextRecords[nextSelectedIndex], null, 2) : ""); setError(""); setStatus(`Deleted ${formatTypeLabel(activeType)} record. Pending commit.`); } function handleDialogueNodeFieldChange(key: string, nextRaw: string): void { if (!selectedRecord || !selectedDialogueNode) { return; } const currentValue = selectedDialogueNode[key]; let nextValue: JsonValue = nextRaw; if (typeof currentValue === "number") { const parsed = Number(nextRaw); nextValue = Number.isFinite(parsed) ? parsed : 0; } else if (typeof currentValue === "boolean") { nextValue = nextRaw === "true"; } else if (currentValue === null) { nextValue = nextRaw.trim().length === 0 ? null : nextRaw; } const nextNodes = dialogueNodes.map((node, index) => { if (index !== activeDialogueNodeIndex) { return node; } return { ...node, [key]: nextValue, }; }); commitSelectedRecord({ ...selectedRecord, dialogueNodes: nextNodes, }); } function addDialogueNode(): void { if (!selectedRecord) { return; } const nextNode: JsonObject = { id: `node_${Date.now()}`, description: "", text: "", nextNodeId: "", order: dialogueNodes.length + 1, conditions: [], reactions: [], choices: [], }; const nextNodes = normalizeDialogueNodesForSave(dialogueNodes.concat(nextNode)); commitSelectedRecord({ ...selectedRecord, dialogueNodes: nextNodes, }); setSelectedDialogueNodeIndex(nextNodes.length - 1); setStatus("Added dialogue node."); } function deleteDialogueNode(): void { if (!selectedRecord || !selectedDialogueNode) { return; } const confirmed = window.confirm("Delete selected dialogue node?"); if (!confirmed) { return; } const nextNodes = normalizeDialogueNodesForSave(dialogueNodes.filter((_, index) => index !== activeDialogueNodeIndex)); commitSelectedRecord({ ...selectedRecord, dialogueNodes: nextNodes, }); setSelectedDialogueNodeIndex(Math.max(0, activeDialogueNodeIndex - 1)); setStatus("Deleted dialogue node."); } function moveDialogueNode(direction: -1 | 1): void { if (!selectedRecord || !selectedDialogueNode) { return; } const targetIndex = activeDialogueNodeIndex + direction; if (targetIndex < 0 || targetIndex >= dialogueNodes.length) { return; } const nextNodes = [...dialogueNodes]; const current = nextNodes[activeDialogueNodeIndex]; nextNodes[activeDialogueNodeIndex] = nextNodes[targetIndex]; nextNodes[targetIndex] = current; const normalizedNodes = normalizeDialogueNodesForSave(nextNodes); commitSelectedRecord({ ...selectedRecord, dialogueNodes: normalizedNodes, }); setSelectedDialogueNodeIndex(targetIndex); setStatus("Reordered dialogue node."); } function patchSelectedDialogueNode(updater: (node: JsonObject) => JsonObject): void { if (!selectedRecord || !selectedDialogueNode) { return; } const nextNodes = dialogueNodes.map((node, index) => { if (index !== activeDialogueNodeIndex) { return node; } return updater({ ...node }); }); commitSelectedRecord({ ...selectedRecord, dialogueNodes: nextNodes, }); } function patchFlowSteps(updater: (steps: DialogueFlowStep[]) => DialogueFlowStep[]): void { patchSelectedDialogueNode((node) => { const current = nodeToFlowSteps(node); const nextSteps = updater(current).map((step) => ({ ...step, id: String(step.id || `${step.kind}_${Date.now()}`), })); return flowStepsToNode(node, nextSteps); }); } function getFlowStepSummary(step: DialogueFlowStep): string { if (step.kind === "condition") { const notText = step.conditionNot ? "not " : ""; const value = String(step.conditionValue || "").trim(); return `${notText}${step.conditionType || "always"}${value ? ` = ${value}` : ""}`; } if (step.kind === "choice") { return String(step.text || "(empty choice)"); } if (step.kind === "text") { return String(step.text || "(empty text)").slice(0, 70); } if (step.kind === "jump") { return String(step.nextId || "(no target)"); } if (step.kind === "action") { return `${step.reactionType || "none"}${step.reactionValue ? ` : ${step.reactionValue}` : ""}`; } return "Conversation ends"; } function isStepCollapsed(stepId: string): boolean { return collapsedStepIds[stepId] ?? true; } function setStepCollapsed(stepId: string, collapsed: boolean): void { setCollapsedStepIds((prev) => ({ ...prev, [stepId]: collapsed, })); } function moveFlowStepById(sourceId: string, targetId: string): void { if (!sourceId || !targetId || sourceId === targetId) { return; } patchFlowSteps((steps) => { const sourceIndex = steps.findIndex((step) => step.id === sourceId); const targetIndex = steps.findIndex((step) => step.id === targetId); if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) { return steps; } const next = [...steps]; const [moved] = next.splice(sourceIndex, 1); const insertAt = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex; next.splice(insertAt, 0, moved); return next; }); } function moveFlowStepByDirection(stepId: string, direction: -1 | 1): void { patchFlowSteps((steps) => { const index = steps.findIndex((step) => step.id === stepId); const target = index + direction; if (index < 0 || target < 0 || target >= steps.length) { return steps; } const next = [...steps]; const [moved] = next.splice(index, 1); next.splice(target, 0, moved); return next; }); } function getItemActions(record: JsonObject | null): JsonObject[] { if (!record) { return []; } return getPlainObjectArray(record.actionsList); } function getQuestSteps(record: JsonObject | null): JsonObject[] { if (!record) { return []; } return getPlainObjectArray(record.steps); } function patchSelectedRecordArray(field: string, updater: (entries: JsonObject[]) => JsonObject[]): void { if (!selectedRecord) { return; } const current = getPlainObjectArray(selectedRecord[field]); commitSelectedRecord({ ...selectedRecord, [field]: normalizePlainObjectArray(updater(current)), }); } function patchSelectedRecord(updater: (record: JsonObject) => JsonObject): void { if (!selectedRecord) { return; } commitSelectedRecord(updater({ ...selectedRecord })); } function getQuestStepSummary(step: JsonObject, index: number): string { const name = String(step.name || step.description || "").trim(); const stepId = String(step.stepID || index + 1); return `${stepId}${name ? ` - ${name}` : ""}`; } function getItemActionSummary(actionEntry: JsonObject, index: number): string { const action = String(actionEntry.action || `Action ${index + 1}`).trim(); const triggerCount = getPlainObjectArray(actionEntry.triggers).length; const effectCount = getPlainObjectArray(actionEntry.effects).length; const flowCount = Array.isArray(actionEntry.flowSteps) ? actionEntry.flowSteps.length : 0; return `${action} • ${triggerCount} trigger${triggerCount === 1 ? "" : "s"} • ${effectCount} effect${effectCount === 1 ? "" : "s"} • ${flowCount} step${flowCount === 1 ? "" : "s"}`; } function patchItemActionFlowSteps(actionIndex: number, updater: (steps: DialogueFlowStep[]) => DialogueFlowStep[]): void { patchSelectedRecordArray("actionsList", (entries) => entries.map((entry, idx) => { if (idx !== actionIndex) { return entry; } const currentSteps = getPlainObjectArray(entry.flowSteps) as unknown as DialogueFlowStep[]; return { ...entry, flowSteps: normalizePlainObjectArray(updater(currentSteps) as unknown as JsonObject[]), }; })); } function patchQuestSteps(updater: (steps: JsonObject[]) => JsonObject[]): void { patchSelectedRecordArray("steps", (steps) => { const next = updater(steps); return next.map((step, index) => ({ ...step, stepID: index + 1, })); }); } function addItemActionEntry(): void { patchSelectedRecordArray("actionsList", (entries) => ([ ...entries, { action: "Use", effects: [], triggers: [], flowSteps: [], }, ])); } function deleteItemActionEntry(actionIndex: number): void { patchSelectedRecordArray("actionsList", (entries) => entries.filter((_, index) => index !== actionIndex)); } function moveItemActionEntry(actionIndex: number, direction: -1 | 1): void { patchSelectedRecordArray("actionsList", (entries) => { const target = actionIndex + direction; if (actionIndex < 0 || target < 0 || target >= entries.length) { return entries; } const next = [...entries]; const [moved] = next.splice(actionIndex, 1); next.splice(target, 0, moved); return next; }); } function addQuestStepEntry(): void { patchQuestSteps((steps) => ([ ...steps, { stepID: steps.length + 1, id: `step_${Date.now()}`, name: "New Quest Step", conditionType: "always", conditionValue: "", }, ])); } function deleteQuestStepEntry(stepIndex: number): void { patchQuestSteps((steps) => steps.filter((_, index) => index !== stepIndex)); } function moveQuestStepEntry(stepIndex: number, direction: -1 | 1): void { patchQuestSteps((steps) => { const target = stepIndex + direction; if (stepIndex < 0 || target < 0 || target >= steps.length) { return steps; } const next = [...steps]; const [moved] = next.splice(stepIndex, 1); next.splice(target, 0, moved); return next; }); } function getContentFieldKeysByType(type: string): string[] { const records = getContentRecordsForType(contentDataByType, type); const keys = new Set(); records.forEach((record) => { Object.keys(record).forEach((key) => { if (key !== "schemaVersion") { keys.add(key); } }); }); return Array.from(keys); } function getConditionSublistType(conditionType: string): string { return String(conditionCatalogMap.get(getConditionBaseType(conditionType))?.sublistType || "").trim(); } function getConditionDisplayKeys(conditionType: string): string[] { return normalizeStringList(conditionCatalogMap.get(getConditionBaseType(conditionType))?.displayKeys); } function getConditionPassKeys(conditionType: string): string[] { return normalizeStringList(conditionCatalogMap.get(getConditionBaseType(conditionType))?.passKeys); } function formatRecordLabel(record: JsonObject, displayKeys: string[], fallbackLabel: string): string { const parts = displayKeys .map((field) => { const value = record[field]; if (value === undefined || value === null || String(value).trim().length === 0) { return ""; } return String(value); }) .filter(Boolean); if (parts.length > 0) { return parts.join(" • "); } return fallbackLabel; } function getDefaultConditionValue(conditionType: string): string { const sublistType = getConditionSublistType(conditionType); if (!sublistType) { return ""; } const records = getContentRecordsForType(contentDataByType, sublistType); if (records.length === 0) { return ""; } const passField = getConditionPassKeys(conditionType)[0] || "id"; const first = records[0]; if (getConditionBaseType(conditionType) === "item") { const itemId = String(first?.id || "").trim(); return itemId ? `${itemId}:1` : ""; } const value = first?.[passField] ?? first?.id ?? first?.questId ?? ""; return String(value || ""); } function renderConditionValueField(conditionType: string, currentValue: string, onValueChange: (value: string) => void) { const baseType = getConditionBaseType(conditionType); const sublistType = getConditionSublistType(baseType); const records = sublistType ? getContentRecordsForType(contentDataByType, sublistType) : []; const displayKeys = getConditionDisplayKeys(baseType); const passKeys = getConditionPassKeys(baseType); if (!sublistType || records.length === 0) { return onValueChange(event.target.value)} placeholder="Value" />; } if (baseType === "item") { const parsed = parseItemValue(currentValue); const itemId = parsed.itemId; const quantity = parsed.quantity; return (
onValueChange(`${itemId}:${Math.max(1, Number(event.target.value) || 1)}`)} disabled={!itemId} />
); } return ( ); } function renderSimulationNode(nodeId: string, sandboxInput: DialogueSandbox, applyEnterReactions: boolean): void { const sourceSandbox = cloneSandbox(sandboxInput); const node = dialogueNodeById.get(nodeId); if (!node) { setSimCurrentNodeId(""); setSimText(`Node '${nodeId}' not found.`); setSimChoices([]); setSimFallbackNextId(""); setSimEnded(true); return; } const sandboxForEvaluation = applyEnterReactions ? applyNodeReactionsToSandbox(node, sourceSandbox) : sourceSandbox; const conditionOutcome = resolveNodeConditionTextAndNext(node, sandboxForEvaluation); const visibleChoices = getVisibleChoices(node, sandboxForEvaluation); setSimSandbox(sandboxForEvaluation); setSimCurrentNodeId(nodeId); setSimText(conditionOutcome.text || "(no text on this node)"); setSimChoices(visibleChoices); setSimFallbackNextId(conditionOutcome.nextId); setSimEnded(false); } function enterSimNode(nodeId: string, sandboxInput?: DialogueSandbox): void { const sourceSandbox = sandboxInput ? cloneSandbox(sandboxInput) : cloneSandbox(simSandbox); renderSimulationNode(nodeId, sourceSandbox, true); } function updateSandbox(nextSandbox: DialogueSandbox): void { if (!simEnded && simCurrentNodeId) { renderSimulationNode(simCurrentNodeId, nextSandbox, false); return; } setSimSandbox(nextSandbox); } function startSimulation(): void { if (dialogueNodes.length === 0) { setSimCurrentNodeId(""); setSimText("No dialogue nodes available for this dialogue record."); setSimChoices([]); setSimFallbackNextId(""); setSimEnded(true); return; } const sorted = [...dialogueNodes].sort((a, b) => Number(a.order || 0) - Number(b.order || 0)); const startNodeId = String(sorted[0]?.id || "").trim(); if (!startNodeId) { setSimCurrentNodeId(""); setSimText("Start node is missing an id."); setSimChoices([]); setSimFallbackNextId(""); setSimEnded(true); return; } enterSimNode(startNodeId); } function chooseSimChoice(choice: DialogueChoice): void { let nextSandbox = cloneSandbox(simSandbox); if (choice.reactionType && choice.reactionType !== "none") { nextSandbox = applyReactionToSandbox(nextSandbox, choice.reactionType, choice.reactionValue); setSimSandbox(nextSandbox); } if (!choice.nextId) { setSimEnded(true); setSimText((prev) => `${prev}\n\n[Conversation ended]`); return; } enterSimNode(choice.nextId, nextSandbox); } function continueSimulation(): void { if (!simFallbackNextId) { setSimEnded(true); setSimText((prev) => `${prev}\n\n[Conversation ended]`); return; } enterSimNode(simFallbackNextId); } async function handleSave(): Promise { if (!activeType || parsedJsonError || validationIssues.length > 0) { if (validationIssues.length > 0) { setStatus(`Save blocked: ${validationIssues.length} validation issue(s).`); } return; } setIsSaving(true); setError(""); setStatus(`Saving ${formatTypeLabel(activeType)}...`); try { const parsed = JSON.parse(jsonText) as JsonValue; const payload = activeType === "npcs" ? normalizeNpcPayloadForSave(parsed) : activeType === "images" ? normalizeImagesPayloadForSave(parsed) : activeType === "sprites" ? normalizeSpritePayloadForSave(parsed) : activeType === "tiles" ? normalizeTilesPayloadForSave(parsed) : parsed; const payloadText = JSON.stringify(payload, null, 2); setJsonText(payloadText); const res = await fetch(`/api/content/${activeType}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) { const body = await res.text(); throw new Error(`Save failed (${res.status}): ${body.slice(0, 180)}`); } setSavedContentJsonText(payloadText); setStatus(`Saved ${formatTypeLabel(activeType)}.`); } catch (err: unknown) { setError(String(err)); setStatus(`Save failed for ${formatTypeLabel(activeType)}.`); } finally { setIsSaving(false); } } async function resolveDefaultWorldshaperStudioWorldId(): Promise { const payload = await fetchJsonOrThrow<{ worldId?: string; world?: JsonObject }>("/api/world-default"); const resolvedWorldId = String(payload.worldId || payload.world?.id || "").trim(); return resolvedWorldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK; } async function handleLaunchWorldshaperStudio(): Promise { try { setError(""); setStatus("Preparing Worldshaper Studio..."); const nextWorldId = await resolveDefaultWorldshaperStudioWorldId().catch(() => DEFAULT_EDITOR_WORLD_ID_FALLBACK); const popup = openWorldshaperStudioWindow(nextWorldId, window, { worldId: nextWorldId }); if (!popup) { setError("The browser blocked the Worldshaper Studio window."); setStatus("Worldshaper Studio unavailable: studio window was blocked."); return; } setStatus(`Opening Worldshaper Studio for ${nextWorldId}...`); } catch (err: unknown) { setError(String(err)); setStatus("Worldshaper Studio unavailable: failed to prepare world data."); } } return (

Worldshaper Studio

Worldbuilding studio with tabbed pages, structured editing, and raw JSON fallback.

{activeSection === "content" ? ( ) : ( setSelectedConfigIndexByTab((prev) => ({ ...prev, [activeConfigKey]: index }))} onUpdateActiveConfigEntry={updateActiveConfigEntry} /> )}
); } export default App;