2026-06-26 18:18:14 -04:00
|
|
|
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";
|
2026-06-26 20:30:30 -04:00
|
|
|
import { openWorldshaperStudioWindow } from "./worldshaperStudio/windowing";
|
2026-06-26 18:18:14 -04:00
|
|
|
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[];
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-26 20:30:30 -04:00
|
|
|
const LAST_ACTIVE_TYPE_STORAGE_KEY = "worldshaper:lastActiveType";
|
2026-06-26 18:18:14 -04:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 20:30:30 -04:00
|
|
|
async function resolveDefaultWorldshaperStudioWorldId(): Promise<string> {
|
2026-06-26 18:18:14 -04:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 20:30:30 -04:00
|
|
|
async function handleLaunchWorldshaperStudio(): Promise<void> {
|
2026-06-26 18:18:14 -04:00
|
|
|
try {
|
|
|
|
|
setError("");
|
2026-06-26 20:30:30 -04:00
|
|
|
setStatus("Preparing Worldshaper Studio...");
|
|
|
|
|
const nextWorldId = await resolveDefaultWorldshaperStudioWorldId().catch(() => DEFAULT_EDITOR_WORLD_ID_FALLBACK);
|
|
|
|
|
const popup = openWorldshaperStudioWindow(nextWorldId, window, { worldId: nextWorldId });
|
2026-06-26 18:18:14 -04:00
|
|
|
if (!popup) {
|
2026-06-26 20:30:30 -04:00
|
|
|
setError("The browser blocked the Worldshaper Studio window.");
|
|
|
|
|
setStatus("Worldshaper Studio unavailable: studio window was blocked.");
|
2026-06-26 18:18:14 -04:00
|
|
|
return;
|
|
|
|
|
}
|
2026-06-26 20:30:30 -04:00
|
|
|
setStatus(`Opening Worldshaper Studio for ${nextWorldId}...`);
|
2026-06-26 18:18:14 -04:00
|
|
|
} catch (err: unknown) {
|
|
|
|
|
setError(String(err));
|
2026-06-26 20:30:30 -04:00
|
|
|
setStatus("Worldshaper Studio unavailable: failed to prepare world data.");
|
2026-06-26 18:18:14 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="page-shell">
|
|
|
|
|
<header className="header-card">
|
|
|
|
|
<div className="header-copy">
|
|
|
|
|
<p className="eyebrow">New RPG</p>
|
2026-06-26 20:30:30 -04:00
|
|
|
<h1>Worldshaper</h1>
|
|
|
|
|
<p className="lede">Worldbuilding studio with tabbed pages, structured editing, and raw JSON fallback.</p>
|
2026-06-26 18:18:14 -04:00
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="header-map-editor-btn"
|
2026-06-26 20:30:30 -04:00
|
|
|
onClick={handleLaunchWorldshaperStudio}
|
2026-06-26 18:18:14 -04:00
|
|
|
disabled={isLoading}
|
|
|
|
|
>
|
2026-06-26 20:30:30 -04:00
|
|
|
<span className="header-map-editor-btn-label">Worldshaper Studio</span>
|
2026-06-26 18:18:14 -04:00
|
|
|
</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;
|
|
|
|
|
|
2026-06-26 20:30:30 -04:00
|
|
|
|