Worldshaper/src/App.tsx

1783 lines
63 KiB
TypeScript

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 "./worldshaperStudio/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<string, JsonObject>, 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<string[]>([]);
const [activeType, setActiveType] = useState<string>("");
const [selectedIndex, setSelectedIndex] = useState<number>(0);
const [selectedDialogueNodeIndex, setSelectedDialogueNodeIndex] = useState<number>(0);
const [collapsedStepIds, setCollapsedStepIds] = useState<Record<string, boolean>>({});
const [draggingStepId, setDraggingStepId] = useState<string>("");
const [dropTargetStepId, setDropTargetStepId] = useState<string>("");
const [activeSection, setActiveSection] = useState<"content" | "config">("content");
const [activeConfigTab, setActiveConfigTab] = useState<ConfigTabLabel>("Conditions");
const [selectedConfigIndexByTab, setSelectedConfigIndexByTab] = useState<Record<string, number>>({});
const [activeEditTabByType, setActiveEditTabByType] = useState<Record<string, string>>({});
const [catalogMeta, setCatalogMeta] = useState<CatalogMeta>({
conditions: [],
itemActions: [],
systemActions: [],
effects: [],
colors: createDefaultColorEntries(0),
});
const [contentDataByType, setContentDataByType] = useState<Record<string, JsonObject>>({});
const [simSandbox, setSimSandbox] = useState<DialogueSandbox>({ questStarted: [], questCompleted: [], inventory: { copper_ore: 0 } });
const [simCurrentNodeId, setSimCurrentNodeId] = useState<string>("");
const [simText, setSimText] = useState<string>("");
const [simChoices, setSimChoices] = useState<DialogueChoice[]>([]);
const [simFallbackNextId, setSimFallbackNextId] = useState<string>("");
const [simEnded, setSimEnded] = useState<boolean>(true);
const [jsonText, setJsonText] = useState<string>("");
const [savedContentJsonText, setSavedContentJsonText] = useState<string>("");
const [savedCatalogMetaJsonText, setSavedCatalogMetaJsonText] = useState<string>("");
const [recordsDraft, setRecordsDraft] = useState<JsonObject[] | null>(null);
const [recordJsonDraft, setRecordJsonDraft] = useState<string>("");
const [recordDraftPending, setRecordDraftPending] = useState<boolean>(false);
const [npcSpriteSearchQuery, setNpcSpriteSearchQuery] = useState<string>("");
const [activeSpritePaintSymbol, setActiveSpritePaintSymbol] = useState<string>("0");
const [status, setStatus] = useState<string>("Loading available content types...");
const [error, setError] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [validationIssues, setValidationIssues] = useState<string[]>([]);
const validationWorkerRef = useRef<Worker | null>(null);
const validationRequestIdRef = useRef<number>(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<CatalogMeta>("/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<JsonObject>(`/api/content/${type}`)))
.then((payloads) => {
const nextByType: Record<string, JsonObject> = {};
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<JsonValue>(`/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<string, JsonObject>();
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<string, CatalogEntry>();
(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<string>();
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<ValidationWorkerResponse>) => {
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<string>();
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<CatalogEntry>): 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<void> {
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<CatalogMeta>("/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<string>();
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 <input value={currentValue} onChange={(event) => onValueChange(event.target.value)} placeholder="Value" />;
}
if (baseType === "item") {
const parsed = parseItemValue(currentValue);
const itemId = parsed.itemId;
const quantity = parsed.quantity;
return (
<div className="condition-composite">
<select value={itemId} onChange={(event) => onValueChange(`${event.target.value}:${quantity}`)}>
<option value="">Select item</option>
{records.map((record, index) => {
const id = String(record.id || index);
const label = formatRecordLabel(record, displayKeys, id);
return (
<option key={`cond-item-${id}-${index}`} value={id}>
{label}
</option>
);
})}
</select>
<input
type="number"
min="1"
value={quantity}
onChange={(event) => onValueChange(`${itemId}:${Math.max(1, Number(event.target.value) || 1)}`)}
disabled={!itemId}
/>
</div>
);
}
return (
<select value={currentValue} onChange={(event) => onValueChange(event.target.value)}>
<option value="">Select value</option>
{records.map((record, index) => {
const passField = passKeys[0] || "id";
const passValue = String(record?.[passField] ?? record?.id ?? record?.questId ?? index);
const label = formatRecordLabel(record, displayKeys, passValue);
return (
<option key={`cond-value-${sublistType}-${index}-${passValue}`} value={passValue}>
{label}
</option>
);
})}
</select>
);
}
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<void> {
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<string> {
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<void> {
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 (
<div className="page-shell">
<header className="header-card">
<div className="header-copy">
<p className="eyebrow">New RPG</p>
<h1>Worldshaper</h1>
<p className="lede">Worldbuilding studio with tabbed pages, structured editing, and raw JSON fallback.</p>
</div>
<button
type="button"
className="header-map-editor-btn"
onClick={handleLaunchWorldshaperStudio}
disabled={isLoading}
>
<span className="header-map-editor-btn-label">Worldshaper Studio</span>
</button>
</header>
<section className="workspace-card">
<TopNavTabs
contentTypes={contentTypes}
configTabLabels={configTabLabels}
activeSection={activeSection}
activeType={activeType}
activeConfigTab={activeConfigTab}
hasPendingRecordChanges={hasPendingRecordChanges}
isLoading={isLoading}
isSaving={isSaving}
formatTypeLabel={formatTypeLabel}
onError={setError}
onSetActiveSection={setActiveSection}
onSetActiveType={requestActiveType}
onSetActiveConfigTab={setActiveConfigTab}
/>
<EditorToolbar
activeSection={activeSection}
activeType={activeType}
activeConfigTab={activeConfigTab}
activeConfigEntriesLength={activeConfigEntries.length}
selectedConfigIndex={selectedConfigIndex}
selectedConfigEntry={selectedConfigEntry}
hasUnsavedContentChanges={hasUnsavedContentChanges}
hasUnsavedConfigChanges={hasUnsavedConfigChanges}
parsedJsonError={parsedJsonError}
validationIssuesLength={validationIssues.length}
hasPendingRecordChanges={hasPendingRecordChanges}
recordDraftError={recordDraftError}
isLoading={isLoading}
isSaving={isSaving}
onSaveContent={handleSave}
onAddRecord={addRecord}
onDeleteRecord={deleteSelectedRecord}
onSaveConfig={handleSaveConfig}
onAddConfigEntry={addConfigEntry}
onDeleteConfigEntry={deleteActiveConfigEntry}
onMoveConfigEntry={moveConfigEntry}
error={error}
/>
{activeSection === "content" ? (
<ContentSection
ctx={{
activeType,
setActiveType,
setActiveSection,
records,
selectedIndex: selectedRecordIndex,
isAllRecordsSelected,
hasPendingRecordChanges,
getRecordLabel,
buildSpritePreviewDataUrl,
normalizeHexColor,
selectRecordIndex,
commitPendingRecordChanges,
revertPendingRecordChanges,
recordDraftError,
isLoading,
isSaving,
selectedRecord,
recordJsonDraft,
jsonText,
handleRawJsonEditorChange,
activeEditTabs,
activeEditTab,
setActiveEditTabByType,
selectedFieldEntriesForTab,
isPlainObject,
toFieldLabel,
handlePrimitiveFieldChange,
npcTownOptions,
npcFactionOptions,
handleNpcPositionFieldChange,
npcSpriteSearchQuery,
setNpcSpriteSearchQuery,
filteredNpcSpriteOptions,
npcSpriteOptions,
allNpcRecords,
allNpcTemplateRecords,
contentDataByType,
patchSelectedRecord,
activeSpritePaintSymbol: resolvedActiveSpritePaintSymbol,
setActiveSpritePaintSymbol,
selectedSpriteEditorSize,
getSpriteCellSymbol,
getSpritePalette,
paintSpriteCell,
colorPaletteEntries: Array.isArray(catalogMeta.colors) ? catalogMeta.colors : [],
addItemActionEntry,
selectedItemActions,
isStepCollapsed,
setStepCollapsed,
getItemActionSummary,
moveItemActionEntry,
deleteItemActionEntry,
getPlainObjectArray,
patchSelectedRecordArray,
patchItemActionFlowSteps,
createFlowStep,
FLOW_KIND_LABELS,
getFlowStepSummary,
conditionTypeOptions,
getDefaultConditionValue,
renderConditionValueField,
selectedQuestSteps,
getQuestStepSummary,
moveQuestStepEntry,
deleteQuestStepEntry,
patchQuestSteps,
addQuestStepEntry,
addDialogueNode,
deleteDialogueNode,
selectedDialogueNode,
moveDialogueNode,
selectedDialogueNodeIndex: activeDialogueNodeIndex,
dialogueNodes,
setSelectedDialogueNodeIndex,
selectedDialogueFieldEntries,
handleDialogueNodeFieldChange,
patchFlowSteps,
moveFlowStepByDirection,
selectedFlowSteps,
dropTargetStepId,
setDraggingStepId,
setDropTargetStepId,
moveFlowStepById,
dialogueNodeIds,
DIALOGUE_REACTION_TYPES,
simSandbox,
simCurrentNodeId,
simText,
simChoices,
simFallbackNextId,
simEnded,
startSimulation,
updateSandbox,
chooseSimChoice,
continueSimulation,
draggingStepId,
}}
/>
) : (
<ConfigSection
activeConfigTab={activeConfigTab}
activeConfigKey={activeConfigKey}
activeConfigEntries={activeConfigEntries}
selectedConfigIndex={selectedConfigIndex}
selectedConfigEntry={selectedConfigEntry}
rootKeys={Object.keys(ROOT_KEY_BY_TYPE)}
typeLabels={TYPE_LABELS}
getCatalogEntryIdValue={getCatalogEntryIdValue}
normalizeStringList={normalizeStringList}
parseCsv={parseCsv}
getContentFieldKeysByType={getContentFieldKeysByType}
onSetSelectedConfigIndex={(index) => setSelectedConfigIndexByTab((prev) => ({ ...prev, [activeConfigKey]: index }))}
onUpdateActiveConfigEntry={updateActiveConfigEntry}
/>
)}
<StatusFooter
status={status}
validationIssues={visibleValidationIssues}
parsedJsonError={parsedJsonError}
recordDraftError={recordDraftError}
/>
</section>
</div>
);
}
export default App;