Worldshaper/server.js
2026-06-26 18:18:14 -04:00

2693 lines
89 KiB
JavaScript

import express from "express";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const port = Number(process.env.PORT) || 5180;
const host = process.env.HOST || "0.0.0.0";
function resolveContentRoot() {
const envPath = String(process.env.CONTENT_ROOT || "").trim();
if (envPath) {
return path.resolve(envPath);
}
const candidates = [
path.resolve(__dirname, "content"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return candidates[0];
}
const contentRoot = resolveContentRoot();
const worldsRoot = path.join(contentRoot, "worlds");
const worldsIndexPath = path.join(contentRoot, "worlds.json");
const imagesRoot = path.join(contentRoot, "Images");
const backupRoot = path.resolve(__dirname, "backups");
const dataRoot = path.resolve(__dirname, "data");
const catalogMetaPath = path.join(dataRoot, "catalog_meta.json");
const dialogueNodeMetaPath = path.join(dataRoot, "dialogue_node_meta.json");
const editorSettingsPath = path.join(dataRoot, "editor_settings.json");
const docsRoot = path.resolve(__dirname, "docs");
const wikiPath = path.join(docsRoot, "index.html");
const dialogueBuilderPath = path.join(docsRoot, "dialogue-builder.html");
const imagesCatalogPath = path.join(contentRoot, "images.json");
const legacyTilesCatalogPath = path.join(contentRoot, "tiles.json");
const legacySpritesCatalogPath = path.join(contentRoot, "sprites.json");
const recentSaveEvents = [];
const DEFAULT_MAP_EDITOR_THEME_PRESET = "azure";
const MAP_EDITOR_THEME_PRESET_IDS = new Set(["azure", "verdant", "ember", "amethyst"]);
const contentMap = {
npcs: { file: "npcs.json", root: "npcs" },
npc_templates: { file: "npc_templates.json", root: "npcTemplates" },
dialogues: { file: "dialogues.json", root: "dialogues" },
monsters: { file: "monsters.json", root: "monsters" },
items: { file: "items.json", root: "items" },
abilities: { file: "abilities.json", root: "abilities" },
loot_tables: { file: "loot_tables.json", root: "lootTables" },
quests: { file: "quests.json", root: "quests" },
images: { file: "images.json", root: "images" },
factions: { file: "factions.json", root: "factions" },
};
const REQUIRED_ID_KEY_BY_TYPE = {
npcs: "id",
npc_templates: "id",
dialogues: "id",
monsters: "id",
items: "id",
abilities: "id",
loot_tables: "id",
quests: "questId",
images: "id",
sprites: "id",
tiles: "id",
factions: "id",
};
const FROZEN_CATALOG_KEYS = ["conditions", "itemActions", "systemActions", "effects", "colors"];
const DEFAULT_COLOR_HEXES_ORDERED = [
"#291814",
"#111D35",
"#422136",
"#125359",
"#742F29",
"#49333B",
"#A28879",
"#F3EF7D",
"#BE1250",
"#FF6C24",
"#A8E72E",
"#00B543",
"#065AB5",
"#754665",
"#FF6E59",
"#FF9D81",
"#000000",
"#1D2B53",
"#7E2553",
"#008751",
"#AB5236",
"#5F574F",
"#C2C3C7",
"#FFF1E8",
"#FF004D",
"#FFA300",
"#FFEC27",
"#00E436",
"#29ADFF",
"#83769C",
"#FF77A8",
"#FFCCAA",
];
const DEFAULT_COLOR_SYMBOLS_ORDERED = [
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
"K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
"U", "V",
];
const DEFAULT_MAP_BACKGROUND_COLOR = "#060A14";
const DEFAULT_WORLD_CHUNK_SIZE = 32;
function normalizeHexColorValue(value, fallback = "#FFFFFF") {
const raw = String(value || "").trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw)) {
return raw.toUpperCase();
}
return fallback;
}
function normalizeMapBackgroundColor(value, fallback = DEFAULT_MAP_BACKGROUND_COLOR) {
const raw = String(value || "").trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw)) {
return raw.toUpperCase();
}
return fallback;
}
function normalizeHeightBlurStep(value, fallback = 0.1) {
const normalized = Number(value);
if (!Number.isFinite(normalized)) {
return fallback;
}
return Math.max(0, Math.min(1, normalized));
}
function normalizeMapEditorThemePreset(value) {
const normalized = String(value || "").trim().toLowerCase();
return MAP_EDITOR_THEME_PRESET_IDS.has(normalized) ? normalized : DEFAULT_MAP_EDITOR_THEME_PRESET;
}
function createDefaultEditorSettings() {
return {
schemaVersion: 1,
mapEditor: {
themePreset: DEFAULT_MAP_EDITOR_THEME_PRESET,
engineOverrides: [],
},
};
}
function normalizeEditorEngineOverrides(value) {
const entries = Array.isArray(value) ? value : [];
const byKey = new Map();
entries.forEach((entry, index) => {
const source = entry && typeof entry === "object" && !Array.isArray(entry)
? entry
: null;
if (!source) {
return;
}
const key = String(source.key || "").trim();
if (key !== "heightBlurStep" && key !== "rendererDebug") {
return;
}
const fallbackId = `override_${key}_${index + 1}`;
let normalizedValue = null;
if (key === "rendererDebug") {
if (typeof source.value === "string") {
const normalized = String(source.value || "").trim().toLowerCase();
normalizedValue = normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
} else {
normalizedValue = Boolean(source.value);
}
} else {
const rawNumber = Number(source.value);
normalizedValue = Math.max(0, Math.min(1, Number.isFinite(rawNumber) ? rawNumber : 0.1));
}
byKey.set(key, {
id: String(source.id || fallbackId).trim() || fallbackId,
key,
value: normalizedValue,
});
});
return ["heightBlurStep", "rendererDebug"]
.map((key) => byKey.get(key) || null)
.filter(Boolean);
}
function normalizeEditorSettings(payload) {
const fallback = createDefaultEditorSettings();
const source = payload && typeof payload === "object" && !Array.isArray(payload)
? payload
: fallback;
const mapEditor = source.mapEditor && typeof source.mapEditor === "object" && !Array.isArray(source.mapEditor)
? source.mapEditor
: fallback.mapEditor;
return {
schemaVersion: typeof source.schemaVersion === "number" ? source.schemaVersion : fallback.schemaVersion,
mapEditor: {
themePreset: normalizeMapEditorThemePreset(mapEditor.themePreset),
engineOverrides: normalizeEditorEngineOverrides(mapEditor.engineOverrides),
},
};
}
function readEditorSettings() {
return normalizeEditorSettings(readJsonSafe(editorSettingsPath, createDefaultEditorSettings()));
}
function normalizeBackgroundTileId(value, idToSymbol = null) {
const normalizedId = String(value || "").trim();
if (!normalizedId) {
return "";
}
if (idToSymbol instanceof Map && idToSymbol.size > 0 && !idToSymbol.has(normalizedId)) {
return "";
}
return normalizedId;
}
function areRowsOnlyFillChar(rows, fillChar = ".") {
if (!Array.isArray(rows) || rows.length === 0) {
return true;
}
return rows.every((row) => {
const normalizedRow = String(row || "");
return normalizedRow.length === 0 || normalizedRow.split("").every((ch) => ch === fillChar);
});
}
function createDefaultColorCatalogEntries() {
return DEFAULT_COLOR_HEXES_ORDERED.map((hex, index) => {
const symbol = DEFAULT_COLOR_SYMBOLS_ORDERED[index] || `X${index}`;
return {
entryId: `colors-default-${index}`,
sourceKey: symbol,
key: symbol,
originalName: symbol,
description: `Palette color ${index + 1}`,
color: normalizeHexColorValue(hex),
sublistType: "",
displayKeys: [],
passKeys: [],
};
});
}
app.use(express.json({ limit: "10mb" }));
app.use(express.static(path.join(__dirname, "dist")));
app.use("/wiki-assets", express.static(docsRoot));
app.get("/wiki", (_req, res) => {
try {
res.sendFile(wikiPath);
} catch (err) {
res.status(500).send(`Failed to load wiki: ${String(err)}`);
}
});
app.get("/dialogue-builder", (_req, res) => {
try {
res.sendFile(dialogueBuilderPath);
} catch (err) {
res.status(500).send(`Failed to load dialogue builder prototype: ${String(err)}`);
}
});
function resolveContent(type) {
const entry = contentMap[type];
if (!entry) {
return null;
}
return {
...entry,
fullPath: path.join(contentRoot, entry.file),
};
}
function readJson(fullPath) {
const raw = fs.readFileSync(fullPath, "utf8");
const sanitized = raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw;
return JSON.parse(sanitized);
}
function readJsonSafe(fullPath, fallback) {
try {
if (!fs.existsSync(fullPath)) {
return fallback;
}
return readJson(fullPath);
} catch (_err) {
return fallback;
}
}
function toContentAbs(relPath) {
const normalized = String(relPath || "").replace(/\\/g, "/").replace(/^\/+/, "");
return path.resolve(contentRoot, normalized);
}
function sanitizeWorldId(worldId) {
const raw = String(worldId || "").trim();
if (!raw) {
return "world";
}
return raw.replace(/[^a-zA-Z0-9_-]/g, "_");
}
function defaultWorldDirRel(worldId) {
return `worlds/${sanitizeWorldId(worldId)}`;
}
function buildWorldChunkFileName(chunkX, chunkY) {
return `${Math.floor(Number(chunkX) || 0)}_${Math.floor(Number(chunkY) || 0)}.json`;
}
function getWorldStoragePaths(worldEntryOrId) {
const worldId = typeof worldEntryOrId === "string"
? String(worldEntryOrId || "").trim()
: String(worldEntryOrId?.id || "").trim();
const worldDirRel = typeof worldEntryOrId === "string"
? defaultWorldDirRel(worldId)
: String(worldEntryOrId?.worldDir || defaultWorldDirRel(worldId));
const worldDirAbs = toContentAbs(worldDirRel);
const chunksDirRel = `${worldDirRel}/chunks`;
return {
worldId,
worldDirRel,
worldDirAbs,
worldJsonRel: `${worldDirRel}/world.json`,
worldJsonAbs: path.join(worldDirAbs, "world.json"),
bookmarksRel: `${worldDirRel}/bookmarks.json`,
bookmarksAbs: path.join(worldDirAbs, "bookmarks.json"),
chunksDirRel,
chunksDirAbs: path.join(worldDirAbs, "chunks"),
};
}
function normalizeWorldIndexEntry(entry) {
const id = sanitizeWorldId(entry?.id || "");
return {
id,
name: String(entry?.name || id || "World"),
worldDir: String(entry?.worldDir || defaultWorldDirRel(id)),
};
}
function readWorldIndexPayload() {
const fallback = { schemaVersion: 1, worlds: [] };
const payload = readJsonSafe(worldsIndexPath, fallback);
const worlds = Array.isArray(payload?.worlds)
? payload.worlds
.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
.map((entry) => normalizeWorldIndexEntry(entry))
: [];
return {
schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1,
worlds,
};
}
function normalizeWorldDefinitionPayload(payload, fallbackId = "") {
const normalizedId = sanitizeWorldId(payload?.id || fallbackId);
const chunkWidth = Math.max(1, Math.floor(Number(payload?.chunkWidth) || DEFAULT_WORLD_CHUNK_SIZE));
const chunkHeight = Math.max(1, Math.floor(Number(payload?.chunkHeight) || DEFAULT_WORLD_CHUNK_SIZE));
return {
schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1,
id: normalizedId,
name: String(payload?.name || normalizedId || "World"),
chunkWidth,
chunkHeight,
tileSize: Math.max(8, Number(payload?.tileSize) || 32),
backgroundColor: normalizeMapBackgroundColor(payload?.backgroundColor),
defaultBackgroundTileId: String(payload?.defaultBackgroundTileId || "").trim(),
heightBlurStep: normalizeHeightBlurStep(payload?.heightBlurStep ?? payload?.heightDetailStep),
editorUi: normalizeEditorUiState(payload?.editorUi),
spawn: {
x: Math.floor(Number(payload?.spawn?.x) || 0),
y: Math.floor(Number(payload?.spawn?.y) || 0),
},
editor: {
defaultZoom: Number.isFinite(Number(payload?.editor?.defaultZoom)) ? Number(payload.editor.defaultZoom) : 1,
gridVisible: payload?.editor?.gridVisible !== false,
},
};
}
function createDefaultWorldDefinition(worldId, overrides = {}) {
return normalizeWorldDefinitionPayload({
schemaVersion: 1,
id: sanitizeWorldId(worldId),
name: String(overrides?.name || worldId || "World"),
chunkWidth: Number(overrides?.chunkWidth) || DEFAULT_WORLD_CHUNK_SIZE,
chunkHeight: Number(overrides?.chunkHeight) || DEFAULT_WORLD_CHUNK_SIZE,
tileSize: Number(overrides?.tileSize) || 32,
backgroundColor: normalizeMapBackgroundColor(overrides?.backgroundColor),
defaultBackgroundTileId: String(overrides?.defaultBackgroundTileId || "").trim(),
heightBlurStep: normalizeHeightBlurStep(overrides?.heightBlurStep ?? overrides?.heightDetailStep),
editorUi: normalizeEditorUiState(overrides?.editorUi),
spawn: {
x: Math.floor(Number(overrides?.spawn?.x) || 0),
y: Math.floor(Number(overrides?.spawn?.y) || 0),
},
editor: {
defaultZoom: Number.isFinite(Number(overrides?.editor?.defaultZoom)) ? Number(overrides.editor.defaultZoom) : 1,
gridVisible: overrides?.editor?.gridVisible !== false,
},
}, worldId);
}
function readWorldDefinitionPayload(worldId) {
const normalizedId = sanitizeWorldId(worldId);
const indexEntry = readWorldIndexPayload().worlds.find((entry) => entry.id === normalizedId) || { id: normalizedId };
const storage = getWorldStoragePaths(indexEntry);
return normalizeWorldDefinitionPayload(
readJsonSafe(storage.worldJsonAbs, createDefaultWorldDefinition(normalizedId)),
normalizedId,
);
}
function normalizeWorldBookmark(entry, index = 0) {
const fallbackId = `bookmark_${index + 1}`;
return {
id: String(entry?.id || fallbackId).trim() || fallbackId,
label: String(entry?.label || entry?.id || fallbackId).trim() || fallbackId,
x: Math.floor(Number(entry?.x) || 0),
y: Math.floor(Number(entry?.y) || 0),
};
}
function readWorldBookmarksPayload(worldId) {
const normalizedId = sanitizeWorldId(worldId);
const storage = getWorldStoragePaths(normalizedId);
const fallback = { schemaVersion: 1, worldId: normalizedId, bookmarks: [] };
const payload = readJsonSafe(storage.bookmarksAbs, fallback);
const bookmarks = Array.isArray(payload?.bookmarks)
? payload.bookmarks
.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
.map((entry, index) => normalizeWorldBookmark(entry, index))
: [];
return {
schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1,
worldId: normalizedId,
bookmarks,
};
}
function normalizeChunkLayerPayload(layer, width, height) {
const layerNumber = Number(layer?.layer) || 0;
const fillChar = layerNumber === 0 ? "." : " ";
return {
layer: layerNumber,
name: typeof layer?.name === "string" && layer.name.trim() ? layer.name.trim() : undefined,
rows: normalizeRowsForDims(layer?.rows, width, height, fillChar),
instanceIds: normalizeStringIdList(layer?.instanceIds),
};
}
function extractNpcTilePosition(record) {
const pos = record?.position && typeof record.position === "object" && !Array.isArray(record.position)
? record.position
: null;
const x = Number(pos?.x ?? record?.x);
const y = Number(pos?.y ?? record?.y);
return {
x: Number.isFinite(x) ? Math.floor(x) : null,
y: Number.isFinite(y) ? Math.floor(y) : null,
};
}
function normalizeWorldChunkPayload(payload, worldDefinition, chunkX, chunkY) {
const normalizedWorld = normalizeWorldDefinitionPayload(worldDefinition, payload?.worldId || worldDefinition?.id || "");
const width = Math.max(1, Math.floor(Number(payload?.width) || normalizedWorld.chunkWidth));
const height = Math.max(1, Math.floor(Number(payload?.height) || normalizedWorld.chunkHeight));
const backgroundTileId = normalizeBackgroundTileId(payload?.backgroundTileId);
const rawLayers = Array.isArray(payload?.roomLayers)
? payload.roomLayers.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
: [];
const roomLayers = rawLayers
.map((layer) => normalizeChunkLayerPayload(layer, width, height))
.sort((a, b) => a.layer - b.layer);
if (!roomLayers.some((layer) => layer.layer === 0)) {
roomLayers.unshift({
layer: 0,
name: undefined,
rows: normalizeRowsForDims([], width, height, "."),
instanceIds: [],
});
}
if (!roomLayers.some((layer) => layer.layer === 1)) {
roomLayers.push({
layer: 1,
name: undefined,
rows: normalizeRowsForDims([], width, height, " "),
instanceIds: [],
});
}
const instances = Array.isArray(payload?.instances)
? payload.instances
.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
.map((entry) => ({
id: String(entry.id || "").trim(),
templateId: String(entry.templateId || "").trim(),
layer: Number(entry.layer) || 0,
x: Math.max(0, Math.min(width - 1, Math.floor(Number(entry.x) || 0))),
y: Math.max(0, Math.min(height - 1, Math.floor(Number(entry.y) || 0))),
record: entry.record && typeof entry.record === "object" && !Array.isArray(entry.record)
? { ...entry.record }
: {},
}))
.filter((entry) => entry.id)
: [];
roomLayers.forEach((layer) => {
const layerNumber = Number(layer.layer) || 0;
layer.instanceIds = normalizeStringIdList([
...layer.instanceIds,
...instances.filter((entry) => (Number(entry.layer) || 0) === layerNumber).map((entry) => entry.id),
]);
});
return {
schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1,
worldId: normalizedWorld.id,
chunkX: Math.floor(Number(chunkX) || 0),
chunkY: Math.floor(Number(chunkY) || 0),
width,
height,
backgroundTileId,
roomLayers,
heightLayers: normalizeHeightLayersForDims(payload?.heightLayers, width, height),
instances,
};
}
function createEmptyWorldChunk(worldDefinition, chunkX, chunkY) {
return normalizeWorldChunkPayload({
schemaVersion: 1,
worldId: worldDefinition.id,
chunkX,
chunkY,
width: worldDefinition.chunkWidth,
height: worldDefinition.chunkHeight,
backgroundTileId: "",
roomLayers: [
{
layer: 0,
rows: Array.from({ length: worldDefinition.chunkHeight }, () => ".".repeat(worldDefinition.chunkWidth)),
instanceIds: [],
},
{
layer: 1,
rows: Array.from({ length: worldDefinition.chunkHeight }, () => " ".repeat(worldDefinition.chunkWidth)),
instanceIds: [],
},
],
heightLayers: [],
instances: [],
}, worldDefinition, chunkX, chunkY);
}
function readWorldChunkPayload(worldId, chunkX, chunkY, options = {}) {
const worldDefinition = readWorldDefinitionPayload(worldId);
const storage = getWorldStoragePaths(worldDefinition.id);
const fileName = buildWorldChunkFileName(chunkX, chunkY);
const fullPath = path.join(storage.chunksDirAbs, fileName);
const payload = readJsonSafe(fullPath, null);
if (!payload) {
return options.createIfMissing ? createEmptyWorldChunk(worldDefinition, chunkX, chunkY) : null;
}
return normalizeWorldChunkPayload(payload, worldDefinition, chunkX, chunkY);
}
function writeWorldChunkPayload(worldId, chunkPayload) {
const worldDefinition = readWorldDefinitionPayload(worldId);
const normalized = normalizeWorldChunkPayload(chunkPayload, worldDefinition, chunkPayload?.chunkX, chunkPayload?.chunkY);
const storage = getWorldStoragePaths(worldDefinition.id);
const fullPath = path.join(storage.chunksDirAbs, buildWorldChunkFileName(normalized.chunkX, normalized.chunkY));
writeJsonAtomic(fullPath, normalized);
return normalized;
}
function listWorldChunkFiles(worldId) {
const storage = getWorldStoragePaths(worldId);
if (!fs.existsSync(storage.chunksDirAbs)) {
return [];
}
return fs.readdirSync(storage.chunksDirAbs)
.filter((name) => /^-?\d+_-?\d+\.json$/i.test(name))
.sort((a, b) => a.localeCompare(b));
}
function countSymbolOccurrencesInRows(rows, targetSymbol) {
const normalizedTarget = String(targetSymbol || "").charAt(0);
if (!normalizedTarget) {
return 0;
}
return (Array.isArray(rows) ? rows : []).reduce((count, row) => (
count + Array.from(String(row || "")).filter((ch) => ch === normalizedTarget).length
), 0);
}
function replaceSymbolInRows(rows, targetSymbol, replacementSymbol) {
const normalizedTarget = String(targetSymbol || "").charAt(0);
const normalizedReplacement = String(replacementSymbol || "").charAt(0) || " ";
if (!normalizedTarget) {
return {
rows: Array.isArray(rows) ? rows.map((row) => String(row || "")) : [],
changedCells: 0,
};
}
let changedCells = 0;
const nextRows = (Array.isArray(rows) ? rows : []).map((row) => Array.from(String(row || "")).map((ch) => {
if (ch !== normalizedTarget) {
return ch;
}
changedCells += 1;
return normalizedReplacement;
}).join(""));
return {
rows: nextRows,
changedCells,
};
}
function scrubTileReferencesFromRoomLayers(roomLayers, targetSymbol, width, height) {
let changedCells = 0;
const nextLayers = (Array.isArray(roomLayers) ? roomLayers : [])
.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
.map((layer) => {
const layerNumber = Number(layer.layer) || 0;
const fillChar = layerNumber === 0 ? "." : " ";
const normalizedRows = normalizeRowsForDims(layer.rows, width, height, fillChar);
const scrubbedRows = replaceSymbolInRows(normalizedRows, targetSymbol, fillChar);
changedCells += scrubbedRows.changedCells;
return {
...layer,
layer: layerNumber,
rows: scrubbedRows.rows,
};
})
.sort((a, b) => (Number(a.layer) || 0) - (Number(b.layer) || 0));
return {
roomLayers: nextLayers,
changedCells,
};
}
function scrubTileReferencesFromHeightLayers(heightLayers, targetSymbol, width, height) {
let changedCells = 0;
const nextEntries = (Array.isArray(heightLayers) ? heightLayers : [])
.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
.map((entry) => {
const scrubbedRows = replaceSymbolInRows(entry.rows, targetSymbol, " ");
changedCells += scrubbedRows.changedCells;
return {
...entry,
rows: scrubbedRows.rows,
};
});
return {
heightLayers: normalizeHeightLayersForDims(nextEntries, width, height),
changedCells,
};
}
function scrubTileReferencesFromEditorUi(editorUi, tileId) {
const normalizedTileId = String(tileId || "").trim();
const nextEditorUi = normalizeEditorUiState(editorUi);
if (!normalizedTileId) {
return {
editorUi: nextEditorUi,
changed: false,
};
}
const tileNodeId = `item:${normalizedTileId}`;
let changed = false;
const nextPanelLayouts = {};
Object.entries(nextEditorUi.panelLayouts || {}).forEach(([panelKey, rawLayout]) => {
if (!rawLayout || typeof rawLayout !== "object" || Array.isArray(rawLayout)) {
nextPanelLayouts[panelKey] = rawLayout;
return;
}
const nextLayout = JSON.parse(JSON.stringify(rawLayout));
if (panelKey === "tiles") {
const previousRootOrder = Array.isArray(nextLayout.rootOrder) ? nextLayout.rootOrder.length : 0;
nextLayout.rootOrder = Array.isArray(nextLayout.rootOrder)
? nextLayout.rootOrder.filter((entry) => String(entry || "").trim() !== tileNodeId && String(entry || "").trim() !== normalizedTileId)
: [];
if (nextLayout.rootOrder.length !== previousRootOrder) {
changed = true;
}
const folders = nextLayout.folders && typeof nextLayout.folders === "object" && !Array.isArray(nextLayout.folders)
? nextLayout.folders
: {};
Object.values(folders).forEach((folder) => {
if (!folder || typeof folder !== "object" || Array.isArray(folder)) {
return;
}
const previousItemCount = Array.isArray(folder.itemOrder) ? folder.itemOrder.length : 0;
folder.itemOrder = Array.isArray(folder.itemOrder)
? folder.itemOrder.filter((entry) => String(entry || "").trim() !== normalizedTileId)
: [];
if (folder.itemOrder.length !== previousItemCount) {
changed = true;
}
});
}
nextPanelLayouts[panelKey] = nextLayout;
});
return {
editorUi: {
...nextEditorUi,
panelLayouts: nextPanelLayouts,
},
changed,
};
}
function deleteTileFromStorage(tileId) {
const normalizedTileId = String(tileId || "").trim();
if (!normalizedTileId) {
throw new Error("Tile id is required.");
}
const imagesPayload = readImagesCatalogPayload();
const tilesPayload = buildTilesPayloadFromImages(imagesPayload);
const tiles = Array.isArray(tilesPayload?.tiles) ? tilesPayload.tiles : [];
const tileRecord = tiles.find((entry) => entry && typeof entry === "object" && !Array.isArray(entry) && String(entry.id || "").trim() === normalizedTileId) || null;
if (!tileRecord) {
throw new Error(`Tile ${normalizedTileId} not found.`);
}
const tileSymbol = String(tileRecord.symbol || "").charAt(0);
if (!tileSymbol || tileSymbol === "." || tileSymbol === " ") {
throw new Error(`Tile ${normalizedTileId} cannot be deleted.`);
}
const nextTilesPayload = {
schemaVersion: typeof tilesPayload?.schemaVersion === "number" ? tilesPayload.schemaVersion : 1,
tiles: tiles.filter((entry) => !(entry && typeof entry === "object" && !Array.isArray(entry) && String(entry.id || "").trim() === normalizedTileId)),
};
const stats = {
removedTileId: normalizedTileId,
removedTileName: String(tileRecord.name || normalizedTileId).trim() || normalizedTileId,
removedTileSymbol: tileSymbol,
updatedMaps: 0,
updatedWorlds: 0,
updatedChunks: 0,
scrubbedRoomCells: 0,
scrubbedHeightCells: 0,
scrubbedBackgroundRefs: 0,
scrubbedEditorUiRefs: 0,
};
const worldIndexPayload = readWorldIndexPayload();
worldIndexPayload.worlds.forEach((worldEntry) => {
const worldId = String(worldEntry?.id || "").trim();
if (!worldId) {
return;
}
const storage = getWorldStoragePaths(worldEntry);
const existingWorld = readWorldDefinitionPayload(worldId);
const scrubbedEditorUi = scrubTileReferencesFromEditorUi(existingWorld.editorUi, normalizedTileId);
const clearsWorldBackground = String(existingWorld.defaultBackgroundTileId || "").trim() === normalizedTileId;
if (clearsWorldBackground || scrubbedEditorUi.changed) {
stats.updatedWorlds += 1;
if (clearsWorldBackground) {
stats.scrubbedBackgroundRefs += 1;
}
if (scrubbedEditorUi.changed) {
stats.scrubbedEditorUiRefs += 1;
}
writeJsonAtomic(storage.worldJsonAbs, normalizeWorldDefinitionPayload({
...existingWorld,
defaultBackgroundTileId: clearsWorldBackground ? "" : existingWorld.defaultBackgroundTileId,
editorUi: scrubbedEditorUi.editorUi,
}, worldId));
}
listWorldChunkFiles(worldId).forEach((fileName) => {
const match = /^(-?\d+)_(-?\d+)\.json$/i.exec(String(fileName || "").trim());
if (!match) {
return;
}
const chunkX = Math.floor(Number(match[1]) || 0);
const chunkY = Math.floor(Number(match[2]) || 0);
const chunkPayload = readWorldChunkPayload(worldId, chunkX, chunkY, { createIfMissing: false });
if (!chunkPayload) {
return;
}
const scrubbedLayers = scrubTileReferencesFromRoomLayers(chunkPayload.roomLayers, tileSymbol, chunkPayload.width, chunkPayload.height);
const scrubbedHeightLayers = scrubTileReferencesFromHeightLayers(chunkPayload.heightLayers, tileSymbol, chunkPayload.width, chunkPayload.height);
const clearsChunkBackground = String(chunkPayload.backgroundTileId || "").trim() === normalizedTileId;
const changed = scrubbedLayers.changedCells > 0 || scrubbedHeightLayers.changedCells > 0 || clearsChunkBackground;
if (!changed) {
return;
}
stats.updatedChunks += 1;
stats.scrubbedRoomCells += scrubbedLayers.changedCells;
stats.scrubbedHeightCells += scrubbedHeightLayers.changedCells;
if (clearsChunkBackground) {
stats.scrubbedBackgroundRefs += 1;
}
writeWorldChunkPayload(worldId, {
...chunkPayload,
backgroundTileId: clearsChunkBackground ? "" : String(chunkPayload.backgroundTileId || "").trim(),
roomLayers: scrubbedLayers.roomLayers,
heightLayers: scrubbedHeightLayers.heightLayers,
});
});
});
const nextImages = [];
imagesPayload.images.forEach((entry) => {
const imageId = String(entry?.id || "").trim();
if (imageId !== normalizedTileId) {
nextImages.push(entry);
return;
}
const roles = Array.isArray(entry?.roles) ? entry.roles.filter((role) => role !== "tile") : [];
if (roles.length === 0) {
return;
}
nextImages.push(normalizeImageRecord({
...entry,
roles,
tileSymbol: "",
}));
});
writeImagesCatalogPayload({
schemaVersion: typeof imagesPayload?.schemaVersion === "number" ? imagesPayload.schemaVersion : 1,
images: nextImages,
});
return {
tile: {
id: normalizedTileId,
name: stats.removedTileName,
symbol: tileSymbol,
},
tilesPayload: nextTilesPayload,
stats,
};
}
function scrubSpriteReferencesFromRecord(record, spriteId) {
if (!record || typeof record !== "object" || Array.isArray(record)) {
return { record, changed: false };
}
const normalizedSpriteId = String(spriteId || "").trim();
let changed = false;
const nextRecord = { ...record };
["spriteId", "spriteIdOverride"].forEach((key) => {
if (String(nextRecord[key] || "").trim() !== normalizedSpriteId) {
return;
}
nextRecord[key] = "";
changed = true;
});
return {
record: nextRecord,
changed,
};
}
function deleteSpriteFromStorage(spriteId) {
const normalizedSpriteId = String(spriteId || "").trim();
if (!normalizedSpriteId) {
throw new Error("Sprite id is required.");
}
const imagesPayload = readImagesCatalogPayload();
const spritesPayload = buildSpritesPayloadFromImages(imagesPayload);
const sprites = Array.isArray(spritesPayload?.sprites) ? spritesPayload.sprites : [];
const spriteRecord = sprites.find((entry) => entry && typeof entry === "object" && !Array.isArray(entry) && String(entry.id || "").trim() === normalizedSpriteId) || null;
if (!spriteRecord) {
throw new Error(`Sprite ${normalizedSpriteId} not found.`);
}
const stats = {
removedSpriteId: normalizedSpriteId,
removedSpriteName: String(spriteRecord.name || normalizedSpriteId).trim() || normalizedSpriteId,
updatedNpcRecords: 0,
updatedNpcTemplateRecords: 0,
updatedChunks: 0,
scrubbedPlacedEntities: 0,
};
[
{ type: "npcs", root: "npcs", statKey: "updatedNpcRecords" },
{ type: "npc_templates", root: "npcTemplates", statKey: "updatedNpcTemplateRecords" },
].forEach(({ type, root, statKey }) => {
const resolved = resolveContent(type);
if (!resolved) {
return;
}
const payload = readJsonSafe(resolved.fullPath, defaultPayloadForType(type, root));
const records = Array.isArray(payload?.[root]) ? payload[root] : [];
let changedCount = 0;
const nextRecords = records.map((entry) => {
const scrubbed = scrubSpriteReferencesFromRecord(entry, normalizedSpriteId);
if (scrubbed.changed) {
changedCount += 1;
}
return scrubbed.record;
});
if (changedCount <= 0) {
return;
}
stats[statKey] += changedCount;
writeJsonAtomic(resolved.fullPath, {
schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1,
[root]: nextRecords,
});
});
const worldIndexPayload = readWorldIndexPayload();
worldIndexPayload.worlds.forEach((worldEntry) => {
const worldId = String(worldEntry?.id || "").trim();
if (!worldId) {
return;
}
listWorldChunkFiles(worldId).forEach((fileName) => {
const match = /^(-?\d+)_(-?\d+)\.json$/i.exec(String(fileName || "").trim());
if (!match) {
return;
}
const chunkX = Math.floor(Number(match[1]) || 0);
const chunkY = Math.floor(Number(match[2]) || 0);
const chunkPayload = readWorldChunkPayload(worldId, chunkX, chunkY, { createIfMissing: false });
if (!chunkPayload) {
return;
}
let changedEntities = 0;
const nextInstances = (Array.isArray(chunkPayload.instances) ? chunkPayload.instances : []).map((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return entry;
}
const nextEntry = { ...entry };
const scrubbedRecord = scrubSpriteReferencesFromRecord(
nextEntry.record && typeof nextEntry.record === "object" && !Array.isArray(nextEntry.record) ? nextEntry.record : {},
normalizedSpriteId,
);
const hadTopLevelSprite = String(nextEntry.spriteId || "").trim() === normalizedSpriteId;
if (hadTopLevelSprite) {
nextEntry.spriteId = "";
}
if (scrubbedRecord.changed || hadTopLevelSprite) {
changedEntities += 1;
}
nextEntry.record = scrubbedRecord.record;
return nextEntry;
});
if (changedEntities <= 0) {
return;
}
stats.updatedChunks += 1;
stats.scrubbedPlacedEntities += changedEntities;
writeWorldChunkPayload(worldId, {
...chunkPayload,
instances: nextInstances,
});
});
});
const nextImages = [];
imagesPayload.images.forEach((entry) => {
const imageId = String(entry?.id || "").trim();
if (imageId !== normalizedSpriteId) {
nextImages.push(entry);
return;
}
const roles = Array.isArray(entry?.roles) ? entry.roles.filter((role) => role !== "sprite") : [];
if (roles.length === 0) {
return;
}
nextImages.push(normalizeImageRecord({
...entry,
roles,
}));
});
const nextImagesPayload = {
schemaVersion: typeof imagesPayload?.schemaVersion === "number" ? imagesPayload.schemaVersion : 1,
images: nextImages,
};
writeImagesCatalogPayload(nextImagesPayload);
return {
sprite: {
id: normalizedSpriteId,
name: stats.removedSpriteName,
},
imagesPayload: nextImagesPayload,
stats,
};
}
function normalizeRowsForDims(rows, width, height, fillChar) {
const safeWidth = Math.max(1, Number(width) || 1);
const safeHeight = Math.max(1, Number(height) || 1);
return Array.from({ length: safeHeight }, (_, y) => {
const src = Array.isArray(rows) ? String(rows[y] || "") : "";
if (src.length >= safeWidth) {
return src.slice(0, safeWidth);
}
return src + String(fillChar || " ").repeat(Math.max(0, safeWidth - src.length));
});
}
function normalizeStringIdList(value) {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
const normalized = [];
value.forEach((entry) => {
const id = String(entry || "").trim();
if (!id || seen.has(id)) {
return;
}
seen.add(id);
normalized.push(id);
});
return normalized;
}
function normalizeElevationMasksForDims(value, width, height) {
if (!Array.isArray(value)) {
return [];
}
const seenZ = new Set();
return value
.flatMap((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return [];
}
const z = Math.max(1, Math.min(5, Number(entry.z) || 0));
if (!Number.isInteger(z) || z < 1 || z > 5 || seenZ.has(z)) {
return [];
}
seenZ.add(z);
const rows = normalizeRowsForDims(entry.rows, width, height, ".").map((row) => (
row.split("").map((ch) => (ch && ch !== "." ? "#" : ".")).join("")
));
return rows.some((row) => row.includes("#")) ? [{ z, rows }] : [];
})
.sort((a, b) => a.z - b.z);
}
function trimHeightLayerRows(rows, originX, originY) {
const normalizedRows = Array.isArray(rows)
? rows.map((row) => String(row || "").replace(/\./g, " ").replace(/\s+$/g, ""))
: [];
let top = 0;
let bottom = normalizedRows.length - 1;
while (top <= bottom && !normalizedRows[top].split("").some((ch) => ch !== " ")) {
top += 1;
}
while (bottom >= top && !normalizedRows[bottom].split("").some((ch) => ch !== " ")) {
bottom -= 1;
}
if (top > bottom) {
return {
x: Math.max(0, Number(originX) || 0),
y: Math.max(0, Number(originY) || 0),
rows: [],
};
}
const croppedRows = normalizedRows.slice(top, bottom + 1);
let left = Number.POSITIVE_INFINITY;
let right = -1;
croppedRows.forEach((row) => {
row.split("").forEach((ch, index) => {
if (ch === " ") {
return;
}
left = Math.min(left, index);
right = Math.max(right, index);
});
});
if (!Number.isFinite(left) || right < left) {
return {
x: Math.max(0, Number(originX) || 0),
y: Math.max(0, Number(originY) || 0),
rows: [],
};
}
return {
x: Math.max(0, Number(originX) || 0) + left,
y: Math.max(0, Number(originY) || 0) + top,
rows: croppedRows.map((row) => row.slice(left, right + 1).replace(/\s+$/g, "")),
};
}
function normalizeHeightLayersForDims(value, width, height) {
if (!Array.isArray(value)) {
return [];
}
const safeWidth = Math.max(1, Number(width) || 1);
const safeHeight = Math.max(1, Number(height) || 1);
const seenIds = new Set();
return value
.flatMap((entry, index) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return [];
}
const fallbackId = `height_${index + 1}`;
const id = String(entry.id || fallbackId).trim() || fallbackId;
if (seenIds.has(id)) {
return [];
}
seenIds.add(id);
let x = Math.floor(Number(entry.x) || 0);
let y = Math.floor(Number(entry.y) || 0);
let rows = Array.isArray(entry.rows) ? entry.rows.map((row) => String(row || "").replace(/\./g, " ")) : [];
if (y < 0) {
rows = rows.slice(-y);
y = 0;
}
if (x < 0) {
rows = rows.map((row) => row.slice(-x));
x = 0;
}
if (y >= safeHeight || x >= safeWidth) {
rows = [];
} else {
rows = rows.slice(0, Math.max(0, safeHeight - y));
rows = rows.map((row) => row.slice(0, Math.max(0, safeWidth - x)));
}
const trimmed = trimHeightLayerRows(rows, x, y);
return [{
id,
name: typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
z: Math.max(1, Math.floor(Number(entry.z) || 1)),
x: trimmed.x,
y: trimmed.y,
rows: trimmed.rows,
}];
})
.sort((a, b) => {
if (a.z !== b.z) {
return a.z - b.z;
}
return String(a.name || a.id).localeCompare(String(b.name || b.id));
});
}
function readTileCatalogMaps() {
const payload = buildTilesPayloadFromImages(readImagesCatalogPayload());
const tiles = Array.isArray(payload?.tiles) ? payload.tiles : [];
const idToSymbol = new Map();
tiles.forEach((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return;
}
const id = String(entry.id || "").trim();
const symbol = String(entry.symbol || "").charAt(0);
if (!id || !symbol) {
return;
}
if (!idToSymbol.has(id)) {
idToSymbol.set(id, symbol);
}
});
return { idToSymbol };
}
function normalizeEditorUiState(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { panelLayouts: {} };
}
const panelLayouts = value.panelLayouts && typeof value.panelLayouts === "object" && !Array.isArray(value.panelLayouts)
? value.panelLayouts
: {};
return {
panelLayouts: JSON.parse(JSON.stringify(panelLayouts)),
};
}
function createDefaultCatalogMeta() {
return {
schemaVersion: 1,
conditions: [],
itemActions: [],
systemActions: [],
effects: [],
colors: createDefaultColorCatalogEntries(),
};
}
function normalizeStringList(value) {
if (!Array.isArray(value)) {
return [];
}
return Array.from(new Set(value.map((entry) => String(entry || "").trim()).filter(Boolean)));
}
function resolveStringList(value, fallback) {
const normalized = normalizeStringList(value);
if (normalized.length > 0) {
return normalized;
}
return normalizeStringList(fallback);
}
function getDefaultConditionCatalogMeta(key) {
const baseType = String(key || "").trim();
if (["item", "item_not"].includes(baseType)) {
return { sublistType: "items", displayKeys: ["id", "name"], passKeys: ["id"] };
}
if (["quest_started", "quest_not_started", "quest_completed", "quest_not_completed", "quest_step_completed", "quest_step_not_completed"].includes(baseType)) {
return { sublistType: "quests", displayKeys: ["questId", "name"], passKeys: ["questId"] };
}
return { sublistType: "", displayKeys: [], passKeys: [] };
}
function getDefaultSystemActionCatalogMeta(key) {
const baseType = String(key || "").trim();
if (["grant_item", "remove_item"].includes(baseType)) {
return { sublistType: "items", displayKeys: ["id", "name"], passKeys: ["id"] };
}
if (["start_quest", "complete_quest"].includes(baseType)) {
return { sublistType: "quests", displayKeys: ["questId", "name"], passKeys: ["questId"] };
}
return { sublistType: "", displayKeys: [], passKeys: [] };
}
function normalizeCatalogMeta(payload) {
const safe = payload && typeof payload === "object" && !Array.isArray(payload)
? payload
: createDefaultCatalogMeta();
const rawConditions = Array.isArray(safe.conditions) ? safe.conditions : [];
const rawTriggers = Array.isArray(safe.triggers) ? safe.triggers : [];
const rawItemActions = Array.isArray(safe.itemActions) ? safe.itemActions : [];
const rawSystemActions = Array.isArray(safe.systemActions) ? safe.systemActions : [];
const rawColors = Array.isArray(safe.colors) ? safe.colors : createDefaultColorCatalogEntries();
const normalizeEntries = (type, entries) => (
Array.isArray(entries)
? (() => {
const seenEntryIds = new Set();
return entries
.map((entry, index) => {
const sourceKey = String(entry?.sourceKey || entry?.key || "").trim();
const key = String(entry?.key || sourceKey).trim();
const originalName = String(entry?.originalName || key).trim();
const description = String(entry?.description || "");
const defaultMeta = type === "conditions"
? getDefaultConditionCatalogMeta(key)
: (type === "systemActions" ? getDefaultSystemActionCatalogMeta(key) : { sublistType: "", displayKeys: [], passKeys: [] });
const entryId = String(entry?.entryId || `${type}-${index}-${sourceKey || key}`).trim();
if (!entryId || !sourceKey || !key || seenEntryIds.has(entryId)) {
return null;
}
seenEntryIds.add(entryId);
return {
entryId,
sourceKey,
key,
originalName: originalName || key,
description,
color: type === "colors" ? normalizeHexColorValue(entry?.color) : undefined,
sublistType: String(entry?.sublistType || defaultMeta.sublistType || "").trim(),
displayKeys: resolveStringList(entry?.displayKeys, defaultMeta.displayKeys),
passKeys: resolveStringList(entry?.passKeys, defaultMeta.passKeys),
};
})
.filter(Boolean);
})()
: []
);
return {
schemaVersion: 1,
conditions: (() => {
const normalized = normalizeEntries("conditions", [...rawConditions, ...rawTriggers]);
const seenSources = new Set();
return normalized.filter((entry) => {
const source = String(entry?.sourceKey || entry?.key || "");
if (!source || seenSources.has(source)) {
return false;
}
seenSources.add(source);
return true;
});
})(),
itemActions: normalizeEntries("itemActions", rawItemActions),
systemActions: normalizeEntries("systemActions", rawSystemActions),
effects: normalizeEntries("effects", safe.effects),
colors: normalizeEntries("colors", rawColors),
};
}
function readCatalogMeta() {
try {
if (!fs.existsSync(catalogMetaPath)) {
return createDefaultCatalogMeta();
}
return normalizeCatalogMeta(readJson(catalogMetaPath));
} catch (_err) {
return createDefaultCatalogMeta();
}
}
function createDefaultDialogueNodeMeta() {
return {
schemaVersion: 1,
npcs: {},
};
}
function normalizeDialogueNodeMeta(payload) {
const safe = payload && typeof payload === "object" && !Array.isArray(payload)
? payload
: createDefaultDialogueNodeMeta();
const rawNpcs = safe.npcs && typeof safe.npcs === "object" && !Array.isArray(safe.npcs)
? safe.npcs
: {};
const npcs = {};
Object.entries(rawNpcs).forEach(([npcId, nodeMap]) => {
const normalizedNpcId = String(npcId || "").trim();
if (!normalizedNpcId || !nodeMap || typeof nodeMap !== "object" || Array.isArray(nodeMap)) {
return;
}
const normalizedNodeMap = {};
Object.entries(nodeMap).forEach(([nodeId, description]) => {
const normalizedNodeId = String(nodeId || "").trim();
const normalizedDescription = String(description || "").trim();
if (!normalizedNodeId || !normalizedDescription) {
return;
}
normalizedNodeMap[normalizedNodeId] = normalizedDescription;
});
if (Object.keys(normalizedNodeMap).length > 0) {
npcs[normalizedNpcId] = normalizedNodeMap;
}
});
return {
schemaVersion: 1,
npcs,
};
}
function readDialogueNodeMeta() {
try {
if (!fs.existsSync(dialogueNodeMetaPath)) {
return createDefaultDialogueNodeMeta();
}
return normalizeDialogueNodeMeta(readJson(dialogueNodeMetaPath));
} catch (_err) {
return createDefaultDialogueNodeMeta();
}
}
function buildDialogueNodeMetaFromNpcPayload(payload) {
const npcs = Array.isArray(payload?.npcs) ? payload.npcs : [];
const npcMap = {};
npcs.forEach((npc) => {
const npcId = String(npc?.id || "").trim();
if (!npcId) {
return;
}
const nodes = Array.isArray(npc?.dialogueNodes) ? npc.dialogueNodes : [];
const nodeMap = {};
nodes.forEach((node) => {
const nodeId = String(node?.id || "").trim();
const description = String(node?.description || "").trim();
if (!nodeId || !description) {
return;
}
nodeMap[nodeId] = description;
});
if (Object.keys(nodeMap).length > 0) {
npcMap[npcId] = nodeMap;
}
});
return normalizeDialogueNodeMeta({
schemaVersion: 1,
npcs: npcMap,
});
}
function stripNpcNodeDescriptions(payload) {
if (!payload || typeof payload !== "object" || !Array.isArray(payload.npcs)) {
return payload;
}
return {
...payload,
npcs: payload.npcs.map((npc) => {
const nodes = Array.isArray(npc?.dialogueNodes) ? npc.dialogueNodes : [];
return {
...npc,
dialogueNodes: nodes.map((node) => {
const { description: _description, ...restNode } = node || {};
return restNode;
}),
};
}),
};
}
function injectNpcNodeDescriptions(payload, meta) {
if (!payload || typeof payload !== "object" || !Array.isArray(payload.npcs)) {
return payload;
}
const npcMeta = meta?.npcs && typeof meta.npcs === "object" ? meta.npcs : {};
return {
...payload,
npcs: payload.npcs.map((npc) => {
const npcId = String(npc?.id || "").trim();
const nodeMeta = npcId && npcMeta[npcId] && typeof npcMeta[npcId] === "object"
? npcMeta[npcId]
: {};
const nodes = Array.isArray(npc?.dialogueNodes) ? npc.dialogueNodes : [];
return {
...npc,
dialogueNodes: nodes.map((node) => {
const nodeId = String(node?.id || "").trim();
const description = nodeId && typeof nodeMeta[nodeId] === "string" ? nodeMeta[nodeId] : "";
return {
...node,
description,
};
}),
};
}),
};
}
function validatePayload(payload, type, rootKey) {
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
return "Payload must be an object";
}
if (typeof payload.schemaVersion !== "number") {
return "schemaVersion must be a number";
}
const allowedTopLevel = new Set(["schemaVersion", rootKey]);
const unknownTopLevel = Object.keys(payload).filter((key) => !allowedTopLevel.has(key));
if (unknownTopLevel.length > 0) {
return `Unsupported top-level keys for ${type}: ${unknownTopLevel.join(", ")}`;
}
if (!Array.isArray(payload[rootKey])) {
return `Missing array root: ${rootKey}`;
}
const idKey = REQUIRED_ID_KEY_BY_TYPE[type];
if (!idKey) {
return null;
}
const list = payload[rootKey];
for (let index = 0; index < list.length; index += 1) {
const entry = list[index];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return `${rootKey}[${index}] must be an object`;
}
const idValue = String(entry[idKey] ?? "").trim();
if (!idValue) {
return `${rootKey}[${index}] is missing required key: ${idKey}`;
}
}
return null;
}
function validateCatalogMetaPayload(payload) {
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
return "Catalog payload must be an object";
}
if (typeof payload.schemaVersion !== "number") {
return "schemaVersion must be a number";
}
const allowedTopLevel = new Set(["schemaVersion", ...FROZEN_CATALOG_KEYS]);
const unknownTopLevel = Object.keys(payload).filter((key) => !allowedTopLevel.has(key));
if (unknownTopLevel.length > 0) {
return `Unsupported catalog keys: ${unknownTopLevel.join(", ")}`;
}
for (const key of FROZEN_CATALOG_KEYS) {
if (!Array.isArray(payload[key])) {
return `${key} must be an array`;
}
}
return null;
}
function writeJsonAtomic(fullPath, data) {
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
const tmpPath = `${fullPath}.tmp`;
fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
fs.renameSync(tmpPath, fullPath);
}
function defaultPayloadForType(type, rootKey) {
if (type === "npcs") {
return { schemaVersion: 1, npcs: [] };
}
return { schemaVersion: 1, [rootKey]: [] };
}
function backupFile(type, fullPath) {
try {
fs.mkdirSync(backupRoot, { recursive: true });
if (!fs.existsSync(fullPath)) {
// Some content types (like npcs) may be storage-composed without a legacy flat file.
return;
}
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const fileName = `${type}-${stamp}.json`;
const target = path.join(backupRoot, fileName);
fs.copyFileSync(fullPath, target);
} catch (err) {
// Backups are best-effort and should never block content saves.
console.warn(`[backup] Skipped backup for ${type}: ${String(err)}`);
}
}
function normalizeUniqueStringList(value, options = {}) {
const config = options && typeof options === "object" ? options : {};
const normalizeValue = typeof config.normalizeValue === "function"
? config.normalizeValue
: ((entry) => String(entry || "").trim());
const dedupeKey = typeof config.dedupeKey === "function"
? config.dedupeKey
: ((entry) => normalizeValue(entry));
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
const normalized = [];
value.forEach((entry) => {
const next = normalizeValue(entry);
const key = dedupeKey(entry);
if (!next || !key || seen.has(key)) {
return;
}
seen.add(key);
normalized.push(next);
});
return normalized;
}
function normalizeTagList(value) {
if (!Array.isArray(value)) {
return [];
}
return normalizeUniqueStringList(value, {
normalizeValue: (entry) => String(entry || "").replace(/\s+/g, " ").trim(),
dedupeKey: (entry) => String(entry || "").replace(/\s+/g, " ").trim().toLowerCase(),
});
}
function normalizeImageRoles(value) {
return normalizeUniqueStringList(value, {
normalizeValue: (entry) => String(entry || "").trim().toLowerCase(),
dedupeKey: (entry) => String(entry || "").trim().toLowerCase(),
}).filter((role) => role === "tile" || role === "sprite");
}
function normalizeImageRows(value) {
if (!Array.isArray(value)) {
return [];
}
return value.map((row) => String(row || ""));
}
function normalizeImagePlayback(value) {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "rewind" || normalized === "stop") {
return normalized;
}
return "normal";
}
function normalizeImageFrameRecord(frame, fallback, index) {
const source = frame && typeof frame === "object" && !Array.isArray(frame) ? frame : {};
const width = Math.max(1, Math.floor(Number(source.width) || Number(fallback?.width) || 16));
const height = Math.max(1, Math.floor(Number(source.height) || Number(fallback?.height) || 16));
return {
id: String(source.id || `frame_${index}`).trim() || `frame_${index}`,
rows: normalizeRowsForDims(normalizeImageRows(source.rows), width, height, "."),
enabled: source.enabled !== false,
index: Number.isFinite(Number(source.index)) ? Math.max(0, Math.floor(Number(source.index))) : index,
};
}
function getNormalizedImageFrames(source, width, height) {
const inputFrames = Array.isArray(source?.frames)
? source.frames.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
: [];
const legacyRows = normalizeRowsForDims(normalizeImageRows(source?.rows), width, height, ".");
let frames = inputFrames.map((entry, index) => normalizeImageFrameRecord(entry, { width, height }, index));
if (frames.length === 0) {
frames = [normalizeImageFrameRecord({
id: "frame_0",
rows: legacyRows,
}, { width, height }, 0)];
}
const requestedDefaultFrameId = String(source?.defaultFrame || "").trim();
const resolvedDefaultFrameId = String(
frames.find((entry) => String(entry.id || "").trim() === requestedDefaultFrameId)?.id
|| frames[0]?.id
|| "frame_0"
).trim() || "frame_0";
const hasExplicitLegacyRows = Array.isArray(source?.rows) && source.rows.length > 0 && !areRowsOnlyFillChar(source.rows, ".");
if (hasExplicitLegacyRows) {
frames = frames.map((entry, index) => (
String(entry.id || "").trim() === resolvedDefaultFrameId
? normalizeImageFrameRecord({
...entry,
id: resolvedDefaultFrameId,
rows: legacyRows,
index,
}, { width, height }, index)
: entry
));
}
const defaultFrame = frames.find((entry) => String(entry.id || "").trim() === resolvedDefaultFrameId) || frames[0];
return {
frames,
defaultFrameId: resolvedDefaultFrameId,
rows: Array.isArray(defaultFrame?.rows) ? defaultFrame.rows.map((row) => String(row || "")) : legacyRows,
};
}
function getResolvedImageRows(source, width, height) {
const safeWidth = Math.max(1, Math.floor(Number(width) || 16));
const safeHeight = Math.max(1, Math.floor(Number(height) || 16));
return getNormalizedImageFrames(source, safeWidth, safeHeight).rows;
}
function normalizeImageRecord(record) {
const source = record && typeof record === "object" && !Array.isArray(record) ? record : {};
const id = String(source.id || "").trim();
const name = typeof source.name === "string" ? source.name : "";
const description = typeof source.description === "string" ? source.description : "";
const width = Math.max(1, Math.floor(Number(source.width) || 16));
const height = Math.max(1, Math.floor(Number(source.height) || 16));
const pixelScale = Math.max(1, Math.floor(Number(source.pixelScale) || 1));
const opacity = Number.isFinite(Number(source.opacity)) ? Math.max(0, Math.min(1, Number(source.opacity))) : 1;
const tags = normalizeTagList(source.tags);
const roles = normalizeImageRoles(source.roles);
const tileSymbol = roles.includes("tile")
? String(source.tileSymbol || source.symbol || "").charAt(0)
: "";
const normalizedFrames = getNormalizedImageFrames(source, width, height);
return {
id,
name,
description,
width,
height,
pixelScale,
opacity,
rows: normalizedFrames.rows,
frames: normalizedFrames.frames,
defaultFrame: normalizedFrames.defaultFrameId,
speed: Number.isFinite(Number(source.speed)) && Number(source.speed) >= 0 ? Number(source.speed) : 0,
playback: normalizeImagePlayback(source.playback),
tags,
roles,
tileSymbol: tileSymbol || "",
};
}
function normalizeImageRecordForDisk(record) {
const normalized = normalizeImageRecord(record);
return {
id: normalized.id,
name: normalized.name,
description: normalized.description,
width: normalized.width,
height: normalized.height,
pixelScale: normalized.pixelScale,
opacity: normalized.opacity,
tags: normalized.tags,
roles: normalized.roles,
tileSymbol: normalized.tileSymbol,
frames: Array.isArray(normalized.frames)
? normalized.frames.map((entry, index) => normalizeImageFrameRecord(entry, normalized, index))
: [],
defaultFrame: String(normalized.defaultFrame || "frame_0").trim() || "frame_0",
speed: Number.isFinite(Number(normalized.speed)) && Number(normalized.speed) >= 0 ? Number(normalized.speed) : 0,
playback: normalizeImagePlayback(normalized.playback),
};
}
function mergeImageRecord(baseRecord, overlayRecord) {
const base = normalizeImageRecord(baseRecord);
const overlay = normalizeImageRecord(overlayRecord);
const roles = Array.from(new Set([...(base.roles || []), ...(overlay.roles || [])]));
const overlayHasTags = Array.isArray(overlayRecord?.tags);
const overlayHasFrames = Array.isArray(overlayRecord?.frames) && overlayRecord.frames.length > 0;
const overlayHasRows = Array.isArray(overlayRecord?.rows) && overlayRecord.rows.length > 0 && !areRowsOnlyFillChar(overlayRecord.rows, ".");
return normalizeImageRecord({
...base,
...overlay,
id: String(overlay.id || base.id || "").trim(),
name: String(overlay.name || base.name || "").trim(),
description: String(overlay.description || base.description || "").trim(),
width: Math.max(1, Number(overlay.width) || Number(base.width) || 16),
height: Math.max(1, Number(overlay.height) || Number(base.height) || 16),
pixelScale: Math.max(1, Number(overlay.pixelScale) || Number(base.pixelScale) || 1),
opacity: Number.isFinite(Number(overlay.opacity)) ? Number(overlay.opacity) : base.opacity,
rows: overlayHasRows ? overlay.rows : base.rows,
frames: overlayHasFrames ? overlay.frames : base.frames,
defaultFrame: String(overlay.defaultFrame || base.defaultFrame || "").trim(),
speed: Number.isFinite(Number(overlay.speed)) ? Number(overlay.speed) : base.speed,
playback: normalizeImagePlayback(overlay.playback || base.playback),
tags: overlayHasTags ? normalizeTagList(overlayRecord.tags) : normalizeTagList(base.tags),
roles,
tileSymbol: String(overlay.tileSymbol || base.tileSymbol || "").charAt(0),
});
}
function createImageRecordFromTileRecord(record) {
return normalizeImageRecord({
id: String(record?.id || "").trim(),
name: String(record?.name || "").trim(),
description: String(record?.description || "").trim(),
width: Number(record?.width) || 16,
height: Number(record?.height) || 16,
pixelScale: Number(record?.pixelScale) || 1,
rows: normalizeImageRows(record?.rows),
tags: normalizeTagList(record?.tags),
roles: ["tile"],
tileSymbol: String(record?.symbol || "").charAt(0),
});
}
function createImageRecordFromSpriteRecord(record) {
const graphicRole = String(record?.graphicRole || "sprite").trim().toLowerCase();
return normalizeImageRecord({
id: String(record?.id || "").trim(),
name: String(record?.name || "").trim(),
description: String(record?.description || "").trim(),
width: Number(record?.width) || 16,
height: Number(record?.height) || 16,
pixelScale: Number(record?.pixelScale) || 1,
rows: normalizeImageRows(record?.rows),
tags: normalizeTagList(record?.tags),
roles: graphicRole === "other" ? [] : ["sprite"],
});
}
function buildImagesPayloadFromLegacyCatalogs() {
const tilesPayload = readJsonSafe(legacyTilesCatalogPath, { schemaVersion: 1, tiles: [] });
const spritesPayload = readJsonSafe(legacySpritesCatalogPath, { schemaVersion: 1, sprites: [] });
const imagesById = new Map();
const imageOrder = [];
const upsert = (record) => {
const normalized = normalizeImageRecord(record);
if (!normalized.id) {
return;
}
if (!imagesById.has(normalized.id)) {
imageOrder.push(normalized.id);
imagesById.set(normalized.id, normalized);
return;
}
imagesById.set(normalized.id, mergeImageRecord(imagesById.get(normalized.id), normalized));
};
const sprites = Array.isArray(spritesPayload?.sprites) ? spritesPayload.sprites : [];
sprites.forEach((record) => upsert(createImageRecordFromSpriteRecord(record)));
const tiles = Array.isArray(tilesPayload?.tiles) ? tilesPayload.tiles : [];
tiles.forEach((record) => upsert(createImageRecordFromTileRecord(record)));
return {
schemaVersion: 1,
images: imageOrder
.map((id) => imagesById.get(id))
.filter(Boolean),
};
}
function ensureImagesCatalogExists() {
if (fs.existsSync(imagesCatalogPath)) {
return;
}
const migratedPayload = buildImagesPayloadFromLegacyCatalogs();
writeJsonAtomic(imagesCatalogPath, migratedPayload);
}
function readImagesCatalogPayload() {
ensureImagesCatalogExists();
const payload = readJsonSafe(imagesCatalogPath, { schemaVersion: 1, images: [] });
const images = Array.isArray(payload?.images) ? payload.images : [];
return {
schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1,
images: images
.map((entry) => normalizeImageRecord(entry))
.filter((entry) => entry.id),
};
}
function writeImagesCatalogPayload(payload) {
const images = Array.isArray(payload?.images) ? payload.images : [];
writeJsonAtomic(imagesCatalogPath, {
schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1,
images: images
.map((entry) => normalizeImageRecordForDisk(entry))
.filter((entry) => entry.id),
});
}
function buildTilesPayloadFromImages(imagesPayload) {
const images = Array.isArray(imagesPayload?.images) ? imagesPayload.images : [];
return {
schemaVersion: typeof imagesPayload?.schemaVersion === "number" ? imagesPayload.schemaVersion : 1,
tiles: images
.filter((entry) => Array.isArray(entry?.roles) && entry.roles.includes("tile"))
.map((entry) => ({
id: String(entry.id || "").trim(),
symbol: String(entry.tileSymbol || "").charAt(0),
name: String(entry.name || "").trim(),
description: String(entry.description || "").trim(),
width: Math.max(1, Number(entry.width) || 16),
height: Math.max(1, Number(entry.height) || 16),
pixelScale: Math.max(1, Number(entry.pixelScale) || 1),
rows: getResolvedImageRows(entry, entry.width, entry.height),
tags: normalizeTagList(entry.tags),
}))
.filter((entry) => entry.id && entry.symbol),
};
}
function buildSpritesPayloadFromImages(imagesPayload) {
const images = Array.isArray(imagesPayload?.images) ? imagesPayload.images : [];
return {
schemaVersion: typeof imagesPayload?.schemaVersion === "number" ? imagesPayload.schemaVersion : 1,
sprites: images
.filter((entry) => {
const roles = Array.isArray(entry?.roles) ? entry.roles : [];
return roles.includes("sprite") || roles.length === 0;
})
.map((entry) => ({
id: String(entry.id || "").trim(),
name: String(entry.name || "").trim(),
description: String(entry.description || "").trim(),
width: Math.max(1, Number(entry.width) || 16),
height: Math.max(1, Number(entry.height) || 16),
pixelScale: Math.max(1, Number(entry.pixelScale) || 1),
rows: getResolvedImageRows(entry, entry.width, entry.height),
tags: normalizeTagList(entry.tags),
graphicRole: Array.isArray(entry?.roles) && entry.roles.includes("sprite") ? "sprite" : "other",
}))
.filter((entry) => entry.id),
};
}
function mergeIncomingTilesPayloadIntoImages(payload) {
const imagesPayload = readImagesCatalogPayload();
const nextImages = new Map();
imagesPayload.images.forEach((entry) => {
nextImages.set(String(entry.id || "").trim(), normalizeImageRecord(entry));
});
const incomingTiles = Array.isArray(payload?.tiles) ? payload.tiles : [];
const incomingTileIds = new Set();
incomingTiles.forEach((entry) => {
const tileImageRecord = createImageRecordFromTileRecord(entry);
if (!tileImageRecord.id || !tileImageRecord.tileSymbol) {
return;
}
incomingTileIds.add(tileImageRecord.id);
const existing = nextImages.get(tileImageRecord.id);
const merged = mergeImageRecord(existing || {}, tileImageRecord);
const roles = Array.from(new Set([...(merged.roles || []), "tile"]));
nextImages.set(tileImageRecord.id, normalizeImageRecord({
...merged,
roles,
tileSymbol: tileImageRecord.tileSymbol,
}));
});
Array.from(nextImages.entries()).forEach(([id, entry]) => {
const roles = Array.isArray(entry?.roles) ? entry.roles.slice() : [];
if (!roles.includes("tile") || incomingTileIds.has(id)) {
return;
}
const nextRoles = roles.filter((role) => role !== "tile");
if (nextRoles.length === 0) {
nextImages.delete(id);
return;
}
nextImages.set(id, normalizeImageRecord({
...entry,
roles: nextRoles,
tileSymbol: "",
}));
});
const nextPayload = {
schemaVersion: typeof imagesPayload?.schemaVersion === "number" ? imagesPayload.schemaVersion : 1,
images: Array.from(nextImages.values()),
};
writeImagesCatalogPayload(nextPayload);
return nextPayload;
}
function mergeIncomingSpritesPayloadIntoImages(payload) {
const imagesPayload = readImagesCatalogPayload();
const nextImages = new Map();
imagesPayload.images.forEach((entry) => {
nextImages.set(String(entry.id || "").trim(), normalizeImageRecord(entry));
});
const incomingSprites = Array.isArray(payload?.sprites) ? payload.sprites : [];
const incomingSpriteIds = new Set();
incomingSprites.forEach((entry) => {
const spriteImageRecord = createImageRecordFromSpriteRecord(entry);
if (!spriteImageRecord.id) {
return;
}
incomingSpriteIds.add(spriteImageRecord.id);
const existing = nextImages.get(spriteImageRecord.id);
const merged = mergeImageRecord(existing || {}, spriteImageRecord);
const wantsSpriteRole = String(entry?.graphicRole || "sprite").trim().toLowerCase() !== "other";
const nextRoles = wantsSpriteRole
? Array.from(new Set([...(merged.roles || []), "sprite"]))
: (merged.roles || []).filter((role) => role !== "sprite");
nextImages.set(spriteImageRecord.id, normalizeImageRecord({
...merged,
roles: nextRoles,
}));
});
Array.from(nextImages.entries()).forEach(([id, entry]) => {
if (incomingSpriteIds.has(id)) {
return;
}
const roles = Array.isArray(entry?.roles) ? entry.roles.slice() : [];
if (roles.includes("sprite")) {
const nextRoles = roles.filter((role) => role !== "sprite");
if (nextRoles.length === 0) {
nextImages.delete(id);
return;
}
nextImages.set(id, normalizeImageRecord({
...entry,
roles: nextRoles,
}));
return;
}
if (roles.length === 0 && !buildTilesPayloadFromImages({ schemaVersion: 1, images: [entry] }).tiles.length) {
nextImages.delete(id);
}
});
const nextPayload = {
schemaVersion: typeof imagesPayload?.schemaVersion === "number" ? imagesPayload.schemaVersion : 1,
images: Array.from(nextImages.values()),
};
writeImagesCatalogPayload(nextPayload);
return nextPayload;
}
function recordSaveEvent(event) {
recentSaveEvents.unshift({
at: new Date().toISOString(),
contentRoot,
...event,
});
if (recentSaveEvents.length > 25) {
recentSaveEvents.length = 25;
}
}
function safeFileStat(fullPath) {
try {
if (!fs.existsSync(fullPath)) {
return { exists: false };
}
const stat = fs.statSync(fullPath);
return {
exists: true,
size: stat.size,
mtime: stat.mtime.toISOString(),
};
} catch {
return { exists: false };
}
}
function summarizeRows(rows, maxRows = 2, maxChars = 24) {
const safeRows = Array.isArray(rows) ? rows : [];
return {
rowCount: safeRows.length,
preview: safeRows.slice(0, maxRows).map((row) => String(row || "").slice(0, maxChars)),
};
}
function summarizeInstances(instances, maxItems = 5) {
const safeInstances = Array.isArray(instances) ? instances : [];
return {
count: safeInstances.length,
sample: safeInstances.slice(0, maxItems).map((entry) => ({
id: String(entry?.id || ""),
name: String(entry?.name || ""),
mapId: String(entry?.mapId || ""),
templateId: String(entry?.templateId || ""),
x: Number(entry?.x),
y: Number(entry?.y),
placed: Number.isFinite(Number(entry?.x)) && Number.isFinite(Number(entry?.y)),
})),
};
}
app.get("/api/types", (_req, res) => {
res.json({
types: Object.keys(contentMap),
});
});
app.get("/api/debug/paths", (_req, res) => {
const contentFiles = Object.fromEntries(
Object.entries(contentMap).map(([type, entry]) => {
const fullPath = path.join(contentRoot, entry.file);
return [type, {
root: entry.root,
fullPath,
exists: fs.existsSync(fullPath),
}];
}),
);
contentFiles.images = {
root: "images",
fullPath: imagesCatalogPath,
exists: fs.existsSync(imagesCatalogPath),
};
res.json({
ok: true,
cwd: process.cwd(),
contentRoot,
imagesRoot,
contentRootExists: fs.existsSync(contentRoot),
envContentRoot: String(process.env.CONTENT_ROOT || "").trim(),
files: contentFiles,
});
});
app.get("/api/debug/recent-saves", (_req, res) => {
res.json({
ok: true,
contentRoot,
saves: recentSaveEvents,
});
});
app.get("/api/world-default", (_req, res) => {
try {
const indexPayload = readWorldIndexPayload();
const defaultWorldId = String(indexPayload.worlds[0]?.id || "overworld").trim() || "overworld";
res.json({
ok: true,
worldId: defaultWorldId,
world: readWorldDefinitionPayload(defaultWorldId),
});
} catch (err) {
res.status(500).json({
ok: false,
error: String(err),
});
}
});
app.get("/api/world/:worldId", (req, res) => {
const worldId = sanitizeWorldId(req.params.worldId);
try {
const worldDefinition = readWorldDefinitionPayload(worldId);
const bookmarks = readWorldBookmarksPayload(worldId);
const chunkFiles = listWorldChunkFiles(worldId);
res.json({
ok: true,
world: worldDefinition,
bookmarks,
chunkCount: chunkFiles.length,
chunksDir: getWorldStoragePaths(worldId).chunksDirRel,
});
} catch (err) {
res.status(500).json({
ok: false,
worldId,
error: String(err),
});
}
});
app.get("/api/world/:worldId/bookmarks", (req, res) => {
const worldId = sanitizeWorldId(req.params.worldId);
try {
res.json(readWorldBookmarksPayload(worldId));
} catch (err) {
res.status(500).json({
ok: false,
worldId,
error: String(err),
});
}
});
app.post("/api/world/:worldId/bookmarks", (req, res) => {
const worldId = sanitizeWorldId(req.params.worldId);
try {
const bookmarksPayload = {
schemaVersion: typeof req.body?.schemaVersion === "number" ? req.body.schemaVersion : 1,
worldId,
bookmarks: Array.isArray(req.body?.bookmarks)
? req.body.bookmarks
.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
.map((entry, index) => normalizeWorldBookmark(entry, index))
: [],
};
const storage = getWorldStoragePaths(worldId);
writeJsonAtomic(storage.bookmarksAbs, bookmarksPayload);
recordSaveEvent({
type: "world-bookmarks-save",
worldId,
count: bookmarksPayload.bookmarks.length,
});
res.json({
ok: true,
bookmarks: bookmarksPayload,
});
} catch (err) {
res.status(500).json({
ok: false,
worldId,
error: String(err),
});
}
});
app.get("/api/world/:worldId/chunk/:chunkX/:chunkY", (req, res) => {
const worldId = sanitizeWorldId(req.params.worldId);
const chunkX = Math.floor(Number(req.params.chunkX) || 0);
const chunkY = Math.floor(Number(req.params.chunkY) || 0);
const createIfMissing = String(req.query.createIfMissing || "").trim() === "1";
try {
const worldDefinition = readWorldDefinitionPayload(worldId);
const chunk = readWorldChunkPayload(worldId, chunkX, chunkY, { createIfMissing });
if (!chunk) {
res.status(404).json({
ok: false,
worldId,
chunkX,
chunkY,
error: "Chunk not found.",
});
return;
}
res.json({
ok: true,
world: worldDefinition,
chunk,
});
} catch (err) {
res.status(500).json({
ok: false,
worldId,
chunkX,
chunkY,
error: String(err),
});
}
});
app.post("/api/world/:worldId/chunk/:chunkX/:chunkY", (req, res) => {
const worldId = sanitizeWorldId(req.params.worldId);
const chunkX = Math.floor(Number(req.params.chunkX) || 0);
const chunkY = Math.floor(Number(req.params.chunkY) || 0);
try {
const normalizedChunk = writeWorldChunkPayload(worldId, {
...(req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {}),
worldId,
chunkX,
chunkY,
});
recordSaveEvent({
type: "world-chunk-save",
worldId,
chunkX,
chunkY,
});
res.json({
ok: true,
chunk: normalizedChunk,
});
} catch (err) {
res.status(500).json({
ok: false,
worldId,
chunkX,
chunkY,
error: String(err),
});
}
});
app.get("/api/world/:worldId/chunks", (req, res) => {
const worldId = sanitizeWorldId(req.params.worldId);
const centerChunkX = Math.floor(Number(req.query.chunkX) || 0);
const centerChunkY = Math.floor(Number(req.query.chunkY) || 0);
const radius = Math.max(0, Math.min(8, Math.floor(Number(req.query.radius) || 0)));
const createIfMissing = String(req.query.createIfMissing || "").trim() === "1";
try {
const worldDefinition = readWorldDefinitionPayload(worldId);
const chunks = [];
for (let chunkY = centerChunkY - radius; chunkY <= centerChunkY + radius; chunkY += 1) {
for (let chunkX = centerChunkX - radius; chunkX <= centerChunkX + radius; chunkX += 1) {
const chunk = readWorldChunkPayload(worldId, chunkX, chunkY, { createIfMissing });
if (chunk) {
chunks.push(chunk);
}
}
}
res.json({
ok: true,
world: worldDefinition,
center: { chunkX: centerChunkX, chunkY: centerChunkY },
radius,
chunks,
});
} catch (err) {
res.status(500).json({
ok: false,
worldId,
error: String(err),
});
}
});
app.get("/api/world/:worldId/overview", (req, res) => {
const worldId = sanitizeWorldId(req.params.worldId);
try {
const worldDefinition = readWorldDefinitionPayload(worldId);
const chunkFiles = listWorldChunkFiles(worldId);
const chunkCoords = chunkFiles
.map((fileName) => {
const match = /^(-?\d+)_(-?\d+)\.json$/i.exec(String(fileName || "").trim());
if (!match) {
return null;
}
return {
chunkX: Math.floor(Number(match[1]) || 0),
chunkY: Math.floor(Number(match[2]) || 0),
};
})
.filter(Boolean);
const chunks = chunkCoords
.map((coord) => readWorldChunkPayload(worldId, coord.chunkX, coord.chunkY, { createIfMissing: false }))
.filter(Boolean);
const chunkWidth = Math.max(1, Number(worldDefinition.chunkWidth) || DEFAULT_WORLD_CHUNK_SIZE);
const chunkHeight = Math.max(1, Number(worldDefinition.chunkHeight) || DEFAULT_WORLD_CHUNK_SIZE);
const minChunkX = chunks.length > 0 ? Math.min(...chunks.map((chunk) => Math.floor(Number(chunk.chunkX) || 0))) : 0;
const minChunkY = chunks.length > 0 ? Math.min(...chunks.map((chunk) => Math.floor(Number(chunk.chunkY) || 0))) : 0;
const maxChunkX = chunks.length > 0 ? Math.max(...chunks.map((chunk) => Math.floor(Number(chunk.chunkX) || 0))) : 0;
const maxChunkY = chunks.length > 0 ? Math.max(...chunks.map((chunk) => Math.floor(Number(chunk.chunkY) || 0))) : 0;
res.json({
ok: true,
world: worldDefinition,
bounds: {
minChunkX,
minChunkY,
maxChunkX,
maxChunkY,
minTileX: minChunkX * chunkWidth,
minTileY: minChunkY * chunkHeight,
maxTileX: ((maxChunkX + 1) * chunkWidth) - 1,
maxTileY: ((maxChunkY + 1) * chunkHeight) - 1,
},
chunkCount: chunks.length,
chunks,
});
} catch (err) {
res.status(500).json({
ok: false,
worldId,
error: String(err),
});
}
});
app.post("/api/world/:worldId/chunks/batch-save", (req, res) => {
const worldId = sanitizeWorldId(req.params.worldId);
try {
const existingWorld = readWorldDefinitionPayload(worldId);
const nextWorld = normalizeWorldDefinitionPayload({
...existingWorld,
...(req.body?.world && typeof req.body.world === "object" && !Array.isArray(req.body.world) ? req.body.world : {}),
id: worldId,
}, worldId);
const storage = getWorldStoragePaths(worldId);
const indexPayload = readWorldIndexPayload();
const nextWorldIndexEntry = normalizeWorldIndexEntry({
id: worldId,
name: nextWorld.name,
worldDir: storage.worldDirRel,
});
const otherWorlds = indexPayload.worlds.filter((entry) => entry.id !== worldId);
writeJsonAtomic(worldsIndexPath, {
schemaVersion: typeof indexPayload.schemaVersion === "number" ? indexPayload.schemaVersion : 1,
worlds: [...otherWorlds, nextWorldIndexEntry].sort((a, b) => a.id.localeCompare(b.id)),
});
writeJsonAtomic(storage.worldJsonAbs, nextWorld);
let bookmarkCount = 0;
if (req.body?.bookmarks) {
const nextBookmarksPayload = {
schemaVersion: typeof req.body.bookmarks.schemaVersion === "number" ? req.body.bookmarks.schemaVersion : 1,
worldId,
bookmarks: Array.isArray(req.body.bookmarks.bookmarks)
? req.body.bookmarks.bookmarks
.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
.map((entry, index) => normalizeWorldBookmark(entry, index))
: [],
};
bookmarkCount = nextBookmarksPayload.bookmarks.length;
writeJsonAtomic(storage.bookmarksAbs, nextBookmarksPayload);
}
const savedChunks = [];
const inputChunks = Array.isArray(req.body?.chunks) ? req.body.chunks : [];
inputChunks.forEach((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return;
}
const savedChunk = writeWorldChunkPayload(worldId, entry);
savedChunks.push({
chunkX: savedChunk.chunkX,
chunkY: savedChunk.chunkY,
});
});
recordSaveEvent({
type: "world-batch-save",
worldId,
chunkCount: savedChunks.length,
bookmarkCount,
});
res.json({
ok: true,
world: nextWorld,
savedChunks,
bookmarkCount,
});
} catch (err) {
res.status(500).json({
ok: false,
worldId,
error: String(err),
});
}
});
app.get("/api/content/:type", (req, res) => {
if (req.params.type === "images") {
try {
res.json(readImagesCatalogPayload());
} catch (err) {
res.status(500).json({ error: `Failed to read file: ${String(err)}` });
}
return;
}
if (req.params.type === "tiles") {
try {
res.json(buildTilesPayloadFromImages(readImagesCatalogPayload()));
} catch (err) {
res.status(500).json({ error: `Failed to read file: ${String(err)}` });
}
return;
}
if (req.params.type === "sprites") {
try {
res.json(buildSpritesPayloadFromImages(readImagesCatalogPayload()));
} catch (err) {
res.status(500).json({ error: `Failed to read file: ${String(err)}` });
}
return;
}
const resolved = resolveContent(req.params.type);
if (!resolved) {
res.status(404).json({ error: "Unknown content type" });
return;
}
try {
const payload = readJsonSafe(resolved.fullPath, defaultPayloadForType(req.params.type, resolved.root));
const responsePayload = req.params.type === "npcs"
? injectNpcNodeDescriptions(payload, readDialogueNodeMeta())
: payload;
res.json(responsePayload);
} catch (err) {
res.status(500).json({ error: `Failed to read file: ${String(err)}` });
}
});
app.post("/api/content/tiles/:tileId/delete", (req, res) => {
const tileId = String(req.params.tileId || "").trim();
try {
const result = deleteTileFromStorage(tileId);
recordSaveEvent({
type: "tile-delete",
tileId: result.tile.id,
symbol: result.tile.symbol,
updatedMaps: result.stats.updatedMaps,
updatedWorlds: result.stats.updatedWorlds,
updatedChunks: result.stats.updatedChunks,
});
res.json({
ok: true,
tile: result.tile,
tiles: result.tilesPayload,
stats: result.stats,
});
} catch (err) {
const message = String(err || "Tile delete failed.");
const statusCode = /not found/i.test(message)
? 404
: (/cannot be deleted|required/i.test(message) ? 400 : 500);
res.status(statusCode).json({
ok: false,
tileId,
error: message,
});
}
});
app.post("/api/content/sprites/:spriteId/delete", (req, res) => {
const spriteId = String(req.params.spriteId || "").trim();
try {
const result = deleteSpriteFromStorage(spriteId);
recordSaveEvent({
type: "sprite-delete",
spriteId: result.sprite.id,
updatedNpcRecords: result.stats.updatedNpcRecords,
updatedNpcTemplateRecords: result.stats.updatedNpcTemplateRecords,
updatedChunks: result.stats.updatedChunks,
});
res.json({
ok: true,
sprite: result.sprite,
images: result.imagesPayload,
stats: result.stats,
});
} catch (err) {
const message = String(err || "Sprite delete failed.");
const statusCode = /not found/i.test(message)
? 404
: (/required/i.test(message) ? 400 : 500);
res.status(statusCode).json({
ok: false,
spriteId,
error: message,
});
}
});
app.post("/api/content/:type", (req, res) => {
if (req.params.type === "images") {
const validationError = validatePayload(req.body, "images", "images");
if (validationError) {
res.status(400).json({ error: validationError });
return;
}
try {
backupFile("images", imagesCatalogPath);
writeImagesCatalogPayload(req.body);
recordSaveEvent({
type: "images",
ok: true,
stage: "persist",
itemCount: Array.isArray(req.body?.images) ? req.body.images.length : 0,
});
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: `Failed to save file: ${String(err)}` });
}
return;
}
if (req.params.type === "tiles") {
const validationError = validatePayload(req.body, "tiles", "tiles");
if (validationError) {
res.status(400).json({ error: validationError });
return;
}
try {
backupFile("images", imagesCatalogPath);
const nextImagesPayload = mergeIncomingTilesPayloadIntoImages(req.body);
recordSaveEvent({
type: "tiles",
ok: true,
stage: "persist",
backingFile: "images.json",
itemCount: Array.isArray(req.body?.tiles) ? req.body.tiles.length : 0,
imageCount: Array.isArray(nextImagesPayload?.images) ? nextImagesPayload.images.length : 0,
});
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: `Failed to save file: ${String(err)}` });
}
return;
}
if (req.params.type === "sprites") {
const validationError = validatePayload(req.body, "sprites", "sprites");
if (validationError) {
res.status(400).json({ error: validationError });
return;
}
try {
backupFile("images", imagesCatalogPath);
const nextImagesPayload = mergeIncomingSpritesPayloadIntoImages(req.body);
recordSaveEvent({
type: "sprites",
ok: true,
stage: "persist",
backingFile: "images.json",
itemCount: Array.isArray(req.body?.sprites) ? req.body.sprites.length : 0,
imageCount: Array.isArray(nextImagesPayload?.images) ? nextImagesPayload.images.length : 0,
});
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: `Failed to save file: ${String(err)}` });
}
return;
}
const resolved = resolveContent(req.params.type);
if (!resolved) {
res.status(404).json({ error: "Unknown content type" });
return;
}
const preparedPayload = req.params.type === "npcs"
? stripNpcNodeDescriptions(req.body)
: req.body;
const bodyChars = (() => {
try {
return JSON.stringify(preparedPayload).length;
} catch {
return 0;
}
})();
const validationError = validatePayload(preparedPayload, req.params.type, resolved.root);
if (validationError) {
recordSaveEvent({
type: req.params.type,
ok: false,
stage: "validate",
bodyChars,
error: validationError,
});
res.status(400).json({ error: validationError });
return;
}
try {
if (req.params.type === "npcs") {
const dialogueNodeMeta = buildDialogueNodeMetaFromNpcPayload(req.body);
fs.mkdirSync(path.dirname(dialogueNodeMetaPath), { recursive: true });
writeJsonAtomic(dialogueNodeMetaPath, dialogueNodeMeta);
}
backupFile(req.params.type, resolved.fullPath);
writeJsonAtomic(resolved.fullPath, preparedPayload);
recordSaveEvent({
type: req.params.type,
ok: true,
stage: "persist",
bodyChars,
rootKey: resolved.root,
itemCount: Array.isArray(preparedPayload?.[resolved.root]) ? preparedPayload[resolved.root].length : 0,
sampleIds: Array.isArray(preparedPayload?.[resolved.root])
? preparedPayload[resolved.root].slice(0, 3).map((entry) => String(entry?.id || entry?.questId || "")).filter(Boolean)
: [],
});
res.json({ ok: true });
} catch (err) {
recordSaveEvent({
type: req.params.type,
ok: false,
stage: "persist",
bodyChars,
error: String(err),
});
res.status(500).json({ error: `Failed to save file: ${String(err)}` });
}
});
// Serve content/Images/* files
app.get("/api/images/:filename", (req, res) => {
const filename = path.basename(String(req.params.filename || ""));
if (!filename) {
res.status(400).json({ error: "Missing filename" });
return;
}
const filePath = path.join(imagesRoot, filename);
const normalizedPath = path.resolve(filePath);
if (!normalizedPath.startsWith(path.resolve(imagesRoot) + path.sep) &&
normalizedPath !== path.resolve(imagesRoot)) {
res.status(403).json({ error: "Forbidden" });
return;
}
if (!fs.existsSync(normalizedPath)) {
res.status(404).json({ error: "Not found" });
return;
}
res.sendFile(normalizedPath);
});
// List content/Images/*
app.get("/api/images", (_req, res) => {
try {
if (!fs.existsSync(imagesRoot)) {
res.json({ images: [] });
return;
}
const files = fs.readdirSync(imagesRoot).filter((name) =>
/\.(svg|png|jpg|jpeg|webp|gif)$/i.test(name),
);
res.json({ images: files.map((name) => ({ name, url: `/api/images/${encodeURIComponent(name)}` })) });
} catch (err) {
res.status(500).json({ error: `Failed to list images: ${String(err)}` });
}
});
app.get("/api/catalog-meta", (_req, res) => {
try {
const payload = readCatalogMeta();
res.json(payload);
} catch (err) {
res.status(500).json({ error: `Failed to read catalog metadata: ${String(err)}` });
}
});
app.get("/api/editor-settings", (_req, res) => {
try {
res.json(readEditorSettings());
} catch (err) {
res.status(500).json({ error: `Failed to read editor settings: ${String(err)}` });
}
});
app.post("/api/editor-settings", (req, res) => {
try {
const normalized = normalizeEditorSettings(req.body);
fs.mkdirSync(path.dirname(editorSettingsPath), { recursive: true });
writeJsonAtomic(editorSettingsPath, normalized);
res.json(normalized);
} catch (err) {
res.status(500).json({ error: `Failed to save editor settings: ${String(err)}` });
}
});
app.post("/api/catalog-meta", (req, res) => {
try {
const validationError = validateCatalogMetaPayload(req.body);
if (validationError) {
res.status(400).json({ error: validationError });
return;
}
const normalized = normalizeCatalogMeta(req.body);
fs.mkdirSync(path.dirname(catalogMetaPath), { recursive: true });
writeJsonAtomic(catalogMetaPath, normalized);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: `Failed to save catalog metadata: ${String(err)}` });
}
});
app.listen(port, host, () => {
console.log(`Content editor V2 API running at http://${host}:${port}`);
console.log(`[paths] contentRoot=${contentRoot}`);
console.log(`[paths] imagesRoot=${imagesRoot}`);
if (!fs.existsSync(contentRoot)) {
console.warn(`[paths] content root does not exist yet. Create: ${contentRoot}`);
}
});