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}`); } });