Worldshaper/server.js

3683 lines
130 KiB
JavaScript
Raw Normal View History

2026-06-26 18:18:14 -04:00
import express from "express";
import { spawn } from "child_process";
2026-06-26 18:18:14 -04:00
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";
2026-06-27 00:51:20 -04:00
const launcherAdminPassword = String(process.env.LAUNCHER_ADMIN_PASSWORD || "").trim();
2026-06-27 01:12:35 -04:00
const DEFAULT_REQUEST_TAG_DEFINITIONS = [
{ id: "launcher", label: "Launcher", aliases: ["launcher home", "home page", "landing page", "main page"] },
{ id: "request-board", label: "Request Board", aliases: ["requests", "request queue", "moderation", "request intake"] },
{ id: "chunks", label: "Chunks", aliases: ["chunk", "chunk loading", "chunk storage", "chunk overview"] },
{ id: "layers", label: "Layers", aliases: ["layer", "height layers", "z layers"] },
{ id: "tiling", label: "Tiling", aliases: ["tile", "tile editing", "tile painting", "brush"] },
{ id: "graphics-painter", label: "Graphics Painter", aliases: ["graphic painter", "tile art", "sprite painter", "art editor"] },
{ id: "rendering", label: "Rendering", aliases: ["renderer", "viewport", "pixi", "draw"] },
{ id: "animation", label: "Animation", aliases: ["animated", "timeline", "frame playback"] },
{ id: "content", label: "Content", aliases: ["assets", "catalog", "tiles", "sprites", "images"] },
{ id: "world-overview", label: "World Overview", aliases: ["overview", "world map", "bookmarks", "poi"] },
{ id: "windows", label: "Windows", aliases: ["windowing", "popout", "tool windows", "floating window"] },
{ id: "persistence", label: "Persistence", aliases: ["saving", "save pipeline", "history", "undo", "redo"] },
{ id: "performance", label: "Performance", aliases: ["optimization", "slow", "lag", "streaming"] },
{ id: "ui-workflow", label: "UI / Workflow", aliases: ["workflow", "ux", "ui", "quality of life", "qol", "tools", "tooling"] },
{ id: "worlds", label: "Worlds", aliases: ["world", "world bootstrap", "startup", "world systems"] },
{ id: "website", label: "Website", aliases: ["site", "web", "homepage"] },
{ id: "repo", label: "Repo", aliases: ["repository", "forgejo", "git"] },
{ id: "wiki", label: "Wiki", aliases: ["kb", "knowledge base", "docs", "documentation"] },
{ id: "polish", label: "Polish", aliases: ["cleanup", "fit and finish", "presentation"] },
{ id: "general", label: "General", aliases: ["broad", "misc", "cross-system"] },
{ id: "other", label: "Other", aliases: ["unknown", "unclear", "unsorted"] },
];
function normalizeRequestTagLookupValue(value) {
return String(value || "").replace(/\s+/g, " ").trim().toLowerCase();
}
2026-06-27 00:51:20 -04:00
function isLauncherAdminProtectionEnabled() {
return Boolean(launcherAdminPassword);
}
function readLauncherAdminPasswordCandidate(req) {
const headerValue = req.get("x-worldshaper-admin-password");
if (String(headerValue || "").trim()) {
return String(headerValue || "").trim();
}
return String(req.body?.password || "").trim();
}
function requireLauncherAdminAccess(req, res) {
if (!isLauncherAdminProtectionEnabled()) {
res.status(503).json({
error: "Launcher admin access is not configured on the server.",
adminConfigured: false,
});
return false;
}
const submittedPassword = readLauncherAdminPasswordCandidate(req);
if (!submittedPassword || submittedPassword !== launcherAdminPassword) {
res.status(401).json({
error: "Admin access denied.",
adminConfigured: true,
});
return false;
}
return true;
}
2026-06-26 18:18:14 -04:00
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");
const requestAnalysisWorkerScriptPath = path.join(__dirname, "scripts", "request-analysis-worker.mjs");
2026-06-27 01:12:35 -04:00
const requestTagCatalogPath = path.join(__dirname, "docs", "kb", "tags.json");
const requestAnalysisRunState = {
child: null,
restartTimer: null,
};
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-27 01:12:35 -04:00
const REQUEST_TAG_DEFINITIONS = loadRequestTagCatalog();
const REQUEST_TAG_LABELS = REQUEST_TAG_DEFINITIONS.map((entry) => entry.label);
const REQUEST_TAG_LOOKUP = new Map(
REQUEST_TAG_DEFINITIONS.flatMap((entry) => {
const normalizedLabel = normalizeRequestTagLookupValue(entry.label);
return [
[normalizedLabel, entry.label],
...entry.aliases.map((alias) => [normalizeRequestTagLookupValue(alias), entry.label]),
];
}),
);
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"]);
const LEGACY_LAUNCHER_REQUEST_MIGRATIONS = {
request_mqvrray7v2kg9y: [
{
id: "active_layers_step_reordering",
title: "Safer layer reordering",
category: "Layers",
tags: ["Layers", "UI / Workflow"],
summary: "Layer reordering is too coarse right now, and moving the currently selected layer can produce confusing results when 'Move toward Base' jumps it more than expected.",
implementationNotes: "Replace the current coarse move action with explicit 'Move Up' and 'Move Down' commands that swap adjacent layer records only one step at a time. Keep the moved layer selected through the swap, clamp movement at the background/top boundaries, and refresh cached layer metadata after the final order change so the active layer never appears to jump unpredictably.",
},
],
request_mqvrykx1rtgl9l: [
{
id: "active_layers_day_night_lighting",
title: "Day-night lighting layer",
category: "Layers",
tags: ["Layers", "Rendering", "World Systems"],
summary: "Worldshaper should support a top-level lighting layer driven by time of day so the scene brightens and darkens based on a controllable sun exposure model.",
implementationNotes: "Add a dedicated lighting pass above the world layers, then store time-of-day settings on the world definition such as sun angle, daylight curve, ambient minimum, and transition length. The renderer can evaluate those settings per frame and multiply the final scene colors by the resulting exposure value without rewriting individual chunk art.",
},
{
id: "active_layers_weather_overlay",
title: "Weather overlay layer",
category: "Layers",
tags: ["Layers", "Rendering", "World Systems"],
summary: "Weather effects like clouds and rain should exist as their own highest-order layer so they can tint or darken the world without editing the base map.",
implementationNotes: "Treat weather as a dedicated overlay layer that stores brush masks, particle regions, or placed weather sprites. During rendering, composite the weather layer after the main chunk layers and let each effect contribute opacity, tint, and optional darkness so cloud cover can filter light independently from the terrain below.",
},
],
request_mqvs0joxmxvnl6: [
{
id: "active_chunks_background_color_fills",
title: "Per-chunk background colors",
category: "Chunks",
tags: ["Chunks", "Rendering"],
summary: "Chunks should be able to define a flat fallback background color instead of relying only on a background tile asset.",
implementationNotes: "Extend chunk payloads with an optional background color override, expose it in the chunk editing controls, and have the renderer fill the chunk with that color before drawing background tiles or empty cells. If the color is unset, keep falling back to the world-level default background behavior.",
},
],
request_mqvs2pc8tr2wyw: [
{
id: "active_chunks_terrain_chunk_painter",
title: "Terrain chunk painter",
category: "Chunks",
tags: ["Chunks", "Tiling", "Tools"],
summary: "Large terrain features such as rivers, mountains, ravines, and woods should be paintable as semantic terrain operations instead of hand-placing every supporting tile.",
implementationNotes: "Introduce a terrain painting mode that writes high-level masks or terrain types into a chunk-first data layer, then resolve those masks into tile selections with rules for edges, walls, floors, peaks, and deep water. That gives you fast broad painting while keeping the final result grounded in actual tile output.",
},
],
request_mqvs3w4r1n2552: [
{
id: "active_performance_chunk_indexing_audit",
title: "Chunk indexing audit",
category: "Performance",
tags: ["Performance", "Chunks"],
summary: "Evaluate whether spatial indexing structures would materially improve chunk lookup and neighborhood loading, rather than assuming they will.",
implementationNotes: "Profile the current chunk grid and cache path first, because exact chunk lookups are already grid-addressable and usually favor a sparse map plus cached bounds over a quadtree or kd-tree. If profiling shows true range-query pressure later, start with a lightweight in-memory chunk manifest before introducing a more complex spatial tree.",
},
],
request_mqvsarw8jmlzph: [
{
id: "active_tiling_autotile_structures",
title: "Autotile structures",
category: "Tiling",
tags: ["Tiling", "Tools"],
summary: "Users should be able to sketch a structure footprint and have Worldshaper infer the matching walls, doors, windows, flooring, and other tiles automatically.",
implementationNotes: "Build an autotile ruleset system that reads either a painted mask or placed anchor corners, classifies interior, edge, and corner cells, and then emits tile IDs from a structure-specific palette. The first version can target one pattern family such as houses before generalizing to broader autotile presets.",
},
{
id: "active_tiling_prefab_stamps",
title: "Prefab tile stamps",
category: "Tiling",
tags: ["Tiling", "Chunks", "Tools"],
summary: "Reusable stamps should let creators place premade or lightly procedural tile assemblies like houses or forests in one action.",
implementationNotes: "Represent a prefab as a chunk-relative bundle of tiles, optional instances, and parameter slots, then add a stamp placement tool with preview, rotation, and flip support. Once the stamp is accepted, resolve the prefab into normal chunk edits so it works with the rest of the editing pipeline.",
},
{
id: "active_graphics_painter_move_tool",
title: "Graphic painter move tool",
category: "Graphics Painter",
tags: ["Graphics Painter", "Tools"],
summary: "The graphic painter needs a select-and-move workflow so artists can reposition existing pixels or regions without redrawing them by hand.",
implementationNotes: "Add marquee selection in the painter, store the selected pixels as a temporary overlay, and let drag operations translate that overlay before committing it back into the current frame. Once selection movement exists, copy, paste, and flip workflows become much easier to layer on top.",
},
{
id: "active_ui_dockable_tool_windows",
title: "Dockable editor windows",
category: "UI / Workflow",
tags: ["UI / Workflow", "Windows"],
summary: "Floating tool windows should be able to clamp into fixed UI panels when dragged to the sides of the editor.",
implementationNotes: "Extend the existing popout window controller with edge snap targets and a docked layout mode. Persist whether each tool is floating or docked alongside its saved rect so tools can move fluidly between free windows and locked side panels.",
},
],
request_mqvsax8rdgcj1o: [
{
id: "active_tiling_unsnapped_tile_placement",
title: "Unsnapped tile placement",
category: "Tiling",
tags: ["Tiling", "Chunks", "Layers"],
summary: "Tile placement should support a free-placement or sublayer mode that is not forced into the saved chunk row grid.",
implementationNotes: "Add a chunk patch or overlay sublayer format that stores per-placement offsets or patch cells separately from the canonical chunk rows. A hotkey can switch between snapped grid placement and patch placement so freeform details remain possible without destabilizing the base tile grid.",
},
],
request_mqvsb6r8u29ewv: [
{
id: "active_ui_custom_prompts_and_confirms",
title: "Custom prompts and confirmations",
category: "UI / Workflow",
tags: ["UI / Workflow", "Windows"],
summary: "Browser-native prompts and confirmation dialogs should be replaced with editor-native UI so they match the rest of Worldshaper.",
implementationNotes: "Create a reusable modal or popout dialog controller that supports message-only confirms, text input prompts, validation, and custom button labels. Once that exists, swap the current `prompt` and `confirm` call sites over to it so every critical interaction uses the same styled flow.",
},
],
request_mqvsdze7taujmp: [
{
id: "active_layers_elevation_tile_variants",
title: "Elevation-driven tile variants",
category: "Layers",
tags: ["Layers", "Rendering", "World Systems"],
summary: "Tiles with elevation should be able to reveal different visual subframes as the camera or player viewpoint rises through stacked floors.",
implementationNotes: "Add elevation metadata plus optional frame variants to tile definitions, then track current view elevation in runtime state. During rendering, only tiles in the viewport whose elevation thresholds are crossed need to swap to their alternate subframe, which keeps the effect local and efficient.",
},
],
request_mqvsg4r5eaq210: [
{
id: "active_tiling_animated_tilestrip_instances",
title: "Animated tilestrip instances",
category: "Tiling",
tags: ["Tiling", "Animation", "Rendering"],
summary: "An image with multiple animation frames should be usable like a tilestrip, where placement can target a specific subimage or frame without duplicating separate assets.",
implementationNotes: "Let tile definitions reference an image frame set plus a selected default subframe, then have placement store the chosen frame index alongside the tile ID when needed. That keeps one asset source while still allowing different tile instances to resolve to different strip positions or animation states.",
},
{
id: "active_rendering_animation_playback_controls",
title: "Renderer animation playback controls",
category: "Rendering",
tags: ["Rendering", "Animation", "Graphics Painter"],
summary: "Animated graphics should play in the live renderer and be controllable through a hotkey and engine override toggle.",
implementationNotes: "Promote frame timing and playback metadata from the graphic painter into runtime render state, then add a global animation-enabled flag that can be toggled from both input and engine overrides. The renderer can respect that flag before advancing animation clocks for visible animated assets.",
},
{
id: "active_graphics_frame_duplication_effects",
title: "Frame duplication effects",
category: "Graphics Painter",
tags: ["Graphics Painter", "Animation", "Tools"],
summary: "Animation work would move faster with one-click helpers that duplicate a frame and apply a predictable transform such as shifting pixels in a direction.",
implementationNotes: "Add scripted timeline actions inside the graphic painter that clone the selected frame, transform the copied pixels by a configured offset or effect, and append the new result as another frame. Start with duplicate-and-shift, then add room later for fade, opacity, and color-shift helpers.",
},
],
};
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 normalizeLauncherRequestStatus(value) {
return String(value || "").trim().toLowerCase() === "active" ? "active" : "pending";
}
function normalizeLauncherRequestTags(value) {
return normalizeUniqueStringList(value, {
2026-06-27 01:12:35 -04:00
normalizeValue: (entry) => normalizeRequestTag(entry),
dedupeKey: (entry) => normalizeRequestTag(entry).toLowerCase(),
});
}
function normalizeLauncherRequestAnalysisState(value) {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "processing" || normalized === "processed" || normalized === "needs_review" || normalized === "error") {
return normalized;
}
return "unprocessed";
}
function normalizeLauncherRequestAnalysisConfidence(value) {
const rawNumber = Number(value);
if (!Number.isFinite(rawNumber)) {
return null;
}
return Math.max(0, Math.min(1, rawNumber));
}
function normalizeLauncherRequestAnalysisStringList(value) {
return normalizeUniqueStringList(value, {
normalizeValue: (entry) => String(entry || "").replace(/\s+/g, " ").trim(),
dedupeKey: (entry) => String(entry || "").replace(/\s+/g, " ").trim().toLowerCase(),
});
}
2026-06-27 01:12:35 -04:00
function normalizeLauncherRequestAnalysisTags(value, fallback = []) {
const normalized = normalizeLauncherRequestTags(Array.isArray(value) ? value : []);
if (normalized.length > 0) {
return normalized;
}
return normalizeLauncherRequestTags(Array.isArray(fallback) ? fallback : []);
}
function normalizeLauncherRequestAnalysisItem(item, index = 0) {
const source = item && typeof item === "object" && !Array.isArray(item)
? item
: null;
if (!source) {
return null;
}
const fallbackTitle = `Analyzed request ${index + 1}`;
const title = String(source.title || "").trim() || fallbackTitle;
const primaryCategory = String(source.primaryCategory || source.category || "").trim() || "Unsorted";
2026-06-27 01:12:35 -04:00
const tags = normalizeLauncherRequestAnalysisTags(source.tags, [primaryCategory]);
const parsedInterpretation = String(source.parsedInterpretation || source.summary || "").trim();
const implementationApproach = String(source.implementationApproach || source.implementationNotes || "").trim();
2026-06-27 01:12:35 -04:00
const reviewRationale = String(source.reviewRationale || source.reviewReason || "").trim();
const reviewOptions = normalizeLauncherRequestAnalysisStringList(source.reviewOptions);
const statusRecommendationRaw = String(source.statusRecommendation || source.status || "").trim().toLowerCase();
const statusRecommendation = statusRecommendationRaw === "active"
|| statusRecommendationRaw === "duplicate"
|| statusRecommendationRaw === "blocked"
|| statusRecommendationRaw === "needs_review"
? statusRecommendationRaw
: "needs_review";
const problemTypeRaw = String(source.problemType || "").trim().toLowerCase();
const problemType = problemTypeRaw === "feature"
|| problemTypeRaw === "bug"
|| problemTypeRaw === "workflow"
|| problemTypeRaw === "performance"
|| problemTypeRaw === "ux"
|| problemTypeRaw === "content"
? problemTypeRaw
: "unknown";
if (!title || !parsedInterpretation || !implementationApproach) {
return null;
}
return {
title,
primaryCategory,
2026-06-27 01:12:35 -04:00
tags: tags.length > 0 ? tags : ["General"],
statusRecommendation,
parsedInterpretation,
implementationApproach,
affectedSystems: normalizeLauncherRequestAnalysisStringList(source.affectedSystems),
affectedFiles: normalizeLauncherRequestAnalysisStringList(source.affectedFiles),
problemType,
rawExcerpt: String(source.rawExcerpt || "").trim(),
confidence: normalizeLauncherRequestAnalysisConfidence(source.confidence),
2026-06-27 01:12:35 -04:00
reviewRationale,
reviewOptions,
notes: String(source.notes || "").trim(),
};
}
function normalizeLauncherRequestAnalysis(value) {
const source = value && typeof value === "object" && !Array.isArray(value)
? value
: null;
if (!source) {
return undefined;
}
const normalizedItems = Array.isArray(source.items)
? source.items
.map((item, index) => normalizeLauncherRequestAnalysisItem(item, index))
.filter(Boolean)
: [];
const averageConfidence = normalizedItems.length > 0
? normalizedItems.reduce((total, item) => total + (Number.isFinite(item.confidence) ? item.confidence : 0), 0) / normalizedItems.length
: null;
const confidence = normalizeLauncherRequestAnalysisConfidence(source.confidence);
const state = normalizeLauncherRequestAnalysisState(source.state);
const error = String(source.error || "").trim();
const model = String(source.model || "").trim();
const createdAt = String(source.createdAt || "").trim() || new Date().toISOString();
const updatedAt = String(source.updatedAt || "").trim() || createdAt;
const submissionId = String(source.submissionId || "").trim();
const sourceTextSnapshot = String(source.sourceTextSnapshot || source.sourceText || "").trim();
if (!model && !error && !submissionId && !sourceTextSnapshot && normalizedItems.length === 0 && state === "unprocessed") {
return undefined;
}
return {
state,
confidence: confidence ?? averageConfidence,
model: model || undefined,
createdAt,
updatedAt,
error: error || undefined,
submissionId: submissionId || undefined,
sourceTextSnapshot: sourceTextSnapshot || undefined,
itemCount: normalizedItems.length,
items: normalizedItems,
};
}
function buildPendingLauncherRequestTitle(text, fallback = "Pending request") {
const normalized = String(text || "").replace(/\s+/g, " ").trim();
if (!normalized) {
return fallback;
}
const firstSentence = normalized.split(/[\r\n.!?]+/).map((entry) => entry.trim()).find(Boolean) || normalized;
return firstSentence.length > 72 ? `${firstSentence.slice(0, 69).trim()}...` : firstSentence;
}
2026-06-26 22:55:50 -04:00
function normalizeLauncherRequestEntry(entry, index = 0) {
const source = entry && typeof entry === "object" && !Array.isArray(entry)
? entry
: null;
if (!source) {
return null;
}
const sourceText = String(source.sourceText || source.text || "").trim();
const title = String(source.title || "").trim() || buildPendingLauncherRequestTitle(sourceText);
const status = normalizeLauncherRequestStatus(source.status);
const summary = String(source.summary || "").trim()
|| (sourceText ? (status === "pending" ? "Awaiting parsing and categorization." : sourceText) : "");
if (!title || !summary) {
2026-06-26 22:55:50 -04:00
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,
sourceSubmissionId: String(source.sourceSubmissionId || "").trim() || undefined,
title,
status,
category: String(source.category || "").trim() || (status === "active" ? "General" : "Unsorted"),
tags: normalizeLauncherRequestTags(source.tags),
sourceText,
summary,
implementationNotes: String(source.implementationNotes || "").trim(),
analysis: normalizeLauncherRequestAnalysis(source.analysis),
2026-06-26 22:55:50 -04:00
createdAt,
updatedAt,
};
}
function buildLauncherRequestFromAnalysisItem(sourceRequest, analysis, item, index = 0) {
const normalizedItem = normalizeLauncherRequestAnalysisItem(item, index);
if (!sourceRequest || !normalizedItem) {
return null;
}
const now = new Date().toISOString();
const sourceAnalysis = analysis && typeof analysis === "object" && !Array.isArray(analysis)
? analysis
: {};
return normalizeLauncherRequestEntry({
id: createLauncherRequestId(),
sourceSubmissionId: String(sourceRequest.id || "").trim() || undefined,
title: normalizedItem.title,
status: "active",
category: normalizedItem.primaryCategory,
tags: normalizedItem.tags,
sourceText: String(sourceRequest.sourceText || "").trim(),
summary: normalizedItem.parsedInterpretation,
implementationNotes: normalizedItem.implementationApproach,
analysis: {
state: "processed",
confidence: normalizedItem.confidence,
model: sourceAnalysis.model,
createdAt: String(sourceAnalysis.createdAt || "").trim() || now,
updatedAt: now,
submissionId: String(sourceRequest.id || "").trim(),
sourceTextSnapshot: String(sourceRequest.sourceText || "").trim(),
items: [normalizedItem],
},
createdAt: String(sourceRequest.createdAt || "").trim() || now,
updatedAt: now,
}, index);
}
function normalizeBooleanEnvFlag(value, fallback = false) {
const normalized = String(value || "").trim().toLowerCase();
if (!normalized) {
return fallback;
}
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
function resolveRequestAnalyzerProvider() {
const configured = String(process.env.REQUEST_ANALYZER_PROVIDER || "").trim().toLowerCase();
if (configured === "deepseek" || configured === "openai-compatible") {
return configured;
}
return String(process.env.DEEPSEEK_API_KEY || "").trim() ? "deepseek" : "openai-compatible";
}
function isRequestAnalysisAutorunEnabled() {
return normalizeBooleanEnvFlag(process.env.REQUEST_ANALYZER_AUTORUN, false);
}
function isRequestAnalysisConfigured() {
if (!isRequestAnalysisAutorunEnabled()) {
return false;
}
const provider = resolveRequestAnalyzerProvider();
if (provider === "deepseek") {
return Boolean(String(process.env.DEEPSEEK_API_KEY || process.env.REQUEST_ANALYZER_API_KEY || "").trim());
}
return Boolean(String(process.env.REQUEST_ANALYZER_MODEL || "").trim());
}
function isLauncherRequestAutoQueueEligible(entry) {
const analysisState = String(entry?.analysis?.state || "").trim().toLowerCase();
return String(entry?.status || "").trim().toLowerCase() === "pending"
&& (!analysisState || analysisState === "unprocessed");
}
function getQueuedPendingLauncherRequestCount() {
try {
const payload = readLauncherRequestsPayload();
return payload.requests.filter((entry) => isLauncherRequestAutoQueueEligible(entry)).length;
} catch {
return 0;
}
}
function clearRequestAnalysisRestartTimer() {
if (requestAnalysisRunState.restartTimer) {
clearTimeout(requestAnalysisRunState.restartTimer);
requestAnalysisRunState.restartTimer = null;
}
}
function scheduleQueuedRequestAnalysis(reason = "queued-pending-requests", delayMs = 0) {
if (!isRequestAnalysisConfigured()) {
return false;
}
const queuedPendingCount = getQueuedPendingLauncherRequestCount();
if (queuedPendingCount <= 0) {
return false;
}
if (requestAnalysisRunState.child && !requestAnalysisRunState.child.killed) {
return false;
}
clearRequestAnalysisRestartTimer();
const runDelayMs = Math.max(0, Math.floor(Number(delayMs) || 0));
requestAnalysisRunState.restartTimer = setTimeout(() => {
requestAnalysisRunState.restartTimer = null;
launchQueuedRequestAnalysis(reason);
}, runDelayMs);
return true;
}
function launchQueuedRequestAnalysis(reason = "queued-pending-requests") {
if (!isRequestAnalysisConfigured()) {
return { launched: false, reason: "request-analysis-not-configured" };
}
const queuedPendingCount = getQueuedPendingLauncherRequestCount();
if (queuedPendingCount <= 0) {
return { launched: false, reason: "no-pending-requests" };
}
if (requestAnalysisRunState.child && !requestAnalysisRunState.child.killed) {
return { launched: false, reason: "request-analysis-already-running" };
}
const provider = resolveRequestAnalyzerProvider();
const args = [
requestAnalysisWorkerScriptPath,
"--provider",
provider,
"--api-base",
`http://127.0.0.1:${port}`,
"--limit",
String(Math.max(1, Math.floor(Number(process.env.REQUEST_ANALYZER_AUTORUN_LIMIT) || 5))),
];
const child = spawn(process.execPath, args, {
cwd: __dirname,
env: {
...process.env,
REQUEST_ANALYZER_PROVIDER: provider,
REQUEST_ANALYZER_API_BASE: `http://127.0.0.1:${port}`,
},
stdio: ["ignore", "pipe", "pipe"],
});
requestAnalysisRunState.child = child;
2026-06-27 00:38:21 -04:00
recordSaveEvent({
type: "launcher-request-analysis-launch",
provider,
reason,
queuedPendingCount,
pid: child.pid,
});
child.stdout?.on("data", (chunk) => {
const text = String(chunk || "").trim();
if (text) {
console.log(`[request-analysis] ${text}`);
}
});
child.stderr?.on("data", (chunk) => {
const text = String(chunk || "").trim();
if (text) {
console.error(`[request-analysis] ${text}`);
}
});
child.on("exit", (code, signal) => {
requestAnalysisRunState.child = null;
console.log(`[request-analysis] worker finished code=${String(code)} signal=${String(signal || "")} reason=${reason}`);
2026-06-27 00:38:21 -04:00
recordSaveEvent({
type: "launcher-request-analysis-finish",
provider,
reason,
queuedPendingCount: getQueuedPendingLauncherRequestCount(),
code: Number.isFinite(Number(code)) ? Number(code) : null,
signal: signal || "",
});
if (getQueuedPendingLauncherRequestCount() > 0) {
scheduleQueuedRequestAnalysis("drain-pending-requests", 1200);
}
});
child.on("error", (error) => {
requestAnalysisRunState.child = null;
console.error(`[request-analysis] worker launch failed: ${String(error)}`);
2026-06-27 00:38:21 -04:00
recordSaveEvent({
type: "launcher-request-analysis-launch-error",
provider,
reason,
error: String(error),
});
});
console.log(`[request-analysis] launched provider=${provider} queuedPending=${queuedPendingCount} reason=${reason}`);
return {
launched: true,
reason,
queuedPendingCount,
pid: child.pid,
};
}
function expandLegacyLauncherRequestEntry(entry, index = 0) {
const source = entry && typeof entry === "object" && !Array.isArray(entry)
? entry
: null;
if (!source) {
return [];
}
const sourceId = String(source.id || `legacy_request_${index + 1}`).trim() || `legacy_request_${index + 1}`;
const sourceText = String(source.text || "").trim();
const createdAt = String(source.createdAt || "").trim() || new Date().toISOString();
const updatedAt = String(source.updatedAt || "").trim() || createdAt;
const migrations = LEGACY_LAUNCHER_REQUEST_MIGRATIONS[sourceId];
if (Array.isArray(migrations) && migrations.length > 0) {
return migrations
.map((migration, migrationIndex) => normalizeLauncherRequestEntry({
id: migration.id || `${sourceId}_active_${migrationIndex + 1}`,
sourceSubmissionId: sourceId,
title: migration.title,
status: "active",
category: migration.category,
tags: migration.tags,
sourceText,
summary: migration.summary,
implementationNotes: migration.implementationNotes,
createdAt,
updatedAt,
}, migrationIndex))
.filter(Boolean);
}
const pendingEntry = normalizeLauncherRequestEntry({
id: sourceId,
title: buildPendingLauncherRequestTitle(sourceText),
status: "pending",
category: "Unsorted",
tags: [],
sourceText,
summary: "Awaiting parsing and categorization.",
implementationNotes: "",
createdAt,
updatedAt,
}, index);
return pendingEntry ? [pendingEntry] : [];
}
2026-06-26 22:55:50 -04:00
function readLauncherRequestsPayload() {
const fallback = { schemaVersion: 2, requests: [] };
2026-06-26 22:55:50 -04:00
const payload = readJsonSafe(launcherRequestsPath, fallback);
let migrated = false;
2026-06-26 22:55:50 -04:00
const requests = Array.isArray(payload?.requests)
? payload.requests.flatMap((entry, index) => {
const isStructured = entry
&& typeof entry === "object"
&& !Array.isArray(entry)
&& (Object.prototype.hasOwnProperty.call(entry, "status")
|| Object.prototype.hasOwnProperty.call(entry, "title")
|| Object.prototype.hasOwnProperty.call(entry, "summary")
|| Object.prototype.hasOwnProperty.call(entry, "sourceText"));
if (isStructured) {
const normalized = normalizeLauncherRequestEntry(entry, index);
return normalized ? [normalized] : [];
}
migrated = true;
return expandLegacyLauncherRequestEntry(entry, index);
})
2026-06-26 22:55:50 -04:00
: [];
const normalizedPayload = {
schemaVersion: typeof payload?.schemaVersion === "number" ? Math.max(2, payload.schemaVersion) : 2,
2026-06-26 22:55:50 -04:00
requests,
};
if (migrated || normalizedPayload.schemaVersion !== payload?.schemaVersion) {
writeLauncherRequestsPayload(normalizedPayload);
}
return normalizedPayload;
2026-06-26 22:55:50 -04:00
}
function writeLauncherRequestsPayload(payload) {
const requests = Array.isArray(payload?.requests) ? payload.requests : [];
writeJsonAtomic(launcherRequestsPath, {
schemaVersion: typeof payload?.schemaVersion === "number" ? Math.max(2, payload.schemaVersion) : 2,
2026-06-26 22:55:50 -04:00
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;
}
2026-06-27 01:12:35 -04:00
function loadRequestTagCatalog() {
try {
const payload = JSON.parse(fs.readFileSync(requestTagCatalogPath, "utf8"));
const tags = Array.isArray(payload?.tags)
? payload.tags
.map((entry) => ({
id: String(entry?.id || "").trim(),
label: String(entry?.label || "").trim(),
aliases: Array.isArray(entry?.aliases)
? entry.aliases.map((alias) => String(alias || "").trim()).filter(Boolean)
: [],
}))
.filter((entry) => entry.id && entry.label)
: [];
if (tags.length > 0) {
return tags;
}
} catch {
// Fall back to built-in definitions when the KB file is unavailable.
}
return DEFAULT_REQUEST_TAG_DEFINITIONS;
}
function normalizeRequestTag(value) {
const normalizedValue = normalizeRequestTagLookupValue(value);
if (!normalizedValue) {
return "";
}
return REQUEST_TAG_LOOKUP.get(normalizedValue) || "";
}
2026-06-26 18:18:14 -04:00
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) => {
2026-06-27 00:51:20 -04:00
if (!requireLauncherAdminAccess(_req, res)) {
return;
}
2026-06-26 18:18:14 -04:00
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,
});
});
2026-06-27 00:51:20 -04:00
app.post("/api/admin/auth-check", (req, res) => {
if (!isLauncherAdminProtectionEnabled()) {
res.status(503).json({
ok: false,
accessGranted: false,
adminConfigured: false,
error: "Launcher admin access is not configured on the server.",
});
return;
}
const accessGranted = readLauncherAdminPasswordCandidate(req) === launcherAdminPassword;
if (!accessGranted) {
res.status(401).json({
ok: false,
accessGranted: false,
adminConfigured: true,
error: "Admin access denied.",
});
return;
}
res.json({
ok: true,
accessGranted: true,
adminConfigured: true,
});
});
app.get("/api/debug/recent-saves", (req, res) => {
if (!requireLauncherAdminAccess(req, res)) {
return;
}
2026-06-26 18:18:14 -04:00
res.json({
ok: true,
contentRoot,
saves: recentSaveEvents,
});
});
2026-06-27 01:12:35 -04:00
app.get("/api/launcher-request-meta", (_req, res) => {
res.json({
ok: true,
allowedTags: REQUEST_TAG_LABELS,
});
});
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(),
title: buildPendingLauncherRequestTitle(text),
status: "pending",
category: "Unsorted",
tags: [],
sourceText: text,
summary: "Awaiting parsing and categorization.",
implementationNotes: "",
2026-06-26 22:55:50 -04:00
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.sourceText.slice(0, 80),
2026-06-26 22:55:50 -04:00
});
scheduleQueuedRequestAnalysis("launcher-request-add", 250);
2026-06-26 22:55:50 -04:00
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();
2026-06-27 00:51:20 -04:00
if (!requireLauncherAdminAccess(req, res)) {
return;
}
2026-06-26 22:55:50 -04:00
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 updated = normalizeLauncherRequestEntry({
...existing,
title: req.body?.title ?? existing.title,
status: req.body?.status ?? existing.status,
category: req.body?.category ?? existing.category,
tags: Array.isArray(req.body?.tags) ? req.body.tags : existing.tags,
sourceText: req.body?.sourceText ?? existing.sourceText,
summary: req.body?.summary ?? existing.summary,
implementationNotes: req.body?.implementationNotes ?? existing.implementationNotes,
analysis: req.body?.analysis ?? existing.analysis,
2026-06-26 22:55:50 -04:00
updatedAt: new Date().toISOString(),
}, index);
const nextRequests = [...payload.requests];
nextRequests[index] = updated;
writeLauncherRequestsPayload({
schemaVersion: payload.schemaVersion,
requests: nextRequests,
});
recordSaveEvent({
type: "launcher-request-update",
requestId,
status: updated.status,
category: updated.category,
2026-06-26 22:55:50 -04:00
});
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();
2026-06-27 00:51:20 -04:00
if (!requireLauncherAdminAccess(req, res)) {
return;
}
2026-06-26 22:55:50 -04:00
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.sourceText.slice(0, 80),
2026-06-26 22:55:50 -04:00
});
res.json({
ok: true,
deletedRequestId: requestId,
requests: nextRequests,
});
} catch (err) {
res.status(500).json({ error: `Failed to delete launcher request: ${String(err)}` });
}
});
app.post("/api/launcher-requests/:requestId/process-analysis", (req, res) => {
const requestId = String(req.params.requestId || "").trim();
const action = String(req.body?.action || "").trim().toLowerCase();
2026-06-27 00:51:20 -04:00
if (!requireLauncherAdminAccess(req, res)) {
return;
}
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 now = new Date().toISOString();
const submittedAnalysis = normalizeLauncherRequestAnalysis({
...(existing.analysis && typeof existing.analysis === "object" && !Array.isArray(existing.analysis) ? existing.analysis : {}),
...(req.body?.analysis && typeof req.body.analysis === "object" && !Array.isArray(req.body.analysis) ? req.body.analysis : {}),
model: req.body?.model ?? req.body?.analysis?.model ?? existing.analysis?.model,
submissionId: requestId,
sourceTextSnapshot: existing.sourceText,
createdAt: existing.analysis?.createdAt || now,
updatedAt: now,
state: action === "promote"
? "processed"
: (action === "error" ? "error" : "needs_review"),
error: action === "error"
? (req.body?.error || req.body?.analysis?.error || "Unknown analysis error.")
: (req.body?.analysis?.error || ""),
});
if (action === "promote") {
const analyzedItems = Array.isArray(submittedAnalysis?.items) ? submittedAnalysis.items : [];
const promoted = analyzedItems
.filter((item) => item && item.statusRecommendation === "active")
.map((item, itemIndex) => buildLauncherRequestFromAnalysisItem(existing, submittedAnalysis, item, itemIndex))
.filter(Boolean);
if (promoted.length === 0) {
res.status(400).json({ error: "Promotion requires at least one active analyzed item." });
return;
}
const nextRequests = [
...payload.requests.slice(0, index),
...promoted,
...payload.requests.slice(index + 1),
];
writeLauncherRequestsPayload({
schemaVersion: payload.schemaVersion,
requests: nextRequests,
});
recordSaveEvent({
type: "launcher-request-promote",
requestId,
itemCount: promoted.length,
model: submittedAnalysis?.model || "",
});
res.json({
ok: true,
promoted,
requests: nextRequests,
});
return;
}
if (action !== "review" && action !== "error") {
res.status(400).json({ error: "Unsupported analysis action." });
return;
}
const reviewedRequest = normalizeLauncherRequestEntry({
...existing,
analysis: submittedAnalysis,
updatedAt: now,
}, index);
if (!reviewedRequest) {
res.status(500).json({ error: "Failed to normalize analyzed request." });
return;
}
const nextRequests = [...payload.requests];
nextRequests[index] = reviewedRequest;
writeLauncherRequestsPayload({
schemaVersion: payload.schemaVersion,
requests: nextRequests,
});
recordSaveEvent({
type: action === "error" ? "launcher-request-analysis-error" : "launcher-request-review",
requestId,
model: reviewedRequest.analysis?.model || "",
itemCount: reviewedRequest.analysis?.itemCount || 0,
});
res.json({
ok: true,
request: reviewedRequest,
requests: nextRequests,
});
} catch (err) {
res.status(500).json({ error: `Failed to process launcher request analysis: ${String(err)}` });
}
});
2026-06-27 00:51:20 -04:00
app.post("/api/launcher-requests/process-pending", (req, res) => {
if (!requireLauncherAdminAccess(req, res)) {
return;
}
try {
const result = launchQueuedRequestAnalysis("manual-api-trigger");
res.json({
ok: true,
...result,
autorunEnabled: isRequestAnalysisAutorunEnabled(),
configured: isRequestAnalysisConfigured(),
queuedPendingCount: getQueuedPendingLauncherRequestCount(),
});
} catch (err) {
res.status(500).json({ error: `Failed to trigger launcher request analysis: ${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}`);
}
if (isRequestAnalysisAutorunEnabled()) {
console.log(`[request-analysis] autorun enabled provider=${resolveRequestAnalyzerProvider()} configured=${isRequestAnalysisConfigured() ? "yes" : "no"}`);
scheduleQueuedRequestAnalysis("server-startup", 1200);
}
2026-06-26 18:18:14 -04:00
});
2026-06-26 20:30:30 -04:00