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

Compare commits

..

5 commits

Author SHA1 Message Date
super-jalawii
656f4562c1 restore content editor launcher entrypoint 2026-06-27 11:10:27 -04:00
super-jalawii
e79bc2e339 extract server transform and validation helpers 2026-06-27 11:10:23 -04:00
super-jalawii
aea2c9d56b document current world and chunk semantics 2026-06-27 10:48:00 -04:00
super-jalawii
5a9723440c extract graphics transform and row encoding modules 2026-06-27 10:47:55 -04:00
super-jalawii
9f538b1543 add vitest and regression tests for arc 1 2026-06-27 10:31:09 -04:00
43 changed files with 3427 additions and 909 deletions

View file

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

@ -0,0 +1,186 @@
# 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?

1213
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,11 +12,12 @@
"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"
},
"dependencies": {
"compression": "^1.8.1",
"express": "^4.19.2",
"pixi.js": "^8.19.0",
"react": "^19.2.6",
@ -24,6 +25,9 @@
},
"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",
@ -32,8 +36,10 @@
"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"
"vite": "^8.0.12",
"vitest": "^4.1.9"
}
}

224
server.js
View file

@ -1,9 +1,25 @@
import express from "express";
import compression from "compression";
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);
@ -962,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}`;
@ -1000,25 +995,8 @@ function createDefaultColorCatalogEntries() {
});
}
app.use(compression({ threshold: 1024 }));
app.use(express.json({ limit: "10mb" }));
app.use(express.static(path.join(__dirname, "dist"), {
etag: true,
setHeaders(res, filePath) {
const normalizedPath = String(filePath || "").replace(/\\/g, "/");
if (normalizedPath.includes("/dist/assets/")) {
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
return;
}
if (/\.(png|svg|jpg|jpeg|webp|gif|ico)$/i.test(normalizedPath)) {
res.setHeader("Cache-Control", "public, max-age=604800");
return;
}
if (/\.html$/i.test(normalizedPath)) {
res.setHeader("Cache-Control", "no-cache");
}
},
}));
app.use(express.static(path.join(__dirname, "dist")));
function resolveContent(type) {
const entry = contentMap[type];
@ -1048,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 = "") {
@ -1173,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);
@ -1343,33 +1255,6 @@ function listWorldChunkFiles(worldId) {
.sort((a, b) => a.localeCompare(b));
}
function buildWorldChunkOverviewSurfaceRows(chunkPayload, fallbackWidth, fallbackHeight) {
const width = Math.max(1, Number(chunkPayload?.width) || Number(fallbackWidth) || DEFAULT_WORLD_CHUNK_SIZE);
const height = Math.max(1, Number(chunkPayload?.height) || Number(fallbackHeight) || DEFAULT_WORLD_CHUNK_SIZE);
const sortedRoomLayers = Array.isArray(chunkPayload?.roomLayers)
? chunkPayload.roomLayers
.slice()
.sort((left, right) => (Number(left?.layer) || 0) - (Number(right?.layer) || 0))
: [];
return Array.from({ length: height }, (_entry, rowIndex) => {
let row = "";
for (let columnIndex = 0; columnIndex < width; columnIndex += 1) {
let resolvedSymbol = ".";
sortedRoomLayers.forEach((layer) => {
const layerNumber = Number(layer?.layer) || 0;
const fillChar = layerNumber === 0 ? "." : " ";
const sourceRow = Array.isArray(layer?.rows) ? String(layer.rows[rowIndex] || "") : "";
const symbol = String(sourceRow.charAt(columnIndex) || fillChar).charAt(0) || fillChar;
if (symbol !== "." && symbol !== " ") {
resolvedSymbol = symbol;
}
});
row += resolvedSymbol;
}
return row;
});
}
function countSymbolOccurrencesInRows(rows, targetSymbol) {
const normalizedTarget = String(targetSymbol || "").charAt(0);
if (!normalizedTarget) {
@ -2209,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) {
@ -3550,19 +3387,6 @@ app.get("/api/world/:worldId/overview", (req, res) => {
const chunks = chunkCoords
.map((coord) => readWorldChunkPayload(worldId, coord.chunkX, coord.chunkY, { createIfMissing: false }))
.filter(Boolean);
const overviewChunks = chunks.map((chunk) => {
const width = Math.max(1, Number(chunk.width) || Number(worldDefinition.chunkWidth) || DEFAULT_WORLD_CHUNK_SIZE);
const height = Math.max(1, Number(chunk.height) || Number(worldDefinition.chunkHeight) || DEFAULT_WORLD_CHUNK_SIZE);
return {
chunkX: Math.floor(Number(chunk.chunkX) || 0),
chunkY: Math.floor(Number(chunk.chunkY) || 0),
width,
height,
backgroundTileId: String(chunk.backgroundTileId || "").trim(),
surfaceRows: buildWorldChunkOverviewSurfaceRows(chunk, width, height),
instanceCount: Array.isArray(chunk.instances) ? chunk.instances.length : 0,
};
});
const chunkWidth = Math.max(1, Number(worldDefinition.chunkWidth) || DEFAULT_WORLD_CHUNK_SIZE);
const chunkHeight = Math.max(1, Number(worldDefinition.chunkHeight) || DEFAULT_WORLD_CHUNK_SIZE);
const minChunkX = chunks.length > 0 ? Math.min(...chunks.map((chunk) => Math.floor(Number(chunk.chunkX) || 0))) : 0;
@ -3582,8 +3406,8 @@ app.get("/api/world/:worldId/overview", (req, res) => {
maxTileX: ((maxChunkX + 1) * chunkWidth) - 1,
maxTileY: ((maxChunkY + 1) * chunkHeight) - 1,
},
chunkCount: overviewChunks.length,
chunks: overviewChunks,
chunkCount: chunks.length,
chunks,
});
} catch (err) {
res.status(500).json({

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

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

View file

@ -0,0 +1,27 @@
import path from "path";
export function normalizeBackgroundTileId(value, idToSymbol = null) {
const normalizedId = String(value || "").trim();
if (!normalizedId) {
return "";
}
if (idToSymbol instanceof Map && idToSymbol.size > 0 && !idToSymbol.has(normalizedId)) {
return "";
}
return normalizedId;
}
export function areRowsOnlyFillChar(rows, fillChar = ".") {
if (!Array.isArray(rows) || rows.length === 0) {
return true;
}
return rows.every((row) => {
const normalizedRow = String(row || "");
return normalizedRow.length === 0 || normalizedRow.split("").every((ch) => ch === fillChar);
});
}
export function resolveContentPath(contentRoot, relativePath) {
const normalized = String(relativePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
return path.resolve(contentRoot, normalized);
}

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

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

55
server/validation.js Normal file
View file

@ -0,0 +1,55 @@
export function validatePayload(payload, type, rootKey, requiredIdKeyByType) {
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
return "Payload must be an object";
}
if (typeof payload.schemaVersion !== "number") {
return "schemaVersion must be a number";
}
const allowedTopLevel = new Set(["schemaVersion", rootKey]);
const unknownTopLevel = Object.keys(payload).filter((key) => !allowedTopLevel.has(key));
if (unknownTopLevel.length > 0) {
return `Unsupported top-level keys for ${type}: ${unknownTopLevel.join(", ")}`;
}
if (!Array.isArray(payload[rootKey])) {
return `Missing array root: ${rootKey}`;
}
const idKey = requiredIdKeyByType[type];
if (!idKey) {
return null;
}
const list = payload[rootKey];
for (let index = 0; index < list.length; index += 1) {
const entry = list[index];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return `${rootKey}[${index}] must be an object`;
}
const idValue = String(entry[idKey] ?? "").trim();
if (!idValue) {
return `${rootKey}[${index}] is missing required key: ${idKey}`;
}
}
return null;
}
export function validateCatalogMetaPayload(payload, frozenCatalogKeys) {
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
return "Catalog payload must be an object";
}
if (typeof payload.schemaVersion !== "number") {
return "schemaVersion must be a number";
}
const allowedTopLevel = new Set(["schemaVersion", ...frozenCatalogKeys]);
const unknownTopLevel = Object.keys(payload).filter((key) => !allowedTopLevel.has(key));
if (unknownTopLevel.length > 0) {
return `Unsupported catalog keys: ${unknownTopLevel.join(", ")}`;
}
for (const key of frozenCatalogKeys) {
if (!Array.isArray(payload[key])) {
return `${key} must be an array`;
}
}
return null;
}

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

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

71
server/worldTransforms.js Normal file
View file

@ -0,0 +1,71 @@
import path from "path";
import { resolveContentPath } from "./contentTransforms.js";
export function sanitizeWorldId(worldId) {
const raw = String(worldId || "").trim();
if (!raw) {
return "world";
}
return raw.replace(/[^a-zA-Z0-9_-]/g, "_");
}
export function defaultWorldDirRel(worldId) {
return `worlds/${sanitizeWorldId(worldId)}`;
}
export function buildWorldChunkFileName(chunkX, chunkY) {
return `${Math.floor(Number(chunkX) || 0)}_${Math.floor(Number(chunkY) || 0)}.json`;
}
export function getWorldStoragePaths(contentRoot, worldEntryOrId) {
const worldId = typeof worldEntryOrId === "string"
? String(worldEntryOrId || "").trim()
: String(worldEntryOrId?.id || "").trim();
const worldDirRel = typeof worldEntryOrId === "string"
? defaultWorldDirRel(worldId)
: String(worldEntryOrId?.worldDir || defaultWorldDirRel(worldId));
const worldDirAbs = resolveContentPath(contentRoot, worldDirRel);
const chunksDirRel = `${worldDirRel}/chunks`;
return {
worldId,
worldDirRel,
worldDirAbs,
worldJsonRel: `${worldDirRel}/world.json`,
worldJsonAbs: path.join(worldDirAbs, "world.json"),
bookmarksRel: `${worldDirRel}/bookmarks.json`,
bookmarksAbs: path.join(worldDirAbs, "bookmarks.json"),
chunksDirRel,
chunksDirAbs: path.join(worldDirAbs, "chunks"),
};
}
export function normalizeWorldIndexEntry(entry) {
const id = sanitizeWorldId(entry?.id || "");
return {
id,
name: String(entry?.name || id || "World"),
worldDir: String(entry?.worldDir || defaultWorldDirRel(id)),
};
}
export function normalizeWorldIndexPayload(payload) {
const worlds = Array.isArray(payload?.worlds)
? payload.worlds
.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
.map((entry) => normalizeWorldIndexEntry(entry))
: [];
return {
schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1,
worlds,
};
}
export function normalizeWorldBookmark(entry, index = 0) {
const fallbackId = `bookmark_${index + 1}`;
return {
id: String(entry?.id || fallbackId).trim() || fallbackId,
label: String(entry?.label || entry?.id || fallbackId).trim() || fallbackId,
x: Math.floor(Number(entry?.x) || 0),
y: Math.floor(Number(entry?.y) || 0),
};
}

View file

@ -683,6 +683,16 @@ 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");
@ -1364,6 +1374,9 @@ 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

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

11
src/contentMain.tsx Normal file
View file

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

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

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

6
src/contracts/json.ts Normal file
View file

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

17
src/editorCore.test.ts Normal file
View file

@ -0,0 +1,17 @@
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,6 +1,24 @@
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
export type JsonObject = { [key: string]: JsonValue };
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 CatalogEntry = {
entryId?: string;
sourceKey?: string;
@ -314,10 +332,6 @@ 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) => {
@ -440,455 +454,6 @@ 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> = {
@ -1021,20 +586,6 @@ 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

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

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

View file

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

View file

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

View file

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

1
src/test/setup.ts Normal file
View file

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

77
src/worldChunking.test.ts Normal file
View file

@ -0,0 +1,77 @@
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 "./editorCore";
import type { JsonObject } from "./contracts/json";
export const WORLD_INDEX_SCHEMA_VERSION = 1;
export const WORLD_SCHEMA_VERSION = 1;

View file

@ -1,12 +1,11 @@
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

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

View file

@ -1,14 +1,24 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/ban-ts-comment, no-empty */
// @ts-nocheck
export function createHistoryController(scope) {
const documentScope = scope.documentScope || scope;
const renderScope = scope.renderScope || scope;
const historyScope = scope.historyScope || scope;
const uiScope = scope.uiScope || scope;
const sessionScope = scope.sessionScope || scope;
const MAX_HISTORY_ENTRIES = 2;
const MAX_HISTORY_ENTRIES = 40;
const MAX_PERSISTED_HISTORY_CHARS = 1_500_000;
const OPERATION_CHECKPOINT_INTERVAL = 12;
let pendingPersistTimer = 0;
function cloneValue(value) {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function clearPendingPersistTimer() {
if (!pendingPersistTimer) {
return;
@ -19,26 +29,102 @@ export function createHistoryController(scope) {
function persistHistoryState() {
clearPendingPersistTimer();
try {
const savedIndex = Math.max(0, Math.min(
scope.historyEntries.findIndex((entry) => Number(entry?.id) === Number(historyScope.lastSavedHistoryId)),
scope.historyEntries.length - 1,
));
const savedState = scope.historyEntries.length > 0
? captureHistoryStateAtIndex(savedIndex >= 0 ? savedIndex : scope.historyIndex)
: captureState();
const payload = {
mapId: String(documentScope.mapId || scope.mapId || ""),
savedStateSignature: getStateSignature(savedState),
historyEntries: historyScope.historyEntries,
historyIndex: historyScope.historyIndex,
historySelectionIndex: historyScope.historySelectionIndex,
nextHistoryId: historyScope.nextHistoryId,
lastSavedHistoryId: historyScope.lastSavedHistoryId,
};
const serialized = JSON.stringify(payload);
if (serialized.length > MAX_PERSISTED_HISTORY_CHARS) {
window.localStorage.removeItem(historyScope.historyStorageKey);
return false;
}
window.localStorage.setItem(historyScope.historyStorageKey, serialized);
return true;
} catch {}
return false;
}
function schedulePersistHistoryState() {
clearPendingPersistTimer();
pendingPersistTimer = window.setTimeout(() => {
pendingPersistTimer = 0;
persistHistoryState();
}, 120);
return true;
}
function restoreHistoryState() {
try {
const raw = window.localStorage.getItem(historyScope.historyStorageKey);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") {
return null;
}
return parsed;
} catch {
return null;
}
}
function applyHistorySnapshot() {
function applyHistorySnapshot(snapshot) {
if (!snapshot || typeof snapshot !== "object") {
return false;
}
const snapshotMapId = String(snapshot.mapId || "").trim();
const currentMapId = String(documentScope.mapId || scope.mapId || "").trim();
if (snapshotMapId && currentMapId && snapshotMapId !== currentMapId) {
return false;
}
const entries = Array.isArray(snapshot.historyEntries) ? snapshot.historyEntries : null;
if (!entries || entries.length === 0) {
return false;
}
const savedId = Number(snapshot.lastSavedHistoryId) || 0;
const currentId = Number(snapshot.historyEntries?.[Number(snapshot.historyIndex) || 0]?.id) || 0;
if (!savedId || !currentId || savedId !== currentId) {
return false;
}
const savedStateSignature = String(snapshot.savedStateSignature || "").trim();
if (savedStateSignature) {
const currentLoadedSignature = getStateSignature(captureState());
if (savedStateSignature !== currentLoadedSignature) {
return false;
}
}
historyScope.historyEntries = entries;
historyScope.historyIndex = Math.max(0, Math.min(Number(snapshot.historyIndex) || 0, historyScope.historyEntries.length - 1));
historyScope.historySelectionIndex = Math.max(0, Math.min(Number(snapshot.historySelectionIndex) || historyScope.historyIndex, historyScope.historyEntries.length - 1));
historyScope.nextHistoryId = Math.max(1, Number(snapshot.nextHistoryId) || (historyScope.historyEntries[historyScope.historyEntries.length - 1]?.seq || 0) + 1);
historyScope.lastSavedHistoryId = Math.max(1, Number(snapshot.lastSavedHistoryId) || historyScope.historyEntries[historyScope.historyIndex]?.id || 1);
if (!restoreToHistoryIndex(historyScope.historyIndex)) {
const currentState = historyScope.historyEntries[historyScope.historyIndex] && historyScope.historyEntries[historyScope.historyIndex].state ? historyScope.historyEntries[historyScope.historyIndex].state : null;
if (currentState) {
applyState(currentState);
}
}
return true;
}
function captureState() {
return {
width: Math.max(1, Number(documentScope.width) || 1),
height: Math.max(1, Number(documentScope.height) || 1),
width: Number(documentScope.width) || 1,
height: Number(documentScope.height) || 1,
mapName: String(documentScope.mapName || scope.mapId || ""),
backgroundColor: documentScope.normalizeMapBackgroundColor(documentScope.backgroundColor),
backgroundTileId: String(documentScope.backgroundTileId || "").trim(),
@ -56,35 +142,6 @@ export function createHistoryController(scope) {
};
}
function cloneHistoryState(state) {
if (!state || typeof state !== "object") {
return captureState();
}
return {
width: Math.max(1, Number(state.width) || 1),
height: Math.max(1, Number(state.height) || 1),
mapName: String(state.mapName || scope.mapId || ""),
backgroundColor: documentScope.normalizeMapBackgroundColor(state.backgroundColor),
backgroundTileId: documentScope.normalizeBackgroundTileId(state.backgroundTileId),
heightBlurStep: Math.max(0, Math.min(1, Number(state.heightBlurStep ?? state.heightDetailStep) || 0.1)),
layers: documentScope.cloneLayers(Array.isArray(state.layers) ? state.layers : []),
heightLayers: documentScope.cloneHeightLayers(Array.isArray(state.heightLayers) ? state.heightLayers : []),
npcs: documentScope.cloneNpcOverlays(Array.isArray(state.npcs) ? state.npcs : []),
worldChunkBackgrounds: state && state.worldChunkBackgrounds && typeof state.worldChunkBackgrounds === "object" && !Array.isArray(state.worldChunkBackgrounds)
? { ...state.worldChunkBackgrounds }
: {},
worldBookmarks: typeof scope.applyWorldBookmarkState === "function" && Array.isArray(state?.worldBookmarks)
? state.worldBookmarks.map((entry) => ({
id: String(entry?.id || "").trim(),
label: String(entry?.label || entry?.id || "").trim(),
x: Math.floor(Number(entry?.x) || 0),
y: Math.floor(Number(entry?.y) || 0),
}))
: [],
editorUi: documentScope.cloneEditorUiState(state.editorUi || {}),
};
}
function refreshUiAfterHistoryMutation() {
documentScope.ensureBaseLayer();
sessionScope.activeLayer = documentScope.roomLayers.some((layer) => layer.layer === sessionScope.activeLayer) ? sessionScope.activeLayer : 0;
@ -128,9 +185,9 @@ export function createHistoryController(scope) {
documentScope.backgroundColor = documentScope.normalizeMapBackgroundColor(state?.backgroundColor || documentScope.backgroundColor);
documentScope.backgroundTileId = documentScope.normalizeBackgroundTileId(state?.backgroundTileId);
documentScope.heightBlurStep = Math.max(0, Math.min(1, Number(state?.heightBlurStep ?? state?.heightDetailStep) || documentScope.heightBlurStep || documentScope.heightDetailStep || 0.1));
documentScope.roomLayers = documentScope.cloneLayers(Array.isArray(state?.layers) ? state.layers : []);
documentScope.heightLayers = documentScope.cloneHeightLayers(Array.isArray(state?.heightLayers) ? state.heightLayers : []);
const nextNpcs = documentScope.cloneNpcOverlays(Array.isArray(state?.npcs) ? state.npcs : []);
documentScope.roomLayers = documentScope.cloneLayers(Array.isArray(state.layers) ? state.layers : []);
documentScope.heightLayers = documentScope.cloneHeightLayers(Array.isArray(state.heightLayers) ? state.heightLayers : []);
const nextNpcs = documentScope.cloneNpcOverlays(Array.isArray(state.npcs) ? state.npcs : []);
sessionScope.editorUiState = state && state.editorUi ? documentScope.cloneEditorUiState(state.editorUi) : { panelLayouts: {} };
if (!documentScope.getHeightLayerById(sessionScope.activeHeightLayerId)) {
sessionScope.activeHeightLayerId = String(documentScope.heightLayers[0]?.id || "").trim();
@ -145,7 +202,7 @@ export function createHistoryController(scope) {
scope.applyWorldChunkBackgroundState(state?.worldChunkBackgrounds || {});
}
if (typeof scope.applyWorldBookmarkState === "function" && scope.isWorldModeActive?.()) {
scope.applyWorldBookmarkState(Array.isArray(state?.worldBookmarks) ? state.worldBookmarks : []);
scope.applyWorldBookmarkState(state?.worldBookmarks || []);
}
if (typeof scope.rebuildVisibleWorldChunksFromDocument === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
scope.rebuildVisibleWorldChunksFromDocument();
@ -155,25 +212,388 @@ export function createHistoryController(scope) {
}
}
function restoreToHistoryIndex(targetIndex) {
const normalizedTargetIndex = Math.max(0, Math.min(Number(targetIndex) || 0, scope.historyEntries.length - 1));
const targetEntry = scope.historyEntries[normalizedTargetIndex] || null;
if (!targetEntry?.state) {
function ensureLayerForOperation(layerNumber) {
const normalizedLayer = Number(layerNumber) || 0;
let layerEntry = scope.roomLayers.find((layer) => Number(layer.layer) === normalizedLayer) || null;
if (layerEntry) {
return layerEntry;
}
layerEntry = {
layer: normalizedLayer,
name: undefined,
zIndex: 0,
rows: scope.normalizeRows([], normalizedLayer === 0 ? "." : " "),
instanceIds: [],
};
scope.roomLayers.push(layerEntry);
scope.roomLayers = scope.roomLayers
.slice()
.sort((left, right) => Number(left.layer) - Number(right.layer));
return scope.roomLayers.find((layer) => Number(layer.layer) === normalizedLayer) || layerEntry;
}
function setStoredTileCharAt(layerNumber, tileX, tileY, nextStoredChar) {
if (tileX < 0 || tileX >= scope.width || tileY < 0 || tileY >= scope.height) {
return false;
}
applyState(targetEntry.state);
const normalizedLayer = Number(layerNumber) || 0;
const layerEntry = ensureLayerForOperation(normalizedLayer);
const fillChar = normalizedLayer === 0 ? "." : " ";
const rows = scope.normalizeRows(layerEntry.rows, fillChar);
const row = rows[tileY] || fillChar.repeat(scope.width);
const safeChar = String(nextStoredChar || fillChar).charAt(0) || fillChar;
if ((row.charAt(tileX) || fillChar) === safeChar) {
return false;
}
rows[tileY] = row.slice(0, tileX) + safeChar + row.slice(tileX + 1);
layerEntry.rows = rows;
if (typeof scope.syncWorldChunkCellFromLocalTile === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
scope.syncWorldChunkCellFromLocalTile(normalizedLayer, tileX, tileY, safeChar);
}
return true;
}
function resolveTileOperationCellCoord(cell) {
if (typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
const worldX = Number(cell?.worldX);
const worldY = Number(cell?.worldY);
if (Number.isFinite(worldX) && Number.isFinite(worldY)) {
return {
x: Math.floor(worldX - (Number(scope.worldTileOffsetX) || 0)),
y: Math.floor(worldY - (Number(scope.worldTileOffsetY) || 0)),
};
}
}
return {
x: Math.floor(Number(cell?.x) || 0),
y: Math.floor(Number(cell?.y) || 0),
};
}
function applyTileCellsOperation(operation, direction) {
const isRedo = direction !== "undo";
const nextBackgroundTileId = isRedo
? operation.afterBackgroundTileId
: operation.beforeBackgroundTileId;
if (nextBackgroundTileId !== undefined) {
scope.backgroundTileId = scope.normalizeBackgroundTileId(nextBackgroundTileId);
}
const cells = Array.isArray(operation.cells) ? operation.cells : [];
cells.forEach((cell) => {
const resolvedCoord = resolveTileOperationCellCoord(cell, scope.width, scope.height);
const nextStoredChar = isRedo ? cell.afterStoredChar : cell.beforeStoredChar;
setStoredTileCharAt(cell.layer, resolvedCoord.x, resolvedCoord.y, nextStoredChar);
});
if (nextBackgroundTileId !== undefined && typeof scope.rebuildVisibleWorldChunksFromDocument === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) {
scope.rebuildVisibleWorldChunksFromDocument();
}
scope.invalidateTileSurface();
}
function buildNpcTargetEntries(operation, direction) {
const useAfter = direction !== "undo";
const rawEntries = Array.isArray(operation.entries) ? operation.entries : [];
return rawEntries
.map((entry) => {
const snapshot = useAfter ? entry.after : entry.before;
const targetIndex = useAfter ? entry.afterIndex : entry.beforeIndex;
if (!snapshot || typeof snapshot !== "object") {
return null;
}
const cloned = scope.cloneNpcOverlays([cloneValue(snapshot)])[0];
if (!cloned) {
return null;
}
scope.syncNpcOverlayFromRecord(cloned);
return {
npc: cloned,
index: Math.max(0, Number(targetIndex) || 0),
};
})
.filter((entry) => entry !== null)
.sort((left, right) => left.index - right.index);
}
function applyNpcEntriesOperation(operation, direction) {
const rawEntries = Array.isArray(operation.entries) ? operation.entries : [];
const touchedPositions = [];
rawEntries.forEach((entry) => {
const beforePos = entry?.before && typeof entry.before === "object" ? entry.before : null;
const afterPos = entry?.after && typeof entry.after === "object" ? entry.after : null;
if (beforePos) {
const x = Math.floor(Number(beforePos.x));
const y = Math.floor(Number(beforePos.y));
if (Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0) {
touchedPositions.push({ x, y });
}
}
if (afterPos) {
const x = Math.floor(Number(afterPos.x));
const y = Math.floor(Number(afterPos.y));
if (Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0) {
touchedPositions.push({ x, y });
}
}
});
const affectedIds = new Set(
rawEntries.flatMap((entry) => {
const ids = [];
const beforeId = String(entry?.before?.id || "").trim();
const afterId = String(entry?.after?.id || "").trim();
if (beforeId) {
ids.push(beforeId);
}
if (afterId) {
ids.push(afterId);
}
return ids;
}),
);
if (affectedIds.size === 0) {
return;
}
const remainingNpcs = scope.npcOverlays.filter((npc) => !affectedIds.has(String(npc.id || "").trim()));
scope.npcOverlays.length = 0;
remainingNpcs.forEach((npc) => scope.npcOverlays.push(npc));
affectedIds.forEach((npcId) => {
delete scope.npcImages[npcId];
});
const targetEntries = buildNpcTargetEntries(operation, direction);
targetEntries.forEach((entry) => {
const nextIndex = Math.max(0, Math.min(scope.npcOverlays.length, entry.index));
scope.ensureNpcImageLoaded(entry.npc);
scope.npcOverlays.splice(nextIndex, 0, entry.npc);
});
if (typeof scope.rebuildWorldChunksForLocalBounds === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive() && touchedPositions.length > 0) {
const xs = touchedPositions.map((entry) => entry.x);
const ys = touchedPositions.map((entry) => entry.y);
scope.rebuildWorldChunksForLocalBounds({
minX: Math.min(...xs),
minY: Math.min(...ys),
maxX: Math.max(...xs),
maxY: Math.max(...ys),
});
}
}
function applyOperation(operation, direction, options) {
const config = options && typeof options === "object" ? options : {};
if (!operation || typeof operation !== "object") {
return false;
}
if (operation.type === "tile_cells") {
applyTileCellsOperation(operation, direction);
} else if (operation.type === "npc_entries") {
applyNpcEntriesOperation(operation, direction);
} else {
return false;
}
if (!config.deferRefresh) {
refreshUiAfterHistoryMutation();
}
return true;
}
function cloneHistoryState(state) {
if (!state || typeof state !== "object") {
return captureState();
}
return {
width: Math.max(1, Number(state.width) || 1),
height: Math.max(1, Number(state.height) || 1),
mapName: String(state.mapName || scope.mapId || ""),
backgroundColor: documentScope.normalizeMapBackgroundColor(state.backgroundColor),
backgroundTileId: documentScope.normalizeBackgroundTileId(state.backgroundTileId),
heightBlurStep: Math.max(0, Math.min(1, Number(state.heightBlurStep ?? state.heightDetailStep) || 0.1)),
layers: documentScope.cloneLayers(Array.isArray(state.layers) ? state.layers : []),
heightLayers: documentScope.cloneHeightLayers(Array.isArray(state.heightLayers) ? state.heightLayers : []),
npcs: documentScope.cloneNpcOverlays(Array.isArray(state.npcs) ? state.npcs : []),
editorUi: documentScope.cloneEditorUiState(state.editorUi || {}),
};
}
function ensureLayerForStateOperation(state, layerNumber) {
const normalizedLayer = Number(layerNumber) || 0;
let layerEntry = state.layers.find((layer) => Number(layer.layer) === normalizedLayer) || null;
if (layerEntry) {
return layerEntry;
}
layerEntry = {
layer: normalizedLayer,
name: undefined,
zIndex: 0,
rows: scope.normalizeRows([], normalizedLayer === 0 ? "." : " "),
instanceIds: [],
};
state.layers.push(layerEntry);
state.layers = state.layers
.slice()
.sort((left, right) => Number(left.layer) - Number(right.layer));
return state.layers.find((layer) => Number(layer.layer) === normalizedLayer) || layerEntry;
}
function setStoredTileCharAtInState(state, layerNumber, tileX, tileY, nextStoredChar) {
if (tileX < 0 || tileX >= state.width || tileY < 0 || tileY >= state.height) {
return false;
}
const normalizedLayer = Number(layerNumber) || 0;
const layerEntry = ensureLayerForStateOperation(state, normalizedLayer);
const fillChar = normalizedLayer === 0 ? "." : " ";
const rows = scope.normalizeRows(layerEntry.rows, fillChar);
const row = rows[tileY] || fillChar.repeat(state.width);
const safeChar = String(nextStoredChar || fillChar).charAt(0) || fillChar;
if ((row.charAt(tileX) || fillChar) === safeChar) {
return false;
}
rows[tileY] = row.slice(0, tileX) + safeChar + row.slice(tileX + 1);
layerEntry.rows = rows;
return true;
}
function applyTileCellsOperationToState(state, operation, direction) {
const nextState = cloneHistoryState(state);
const isRedo = direction !== "undo";
const nextBackgroundTileId = isRedo
? operation.afterBackgroundTileId
: operation.beforeBackgroundTileId;
if (nextBackgroundTileId !== undefined) {
nextState.backgroundTileId = documentScope.normalizeBackgroundTileId(nextBackgroundTileId);
}
const cells = Array.isArray(operation.cells) ? operation.cells : [];
cells.forEach((cell) => {
const resolvedCoord = resolveTileOperationCellCoord(cell, nextState.width, nextState.height);
const nextStoredChar = isRedo ? cell.afterStoredChar : cell.beforeStoredChar;
setStoredTileCharAtInState(nextState, cell.layer, resolvedCoord.x, resolvedCoord.y, nextStoredChar);
});
return nextState;
}
function applyNpcEntriesOperationToState(state, operation, direction) {
const nextState = cloneHistoryState(state);
const rawEntries = Array.isArray(operation.entries) ? operation.entries : [];
const affectedIds = new Set(
rawEntries.flatMap((entry) => {
const ids = [];
const beforeId = String(entry?.before?.id || "").trim();
const afterId = String(entry?.after?.id || "").trim();
if (beforeId) {
ids.push(beforeId);
}
if (afterId) {
ids.push(afterId);
}
return ids;
}),
);
if (affectedIds.size === 0) {
return nextState;
}
const useAfter = direction !== "undo";
const remainingNpcs = nextState.npcs.filter((npc) => !affectedIds.has(String(npc.id || "").trim()));
const targetEntries = rawEntries
.map((entry) => {
const snapshot = useAfter ? entry.after : entry.before;
const targetIndex = useAfter ? entry.afterIndex : entry.beforeIndex;
if (!snapshot || typeof snapshot !== "object") {
return null;
}
const cloned = documentScope.cloneNpcOverlays([cloneValue(snapshot)])[0];
if (!cloned) {
return null;
}
documentScope.syncNpcOverlayFromRecord(cloned);
return {
npc: cloned,
index: Math.max(0, Number(targetIndex) || 0),
};
})
.filter((entry) => entry !== null)
.sort((left, right) => left.index - right.index);
nextState.npcs = remainingNpcs;
targetEntries.forEach((entry) => {
const nextIndex = Math.max(0, Math.min(nextState.npcs.length, entry.index));
nextState.npcs.splice(nextIndex, 0, entry.npc);
});
return nextState;
}
function captureHistoryStateAtIndex(targetIndex) {
const normalizedTargetIndex = Math.max(0, Math.min(Number(targetIndex) || 0, scope.historyEntries.length - 1));
const targetEntry = scope.historyEntries[normalizedTargetIndex] || null;
if (!targetEntry) {
return captureState();
}
if (targetEntry.state) {
return cloneHistoryState(targetEntry.state);
}
const snapshotIndex = findNearestSnapshotIndex(normalizedTargetIndex);
if (snapshotIndex < 0) {
return captureState();
}
let nextState = cloneHistoryState(scope.historyEntries[snapshotIndex].state);
for (let index = snapshotIndex + 1; index <= normalizedTargetIndex; index += 1) {
const entry = scope.historyEntries[index];
if (!entry) {
continue;
}
if (entry.state) {
nextState = cloneHistoryState(entry.state);
continue;
}
if (!entry.operation || typeof entry.operation !== "object") {
continue;
}
if (entry.operation.type === "tile_cells") {
nextState = applyTileCellsOperationToState(nextState, entry.operation, "redo");
} else if (entry.operation.type === "npc_entries") {
nextState = applyNpcEntriesOperationToState(nextState, entry.operation, "redo");
}
}
return nextState;
}
function findNearestSnapshotIndex(targetIndex) {
for (let index = Math.max(0, Number(targetIndex) || 0); index >= 0; index -= 1) {
if (scope.historyEntries[index] && scope.historyEntries[index].state) {
return index;
}
}
return -1;
}
function restoreToHistoryIndex(targetIndex) {
const normalizedTargetIndex = Math.max(0, Math.min(Number(targetIndex) || 0, scope.historyEntries.length - 1));
const snapshotIndex = findNearestSnapshotIndex(normalizedTargetIndex);
if (snapshotIndex < 0) {
return false;
}
applyState(scope.historyEntries[snapshotIndex].state, { deferRefresh: true });
for (let index = snapshotIndex + 1; index <= normalizedTargetIndex; index += 1) {
const entry = scope.historyEntries[index];
if (!entry) {
continue;
}
if (entry.state) {
applyState(entry.state, { deferRefresh: true });
continue;
}
if (entry.operation) {
applyOperation(entry.operation, "redo", { deferRefresh: true });
}
}
refreshUiAfterHistoryMutation();
return true;
}
function getStateSignature(state) {
const layerSig = scope.cloneLayers(Array.isArray(state?.layers) ? state.layers : [])
.sort((a, b) => Number(a.layer) - Number(b.layer))
const layerSig = scope.cloneLayers(state.layers)
.sort((a, b) => a.layer - b.layer)
.map((layer) => ({
layer: Number(layer.layer) || 0,
layer: layer.layer,
name: typeof layer.name === "string" ? layer.name : "",
rows: scope.normalizeRows(layer.rows, Number(layer.layer) === 0 ? "." : " "),
rows: scope.normalizeRows(layer.rows, layer.layer === 0 ? "." : " "),
}));
const heightLayerSig = scope.cloneHeightLayers(Array.isArray(state?.heightLayers) ? state.heightLayers : [])
const heightLayerSig = scope.cloneHeightLayers(state.heightLayers)
.sort((a, b) => String(a.id || "").localeCompare(String(b.id || "")))
.map((entry) => ({
id: String(entry.id || ""),
@ -183,38 +603,30 @@ export function createHistoryController(scope) {
y: Number(entry.y) || 0,
rows: Array.isArray(entry.rows) ? entry.rows.map((row) => String(row || "")) : [],
}));
const npcSig = scope.cloneNpcOverlays(Array.isArray(state?.npcs) ? state.npcs : [])
.sort((a, b) => String(a.id || "").localeCompare(String(b.id || "")))
const npcSig = scope.cloneNpcOverlays(state.npcs)
.sort((a, b) => a.id.localeCompare(b.id))
.map((entry) => ({
id: String(entry.id || ""),
id: entry.id,
layer: Number(entry.layer) || 0,
name: String(entry.name || ""),
spriteId: String(entry.spriteId || ""),
x: Number(entry.x) || 0,
y: Number(entry.y) || 0,
name: entry.name,
spriteId: entry.spriteId,
x: entry.x,
y: entry.y,
}));
return JSON.stringify({
width: Math.max(1, Number(state?.width) || 1),
height: Math.max(1, Number(state?.height) || 1),
mapName: String(state?.mapName || ""),
backgroundColor: scope.normalizeMapBackgroundColor(state?.backgroundColor),
backgroundTileId: scope.normalizeBackgroundTileId(state?.backgroundTileId),
heightBlurStep: Math.max(0, Math.min(1, Number(state?.heightBlurStep ?? state?.heightDetailStep) || 0.1)),
width: Number(state.width) || 1,
height: Number(state.height) || 1,
mapName: String(state.mapName || ""),
backgroundColor: scope.normalizeMapBackgroundColor(state.backgroundColor),
backgroundTileId: scope.normalizeBackgroundTileId(state.backgroundTileId),
heightBlurStep: Math.max(0, Math.min(1, Number(state.heightBlurStep ?? state.heightDetailStep) || 0.1)),
layerSig,
heightLayerSig,
npcSig,
worldChunkBackgrounds: state && state.worldChunkBackgrounds && typeof state.worldChunkBackgrounds === "object" && !Array.isArray(state.worldChunkBackgrounds)
? state.worldChunkBackgrounds
: {},
worldBookmarks: Array.isArray(state?.worldBookmarks)
? state.worldBookmarks.map((entry) => ({
id: String(entry?.id || ""),
label: String(entry?.label || ""),
x: Number(entry?.x) || 0,
y: Number(entry?.y) || 0,
}))
: [],
editorUi: scope.cloneEditorUiState(state?.editorUi || {}),
editorUi: scope.cloneEditorUiState(state.editorUi || {}),
});
}
@ -230,29 +642,50 @@ export function createHistoryController(scope) {
}
function renderHistoryPreview() {
if (!scope.historyPreviewEl) {
return;
}
const selectedEntry = scope.historyEntries[scope.historySelectionIndex] || null;
if (!selectedEntry) {
scope.historyPreviewEl.innerHTML = '<h4>Undo / Redo</h4><div class="history-preview-empty">Single-step history is empty.</div>';
scope.historyPreviewEl.innerHTML = '<h4>Change Preview</h4><div class="history-preview-empty">Select a history entry to inspect it.</div>';
return;
}
const details = Array.isArray(selectedEntry.details) ? selectedEntry.details : [];
const detailHtml = details.length > 0
? "<ul>" + details.map((detail) => "<li>" + detail + "</li>").join("") + "</ul>"
: '<div class="history-preview-empty">No additional details recorded.</div>';
const title = scope.historySelectionIndex === scope.historyIndex ? "Current state" : "Single undo step";
const currentText = scope.historySelectionIndex === scope.historyIndex ? "Current state" : "Selected step " + selectedEntry.seq;
scope.historyPreviewEl.innerHTML =
"<h4>" + title + "</h4>" +
"<h4>" + currentText + "</h4>" +
'<div style="margin-bottom:6px;">' + formatHistoryLabel(selectedEntry) + "</div>" +
detailHtml +
'<div class="history-preview-empty" style="margin-top:8px;">Only one undo / redo step is kept in memory.</div>';
'<button class="mini-btn" id="jumpHistoryBtn" type="button" style="margin-top:8px;">Restore To Selected</button>';
const nextJumpBtn = document.getElementById("jumpHistoryBtn");
nextJumpBtn.disabled = scope.isSaving || scope.historySelectionIndex === scope.historyIndex;
nextJumpBtn.addEventListener("click", () => {
if (scope.historySelectionIndex === scope.historyIndex) {
return;
}
scope.historyIndex = scope.historySelectionIndex;
restoreToHistoryIndex(scope.historyIndex);
refreshToolbarState();
scope.setStatus("Restored to history step " + scope.historyEntries[scope.historyIndex].seq + ".", false);
});
}
function renderHistoryList() {
if (scope.historyListEl) {
scope.historyListEl.innerHTML = "";
if (scope.historyCurrentEl) {
const currentEntry = scope.historyEntries[scope.historyIndex] || null;
scope.historyCurrentEl.innerHTML = currentEntry
? (
'<div class="history-current-label">Current State</div>' +
'<button type="button" class="history-row current-row">' +
"<span>" + String(currentEntry.seq) + ". " + formatHistoryLabel(currentEntry) + "</span>" +
'<span class="history-meta">' + new Date(currentEntry.createdAt).toLocaleTimeString() + "</span>" +
"</button>"
)
: '<div class="history-current-label">Current State</div><div class="history-current-empty">No history yet.</div>';
}
scope.historyEntries.forEach((entry, index) => {
if (index === scope.historyIndex) {
return;
@ -275,25 +708,12 @@ export function createHistoryController(scope) {
scope.historyListEl.appendChild(row);
});
}
if (scope.historyCurrentEl) {
const currentEntry = scope.historyEntries[scope.historyIndex] || null;
scope.historyCurrentEl.innerHTML = currentEntry
? (
'<div class="history-current-label">Current State</div>' +
'<button type="button" class="history-row current-row">' +
"<span>" + String(currentEntry.seq) + ". " + formatHistoryLabel(currentEntry) + "</span>" +
'<span class="history-meta">' + new Date(currentEntry.createdAt).toLocaleTimeString() + "</span>" +
"</button>"
)
: '<div class="history-current-label">Current State</div><div class="history-current-empty">No history yet.</div>';
}
}
function refreshToolbarState(preserveCurrentStatus) {
const canUndo = scope.historyIndex > 0;
const canRedo = scope.historyIndex < scope.historyEntries.length - 1;
const currentHistoryId = scope.historyEntries[scope.historyIndex] ? Number(scope.historyEntries[scope.historyIndex].id) || 0 : 0;
const isDirtyFromSaved = currentHistoryId !== (Number(scope.lastSavedHistoryId) || 0);
const currentHistoryId = scope.historyEntries[scope.historyIndex] ? scope.historyEntries[scope.historyIndex].id : 0;
const isDirtyFromSaved = currentHistoryId !== scope.lastSavedHistoryId;
scope.undoBtn.disabled = scope.isSaving || !canUndo;
scope.redoBtn.disabled = scope.isSaving || !canRedo;
@ -305,12 +725,13 @@ export function createHistoryController(scope) {
if (preserveCurrentStatus) {
return;
}
if (scope.isSaving) {
scope.setStatus("Saving...", false);
} else if (canRedo) {
scope.setStatus("Redo is available. A new edit will replace it.", false);
scope.setStatus("History branch active. New edits will replace future steps.", false);
} else if (isDirtyFromSaved) {
scope.setStatus("Unsaved changes.", false);
scope.setStatus("Unsaved history changes.", false);
} else {
scope.setStatus("All changes saved.", false);
}
@ -318,10 +739,20 @@ export function createHistoryController(scope) {
function registerHistory(label, before, after, details, options) {
const config = options && typeof options === "object" ? options : {};
const nextState = cloneHistoryState(config.nextState || captureState());
const operation = config.operation ? cloneValue(config.operation) : null;
if (operation && operation.type === "tile_cells" && (!Array.isArray(operation.cells) || operation.cells.length === 0)) {
return;
}
if (operation && operation.type === "npc_entries" && (!Array.isArray(operation.entries) || operation.entries.length === 0)) {
return;
}
const shouldStoreOperationOnly = Boolean(operation);
const nextState = shouldStoreOperationOnly ? null : (config.nextState || captureState());
const currentEntry = scope.historyEntries[scope.historyIndex] || null;
const currentState = currentEntry?.state ? cloneHistoryState(currentEntry.state) : captureState();
if (!config.skipStateCheck && getStateSignature(nextState) === getStateSignature(currentState)) {
const currentState = currentEntry && currentEntry.state
? currentEntry.state
: captureHistoryStateAtIndex(scope.historyIndex);
if (!config.skipStateCheck && nextState && currentState && getStateSignature(nextState) === getStateSignature(currentState)) {
return;
}
@ -329,6 +760,25 @@ export function createHistoryController(scope) {
scope.historyEntries = scope.historyEntries.slice(0, scope.historyIndex + 1);
}
let operationEntriesSinceSnapshot = 0;
if (shouldStoreOperationOnly) {
for (let index = scope.historyEntries.length - 1; index >= 0; index -= 1) {
const entry = scope.historyEntries[index];
if (!entry) {
continue;
}
if (entry.state) {
break;
}
if (entry.operation) {
operationEntriesSinceSnapshot += 1;
}
}
}
const checkpointState = shouldStoreOperationOnly && operationEntriesSinceSnapshot + 1 >= OPERATION_CHECKPOINT_INTERVAL
? captureState()
: null;
const entry = {
id: scope.nextHistoryId,
seq: scope.nextHistoryId,
@ -337,47 +787,47 @@ export function createHistoryController(scope) {
before,
after,
details: Array.isArray(details) ? details : [],
state: nextState,
state: nextState || checkpointState,
operation,
};
scope.nextHistoryId += 1;
scope.historyEntries.push(entry);
if (scope.historyEntries.length > MAX_HISTORY_ENTRIES) {
scope.historyEntries = scope.historyEntries.slice(scope.historyEntries.length - MAX_HISTORY_ENTRIES);
}
scope.historyIndex = scope.historyEntries.length - 1;
scope.historySelectionIndex = scope.historyIndex;
if (scope.historyEntries.length > MAX_HISTORY_ENTRIES) {
const trimmedCount = scope.historyEntries.length - MAX_HISTORY_ENTRIES;
scope.historyEntries = scope.historyEntries.slice(trimmedCount);
scope.historyIndex = Math.max(0, scope.historyIndex - trimmedCount);
scope.historySelectionIndex = Math.max(0, scope.historySelectionIndex - trimmedCount);
}
schedulePersistHistoryState();
refreshToolbarState();
}
function undo() {
if (scope.historyIndex <= 0) {
return false;
return;
}
scope.historyIndex -= 1;
scope.historySelectionIndex = scope.historyIndex;
const restored = restoreToHistoryIndex(scope.historyIndex);
restoreToHistoryIndex(scope.historyIndex);
schedulePersistHistoryState();
refreshToolbarState();
if (restored) {
scope.setStatus("Undid the last change.", false);
}
return restored;
scope.setStatus("Undo to step " + scope.historyEntries[scope.historyIndex].seq + ".", false);
}
function redo() {
if (scope.historyIndex >= scope.historyEntries.length - 1) {
return false;
return;
}
scope.historyIndex += 1;
scope.historySelectionIndex = scope.historyIndex;
const restored = restoreToHistoryIndex(scope.historyIndex);
restoreToHistoryIndex(scope.historyIndex);
schedulePersistHistoryState();
refreshToolbarState();
if (restored) {
scope.setStatus("Redid the last change.", false);
}
return restored;
scope.setStatus("Redo to step " + scope.historyEntries[scope.historyIndex].seq + ".", false);
}
return {
@ -387,7 +837,7 @@ export function createHistoryController(scope) {
applyHistorySnapshot,
captureState,
applyState,
applyOperation: () => false,
applyOperation,
restoreToHistoryIndex,
getStateSignature,
formatCellCoord,

View file

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

View file

@ -6,7 +6,7 @@ import {
buildTilesPayloadFromImagesPayload,
mergeImagesPayloadWithSpritesPayload,
mergeImagesPayloadWithTilesPayload,
} from "../editorCore";
} from "../contentTransforms/graphicsPayload";
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 { getSpritePalette, getSpriteRows, resolveUnifiedColorSymbol } from "../editorCore";
import { resolveUnifiedColorSymbol, getSpritePalette } from "../editorCore";
import { getSpriteRows } from "../graphics/rowEncoding";
export function parseHexColor(value, fallback = 0x060A14) {
const raw = String(value || "").trim();

View file

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

View file

@ -2,14 +2,16 @@
// @ts-nocheck
import {
buildSpritePreviewDataUrl,
fetchJsonOrThrow,
} from "../editorCore";
import {
buildSpritesPayloadFromImagesPayload,
buildTilesPayloadFromImagesPayload,
fetchJsonOrThrow,
mergeImagesPayloadWithSpritesPayload,
mergeImagesPayloadWithTilesPayload,
normalizeImageRecordForSave,
normalizeTileRecordForSave,
} from "../editorCore";
} from "../contentTransforms/graphicsPayload";
import {
buildSpriteCatalog,
buildTileCatalogById,
@ -4513,13 +4515,12 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in
scope.historySelectionIndex = 0;
scope.lastSavedHistoryId = initialEntry.id;
scope.nextHistoryId = initialEntry.id + 1;
try {
if (currentHistoryStorageKey) {
window.localStorage.removeItem(currentHistoryStorageKey);
}
} catch {}
const restoredHistory = scope.restoreHistoryState();
if (!scope.applyHistorySnapshot(restoredHistory)) {
scope.persistHistoryState();
}
}
function refreshUiForLoadedMap() {
scope.refreshInformationPanel();

View file

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

View file

@ -46,9 +46,7 @@ type OverviewChunk = {
height?: number;
backgroundTileId?: string;
roomLayers?: OverviewRoomLayer[];
surfaceRows?: string[];
instances?: unknown[];
instanceCount?: number;
};
type OverviewPayload = {
@ -862,9 +860,7 @@ export function createWorldOverviewWindowController(scope: WorldOverviewScope) {
}
function beginPendingChunkAction(type: "move" | "duplicate", chunkRect: ChunkWorldRect, chunkKey: string, chunk?: OverviewChunk | null) {
const entityCount = Array.isArray(chunk?.instances)
? chunk.instances.length
: Math.max(0, Math.floor(Number(chunk?.instanceCount) || 0));
const entityCount = Array.isArray(chunk?.instances) ? chunk.instances.length : 0;
if (type === "move" && entityCount > 0) {
const confirmed = window.confirm(
"Move chunk " + chunkRect.chunkX + "," + chunkRect.chunkY + "?\n\nThis will also move " + entityCount + " placed entit" + (entityCount === 1 ? "y" : "ies") + ".",
@ -1399,15 +1395,13 @@ export function createWorldOverviewWindowController(scope: WorldOverviewScope) {
return String(entry?.symbol || ".").charAt(0) || ".";
}
function getTopVisibleSymbol(
chunk: OverviewChunk | null | undefined,
sortedRoomLayers: OverviewRoomLayer[],
localX: number,
localY: number,
) {
function getTopVisibleSymbol(chunk: OverviewChunk | null | undefined, localX: number, localY: number) {
const backgroundSymbol = getBackgroundSymbol(chunk);
const roomLayers = Array.isArray(chunk?.roomLayers)
? chunk.roomLayers.slice().sort((left: OverviewRoomLayer, right: OverviewRoomLayer) => (Number(left?.layer) || 0) - (Number(right?.layer) || 0))
: [];
let resolvedSymbol = backgroundSymbol || "";
sortedRoomLayers.forEach((layer: OverviewRoomLayer) => {
roomLayers.forEach((layer: OverviewRoomLayer) => {
const layerNumber = Number(layer?.layer) || 0;
const fillChar = layerNumber === 0 ? "." : " ";
const row = Array.isArray(layer?.rows) ? String(layer.rows[localY] || "") : "";
@ -1439,8 +1433,7 @@ export function createWorldOverviewWindowController(scope: WorldOverviewScope) {
}
function buildChunkSurfaceSignature(chunk: OverviewChunk | null | undefined) {
const surfaceSig = Array.isArray(chunk?.surfaceRows) ? chunk.surfaceRows.join("|") : "";
const roomLayerSig = surfaceSig || (Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : [])
const roomLayerSig = (Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : [])
.map((layer: OverviewRoomLayer) => String(Number(layer?.layer) || 0) + ":" + (Array.isArray(layer?.rows) ? layer.rows.join("|") : ""))
.join("~");
return [
@ -1466,18 +1459,10 @@ export function createWorldOverviewWindowController(scope: WorldOverviewScope) {
canvas.height = chunkHeight;
const ctx = canvas.getContext("2d");
if (ctx) {
const surfaceRows = Array.isArray(chunk?.surfaceRows) ? chunk.surfaceRows : null;
const sortedRoomLayers = Array.isArray(chunk?.roomLayers)
? chunk.roomLayers.slice().sort((left: OverviewRoomLayer, right: OverviewRoomLayer) => (Number(left?.layer) || 0) - (Number(right?.layer) || 0))
: [];
const imageData = ctx.createImageData(chunkWidth, chunkHeight);
for (let localY = 0; localY < chunkHeight; localY += 1) {
const surfaceRow = surfaceRows ? String(surfaceRows[localY] || "") : "";
for (let localX = 0; localX < chunkWidth; localX += 1) {
const visibleSymbol = surfaceRow
? (String(surfaceRow.charAt(localX) || ".").charAt(0) || ".")
: getTopVisibleSymbol(chunk, sortedRoomLayers, localX, localY);
const color = getSymbolColor(visibleSymbol, chunk);
const color = getSymbolColor(getTopVisibleSymbol(chunk, localX, localY), chunk);
const pixelIndex = (localY * chunkWidth + localX) * 4;
imageData.data[pixelIndex] = color[0];
imageData.data[pixelIndex + 1] = color[1];

View file

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

View file

@ -28,6 +28,7 @@ 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"),

10
vitest.config.ts Normal file
View file

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

13
worldshaper-content.html Normal file
View file

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