extract server transform and validation helpers
This commit is contained in:
parent
aea2c9d56b
commit
e79bc2e339
10 changed files with 373 additions and 139 deletions
160
server.js
160
server.js
|
|
@ -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
3
server/contentTransforms.d.ts
vendored
Normal 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;
|
||||
27
server/contentTransforms.js
Normal file
27
server/contentTransforms.js
Normal 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
10
server/validation.d.ts
vendored
Normal 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
55
server/validation.js
Normal 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
36
server/worldTransforms.d.ts
vendored
Normal 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
71
server/worldTransforms.js
Normal 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),
|
||||
};
|
||||
}
|
||||
21
src/server/contentTransforms.test.ts
Normal file
21
src/server/contentTransforms.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
50
src/server/validation.test.ts
Normal file
50
src/server/validation.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
79
src/server/worldTransforms.test.ts
Normal file
79
src/server/worldTransforms.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue