2026-06-26 18:18:14 -04:00
|
|
|
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");
|
2026-06-26 22:55:50 -04:00
|
|
|
const launcherRequestsPath = path.join(dataRoot, "launcher_requests.json");
|
2026-06-26 18:18:14 -04:00
|
|
|
const imagesCatalogPath = path.join(contentRoot, "images.json");
|
|
|
|
|
const legacyTilesCatalogPath = path.join(contentRoot, "tiles.json");
|
|
|
|
|
const legacySpritesCatalogPath = path.join(contentRoot, "sprites.json");
|
|
|
|
|
const recentSaveEvents = [];
|
2026-06-26 20:30:30 -04:00
|
|
|
const DEFAULT_WORLDSHAPER_THEME_PRESET = "azure";
|
|
|
|
|
const WORLDSHAPER_THEME_PRESET_IDS = new Set(["azure", "verdant", "ember", "amethyst"]);
|
2026-06-26 18:18:14 -04:00
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 20:30:30 -04:00
|
|
|
function normalizeWorldshaperThemePreset(value) {
|
2026-06-26 18:18:14 -04:00
|
|
|
const normalized = String(value || "").trim().toLowerCase();
|
2026-06-26 20:30:30 -04:00
|
|
|
return WORLDSHAPER_THEME_PRESET_IDS.has(normalized) ? normalized : DEFAULT_WORLDSHAPER_THEME_PRESET;
|
2026-06-26 18:18:14 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createDefaultEditorSettings() {
|
|
|
|
|
return {
|
|
|
|
|
schemaVersion: 1,
|
2026-06-26 20:30:30 -04:00
|
|
|
worldshaperStudio: {
|
|
|
|
|
themePreset: DEFAULT_WORLDSHAPER_THEME_PRESET,
|
2026-06-26 18:18:14 -04:00
|
|
|
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;
|
2026-06-26 20:30:30 -04:00
|
|
|
const worldshaperStudio = source.worldshaperStudio && typeof source.worldshaperStudio === "object" && !Array.isArray(source.worldshaperStudio)
|
|
|
|
|
? source.worldshaperStudio
|
|
|
|
|
: fallback.worldshaperStudio;
|
2026-06-26 18:18:14 -04:00
|
|
|
return {
|
|
|
|
|
schemaVersion: typeof source.schemaVersion === "number" ? source.schemaVersion : fallback.schemaVersion,
|
2026-06-26 20:30:30 -04:00
|
|
|
worldshaperStudio: {
|
|
|
|
|
themePreset: normalizeWorldshaperThemePreset(worldshaperStudio.themePreset),
|
|
|
|
|
engineOverrides: normalizeEditorEngineOverrides(worldshaperStudio.engineOverrides),
|
2026-06-26 18:18:14 -04:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readEditorSettings() {
|
|
|
|
|
return normalizeEditorSettings(readJsonSafe(editorSettingsPath, createDefaultEditorSettings()));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 22:55:50 -04:00
|
|
|
function createLauncherRequestId() {
|
|
|
|
|
return `request_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeLauncherRequestEntry(entry, index = 0) {
|
|
|
|
|
const source = entry && typeof entry === "object" && !Array.isArray(entry)
|
|
|
|
|
? entry
|
|
|
|
|
: null;
|
|
|
|
|
if (!source) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const text = String(source.text || "").trim();
|
|
|
|
|
if (!text) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const createdAt = String(source.createdAt || "").trim() || new Date().toISOString();
|
|
|
|
|
const updatedAt = String(source.updatedAt || "").trim() || createdAt;
|
|
|
|
|
const fallbackId = `request_${index + 1}`;
|
|
|
|
|
return {
|
|
|
|
|
id: String(source.id || fallbackId).trim() || fallbackId,
|
|
|
|
|
text,
|
|
|
|
|
done: source.done === true,
|
|
|
|
|
createdAt,
|
|
|
|
|
updatedAt,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readLauncherRequestsPayload() {
|
|
|
|
|
const fallback = { schemaVersion: 1, requests: [] };
|
|
|
|
|
const payload = readJsonSafe(launcherRequestsPath, fallback);
|
|
|
|
|
const requests = Array.isArray(payload?.requests)
|
|
|
|
|
? payload.requests
|
|
|
|
|
.map((entry, index) => normalizeLauncherRequestEntry(entry, index))
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
: [];
|
|
|
|
|
return {
|
|
|
|
|
schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1,
|
|
|
|
|
requests,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function writeLauncherRequestsPayload(payload) {
|
|
|
|
|
const requests = Array.isArray(payload?.requests) ? payload.requests : [];
|
|
|
|
|
writeJsonAtomic(launcherRequestsPath, {
|
|
|
|
|
schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1,
|
|
|
|
|
requests: requests
|
|
|
|
|
.map((entry, index) => normalizeLauncherRequestEntry(entry, index))
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 18:18:14 -04:00
|
|
|
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")));
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-26 22:55:50 -04:00
|
|
|
app.get("/api/launcher-requests", (_req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
res.json(readLauncherRequestsPayload());
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ error: `Failed to read launcher requests: ${String(err)}` });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post("/api/launcher-requests", (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const text = String(req.body?.text || "").trim();
|
|
|
|
|
if (!text) {
|
|
|
|
|
res.status(400).json({ error: "Request text is required." });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (text.length > 1000) {
|
|
|
|
|
res.status(400).json({ error: "Request text must be 1000 characters or fewer." });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const payload = readLauncherRequestsPayload();
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
const requestEntry = normalizeLauncherRequestEntry({
|
|
|
|
|
id: createLauncherRequestId(),
|
|
|
|
|
text,
|
|
|
|
|
done: false,
|
|
|
|
|
createdAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
}, payload.requests.length);
|
|
|
|
|
const nextPayload = {
|
|
|
|
|
schemaVersion: payload.schemaVersion,
|
|
|
|
|
requests: [...payload.requests, requestEntry],
|
|
|
|
|
};
|
|
|
|
|
writeLauncherRequestsPayload(nextPayload);
|
|
|
|
|
recordSaveEvent({
|
|
|
|
|
type: "launcher-request-add",
|
|
|
|
|
requestId: requestEntry.id,
|
|
|
|
|
textPreview: requestEntry.text.slice(0, 80),
|
|
|
|
|
});
|
|
|
|
|
res.status(201).json({
|
|
|
|
|
ok: true,
|
|
|
|
|
request: requestEntry,
|
|
|
|
|
requests: nextPayload.requests,
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ error: `Failed to save launcher request: ${String(err)}` });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.patch("/api/launcher-requests/:requestId", (req, res) => {
|
|
|
|
|
const requestId = String(req.params.requestId || "").trim();
|
|
|
|
|
try {
|
|
|
|
|
const payload = readLauncherRequestsPayload();
|
|
|
|
|
const index = payload.requests.findIndex((entry) => entry.id === requestId);
|
|
|
|
|
if (index < 0) {
|
|
|
|
|
res.status(404).json({ error: "Request not found." });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const existing = payload.requests[index];
|
|
|
|
|
const nextDone = req.body?.done === true;
|
|
|
|
|
const updated = normalizeLauncherRequestEntry({
|
|
|
|
|
...existing,
|
|
|
|
|
done: nextDone,
|
|
|
|
|
updatedAt: new Date().toISOString(),
|
|
|
|
|
}, index);
|
|
|
|
|
const nextRequests = [...payload.requests];
|
|
|
|
|
nextRequests[index] = updated;
|
|
|
|
|
writeLauncherRequestsPayload({
|
|
|
|
|
schemaVersion: payload.schemaVersion,
|
|
|
|
|
requests: nextRequests,
|
|
|
|
|
});
|
|
|
|
|
recordSaveEvent({
|
|
|
|
|
type: "launcher-request-update",
|
|
|
|
|
requestId,
|
|
|
|
|
done: updated.done,
|
|
|
|
|
});
|
|
|
|
|
res.json({
|
|
|
|
|
ok: true,
|
|
|
|
|
request: updated,
|
|
|
|
|
requests: nextRequests,
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ error: `Failed to update launcher request: ${String(err)}` });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.delete("/api/launcher-requests/:requestId", (req, res) => {
|
|
|
|
|
const requestId = String(req.params.requestId || "").trim();
|
|
|
|
|
try {
|
|
|
|
|
const payload = readLauncherRequestsPayload();
|
|
|
|
|
const existing = payload.requests.find((entry) => entry.id === requestId);
|
|
|
|
|
if (!existing) {
|
|
|
|
|
res.status(404).json({ error: "Request not found." });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const nextRequests = payload.requests.filter((entry) => entry.id !== requestId);
|
|
|
|
|
writeLauncherRequestsPayload({
|
|
|
|
|
schemaVersion: payload.schemaVersion,
|
|
|
|
|
requests: nextRequests,
|
|
|
|
|
});
|
|
|
|
|
recordSaveEvent({
|
|
|
|
|
type: "launcher-request-delete",
|
|
|
|
|
requestId,
|
|
|
|
|
textPreview: existing.text.slice(0, 80),
|
|
|
|
|
});
|
|
|
|
|
res.json({
|
|
|
|
|
ok: true,
|
|
|
|
|
deletedRequestId: requestId,
|
|
|
|
|
requests: nextRequests,
|
|
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
res.status(500).json({ error: `Failed to delete launcher request: ${String(err)}` });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-26 18:18:14 -04:00
|
|
|
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, () => {
|
2026-06-26 20:30:30 -04:00
|
|
|
console.log(`Worldshaper API running at http://${host}:${port}`);
|
2026-06-26 18:18:14 -04:00
|
|
|
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}`);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-06-26 20:30:30 -04:00
|
|
|
|