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

Compare commits

..

No commits in common. "656f4562c1927eb81eae672716e60e0d5ae995ee" and "0074f0e10cc878f192c065c1f0a24247131e0b84" have entirely different histories.

41 changed files with 631 additions and 2837 deletions

View file

@ -1,75 +0,0 @@
# Arc 1 Smoke Checklist
This checklist is the manual checkpoint for Arc 1.
It is intentionally short and focused on the editor flows that must remain usable while the refactor branch is in motion.
## Preconditions
- start the Vite client with `npm run dev`
- start the API server with `npm run dev:api`
- open the launcher at the local dev URL
## Checklist
### 1. Launcher Opens
- verify the launcher renders
- verify the primary launch actions are visible
- verify the `Content Editor` action is available
### 2. Content Editor Loads
- open the content editor from the launcher
- verify the content editor window loads
- verify the main content domains are listed:
- NPCs
- Dialogues
- Monsters
- Items
- Abilities
- Loot Tables
- Quests
- Graphics
- Factions
### 3. Content Record Edit Round Trip
- open any existing content record
- change one small field value
- use `Commit`
- use `Save`
- verify the save status updates
- revert the field to its original value
- save again so the repository returns to its original content state
### 4. Studio Window Opens
- open the studio from the launcher
- verify the studio bootstraps into a world without console-blocking errors
### 5. World Loads And A Tile Edit Still Works
- verify the current world name and dimensions are visible
- select a tile
- paint one tile on the map
- verify undo/save state updates
- save the world
- revert the tile change before finishing the checkpoint
### 6. Graphics Painter Opens And Saves
- open the graphics browser from the studio
- open a sprite or tile asset in the painter
- change one pixel
- save the asset
- revert the pixel change before finishing the checkpoint
## Verification Notes
Checkpoint commits for Arc 1 should only be considered shareable when:
- `npm test` passes
- `npm run build` passes
- this checklist passes
- temporary smoke-test content edits have been reverted before commit

View file

@ -1,186 +0,0 @@
# World And Chunk Semantics
This document describes the **current** `WorldShaper` world/chunk behavior as of Arc 1.
It is a compatibility and refactor aid, not an endorsement of the long-term model.
## Scope
These notes summarize the behavior currently implemented in:
- `src/worldChunking.ts`
- `src/components/worldshaperShared.ts`
- regression tests in:
- `src/worldChunking.test.ts`
- `src/components/worldshaperShared.test.ts`
## Coordinate Model
### Chunk Dimensions
- chunk width and height are normalized with `normalizeChunkDimension`
- values are floored to integers
- invalid values fall back to `DEFAULT_WORLD_CHUNK_SIZE`
- normalized dimensions are clamped to a minimum of `1`
### World To Chunk Coordinates
- chunk coordinates use floor division
- this applies on both positive and negative world coordinates
- examples with chunk size `32`:
- `0 -> chunk 0`
- `31 -> chunk 0`
- `32 -> chunk 1`
- `-1 -> chunk -1`
- `-33 -> chunk -2`
This means negative coordinates behave as mathematical grid cells, not truncation toward zero.
### World To Local Coordinates
- local coordinates are derived from the resolved chunk coordinate
- formula: `world - (chunk * chunkSize)`
- result stays in the half-open range `[0, chunkSize)`
- examples with chunk size `32`:
- `31 -> local 31`
- `32 -> local 0`
- `-1 -> local 31`
- `-33 -> local 31`
### Local To World Coordinates
- formula: `(chunkCoord * chunkSize) + localCoord`
- examples with chunk size `32`:
- `chunk -2, local 31 -> world -33`
### Address Resolution
`resolveWorldChunkAddress` returns:
- `chunkX`
- `chunkY`
- `localX`
- `localY`
- `chunkKey` in `x:y` form
- `fileName` in `x_y.json` form
## Chunk Identity And Storage
- chunk keys use `buildChunkKey(chunkX, chunkY)`
- chunk filenames use `buildChunkFileName(chunkX, chunkY)`
- filenames preserve negative signs, for example `-3_4.json`
## Empty Chunk Defaults
`createEmptyChunk` currently creates:
- schema version `1`
- top-level `backgroundTileId`
- `roomLayers[0]` filled with `.` characters
- `roomLayers[1]` filled with spaces
- empty `heightLayers`
- empty `instances`
This establishes the current meaning that:
- `.` in layer `0` is the default empty background cell encoding
- spaces in non-background layers represent empty overlay cells
## Background Tile Behavior
`getMapBackgroundTileId` currently resolves background tiles in this order:
1. top-level `backgroundTileId`
2. legacy nested `tiles.backgroundTileId`
3. empty string fallback
Arc 1 should treat the nested `tiles.backgroundTileId` shape as compatibility baggage.
## Room Layer Semantics
`parseRoomLayers` currently does the following:
- parses `record.roomLayers` if present
- ignores malformed entries
- requires a numeric `layer`
- sorts output by ascending `layer`
- normalizes row sizes to map bounds
- uses `.` fill for layer `0`
- uses space fill for non-zero layers
- filters blank `instanceIds`
If no explicit layer `0` exists:
- a synthetic layer `0` is created from top-level `record.rows`
If there are no usable layers at all:
- a single synthetic layer `0` is returned from top-level `record.rows`
### Current `zIndex` Behavior
- layer `0` always gets `zIndex: 0`
- non-zero layers preserve provided `zIndex` if present
- otherwise non-zero layers default to `0`
- non-zero `zIndex` values are clamped into `[0, 5]`
This is an important current behavior to preserve during Arc 1, but it looks at least partly accidental because non-zero layers do **not** derive `zIndex` from layer number.
## Height Patch Semantics
`parseHeightLayers` currently treats height patches as sparse row-based overlays.
### Input Interpretation
- `rows` are string arrays
- `.` is interpreted as empty space
- empty margins are trimmed away
- patches are clipped to map bounds
### Normalization Rules
- duplicate patch ids are dropped after the first occurrence
- `z` is clamped to a minimum of `1`
- `x` and `y` are floored to integers
- rows completely outside bounds become empty
- leading/trailing empty rows are removed
- leading/trailing empty columns are removed by cropping to occupied content
- trailing whitespace inside retained rows is stripped
### Resulting Meaning
The current height patch encoding behaves more like a cropped sparse stamp than a fixed-size tile layer.
That is useful to document now because any future redesign needs to decide whether this sparse behavior is intentional or just a side effect of the current editor implementation.
## Chunk Instance Semantics
Arc 1 has not redesigned chunk instances yet, but the current shape is:
- `id`
- optional `templateId`
- `layer`
- `x`
- `y`
- `record`
The semantics of `templateId + record` are still under-specified and should be treated as a known redesign target for later arcs.
## Likely Accidental Or Under-Specified Behavior
These behaviors are currently preserved, but should not be treated as settled architecture:
- non-background layers default to `zIndex: 0` instead of deriving depth from layer number
- duplicate height patch ids are silently dropped after the first occurrence
- top-level `rows` still act as a fallback source for synthesized background layers
- nested `tiles.backgroundTileId` is still accepted
- layer `0` empties use `.` while non-zero layer empties use spaces
- chunk instance meaning is still implicit rather than explicitly modeled
## Arc 2+ Questions
- Should negative-coordinate behavior remain floor-based, or should world addressing be modeled differently at a higher level?
- Should layer depth be derived from `layer`, `zIndex`, or a clearer world-space model?
- Should height data remain sparse text rows, or become a more explicit numeric structure?
- Should background tiles stay top-level, or belong to a clearer terrain/base-layer contract?
- What is the correct long-term meaning of chunk instances, templates, and per-instance overrides?

1157
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,8 +12,6 @@
"analyze:requests": "node scripts/request-analysis-worker.mjs",
"validate:content": "node scripts/validate-content-schemas.mjs",
"build": "tsc -b && vite build",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"preview": "vite preview"
},
@ -25,9 +23,6 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@ -36,10 +31,9 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
"vitest": "^4.1.9"
"vite": "^8.0.12"
}
}

160
server.js
View file

