1
0
Fork 0
Code Issues Pull requests Projects Releases Packages Wiki Activity Actions Pages

extract server transform and validation helpers

This commit is contained in:
super-jalawii 2026-06-27 11:10:23 -04:00
parent aea2c9d56b
commit e79bc2e339
10 changed files with 373 additions and 139 deletions

160
server.js
View file

@ -3,6 +3,23 @@ import { spawn } from "child_process";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import {
areRowsOnlyFillChar,
normalizeBackgroundTileId,
} from "./server/contentTransforms.js";
import {
buildWorldChunkFileName,
defaultWorldDirRel,
getWorldStoragePaths as buildWorldStoragePaths,
normalizeWorldBookmark,
normalizeWorldIndexEntry,
normalizeWorldIndexPayload,
sanitizeWorldId,
} from "./server/worldTransforms.js";
import {
validateCatalogMetaPayload as validateCatalogMetaPayloadShape,
validatePayload as validatePayloadShape,
} from "./server/validation.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@ -961,27 +978,6 @@ function writeLauncherRequestsPayload(payload) {
});
}
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}`;
@ -1030,70 +1026,14 @@ function readJsonSafe(fullPath, 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)),
};
return buildWorldStoragePaths(contentRoot, worldEntryOrId);
}
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,
};
return normalizeWorldIndexPayload(payload);
}
function normalizeWorldDefinitionPayload(payload, fallbackId = "") {
@ -1155,16 +1095,6 @@ function readWorldDefinitionPayload(worldId) {
);
}
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);
@ -2164,59 +2094,11 @@ function injectNpcNodeDescriptions(payload, meta) {
}
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;
return validatePayloadShape(payload, type, rootKey, REQUIRED_ID_KEY_BY_TYPE);
}
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;
return validateCatalogMetaPayloadShape(payload, FROZEN_CATALOG_KEYS);
}
function writeJsonAtomic(fullPath, data) {

3
server/contentTransforms.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
export function normalizeBackgroundTileId(value: unknown, idToSymbol?: Map<string, unknown> | null): string;
export function areRowsOnlyFillChar(rows: unknown, fillChar?: string): boolean;
export function resolveContentPath(contentRoot: string, relativePath: string): string;

View file

@ -0,0 +1,27 @@
import path from "path";
export 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;
}
export 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);
});
}
export function resolveContentPath(contentRoot, relativePath) {
const normalized = String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
return path.resolve(contentRoot, normalized);
}

10
server/validation.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
export function validatePayload(
payload: unknown,
type: string,
rootKey: string,
requiredIdKeyByType: Record<string, string>,
): string | null;
export function validateCatalogMetaPayload(
payload: unknown,
frozenCatalogKeys: string[],
): string | null;

55
server/validation.js Normal file
View file

@ -0,0 +1,55 @@
export function validatePayload(payload, type, rootKey, requiredIdKeyByType) {
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 = requiredIdKeyByType[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;
}
export function validateCatalogMetaPayload(payload, frozenCatalogKeys) {
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", ...frozenCatalogKeys]);
const unknownTopLevel = Object.keys(payload).filter((key) => !allowedTopLevel.has(key));
if (unknownTopLevel.length > 0) {
return `Unsupported catalog keys: ${unknownTopLevel.join(", ")}`;
}
for (const key of frozenCatalogKeys) {
if (!Array.isArray(payload[key])) {
return `${key} must be an array`;
}
}
return null;
}

36
server/worldTransforms.d.ts vendored Normal file
View file

@ -0,0 +1,36 @@
export function sanitizeWorldId(worldId: unknown): string;
export function defaultWorldDirRel(worldId: unknown): string;
export function buildWorldChunkFileName(chunkX: unknown, chunkY: unknown): string;
export function getWorldStoragePaths(
contentRoot: string,
worldEntryOrId: string | { id?: unknown; worldDir?: unknown },
): {
worldId: string;
worldDirRel: string;
worldDirAbs: string;
worldJsonRel: string;
worldJsonAbs: string;
bookmarksRel: string;
bookmarksAbs: string;
chunksDirRel: string;
chunksDirAbs: string;
};
export function normalizeWorldIndexEntry(entry: { id?: unknown; name?: unknown; worldDir?: unknown } | null | undefined): {
id: string;
name: string;
worldDir: string;
};
export function normalizeWorldIndexPayload(payload: unknown): {
schemaVersion: number;
worlds: Array<{
id: string;
name: string;
worldDir: string;
}>;
};
export function normalizeWorldBookmark(entry: { id?: unknown; label?: unknown; x?: unknown; y?: unknown } | null | undefined, index?: number): {
id: string;
label: string;
x: number;
y: number;
};

71
server/worldTransforms.js Normal file
View file

@ -0,0 +1,71 @@
import path from "path";
import { resolveContentPath } from "./contentTransforms.js";
export function sanitizeWorldId(worldId) {
const raw = String(worldId || "").trim();
if (!raw) {
return "world";
}
return raw.replace(/[^a-zA-Z0-9_-]/g, "_");
}
export function defaultWorldDirRel(worldId) {
return `worlds/${sanitizeWorldId(worldId)}`;
}
export function buildWorldChunkFileName(chunkX, chunkY) {
return `${Math.floor(Number(chunkX) || 0)}_${Math.floor(Number(chunkY) || 0)}.json`;
}
export function getWorldStoragePaths(contentRoot, 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 = resolveContentPath(contentRoot, 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"),
};
}
export function normalizeWorldIndexEntry(entry) {
const id = sanitizeWorldId(entry?.id || "");
return {
id,
name: String(entry?.name || id || "World"),
worldDir: String(entry?.worldDir || defaultWorldDirRel(id)),
};
}
export function normalizeWorldIndexPayload(payload) {
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,
};
}
export 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),
};
}

View file

@ -0,0 +1,21 @@
import { areRowsOnlyFillChar, normalizeBackgroundTileId, resolveContentPath } from "../../server/contentTransforms.js";
describe("server/contentTransforms", () => {
it("normalizes background tile ids and optionally validates them against a known id map", () => {
expect(normalizeBackgroundTileId(" grass ")).toBe("grass");
expect(normalizeBackgroundTileId("", new Map([["grass", "#"]]))).toBe("");
expect(normalizeBackgroundTileId("stone", new Map([["grass", "#"]]))).toBe("");
expect(normalizeBackgroundTileId("grass", new Map([["grass", "#"]]))).toBe("grass");
});
it("detects fill-only row payloads using the provided fill character", () => {
expect(areRowsOnlyFillChar([], ".")).toBe(true);
expect(areRowsOnlyFillChar(["...", ""], ".")).toBe(true);
expect(areRowsOnlyFillChar([" "], " ")).toBe(true);
expect(areRowsOnlyFillChar(["..x"], ".")).toBe(false);
});
it("resolves content-relative paths without allowing leading slashes to escape intent", () => {
expect(resolveContentPath("/workspace/content", "/worlds/overworld/world.json")).toBe("/workspace/content/worlds/overworld/world.json");
});
});

View file

@ -0,0 +1,50 @@
import { validateCatalogMetaPayload, validatePayload } from "../../server/validation.js";
const REQUIRED_ID_KEY_BY_TYPE = {
images: "id",
quests: "questId",
};
const FROZEN_CATALOG_KEYS = ["conditions", "itemActions", "systemActions", "effects", "colors"];
describe("server/validation", () => {
it("validates standard catalog payload roots and required id keys", () => {
expect(validatePayload({
schemaVersion: 1,
images: [{ id: "grass" }],
}, "images", "images", REQUIRED_ID_KEY_BY_TYPE)).toBeNull();
expect(validatePayload([], "images", "images", REQUIRED_ID_KEY_BY_TYPE)).toBe("Payload must be an object");
expect(validatePayload({ images: [] }, "images", "images", REQUIRED_ID_KEY_BY_TYPE)).toBe("schemaVersion must be a number");
expect(validatePayload({ schemaVersion: 1, wrong: [] }, "images", "images", REQUIRED_ID_KEY_BY_TYPE)).toBe("Unsupported top-level keys for images: wrong");
expect(validatePayload({ schemaVersion: 1, images: [{}] }, "images", "images", REQUIRED_ID_KEY_BY_TYPE)).toBe("images[0] is missing required key: id");
});
it("skips per-entry id checks for types without configured required ids", () => {
expect(validatePayload({
schemaVersion: 1,
custom: [{ anything: true }],
}, "custom", "custom", REQUIRED_ID_KEY_BY_TYPE)).toBeNull();
});
it("validates frozen catalog payload shape", () => {
expect(validateCatalogMetaPayload({
schemaVersion: 1,
conditions: [],
itemActions: [],
systemActions: [],
effects: [],
colors: [],
}, FROZEN_CATALOG_KEYS)).toBeNull();
expect(validateCatalogMetaPayload({
schemaVersion: 1,
conditions: [],
itemActions: [],
systemActions: [],
effects: [],
colors: [],
extra: [],
}, FROZEN_CATALOG_KEYS)).toBe("Unsupported catalog keys: extra");
});
});

View file

@ -0,0 +1,79 @@
import {
buildWorldChunkFileName,
defaultWorldDirRel,
getWorldStoragePaths,
normalizeWorldBookmark,
normalizeWorldIndexEntry,
normalizeWorldIndexPayload,
sanitizeWorldId,
} from "../../server/worldTransforms.js";
describe("server/worldTransforms", () => {
it("sanitizes world ids and builds default world directories", () => {
expect(sanitizeWorldId(" Over world!? ")).toBe("Over_world__");
expect(sanitizeWorldId("")).toBe("world");
expect(defaultWorldDirRel("overworld")).toBe("worlds/overworld");
});
it("builds stable chunk file names", () => {
expect(buildWorldChunkFileName(-3, 4)).toBe("-3_4.json");
expect(buildWorldChunkFileName("2.9", "-1.1")).toBe("2_-2.json");
});
it("resolves world storage paths from either a world id or index entry", () => {
expect(getWorldStoragePaths("/repo/content", "overworld")).toEqual({
worldId: "overworld",
worldDirRel: "worlds/overworld",
worldDirAbs: "/repo/content/worlds/overworld",
worldJsonRel: "worlds/overworld/world.json",
worldJsonAbs: "/repo/content/worlds/overworld/world.json",
bookmarksRel: "worlds/overworld/bookmarks.json",
bookmarksAbs: "/repo/content/worlds/overworld/bookmarks.json",
chunksDirRel: "worlds/overworld/chunks",
chunksDirAbs: "/repo/content/worlds/overworld/chunks",
});
expect(getWorldStoragePaths("/repo/content", { id: "city", worldDir: "custom/worlds/city" })).toEqual(
expect.objectContaining({
worldId: "city",
worldDirRel: "custom/worlds/city",
chunksDirRel: "custom/worlds/city/chunks",
}),
);
});
it("normalizes world index entries and payloads", () => {
expect(normalizeWorldIndexEntry({ id: "My World!", name: "", worldDir: "" })).toEqual({
id: "My_World_",
name: "My_World_",
worldDir: "worlds/My_World_",
});
expect(normalizeWorldIndexPayload({
schemaVersion: 3,
worlds: [
{ id: "overworld", name: "Overworld" },
null,
[],
],
})).toEqual({
schemaVersion: 3,
worlds: [
{
id: "overworld",
name: "Overworld",
worldDir: "worlds/overworld",
},
],
});
});
it("normalizes bookmark payload entries", () => {
expect(normalizeWorldBookmark({ id: "", label: "", x: 2.9, y: -3.1 }, 1)).toEqual({
id: "bookmark_2",
label: "bookmark_2",
x: 2,
y: -4,
});
});
});