@ -3,23 +3,6 @@ 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);
@ -978,6 +961,27 @@ 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}`;
@ -1026,14 +1030,70 @@ 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) {
return buildWorldStoragePaths(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 = 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);
return 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,
};
}
function normalizeWorldDefinitionPayload(payload, fallbackId = "") {
@ -1095,6 +1155,16 @@ 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);
@ -2094,11 +2164,59 @@ function injectNpcNodeDescriptions(payload, meta) {
}
function validatePayload(payload, type, rootKey) {
return validatePayloadShape(payload, type, rootKey, REQUIRED_ID_KEY_BY_TYPE);
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) {
return validateCatalogMetaPayloadShape(payload, FROZEN_CATALOG_KEYS);
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) {

View file

@ -1,3 +0,0 @@
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

@ -1,27 +0,0 @@
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);
}

View file

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

View file

@ -1,55 +0,0 @@
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;
}

View file

@ -1,36 +0,0 @@
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;
};

View file

@ -1,71 +0,0 @@
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

@ -683,16 +683,6 @@ function openSharedContractNote(): void {
window.location.assign(noteUrl.toString());
}
function openContentEditor(): void {
const editorUrl = new URL("./worldshaper-content.html", window.location.href);
const popup = window.open(editorUrl.toString(), "worldshaper-content-editor", "popup=yes,width=1600,height=980,resizable=yes,scrollbars=yes");
if (popup) {
popup.focus();
return;
}
window.location.assign(editorUrl.toString());
}
function openAdminPanelWindow(): boolean {
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.set("admin", "requests");
@ -1374,9 +1364,6 @@ function WorldshaperLauncher() {
<button type="button" className="launcher-primary-btn" onClick={() => void handleLaunch()} disabled={isBusy}>
Launch
</button>
<button type="button" className="launcher-secondary-btn" onClick={openContentEditor} disabled={isBusy}>
Content Editor
</button>
<button type="button" className="launcher-secondary-btn" onClick={openSharedContractNote} disabled={isBusy}>
Shared Contract
</button>

View file

@ -1,81 +0,0 @@
import {
getMapBackgroundTileId,
parseHeightLayers,
parseRoomLayers,
} from "./worldshaperShared";
import type { JsonObject } from "../editorCore";
describe("worldshaperShared", () => {
it("prefers top-level background tile ids and falls back to the legacy nested shape", () => {
expect(getMapBackgroundTileId({ backgroundTileId: "grass" })).toBe("grass");
expect(getMapBackgroundTileId({ tiles: { backgroundTileId: "water" } as unknown as JsonObject })).toBe("water");
expect(getMapBackgroundTileId({})).toBe("");
});
it("synthesizes and resizes room layers from the current map payload", () => {
const result = parseRoomLayers({
rows: ["##", "#"],
roomLayers: [
{
layer: 2,
name: "Objects",
rows: ["A"],
instanceIds: ["npc_1", ""],
},
],
}, 3, 2);
expect(result).toEqual([
{
layer: 0,
name: undefined,
zIndex: 0,
rows: ["##.", "#.."],
instanceIds: [],
},
{
layer: 2,
name: "Objects",
zIndex: 0,
rows: ["A ", " "],
instanceIds: ["npc_1"],
},
]);
});
it("normalizes, trims, and bounds height patches while dropping duplicate ids", () => {
const result = parseHeightLayers({
heightLayers: [
{
id: "ridge",
z: 3,
x: -1,
y: -1,
rows: [
"...",
".9.",
"..8",
],
},
{
id: "ridge",
z: 9,
x: 0,
y: 0,
rows: ["1"],
},
],
}, 4, 4);
expect(result).toEqual([
{
id: "ridge",
name: undefined,
z: 3,
x: 0,
y: 0,
rows: ["9", " 8"],
},
]);
});
});

View file

@ -1,5 +1,5 @@
import { resolveUnifiedColorSymbol } from "../editorCore";
import type { JsonObject } from "../contracts/json";
import type { JsonObject } from "../editorCore";
export const TILE_COLORS: Record<string, string> = {
"#": resolveUnifiedColorSymbol("L", "#3d4f6a"),

View file

@ -1,11 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import "./App.css";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View file

@ -1,159 +0,0 @@
import {
buildSpritesPayloadFromImagesPayload,
buildTilesPayloadFromImagesPayload,
mergeImagesPayloadWithTilesPayload,
normalizeImageRecordForSave,
normalizeImagesPayloadForSave,
normalizeTileRecordForSave,
} from "./graphicsPayload";
import type { JsonObject, JsonValue } from "../contracts/json";
import { getSpriteRows } from "../graphics/rowEncoding";
describe("graphicsPayload", () => {
it("normalizes tile records with padded rows and compatibility symbols", () => {
const result = normalizeTileRecordForSave({
id: "tile_grass",
rows: ["A", "BC", "DROP"],
width: 2,
height: 2,
});
expect(result).toEqual(expect.objectContaining({
id: "tile_grass",
symbol: "t",
rows: ["A.", "BC"],
width: 2,
height: 2,
}));
});
it("normalizes image records into frame-based storage while preserving tile compatibility", () => {
const result = normalizeImageRecordForSave({
id: "tile_tree",
roles: ["tile", "sprite", "other", "tile"],
rows: ["A", "BC"],
width: 2,
height: 2,
speed: -10,
playback: "invalid",
tileSymbol: "$",
});
expect(result.rows).toBeUndefined();
expect(result.symbol).toBeUndefined();
expect(result.graphicRole).toBeUndefined();
expect(result.roles).toEqual(["tile", "sprite"]);
expect(result.defaultFrame).toBe("frame_0");
expect(result.speed).toBe(0);
expect(result.playback).toBe("normal");
expect(result.tileSymbol).toBe("$");
expect(result.frames).toEqual([
expect.objectContaining({
id: "frame_0",
rows: ["A.", "BC"],
}),
]);
});
it("resolves sprite rows from the default enabled frame", () => {
const record: JsonObject = {
defaultFrame: "walk_1",
frames: [
{ id: "idle", rows: ["II"], enabled: true },
{ id: "walk_1", rows: ["WW"], enabled: true },
{ id: "disabled", rows: ["DD"], enabled: false },
],
};
expect(getSpriteRows(record)).toEqual(["WW"]);
});
it("normalizes image payload arrays without changing non-record entries", () => {
const payload: JsonValue = {
schemaVersion: 1,
images: [
{
id: "sprite_hero",
roles: ["sprite"],
rows: ["X"],
},
"leave-me-alone",
],
};
expect(normalizeImagesPayloadForSave(payload)).toEqual({
schemaVersion: 1,
images: [
expect.objectContaining({
id: "sprite_hero",
roles: ["sprite"],
defaultFrame: "frame_0",
}),
"leave-me-alone",
],
});
});
it("projects tiles and sprites from images while preserving current compatibility behavior", () => {
const imagesPayload: JsonValue = {
schemaVersion: 1,
images: [
{
id: "tile_grass",
roles: ["tile"],
tileSymbol: "G",
width: 2,
height: 2,
frames: [{ id: "frame_0", rows: ["AA", "BB"] }],
},
{
id: "sprite_hero",
roles: ["sprite"],
width: 1,
height: 1,
frames: [{ id: "frame_0", rows: ["H"] }],
},
],
};
expect(buildTilesPayloadFromImagesPayload(imagesPayload)).toEqual({
schemaVersion: 1,
tiles: [expect.objectContaining({ id: "tile_grass", symbol: "G", rows: ["AA", "BB"] })],
});
expect(buildSpritesPayloadFromImagesPayload(imagesPayload)).toEqual({
schemaVersion: 1,
sprites: [expect.objectContaining({ id: "sprite_hero", rows: ["H"] })],
});
});
it("merges tile payloads into images and removes tile role when a tile disappears", () => {
const merged = mergeImagesPayloadWithTilesPayload(
{
schemaVersion: 1,
images: [
{
id: "shared",
roles: ["tile", "sprite"],
tileSymbol: "S",
frames: [{ id: "frame_0", rows: ["A"] }],
},
],
},
{
schemaVersion: 1,
tiles: [],
},
);
expect(merged).toEqual({
schemaVersion: 1,
images: [
expect.objectContaining({
id: "shared",
roles: ["sprite"],
tileSymbol: "",
}),
],
});
});
});

View file

@ -1,413 +0,0 @@
import { isPlainObject, type JsonObject, type JsonValue } from "../contracts/json";
import { getDirectSpriteRows, getRawImageFrames, getSpriteRows, normalizeRowsToSize } from "../graphics/rowEncoding";
import { normalizeStringList } from "../shared/normalization";
const IMAGES_ROOT = "images";
const SPRITES_ROOT = "sprites";
const TILES_ROOT = "tiles";
function createRandomIdFragment(): string {
try {
const bytes = new Uint8Array(5);
crypto.getRandomValues(bytes);
return Array.from(bytes).map((value) => value.toString(16).padStart(2, "0")).join("");
} catch {
return Math.random().toString(16).slice(2, 12);
}
}
function normalizeImageRoles(value: JsonValue | undefined): string[] {
if (!Array.isArray(value)) {
return [];
}
return Array.from(new Set(
value
.map((entry) => String(entry || "").trim().toLowerCase())
.filter((entry) => entry === "tile" || entry === "sprite"),
));
}
function normalizeSpriteLikeRecord(record: JsonObject, defaultIdPrefix: string): JsonObject {
const nextRecord: JsonObject = { ...record };
const id = String(nextRecord.id ?? "").trim();
nextRecord.id = id || `${defaultIdPrefix}_${createRandomIdFragment()}`;
if (typeof nextRecord.name !== "string") {
nextRecord.name = "";
}
const width = Number(nextRecord.width);
const height = Number(nextRecord.height);
const pixelScale = Number(nextRecord.pixelScale);
const opacity = Number(nextRecord.opacity);
nextRecord.width = Number.isFinite(width) && width > 0 ? Math.floor(width) : 1;
nextRecord.height = Number.isFinite(height) && height > 0 ? Math.floor(height) : 1;
nextRecord.pixelScale = Number.isFinite(pixelScale) && pixelScale > 0 ? Math.floor(pixelScale) : 1;
nextRecord.opacity = Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1;
delete nextRecord.palette;
nextRecord.rows = normalizeRowsToSize(
getDirectSpriteRows(nextRecord),
Number(nextRecord.width) || 1,
Number(nextRecord.height) || 1,
) as unknown as JsonValue;
return nextRecord;
}
function normalizeImageFrameRecord(
frame: JsonObject,
fallbackRecord: JsonObject,
index: number,
): JsonObject {
const nextFrameId = String(frame.id || "").trim() || `frame_${index}`;
const normalizedFrame = normalizeSpriteLikeRecord({
...frame,
id: nextFrameId,
width: Number(frame.width) || Number(fallbackRecord.width) || 1,
height: Number(frame.height) || Number(fallbackRecord.height) || 1,
pixelScale: Number(frame.pixelScale) || Number(fallbackRecord.pixelScale) || 1,
opacity: Number(frame.opacity ?? fallbackRecord.opacity ?? 1),
rows: Array.isArray(frame.rows) ? frame.rows : getDirectSpriteRows(fallbackRecord),
}, "frame");
return {
...normalizedFrame,
id: nextFrameId,
enabled: frame.enabled !== false,
index: Number.isFinite(Number(frame.index)) ? Math.max(0, Math.floor(Number(frame.index))) : index,
};
}
export function normalizeSpritePayloadForSave(payload: JsonValue): JsonValue {
if (!isPlainObject(payload)) {
return payload;
}
const records = payload[SPRITES_ROOT];
if (!Array.isArray(records)) {
return payload;
}
return {
...payload,
[SPRITES_ROOT]: records.map((entry) => (isPlainObject(entry) ? normalizeSpriteLikeRecord(entry, "sprite") : entry)),
};
}
export function normalizeTileRecordForSave(record: JsonObject): JsonObject {
const nextRecord = normalizeSpriteLikeRecord(record, "tile");
const symbol = String(nextRecord.symbol ?? "").trim().charAt(0);
nextRecord.symbol = symbol || String(nextRecord.id || "T").charAt(0) || "T";
if (typeof nextRecord.description !== "string") {
nextRecord.description = "";
}
return nextRecord;
}
export function normalizeTilesPayloadForSave(payload: JsonValue): JsonValue {
if (!isPlainObject(payload)) {
return payload;
}
const records = payload[TILES_ROOT];
if (!Array.isArray(records)) {
return payload;
}
return {
...payload,
[TILES_ROOT]: records.map((entry) => (isPlainObject(entry) ? normalizeTileRecordForSave(entry) : entry)),
};
}
export function normalizeImagePlayback(value: JsonValue | undefined): "normal" | "rewind" | "stop" {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "rewind" || normalized === "stop") {
return normalized;
}
return "normal";
}
export function normalizeImageRecordForSave(record: JsonObject): JsonObject {
const nextRecord = normalizeSpriteLikeRecord(record, "image");
const roles = normalizeImageRoles(nextRecord.roles);
const inputFrames = getRawImageFrames(record);
const explicitRows = getDirectSpriteRows(record);
nextRecord.description = typeof nextRecord.description === "string" ? nextRecord.description : "";
nextRecord.tags = normalizeStringList(nextRecord.tags);
nextRecord.roles = roles as unknown as JsonValue;
let normalizedFrames = inputFrames.map((entry, index) => normalizeImageFrameRecord(entry, nextRecord, index));
if (normalizedFrames.length <= 0) {
normalizedFrames = [normalizeImageFrameRecord({
id: "frame_0",
rows: explicitRows.length > 0 ? explicitRows : getDirectSpriteRows(nextRecord),
}, nextRecord, 0)];
}
const requestedDefaultFrameId = String(record.defaultFrame || nextRecord.defaultFrame || "").trim();
const resolvedDefaultFrameId = String(
normalizedFrames.find((entry) => String(entry.id || "").trim() === requestedDefaultFrameId)?.id
|| normalizedFrames[0]?.id
|| "frame_0",
).trim() || "frame_0";
if (explicitRows.length > 0) {
normalizedFrames = normalizedFrames.map((entry) => (
String(entry.id || "").trim() !== resolvedDefaultFrameId
? entry
: normalizeImageFrameRecord({
...entry,
id: resolvedDefaultFrameId,
rows: explicitRows,
width: Number(nextRecord.width) || 1,
height: Number(nextRecord.height) || 1,
pixelScale: Number(nextRecord.pixelScale) || 1,
opacity: Number(nextRecord.opacity ?? 1),
}, nextRecord, normalizedFrames.findIndex((candidate) => String(candidate.id || "").trim() === resolvedDefaultFrameId))
));
}
nextRecord.defaultFrame = resolvedDefaultFrameId;
nextRecord.speed = Number.isFinite(Number(record.speed)) && Number(record.speed) >= 0 ? Number(record.speed) : 0;
nextRecord.playback = normalizeImagePlayback(record.playback);
nextRecord.frames = normalizedFrames as unknown as JsonValue;
nextRecord.tileSymbol = roles.includes("tile")
? (String(nextRecord.tileSymbol ?? nextRecord.symbol ?? "").trim().charAt(0) || String(nextRecord.id || "T").charAt(0) || "T")
: "";
delete nextRecord.rows;
delete nextRecord.symbol;
delete nextRecord.graphicRole;
return nextRecord;
}
export function normalizeImagesPayloadForSave(payload: JsonValue): JsonValue {
if (!isPlainObject(payload)) {
return payload;
}
const records = payload[IMAGES_ROOT];
if (!Array.isArray(records)) {
return payload;
}
return {
...payload,
[IMAGES_ROOT]: records.map((entry) => (isPlainObject(entry) ? normalizeImageRecordForSave(entry) : entry)),
};
}
export function buildTilesPayloadFromImagesPayload(payload: JsonValue): JsonObject {
const normalizedPayload = normalizeImagesPayloadForSave(payload);
const records = isPlainObject(normalizedPayload) && Array.isArray(normalizedPayload.images)
? normalizedPayload.images
: [];
return {
schemaVersion: isPlainObject(normalizedPayload) && typeof normalizedPayload.schemaVersion === "number" ? normalizedPayload.schemaVersion : 1,
tiles: records
.filter((entry): entry is JsonObject => isPlainObject(entry))
.filter((entry) => normalizeImageRoles(entry.roles).includes("tile"))
.map((entry) => normalizeTileRecordForSave({
id: String(entry.id || "").trim(),
symbol: String(entry.tileSymbol || entry.symbol || "").trim().charAt(0),
name: String(entry.name || "").trim(),
description: String(entry.description || "").trim(),
width: Number(entry.width) || 16,
height: Number(entry.height) || 16,
pixelScale: Number(entry.pixelScale) || 1,
opacity: Number(entry.opacity ?? 1),
rows: getSpriteRows(entry),
tags: normalizeStringList(entry.tags),
})),
};
}
export function buildSpritesPayloadFromImagesPayload(payload: JsonValue): JsonObject {
const normalizedPayload = normalizeImagesPayloadForSave(payload);
const records = isPlainObject(normalizedPayload) && Array.isArray(normalizedPayload.images)
? normalizedPayload.images
: [];
return {
schemaVersion: isPlainObject(normalizedPayload) && typeof normalizedPayload.schemaVersion === "number" ? normalizedPayload.schemaVersion : 1,
sprites: records
.filter((entry): entry is JsonObject => isPlainObject(entry))
.filter((entry) => {
const roles = normalizeImageRoles(entry.roles);
return roles.includes("sprite") || roles.length === 0;
})
.map((entry) => {
const roles = normalizeImageRoles(entry.roles);
return normalizeSpriteLikeRecord({
id: String(entry.id || "").trim(),
name: String(entry.name || "").trim(),
description: String(entry.description || "").trim(),
width: Number(entry.width) || 16,
height: Number(entry.height) || 16,
pixelScale: Number(entry.pixelScale) || 1,
opacity: Number(entry.opacity ?? 1),
rows: getSpriteRows(entry),
tags: normalizeStringList(entry.tags),
graphicRole: roles.includes("sprite") ? "sprite" : "other",
}, "sprite");
}),
};
}
export function mergeImagesPayloadWithTilesPayload(imagesPayload: JsonValue, tilesPayload: JsonValue): JsonObject {
const normalizedImagesPayload = normalizeImagesPayloadForSave(imagesPayload);
const normalizedTilesPayload = normalizeTilesPayloadForSave(tilesPayload);
const nextImagesById = new Map<string, JsonObject>();
const nextOrder: string[] = [];
const existingImages = isPlainObject(normalizedImagesPayload) && Array.isArray(normalizedImagesPayload.images)
? normalizedImagesPayload.images
: [];
existingImages.forEach((entry) => {
if (!isPlainObject(entry)) {
return;
}
const normalizedEntry = normalizeImageRecordForSave(entry);
const id = String(normalizedEntry.id || "").trim();
if (!id) {
return;
}
nextImagesById.set(id, normalizedEntry);
nextOrder.push(id);
});
const incomingTiles = isPlainObject(normalizedTilesPayload) && Array.isArray(normalizedTilesPayload.tiles)
? normalizedTilesPayload.tiles
: [];
const seenTileIds = new Set<string>();
incomingTiles.forEach((entry) => {
if (!isPlainObject(entry)) {
return;
}
const normalizedTile = normalizeTileRecordForSave(entry);
const id = String(normalizedTile.id || "").trim();
if (!id) {
return;
}
seenTileIds.add(id);
const existing = nextImagesById.get(id);
const existingRoles = existing ? normalizeImageRoles(existing.roles) : [];
const nextImage = normalizeImageRecordForSave({
...(existing || {}),
id,
name: String(normalizedTile.name || existing?.name || "").trim(),
description: String(normalizedTile.description || existing?.description || "").trim(),
width: Number(normalizedTile.width) || Number(existing?.width) || 16,
height: Number(normalizedTile.height) || Number(existing?.height) || 16,
pixelScale: Number(normalizedTile.pixelScale) || Number(existing?.pixelScale) || 1,
opacity: Number(normalizedTile.opacity ?? existing?.opacity ?? 1),
rows: getSpriteRows(normalizedTile),
tags: normalizeStringList(normalizedTile.tags ?? existing?.tags),
roles: Array.from(new Set([...existingRoles, "tile"])),
tileSymbol: String(normalizedTile.symbol || existing?.tileSymbol || "").trim().charAt(0),
});
if (!nextImagesById.has(id)) {
nextOrder.push(id);
}
nextImagesById.set(id, nextImage);
});
Array.from(nextImagesById.entries()).forEach(([id, entry]) => {
const roles = normalizeImageRoles(entry.roles);
if (!roles.includes("tile") || seenTileIds.has(id)) {
return;
}
const nextRoles = roles.filter((role) => role !== "tile");
if (nextRoles.length === 0) {
nextImagesById.delete(id);
return;
}
nextImagesById.set(id, normalizeImageRecordForSave({
...entry,
roles: nextRoles,
tileSymbol: "",
}));
});
return {
schemaVersion: isPlainObject(normalizedImagesPayload) && typeof normalizedImagesPayload.schemaVersion === "number" ? normalizedImagesPayload.schemaVersion : 1,
images: nextOrder
.map((id) => nextImagesById.get(id))
.filter((entry): entry is JsonObject => isPlainObject(entry)),
};
}
export function mergeImagesPayloadWithSpritesPayload(imagesPayload: JsonValue, spritesPayload: JsonValue): JsonObject {
const normalizedImagesPayload = normalizeImagesPayloadForSave(imagesPayload);
const normalizedSpritesPayload = normalizeSpritePayloadForSave(spritesPayload);
const nextImagesById = new Map<string, JsonObject>();
const nextOrder: string[] = [];
const existingImages = isPlainObject(normalizedImagesPayload) && Array.isArray(normalizedImagesPayload.images)
? normalizedImagesPayload.images
: [];
existingImages.forEach((entry) => {
if (!isPlainObject(entry)) {
return;
}
const normalizedEntry = normalizeImageRecordForSave(entry);
const id = String(normalizedEntry.id || "").trim();
if (!id) {
return;
}
nextImagesById.set(id, normalizedEntry);
nextOrder.push(id);
});
const incomingSprites = isPlainObject(normalizedSpritesPayload) && Array.isArray(normalizedSpritesPayload.sprites)
? normalizedSpritesPayload.sprites
: [];
const seenSpriteIds = new Set<string>();
incomingSprites.forEach((entry) => {
if (!isPlainObject(entry)) {
return;
}
const normalizedSprite = normalizeSpriteLikeRecord(entry, "sprite");
const id = String(normalizedSprite.id || "").trim();
if (!id) {
return;
}
seenSpriteIds.add(id);
const existing = nextImagesById.get(id);
const existingRoles = existing ? normalizeImageRoles(existing.roles) : [];
const wantsSpriteRole = String(normalizedSprite.graphicRole || "sprite").trim().toLowerCase() !== "other";
const nextRoles = wantsSpriteRole
? Array.from(new Set([...existingRoles, "sprite"]))
: existingRoles.filter((role) => role !== "sprite");
const nextImage = normalizeImageRecordForSave({
...(existing || {}),
id,
name: String(normalizedSprite.name || existing?.name || "").trim(),
description: String(normalizedSprite.description || existing?.description || "").trim(),
width: Number(normalizedSprite.width) || Number(existing?.width) || 16,
height: Number(normalizedSprite.height) || Number(existing?.height) || 16,
pixelScale: Number(normalizedSprite.pixelScale) || Number(existing?.pixelScale) || 1,
opacity: Number(normalizedSprite.opacity ?? existing?.opacity ?? 1),
rows: getSpriteRows(normalizedSprite),
tags: normalizeStringList(normalizedSprite.tags ?? existing?.tags),
roles: nextRoles,
tileSymbol: String(existing?.tileSymbol || "").trim().charAt(0),
});
if (!nextImagesById.has(id)) {
nextOrder.push(id);
}
if (nextRoles.length === 0 && !normalizeImageRoles(existing?.roles).includes("tile")) {
nextImagesById.delete(id);
return;
}
nextImagesById.set(id, nextImage);
});
Array.from(nextImagesById.entries()).forEach(([id, entry]) => {
if (seenSpriteIds.has(id)) {
return;
}
const roles = normalizeImageRoles(entry.roles);
if (!roles.includes("sprite")) {
return;
}
const nextRoles = roles.filter((role) => role !== "sprite");
if (nextRoles.length === 0) {
nextImagesById.delete(id);
return;
}
nextImagesById.set(id, normalizeImageRecordForSave({
...entry,
roles: nextRoles,
}));
});
return {
schemaVersion: isPlainObject(normalizedImagesPayload) && typeof normalizedImagesPayload.schemaVersion === "number" ? normalizedImagesPayload.schemaVersion : 1,
images: nextOrder
.map((id) => nextImagesById.get(id))
.filter((entry): entry is JsonObject => isPlainObject(entry)),
};
}

View file

@ -1,6 +0,0 @@
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
export type JsonObject = { [key: string]: JsonValue };
export function isPlainObject(value: unknown): value is JsonObject {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

View file

@ -1,17 +0,0 @@
import {
resolveUnifiedColorSymbol,
setUnifiedColorEntries,
} from "./editorCore";
describe("editorCore", () => {
it("updates the unified color lookup from catalog entries", () => {
setUnifiedColorEntries([
{ key: "A", color: "#123456" },
{ key: "!", color: "#FFFFFF" },
]);
expect(resolveUnifiedColorSymbol("A")).toBe("#123456");
expect(resolveUnifiedColorSymbol(".")).toBe("#00000000");
expect(resolveUnifiedColorSymbol("Z", "#ABCDEF")).toBe("#ABCDEF");
});
});

View file

@ -1,24 +1,6 @@
import { getSpriteRows } from "./graphics/rowEncoding";
import { isPlainObject, type JsonObject, type JsonValue } from "./contracts/json";
export type { JsonObject, JsonValue } from "./contracts/json";
export { isPlainObject } from "./contracts/json";
export { normalizeHexColor, normalizeStringList, parseCsv } from "./shared/normalization";
export { getSpriteRows } from "./graphics/rowEncoding";
export {
buildSpritesPayloadFromImagesPayload,
buildTilesPayloadFromImagesPayload,
mergeImagesPayloadWithSpritesPayload,
mergeImagesPayloadWithTilesPayload,
normalizeImagePlayback,
normalizeImageRecordForSave,
normalizeImagesPayloadForSave,
normalizeSpritePayloadForSave,
normalizeTileRecordForSave,
normalizeTilesPayloadForSave,
} from "./contentTransforms/graphicsPayload";
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
export type JsonObject = { [key: string]: JsonValue };
export type CatalogEntry = {
entryId?: string;
sourceKey?: string;
@ -332,6 +314,10 @@ export function formatTypeLabel(type: string): string {
return TYPE_LABELS[type] || type.replaceAll("_", " ");
}
export function isPlainObject(value: JsonValue | undefined): value is JsonObject {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function buildDefaultRecord(activeType: string, records: JsonObject[]): JsonObject {
if (activeType === "quests") {
const maxQuestId = records.reduce((acc, entry) => {
@ -454,6 +440,455 @@ export function getRecordLabel(record: JsonObject, index: number): string {
return `Record ${index + 1}`;
}
export function normalizeHexColor(value: JsonValue | undefined, fallback = "#7aa2ff"): string {
const raw = String(value || "").trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw)) {
return raw.toLowerCase();
}
return fallback;
}
function getDirectSpriteRows(record: JsonObject): string[] {
const rawRows = record.rows;
if (!Array.isArray(rawRows)) {
return [];
}
return rawRows.map((row) => String(row || ""));
}
function getRawImageFrames(record: JsonObject): JsonObject[] {
if (!Array.isArray(record.frames)) {
return [];
}
return record.frames.filter((entry): entry is JsonObject => isPlainObject(entry));
}
function getSortedImageFrames(record: JsonObject): JsonObject[] {
return getRawImageFrames(record)
.map((entry, index) => ({
entry,
sortIndex: Number.isFinite(Number(entry.index)) ? Number(entry.index) : index,
sourceIndex: index,
}))
.sort((left, right) => (
left.sortIndex !== right.sortIndex
? left.sortIndex - right.sortIndex
: left.sourceIndex - right.sourceIndex
))
.map((entry) => entry.entry);
}
export function getSpriteRows(record: JsonObject): string[] {
const frames = getSortedImageFrames(record);
if (frames.length > 0) {
const defaultFrameId = String(record.defaultFrame || "").trim();
const enabledFrames = frames.filter((entry) => entry.enabled !== false);
const renderFrames = enabledFrames.length > 0 ? enabledFrames : frames;
const resolvedFrame = renderFrames.find((entry) => String(entry.id || "").trim() === defaultFrameId) || renderFrames[0];
return getDirectSpriteRows(resolvedFrame);
}
return getDirectSpriteRows(record);
}
function normalizeImageFrameRecord(
frame: JsonObject,
fallbackRecord: JsonObject,
index: number,
): JsonObject {
const nextFrameId = String(frame.id || "").trim() || `frame_${index}`;
const normalizedFrame = normalizeSpriteLikeRecord({
...frame,
id: nextFrameId,
width: Number(frame.width) || Number(fallbackRecord.width) || 1,
height: Number(frame.height) || Number(fallbackRecord.height) || 1,
pixelScale: Number(frame.pixelScale) || Number(fallbackRecord.pixelScale) || 1,
opacity: Number(frame.opacity ?? fallbackRecord.opacity ?? 1),
rows: Array.isArray(frame.rows) ? frame.rows : getDirectSpriteRows(fallbackRecord),
}, "frame");
return {
...normalizedFrame,
id: nextFrameId,
enabled: frame.enabled !== false,
index: Number.isFinite(Number(frame.index)) ? Math.max(0, Math.floor(Number(frame.index))) : index,
};
}
function normalizeSpriteLikeRecord(record: JsonObject, defaultIdPrefix: string): JsonObject {
const nextRecord: JsonObject = { ...record };
const id = String(nextRecord.id ?? "").trim();
nextRecord.id = id || `${defaultIdPrefix}_${genRandomId()}`;
if (typeof nextRecord.name !== "string") {
nextRecord.name = "";
}
const width = Number(nextRecord.width);
const height = Number(nextRecord.height);
const pixelScale = Number(nextRecord.pixelScale);
const opacity = Number(nextRecord.opacity);
nextRecord.width = Number.isFinite(width) && width > 0 ? Math.floor(width) : 1;
nextRecord.height = Number.isFinite(height) && height > 0 ? Math.floor(height) : 1;
nextRecord.pixelScale = Number.isFinite(pixelScale) && pixelScale > 0 ? Math.floor(pixelScale) : 1;
nextRecord.opacity = Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1;
delete nextRecord.palette;
const rows = getDirectSpriteRows(nextRecord);
const normalizedRows = Array.from({ length: Number(nextRecord.height) || 1 }, (_, rowIndex) => {
const base = rows[rowIndex] || "";
return base.padEnd(Number(nextRecord.width) || 1, ".").slice(0, Number(nextRecord.width) || 1);
});
nextRecord.rows = normalizedRows as unknown as JsonValue;
return nextRecord;
}
export function normalizeSpritePayloadForSave(payload: JsonValue): JsonValue {
if (!isPlainObject(payload)) {
return payload;
}
const root = ROOT_KEY_BY_TYPE.sprites;
const records = payload[root];
if (!Array.isArray(records)) {
return payload;
}
return {
...payload,
[root]: records.map((entry) => (isPlainObject(entry) ? normalizeSpriteLikeRecord(entry, "sprite") : entry)),
};
}
export function normalizeTileRecordForSave(record: JsonObject): JsonObject {
const nextRecord = normalizeSpriteLikeRecord(record, "tile");
const symbol = String(nextRecord.symbol ?? "").trim().charAt(0);
nextRecord.symbol = symbol || String(nextRecord.id || "T").charAt(0) || "T";
if (typeof nextRecord.description !== "string") {
nextRecord.description = "";
}
return nextRecord;
}
export function normalizeTilesPayloadForSave(payload: JsonValue): JsonValue {
if (!isPlainObject(payload)) {
return payload;
}
const root = ROOT_KEY_BY_TYPE.tiles;
const records = payload[root];
if (!Array.isArray(records)) {
return payload;
}
return {
...payload,
[root]: records.map((entry) => (isPlainObject(entry) ? normalizeTileRecordForSave(entry) : entry)),
};
}
function normalizeImageRoles(value: JsonValue | undefined): string[] {
if (!Array.isArray(value)) {
return [];
}
return Array.from(new Set(
value
.map((entry) => String(entry || "").trim().toLowerCase())
.filter((entry) => entry === "tile" || entry === "sprite"),
));
}
export function normalizeImagePlayback(value: JsonValue | undefined): "normal" | "rewind" | "stop" {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "rewind" || normalized === "stop") {
return normalized;
}
return "normal";
}
export function normalizeImageRecordForSave(record: JsonObject): JsonObject {
const nextRecord = normalizeSpriteLikeRecord(record, "image");
const roles = normalizeImageRoles(nextRecord.roles);
const inputFrames = getRawImageFrames(record);
const explicitRows = getDirectSpriteRows(record);
nextRecord.description = typeof nextRecord.description === "string" ? nextRecord.description : "";
nextRecord.tags = normalizeStringList(nextRecord.tags);
nextRecord.roles = roles as unknown as JsonValue;
let normalizedFrames = inputFrames.map((entry, index) => normalizeImageFrameRecord(entry, nextRecord, index));
if (normalizedFrames.length <= 0) {
normalizedFrames = [normalizeImageFrameRecord({
id: "frame_0",
rows: explicitRows.length > 0 ? explicitRows : getDirectSpriteRows(nextRecord),
}, nextRecord, 0)];
}
const requestedDefaultFrameId = String(record.defaultFrame || nextRecord.defaultFrame || "").trim();
const resolvedDefaultFrameId = String(
normalizedFrames.find((entry) => String(entry.id || "").trim() === requestedDefaultFrameId)?.id
|| normalizedFrames[0]?.id
|| "frame_0",
).trim() || "frame_0";
if (explicitRows.length > 0) {
normalizedFrames = normalizedFrames.map((entry) => (
String(entry.id || "").trim() !== resolvedDefaultFrameId
? entry
: normalizeImageFrameRecord({
...entry,
id: resolvedDefaultFrameId,
rows: explicitRows,
width: Number(nextRecord.width) || 1,
height: Number(nextRecord.height) || 1,
pixelScale: Number(nextRecord.pixelScale) || 1,
opacity: Number(nextRecord.opacity ?? 1),
}, nextRecord, normalizedFrames.findIndex((candidate) => String(candidate.id || "").trim() === resolvedDefaultFrameId))
));
}
nextRecord.defaultFrame = resolvedDefaultFrameId;
nextRecord.speed = Number.isFinite(Number(record.speed)) && Number(record.speed) >= 0 ? Number(record.speed) : 0;
nextRecord.playback = normalizeImagePlayback(record.playback);
nextRecord.frames = normalizedFrames as unknown as JsonValue;
nextRecord.tileSymbol = roles.includes("tile")
? (String(nextRecord.tileSymbol ?? nextRecord.symbol ?? "").trim().charAt(0) || String(nextRecord.id || "T").charAt(0) || "T")
: "";
delete nextRecord.rows;
delete nextRecord.symbol;
delete nextRecord.graphicRole;
return nextRecord;
}
export function normalizeImagesPayloadForSave(payload: JsonValue): JsonValue {
if (!isPlainObject(payload)) {
return payload;
}
const records = payload.images;
if (!Array.isArray(records)) {
return payload;
}
return {
...payload,
images: records.map((entry) => (isPlainObject(entry) ? normalizeImageRecordForSave(entry) : entry)),
};
}
export function buildTilesPayloadFromImagesPayload(payload: JsonValue): JsonObject {
const normalizedPayload = normalizeImagesPayloadForSave(payload);
const records = isPlainObject(normalizedPayload) && Array.isArray(normalizedPayload.images)
? normalizedPayload.images
: [];
return {
schemaVersion: isPlainObject(normalizedPayload) && typeof normalizedPayload.schemaVersion === "number" ? normalizedPayload.schemaVersion : 1,
tiles: records
.filter((entry): entry is JsonObject => isPlainObject(entry))
.filter((entry) => normalizeImageRoles(entry.roles).includes("tile"))
.map((entry) => normalizeTileRecordForSave({
id: String(entry.id || "").trim(),
symbol: String(entry.tileSymbol || entry.symbol || "").trim().charAt(0),
name: String(entry.name || "").trim(),
description: String(entry.description || "").trim(),
width: Number(entry.width) || 16,
height: Number(entry.height) || 16,
pixelScale: Number(entry.pixelScale) || 1,
opacity: Number(entry.opacity ?? 1),
rows: getSpriteRows(entry),
tags: normalizeStringList(entry.tags),
})),
};
}
export function buildSpritesPayloadFromImagesPayload(payload: JsonValue): JsonObject {
const normalizedPayload = normalizeImagesPayloadForSave(payload);
const records = isPlainObject(normalizedPayload) && Array.isArray(normalizedPayload.images)
? normalizedPayload.images
: [];
return {
schemaVersion: isPlainObject(normalizedPayload) && typeof normalizedPayload.schemaVersion === "number" ? normalizedPayload.schemaVersion : 1,
sprites: records
.filter((entry): entry is JsonObject => isPlainObject(entry))
.filter((entry) => {
const roles = normalizeImageRoles(entry.roles);
return roles.includes("sprite") || roles.length === 0;
})
.map((entry) => {
const roles = normalizeImageRoles(entry.roles);
return normalizeSpriteLikeRecord({
id: String(entry.id || "").trim(),
name: String(entry.name || "").trim(),
description: String(entry.description || "").trim(),
width: Number(entry.width) || 16,
height: Number(entry.height) || 16,
pixelScale: Number(entry.pixelScale) || 1,
opacity: Number(entry.opacity ?? 1),
rows: getSpriteRows(entry),
tags: normalizeStringList(entry.tags),
graphicRole: roles.includes("sprite") ? "sprite" : "other",
}, "sprite");
}),
};
}
export function mergeImagesPayloadWithTilesPayload(imagesPayload: JsonValue, tilesPayload: JsonValue): JsonObject {
const normalizedImagesPayload = normalizeImagesPayloadForSave(imagesPayload);
const normalizedTilesPayload = normalizeTilesPayloadForSave(tilesPayload);
const nextImagesById = new Map<string, JsonObject>();
const nextOrder: string[] = [];
const existingImages = isPlainObject(normalizedImagesPayload) && Array.isArray(normalizedImagesPayload.images)
? normalizedImagesPayload.images
: [];
existingImages.forEach((entry) => {
if (!isPlainObject(entry)) {
return;
}
const normalizedEntry = normalizeImageRecordForSave(entry);
const id = String(normalizedEntry.id || "").trim();
if (!id) {
return;
}
nextImagesById.set(id, normalizedEntry);
nextOrder.push(id);
});
const incomingTiles = isPlainObject(normalizedTilesPayload) && Array.isArray(normalizedTilesPayload.tiles)
? normalizedTilesPayload.tiles
: [];
const seenTileIds = new Set<string>();
incomingTiles.forEach((entry) => {
if (!isPlainObject(entry)) {
return;
}
const normalizedTile = normalizeTileRecordForSave(entry);
const id = String(normalizedTile.id || "").trim();
if (!id) {
return;
}
seenTileIds.add(id);
const existing = nextImagesById.get(id);
const existingRoles = existing ? normalizeImageRoles(existing.roles) : [];
const nextImage = normalizeImageRecordForSave({
...(existing || {}),
id,
name: String(normalizedTile.name || existing?.name || "").trim(),
description: String(normalizedTile.description || existing?.description || "").trim(),
width: Number(normalizedTile.width) || Number(existing?.width) || 16,
height: Number(normalizedTile.height) || Number(existing?.height) || 16,
pixelScale: Number(normalizedTile.pixelScale) || Number(existing?.pixelScale) || 1,
opacity: Number(normalizedTile.opacity ?? existing?.opacity ?? 1),
rows: getSpriteRows(normalizedTile),
tags: normalizeStringList(normalizedTile.tags ?? existing?.tags),
roles: Array.from(new Set([...existingRoles, "tile"])),
tileSymbol: String(normalizedTile.symbol || existing?.tileSymbol || "").trim().charAt(0),
});
if (!nextImagesById.has(id)) {
nextOrder.push(id);
}
nextImagesById.set(id, nextImage);
});
Array.from(nextImagesById.entries()).forEach(([id, entry]) => {
const roles = normalizeImageRoles(entry.roles);
if (!roles.includes("tile") || seenTileIds.has(id)) {
return;
}
const nextRoles = roles.filter((role) => role !== "tile");
if (nextRoles.length === 0) {
nextImagesById.delete(id);
return;
}
nextImagesById.set(id, normalizeImageRecordForSave({
...entry,
roles: nextRoles,
tileSymbol: "",
}));
});
return {
schemaVersion: isPlainObject(normalizedImagesPayload) && typeof normalizedImagesPayload.schemaVersion === "number" ? normalizedImagesPayload.schemaVersion : 1,
images: nextOrder
.map((id) => nextImagesById.get(id))
.filter((entry): entry is JsonObject => isPlainObject(entry)),
};
}
export function mergeImagesPayloadWithSpritesPayload(imagesPayload: JsonValue, spritesPayload: JsonValue): JsonObject {
const normalizedImagesPayload = normalizeImagesPayloadForSave(imagesPayload);
const normalizedSpritesPayload = normalizeSpritePayloadForSave(spritesPayload);
const nextImagesById = new Map<string, JsonObject>();
const nextOrder: string[] = [];
const existingImages = isPlainObject(normalizedImagesPayload) && Array.isArray(normalizedImagesPayload.images)
? normalizedImagesPayload.images
: [];
existingImages.forEach((entry) => {
if (!isPlainObject(entry)) {
return;
}
const normalizedEntry = normalizeImageRecordForSave(entry);
const id = String(normalizedEntry.id || "").trim();
if (!id) {
return;
}
nextImagesById.set(id, normalizedEntry);
nextOrder.push(id);
});
const incomingSprites = isPlainObject(normalizedSpritesPayload) && Array.isArray(normalizedSpritesPayload.sprites)
? normalizedSpritesPayload.sprites
: [];
const seenSpriteIds = new Set<string>();
incomingSprites.forEach((entry) => {
if (!isPlainObject(entry)) {
return;
}
const normalizedSprite = normalizeSpriteLikeRecord(entry, "sprite");
const id = String(normalizedSprite.id || "").trim();
if (!id) {
return;
}
seenSpriteIds.add(id);
const existing = nextImagesById.get(id);
const existingRoles = existing ? normalizeImageRoles(existing.roles) : [];
const wantsSpriteRole = String(normalizedSprite.graphicRole || "sprite").trim().toLowerCase() !== "other";
const nextRoles = wantsSpriteRole
? Array.from(new Set([...existingRoles, "sprite"]))
: existingRoles.filter((role) => role !== "sprite");
const nextImage = normalizeImageRecordForSave({
...(existing || {}),
id,
name: String(normalizedSprite.name || existing?.name || "").trim(),
description: String(normalizedSprite.description || existing?.description || "").trim(),
width: Number(normalizedSprite.width) || Number(existing?.width) || 16,
height: Number(normalizedSprite.height) || Number(existing?.height) || 16,
pixelScale: Number(normalizedSprite.pixelScale) || Number(existing?.pixelScale) || 1,
opacity: Number(normalizedSprite.opacity ?? existing?.opacity ?? 1),
rows: getSpriteRows(normalizedSprite),
tags: normalizeStringList(normalizedSprite.tags ?? existing?.tags),
roles: nextRoles,
tileSymbol: String(existing?.tileSymbol || "").trim().charAt(0),
});
if (!nextImagesById.has(id)) {
nextOrder.push(id);
}
if (nextRoles.length === 0 && !normalizeImageRoles(existing?.roles).includes("tile")) {
nextImagesById.delete(id);
return;
}
nextImagesById.set(id, nextImage);
});
Array.from(nextImagesById.entries()).forEach(([id, entry]) => {
if (seenSpriteIds.has(id)) {
return;
}
const roles = normalizeImageRoles(entry.roles);
if (!roles.includes("sprite")) {
return;
}
const nextRoles = roles.filter((role) => role !== "sprite");
if (nextRoles.length === 0) {
nextImagesById.delete(id);
return;
}
nextImagesById.set(id, normalizeImageRecordForSave({
...entry,
roles: nextRoles,
}));
});
return {
schemaVersion: isPlainObject(normalizedImagesPayload) && typeof normalizedImagesPayload.schemaVersion === "number" ? normalizedImagesPayload.schemaVersion : 1,
images: nextOrder
.map((id) => nextImagesById.get(id))
.filter((entry): entry is JsonObject => isPlainObject(entry)),
};
}
export function getSpritePalette(record?: JsonObject): Record<string, string> {
void record;
const palette: Record<string, string> = {
@ -586,6 +1021,20 @@ export function toFieldLabel(rawKey: string): string {
return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1);
}
export function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return Array.from(new Set(value.map((entry) => String(entry || "").trim()).filter(Boolean)));
}
export function parseCsv(value: string): string[] {
return String(value || "")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}
export function getCatalogEntryIdValue(entry: CatalogEntry | null | undefined, fallback = ""): string {
return String(entry?.key || entry?.sourceKey || entry?.originalName || fallback).trim();
}

View file

@ -1,52 +0,0 @@
import { isPlainObject, type JsonObject } from "../contracts/json";
export function getDirectSpriteRows(record: JsonObject): string[] {
const rawRows = record.rows;
if (!Array.isArray(rawRows)) {
return [];
}
return rawRows.map((row) => String(row || ""));
}
export function normalizeRowsToSize(rows: string[], width: number, height: number, fillChar = "."): string[] {
const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
const safeHeight = Math.max(1, Math.floor(Number(height) || 1));
return Array.from({ length: safeHeight }, (_, rowIndex) => {
const base = String(rows[rowIndex] || "");
return base.padEnd(safeWidth, fillChar).slice(0, safeWidth);
});
}
export function getRawImageFrames(record: JsonObject): JsonObject[] {
if (!Array.isArray(record.frames)) {
return [];
}
return record.frames.filter((entry): entry is JsonObject => isPlainObject(entry));
}
export function getSortedImageFrames(record: JsonObject): JsonObject[] {
return getRawImageFrames(record)
.map((entry, index) => ({
entry,
sortIndex: Number.isFinite(Number(entry.index)) ? Number(entry.index) : index,
sourceIndex: index,
}))
.sort((left, right) => (
left.sortIndex !== right.sortIndex
? left.sortIndex - right.sortIndex
: left.sourceIndex - right.sourceIndex
))
.map((entry) => entry.entry);
}
export function getSpriteRows(record: JsonObject): string[] {
const frames = getSortedImageFrames(record);
if (frames.length > 0) {
const defaultFrameId = String(record.defaultFrame || "").trim();
const enabledFrames = frames.filter((entry) => entry.enabled !== false);
const renderFrames = enabledFrames.length > 0 ? enabledFrames : frames;
const resolvedFrame = renderFrames.find((entry) => String(entry.id || "").trim() === defaultFrameId) || renderFrames[0];
return getDirectSpriteRows(resolvedFrame);
}
return getDirectSpriteRows(record);
}

View file

@ -1,21 +0,0 @@
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

@ -1,50 +0,0 @@
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

@ -1,79 +0,0 @@
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,
});
});
});

View file

@ -1,23 +0,0 @@
import type { JsonValue } from "../contracts/json";
export function normalizeHexColor(value: JsonValue | undefined, fallback = "#7aa2ff"): string {
const raw = String(value || "").trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw)) {
return raw.toLowerCase();
}
return fallback;
}
export function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return Array.from(new Set(value.map((entry) => String(entry || "").trim()).filter(Boolean)));
}
export function parseCsv(value: string): string[] {
return String(value || "")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
}

View file

@ -1 +0,0 @@
import "@testing-library/jest-dom/vitest";

View file

@ -1,77 +0,0 @@
import {
DEFAULT_WORLD_CHUNK_SIZE,
buildChunkFileName,
buildChunkKey,
createEmptyChunk,
localToWorldCoord,
normalizeChunkDimension,
resolveWorldChunkAddress,
worldToChunkCoord,
worldToLocalCoord,
} from "./worldChunking";
describe("worldChunking", () => {
it("normalizes chunk dimensions with flooring and fallback behavior", () => {
expect(normalizeChunkDimension(12.9)).toBe(12);
expect(normalizeChunkDimension(0, 24)).toBe(24);
expect(normalizeChunkDimension(-5, 24)).toBe(1);
expect(normalizeChunkDimension("bad", 18)).toBe(18);
});
it("converts world coordinates into chunk and local coordinates across the origin", () => {
expect(worldToChunkCoord(0, 32)).toBe(0);
expect(worldToChunkCoord(31, 32)).toBe(0);
expect(worldToChunkCoord(32, 32)).toBe(1);
expect(worldToChunkCoord(-1, 32)).toBe(-1);
expect(worldToChunkCoord(-33, 32)).toBe(-2);
expect(worldToLocalCoord(31, 32)).toBe(31);
expect(worldToLocalCoord(32, 32)).toBe(0);
expect(worldToLocalCoord(-1, 32)).toBe(31);
expect(worldToLocalCoord(-33, 32)).toBe(31);
expect(localToWorldCoord(-2, 31, 32)).toBe(-33);
});
it("resolves stable chunk addressing metadata", () => {
expect(buildChunkKey(-3, 4)).toBe("-3:4");
expect(buildChunkFileName(-3, 4)).toBe("-3_4.json");
expect(resolveWorldChunkAddress(-33, 64, 32, 16)).toEqual({
chunkX: -2,
chunkY: 4,
localX: 31,
localY: 0,
chunkKey: "-2:4",
fileName: "-2_4.json",
});
});
it("creates empty chunks with the current compatibility layer defaults", () => {
const chunk = createEmptyChunk("overworld", 2, -1, "grass", 4, 3);
expect(chunk).toEqual({
schemaVersion: 1,
worldId: "overworld",
chunkX: 2,
chunkY: -1,
width: 4,
height: 3,
backgroundTileId: "grass",
roomLayers: [
{
layer: 0,
rows: ["....", "....", "...."],
instanceIds: [],
},
{
layer: 1,
rows: [" ", " ", " "],
instanceIds: [],
},
],
heightLayers: [],
instances: [],
});
expect(createEmptyChunk("world", 0, 0).width).toBe(DEFAULT_WORLD_CHUNK_SIZE);
});
});

View file

@ -1,4 +1,4 @@
import type { JsonObject } from "./contracts/json";
import type { JsonObject } from "./editorCore";
export const WORLD_INDEX_SCHEMA_VERSION = 1;
export const WORLD_SCHEMA_VERSION = 1;

View file

@ -1,11 +1,12 @@
import {
buildSpritesPayloadFromImagesPayload,
buildTilesPayloadFromImagesPayload,
buildDefaultRecord,
buildSpritePreviewDataUrl,
fetchJsonOrThrow,
normalizeNpcRecordForLoad,
type JsonObject,
} from "../editorCore";
import type { JsonObject } from "../contracts/json";
import { buildSpritesPayloadFromImagesPayload, buildTilesPayloadFromImagesPayload } from "../contentTransforms/graphicsPayload";
import type {
HeightLayerPatchPayload,
NpcOverlay,

View file

@ -1,121 +0,0 @@
import {
buildImageRecordFromSpriteRecord,
buildImageRecordFromTileRecord,
buildTileRecordFromImageRecord,
getImageRecordFromPayload,
normalizeGraphicRoles,
normalizeImagesPayloadSnapshot,
} from "./graphicsDocumentHelpers";
import type { JsonObject } from "../editorCore";
describe("graphicsDocumentHelpers", () => {
it("keeps only supported graphic roles and de-duplicates them", () => {
expect(normalizeGraphicRoles(["tile", "sprite", "tile", "other", "", null])).toEqual(["tile", "sprite"]);
expect(normalizeGraphicRoles("tile")).toEqual([]);
});
it("builds tile-backed image records without inventing new storage rules", () => {
const existingRecord: JsonObject = {
id: "tile_grass",
roles: ["sprite"],
tileSymbol: "g",
tags: ["existing"],
};
const result = buildImageRecordFromTileRecord({
id: "tile_grass",
name: "Grass",
symbol: "#",
rows: ["AB", "C"],
width: 2,
height: 2,
pixelScale: 3,
opacity: 2,
tags: ["terrain", "terrain"],
}, existingRecord);
expect(result.roles).toEqual(["sprite", "tile"]);
expect(result.tileSymbol).toBe("#");
expect(result.rows).toBeUndefined();
expect(result.defaultFrame).toBe("frame_0");
expect(result.frames).toEqual([
expect.objectContaining({
id: "frame_0",
rows: ["AB", "C."],
width: 2,
height: 2,
pixelScale: 3,
opacity: 1,
}),
]);
expect(result.tags).toEqual(["terrain"]);
});
it("updates sprite roles while preserving compatibility fields from existing images", () => {
const existingRecord: JsonObject = {
id: "hero",
roles: ["tile", "sprite"],
tileSymbol: "@",
};
const result = buildImageRecordFromSpriteRecord({
id: "hero",
rows: ["X"],
width: 1,
height: 1,
}, "other", existingRecord);
expect(result.roles).toEqual(["tile"]);
expect(result.tileSymbol).toBe("@");
});
it("hydrates image rows from the resolved default frame", () => {
const payload: JsonObject = {
schemaVersion: 1,
images: [
{
id: "tile_grass",
roles: ["tile"],
tileSymbol: "G",
width: 2,
height: 2,
defaultFrame: "alt",
frames: [
{ id: "base", rows: ["AA", "AA"], enabled: true },
{ id: "alt", rows: ["BB", "BB"], enabled: true },
],
},
],
};
const snapshot = normalizeImagesPayloadSnapshot(payload);
const imageRecord = getImageRecordFromPayload(snapshot, "tile_grass");
expect(imageRecord).toEqual(expect.objectContaining({
id: "tile_grass",
rows: ["BB", "BB"],
}));
});
it("projects tile records from image records through the current compatibility adapter", () => {
const tileRecord = buildTileRecordFromImageRecord({
id: "tile_grass",
tileSymbol: "G",
name: "Grass",
description: "Ground",
width: 2,
height: 2,
frames: [
{ id: "frame_0", rows: ["AB", "CD"] },
],
});
expect(tileRecord).toEqual(expect.objectContaining({
id: "tile_grass",
symbol: "G",
rows: ["AB", "CD"],
width: 2,
height: 2,
}));
});
});

View file

@ -1,10 +1,11 @@
import {
getSpriteRows,
normalizeImageRecordForSave,
normalizeImagesPayloadForSave,
normalizeTileRecordForSave,
} from "../contentTransforms/graphicsPayload";
import { getSpriteRows } from "../graphics/rowEncoding";
import type { JsonObject, JsonValue } from "../contracts/json";
type JsonObject,
type JsonValue,
} from "../editorCore";
export type GraphicRole = "tile" | "sprite" | "other";

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { mergeImagesPayloadWithSpritesPayload, mergeImagesPayloadWithTilesPayload } from "../contentTransforms/graphicsPayload";
import { mergeImagesPayloadWithSpritesPayload, mergeImagesPayloadWithTilesPayload } from "../editorCore";
const TILE_SYMBOL_POOL = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!$%&()*+,-/:;<=>?@[]^_{|}~=";

View file

@ -6,7 +6,7 @@ import {
buildTilesPayloadFromImagesPayload,
mergeImagesPayloadWithSpritesPayload,
mergeImagesPayloadWithTilesPayload,
} from "../contentTransforms/graphicsPayload";
} from "../editorCore";
import { resizeRows } from "../components/worldshaperShared";
import { moveItemRelative } from "./reorderableListController";

View file

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { resolveUnifiedColorSymbol, getSpritePalette } from "../editorCore";
import { getSpriteRows } from "../graphics/rowEncoding";
import { getSpritePalette, getSpriteRows, resolveUnifiedColorSymbol } from "../editorCore";
export function parseHexColor(value, fallback = 0x060A14) {
const raw = String(value || "").trim();

View file

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { getSpriteRows } from "../graphics/rowEncoding";
import { getSpriteRows } from "../editorCore";
import { Application, Container, Sprite, Texture } from "pixi.js";
import {
applyPixelArtTexture,

View file

@ -2,16 +2,14 @@
// @ts-nocheck
import {
buildSpritePreviewDataUrl,
fetchJsonOrThrow,
} from "../editorCore";
import {
buildSpritesPayloadFromImagesPayload,
buildTilesPayloadFromImagesPayload,
fetchJsonOrThrow,
mergeImagesPayloadWithSpritesPayload,
mergeImagesPayloadWithTilesPayload,
normalizeImageRecordForSave,
normalizeTileRecordForSave,
} from "../contentTransforms/graphicsPayload";
} from "../editorCore";
import {
buildSpriteCatalog,
buildTileCatalogById,

View file

@ -3,14 +3,12 @@
import {
buildSpritePreviewDataUrl,
getSpritePalette,
} from "../editorCore";
import {
buildSpritesPayloadFromImagesPayload,
buildTilesPayloadFromImagesPayload,
normalizeImagePlayback,
normalizeImageRecordForSave,
} from "../contentTransforms/graphicsPayload";
getSpritePalette,
} from "../editorCore";
import {
normalizeEditorTagValue,
normalizeEditorTags,

View file

@ -4,7 +4,7 @@
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client", "vitest/globals"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */

View file

@ -28,7 +28,6 @@ export default defineConfig({
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
worldshaperContent: resolve(__dirname, "worldshaper-content.html"),
futureSharedContract: resolve(__dirname, "Future - Shared Contract.html"),
worldshaperStudio: resolve(__dirname, "worldshaper-studio.html"),
worldshaperHeightViewer: resolve(__dirname, "worldshaper-height-viewer.html"),

View file

@ -1,10 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
globals: true,
setupFiles: "./src/test/setup.ts",
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
},
});

View file

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Worldshaper Content Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/contentMain.tsx"></script>
</body>
</html>