From 9f538b1543c497649ba56a0c35f1d1b45976049d Mon Sep 17 00:00:00 2001 From: super-jalawii Date: Sat, 27 Jun 2026 10:31:09 -0400 Subject: [PATCH 1/6] add vitest and regression tests for arc 1 --- package-lock.json | 1157 ++++++++++++++++- package.json | 10 +- src/components/worldshaperShared.test.ts | 81 ++ src/editorCore.test.ts | 106 ++ src/test/setup.ts | 1 + src/worldChunking.test.ts | 77 ++ .../graphicsDocumentHelpers.test.ts | 121 ++ tsconfig.app.json | 2 +- vitest.config.ts | 10 + 9 files changed, 1561 insertions(+), 4 deletions(-) create mode 100644 src/components/worldshaperShared.test.ts create mode 100644 src/editorCore.test.ts create mode 100644 src/test/setup.ts create mode 100644 src/worldChunking.test.ts create mode 100644 src/worldshaperStudio/graphicsDocumentHelpers.test.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 5f18269..af83a55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,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", @@ -23,11 +26,71 @@ "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" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -220,6 +283,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -268,6 +341,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -430,6 +656,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -863,6 +1107,103 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -874,6 +1215,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/earcut": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", @@ -1200,6 +1567,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webgpu/types": { "version": "0.1.70", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.70.tgz", @@ -1268,12 +1748,57 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1297,6 +1822,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/body-parser": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -1442,6 +1977,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1500,6 +2045,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1507,6 +2073,20 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1525,6 +2105,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1541,6 +2128,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -1561,6 +2158,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1603,6 +2208,19 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1621,6 +2239,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -1837,6 +2462,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1862,6 +2497,16 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -2223,6 +2868,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2275,6 +2933,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2313,6 +2981,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2339,6 +3014,57 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2709,6 +3435,27 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2718,6 +3465,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -2778,6 +3532,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2857,6 +3621,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2925,6 +3703,19 @@ "integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==", "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2960,6 +3751,13 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3045,6 +3843,22 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3128,6 +3942,38 @@ "react": "^19.2.7" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -3188,6 +4034,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3359,6 +4218,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3369,6 +4235,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3378,6 +4251,33 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tiny-lru": { "version": "11.4.7", "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", @@ -3387,6 +4287,23 @@ "node": ">=12" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3404,6 +4321,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.4.tgz", + "integrity": "sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.4" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.4.tgz", + "integrity": "sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg==", + "dev": true, + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -3413,6 +4360,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3498,6 +4471,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -3651,6 +4634,144 @@ } } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3667,6 +4788,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3677,6 +4815,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index b2444b2..11a0151 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "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" }, @@ -23,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", @@ -31,9 +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" } } - diff --git a/src/components/worldshaperShared.test.ts b/src/components/worldshaperShared.test.ts new file mode 100644 index 0000000..a70f45f --- /dev/null +++ b/src/components/worldshaperShared.test.ts @@ -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"], + }, + ]); + }); +}); diff --git a/src/editorCore.test.ts b/src/editorCore.test.ts new file mode 100644 index 0000000..62c645d --- /dev/null +++ b/src/editorCore.test.ts @@ -0,0 +1,106 @@ +import { + getSpriteRows, + normalizeImageRecordForSave, + normalizeImagesPayloadForSave, + normalizeTileRecordForSave, + resolveUnifiedColorSymbol, + setUnifiedColorEntries, +} from "./editorCore"; +import type { JsonObject, JsonValue } from "./editorCore"; + +describe("editorCore", () => { + 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("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"); + }); +}); diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/src/worldChunking.test.ts b/src/worldChunking.test.ts new file mode 100644 index 0000000..ffb68c8 --- /dev/null +++ b/src/worldChunking.test.ts @@ -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); + }); +}); diff --git a/src/worldshaperStudio/graphicsDocumentHelpers.test.ts b/src/worldshaperStudio/graphicsDocumentHelpers.test.ts new file mode 100644 index 0000000..fef2aba --- /dev/null +++ b/src/worldshaperStudio/graphicsDocumentHelpers.test.ts @@ -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, + })); + }); +}); diff --git a/tsconfig.app.json b/tsconfig.app.json index 7f42e5f..dccb228 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -4,7 +4,7 @@ "target": "es2023", "lib": ["ES2023", "DOM"], "module": "esnext", - "types": ["vite/client"], + "types": ["vite/client", "vitest/globals"], "skipLibCheck": true, /* Bundler mode */ diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b219793 --- /dev/null +++ b/vitest.config.ts @@ -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"], + }, +}); From 5a9723440cebfeb5cf341c1a46b49a2eecad6ee8 Mon Sep 17 00:00:00 2001 From: super-jalawii Date: Sat, 27 Jun 2026 10:47:55 -0400 Subject: [PATCH 2/6] extract graphics transform and row encoding modules --- src/components/worldshaperShared.ts | 2 +- src/contentTransforms/graphicsPayload.test.ts | 159 ++++++ src/contentTransforms/graphicsPayload.ts | 413 +++++++++++++++ src/contracts/json.ts | 6 + src/editorCore.test.ts | 89 ---- src/editorCore.ts | 489 +----------------- src/graphics/rowEncoding.ts | 52 ++ src/shared/normalization.ts | 23 + src/worldChunking.ts | 2 +- src/worldshaperStudio/bootstrap.ts | 5 +- .../graphicsDocumentHelpers.ts | 7 +- src/worldshaperStudio/importController.ts | 2 +- .../mapDocumentController.ts | 2 +- src/worldshaperStudio/pixiSurfaceHelpers.ts | 4 +- .../pixiTileStageController.ts | 3 +- src/worldshaperStudio/runtime.ts | 6 +- .../tileArtEditorWindowController.ts | 6 +- 17 files changed, 693 insertions(+), 577 deletions(-) create mode 100644 src/contentTransforms/graphicsPayload.test.ts create mode 100644 src/contentTransforms/graphicsPayload.ts create mode 100644 src/contracts/json.ts create mode 100644 src/graphics/rowEncoding.ts create mode 100644 src/shared/normalization.ts diff --git a/src/components/worldshaperShared.ts b/src/components/worldshaperShared.ts index 29a1a70..b6ce820 100644 --- a/src/components/worldshaperShared.ts +++ b/src/components/worldshaperShared.ts @@ -1,5 +1,5 @@ import { resolveUnifiedColorSymbol } from "../editorCore"; -import type { JsonObject } from "../editorCore"; +import type { JsonObject } from "../contracts/json"; export const TILE_COLORS: Record = { "#": resolveUnifiedColorSymbol("L", "#3d4f6a"), diff --git a/src/contentTransforms/graphicsPayload.test.ts b/src/contentTransforms/graphicsPayload.test.ts new file mode 100644 index 0000000..0ebe701 --- /dev/null +++ b/src/contentTransforms/graphicsPayload.test.ts @@ -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: "", + }), + ], + }); + }); +}); diff --git a/src/contentTransforms/graphicsPayload.ts b/src/contentTransforms/graphicsPayload.ts new file mode 100644 index 0000000..1e5c6ba --- /dev/null +++ b/src/contentTransforms/graphicsPayload.ts @@ -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(); + 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(); + 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(); + 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(); + 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)), + }; +} diff --git a/src/contracts/json.ts b/src/contracts/json.ts new file mode 100644 index 0000000..25b6c74 --- /dev/null +++ b/src/contracts/json.ts @@ -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); +} diff --git a/src/editorCore.test.ts b/src/editorCore.test.ts index 62c645d..6e3f25c 100644 --- a/src/editorCore.test.ts +++ b/src/editorCore.test.ts @@ -1,98 +1,9 @@ import { - getSpriteRows, - normalizeImageRecordForSave, - normalizeImagesPayloadForSave, - normalizeTileRecordForSave, resolveUnifiedColorSymbol, setUnifiedColorEntries, } from "./editorCore"; -import type { JsonObject, JsonValue } from "./editorCore"; describe("editorCore", () => { - 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("updates the unified color lookup from catalog entries", () => { setUnifiedColorEntries([ { key: "A", color: "#123456" }, diff --git a/src/editorCore.ts b/src/editorCore.ts index 61c2470..bc7d750 100644 --- a/src/editorCore.ts +++ b/src/editorCore.ts @@ -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(); - 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(); - 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(); - 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(); - 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 { void record; const palette: Record = { @@ -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(); } diff --git a/src/graphics/rowEncoding.ts b/src/graphics/rowEncoding.ts new file mode 100644 index 0000000..a3c8739 --- /dev/null +++ b/src/graphics/rowEncoding.ts @@ -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); +} diff --git a/src/shared/normalization.ts b/src/shared/normalization.ts new file mode 100644 index 0000000..5d1b26c --- /dev/null +++ b/src/shared/normalization.ts @@ -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); +} diff --git a/src/worldChunking.ts b/src/worldChunking.ts index f84be93..f413204 100644 --- a/src/worldChunking.ts +++ b/src/worldChunking.ts @@ -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; diff --git a/src/worldshaperStudio/bootstrap.ts b/src/worldshaperStudio/bootstrap.ts index e7d2cb9..0a24e66 100644 --- a/src/worldshaperStudio/bootstrap.ts +++ b/src/worldshaperStudio/bootstrap.ts @@ -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, diff --git a/src/worldshaperStudio/graphicsDocumentHelpers.ts b/src/worldshaperStudio/graphicsDocumentHelpers.ts index d3e122f..b131daf 100644 --- a/src/worldshaperStudio/graphicsDocumentHelpers.ts +++ b/src/worldshaperStudio/graphicsDocumentHelpers.ts @@ -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"; diff --git a/src/worldshaperStudio/importController.ts b/src/worldshaperStudio/importController.ts index 79afe80..8cc5dca 100644 --- a/src/worldshaperStudio/importController.ts +++ b/src/worldshaperStudio/importController.ts @@ -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!$%&()*+,-/:;<=>?@[]^_{|}~="; diff --git a/src/worldshaperStudio/mapDocumentController.ts b/src/worldshaperStudio/mapDocumentController.ts index abd77ed..f848e31 100644 --- a/src/worldshaperStudio/mapDocumentController.ts +++ b/src/worldshaperStudio/mapDocumentController.ts @@ -6,7 +6,7 @@ import { buildTilesPayloadFromImagesPayload, mergeImagesPayloadWithSpritesPayload, mergeImagesPayloadWithTilesPayload, -} from "../editorCore"; +} from "../contentTransforms/graphicsPayload"; import { resizeRows } from "../components/worldshaperShared"; import { moveItemRelative } from "./reorderableListController"; diff --git a/src/worldshaperStudio/pixiSurfaceHelpers.ts b/src/worldshaperStudio/pixiSurfaceHelpers.ts index 6f1ec19..f8e764c 100644 --- a/src/worldshaperStudio/pixiSurfaceHelpers.ts +++ b/src/worldshaperStudio/pixiSurfaceHelpers.ts @@ -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(); diff --git a/src/worldshaperStudio/pixiTileStageController.ts b/src/worldshaperStudio/pixiTileStageController.ts index 2da8c8f..9073a4d 100644 --- a/src/worldshaperStudio/pixiTileStageController.ts +++ b/src/worldshaperStudio/pixiTileStageController.ts @@ -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, diff --git a/src/worldshaperStudio/runtime.ts b/src/worldshaperStudio/runtime.ts index 3c6c8d0..63aadc9 100644 --- a/src/worldshaperStudio/runtime.ts +++ b/src/worldshaperStudio/runtime.ts @@ -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, diff --git a/src/worldshaperStudio/tileArtEditorWindowController.ts b/src/worldshaperStudio/tileArtEditorWindowController.ts index 597f0ee..136373d 100644 --- a/src/worldshaperStudio/tileArtEditorWindowController.ts +++ b/src/worldshaperStudio/tileArtEditorWindowController.ts @@ -3,12 +3,14 @@ import { buildSpritePreviewDataUrl, + getSpritePalette, +} from "../editorCore"; +import { buildSpritesPayloadFromImagesPayload, buildTilesPayloadFromImagesPayload, normalizeImagePlayback, normalizeImageRecordForSave, - getSpritePalette, -} from "../editorCore"; +} from "../contentTransforms/graphicsPayload"; import { normalizeEditorTagValue, normalizeEditorTags, From aea2c9d56be99764b747ba97806e534cc2440ecb Mon Sep 17 00:00:00 2001 From: super-jalawii Date: Sat, 27 Jun 2026 10:48:00 -0400 Subject: [PATCH 3/6] document current world and chunk semantics --- docs/refactor/world-and-chunk-semantics.md | 186 +++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/refactor/world-and-chunk-semantics.md diff --git a/docs/refactor/world-and-chunk-semantics.md b/docs/refactor/world-and-chunk-semantics.md new file mode 100644 index 0000000..d1edf4e --- /dev/null +++ b/docs/refactor/world-and-chunk-semantics.md @@ -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? From e79bc2e339af8aa34a42cc615aaf581648c14e33 Mon Sep 17 00:00:00 2001 From: super-jalawii Date: Sat, 27 Jun 2026 11:10:23 -0400 Subject: [PATCH 4/6] extract server transform and validation helpers --- server.js | 160 ++++----------------------- server/contentTransforms.d.ts | 3 + server/contentTransforms.js | 27 +++++ server/validation.d.ts | 10 ++ server/validation.js | 55 +++++++++ server/worldTransforms.d.ts | 36 ++++++ server/worldTransforms.js | 71 ++++++++++++ src/server/contentTransforms.test.ts | 21 ++++ src/server/validation.test.ts | 50 +++++++++ src/server/worldTransforms.test.ts | 79 +++++++++++++ 10 files changed, 373 insertions(+), 139 deletions(-) create mode 100644 server/contentTransforms.d.ts create mode 100644 server/contentTransforms.js create mode 100644 server/validation.d.ts create mode 100644 server/validation.js create mode 100644 server/worldTransforms.d.ts create mode 100644 server/worldTransforms.js create mode 100644 src/server/contentTransforms.test.ts create mode 100644 src/server/validation.test.ts create mode 100644 src/server/worldTransforms.test.ts diff --git a/server.js b/server.js index caf42fc..7efea2b 100644 --- a/server.js +++ b/server.js @@ -3,6 +3,23 @@ import { spawn } from "child_process"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; +import { + areRowsOnlyFillChar, + normalizeBackgroundTileId, +} from "./server/contentTransforms.js"; +import { + buildWorldChunkFileName, + defaultWorldDirRel, + getWorldStoragePaths as buildWorldStoragePaths, + normalizeWorldBookmark, + normalizeWorldIndexEntry, + normalizeWorldIndexPayload, + sanitizeWorldId, +} from "./server/worldTransforms.js"; +import { + validateCatalogMetaPayload as validateCatalogMetaPayloadShape, + validatePayload as validatePayloadShape, +} from "./server/validation.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -961,27 +978,6 @@ function writeLauncherRequestsPayload(payload) { }); } -function normalizeBackgroundTileId(value, idToSymbol = null) { - const normalizedId = String(value || "").trim(); - if (!normalizedId) { - return ""; - } - if (idToSymbol instanceof Map && idToSymbol.size > 0 && !idToSymbol.has(normalizedId)) { - return ""; - } - return normalizedId; -} - -function areRowsOnlyFillChar(rows, fillChar = ".") { - if (!Array.isArray(rows) || rows.length === 0) { - return true; - } - return rows.every((row) => { - const normalizedRow = String(row || ""); - return normalizedRow.length === 0 || normalizedRow.split("").every((ch) => ch === fillChar); - }); -} - function createDefaultColorCatalogEntries() { return DEFAULT_COLOR_HEXES_ORDERED.map((hex, index) => { const symbol = DEFAULT_COLOR_SYMBOLS_ORDERED[index] || `X${index}`; @@ -1030,70 +1026,14 @@ function readJsonSafe(fullPath, fallback) { } } -function toContentAbs(relPath) { - const normalized = String(relPath || "").replace(/\\/g, "/").replace(/^\/+/, ""); - return path.resolve(contentRoot, normalized); -} - -function sanitizeWorldId(worldId) { - const raw = String(worldId || "").trim(); - if (!raw) { - return "world"; - } - return raw.replace(/[^a-zA-Z0-9_-]/g, "_"); -} - -function defaultWorldDirRel(worldId) { - return `worlds/${sanitizeWorldId(worldId)}`; -} - -function buildWorldChunkFileName(chunkX, chunkY) { - return `${Math.floor(Number(chunkX) || 0)}_${Math.floor(Number(chunkY) || 0)}.json`; -} - function getWorldStoragePaths(worldEntryOrId) { - const worldId = typeof worldEntryOrId === "string" - ? String(worldEntryOrId || "").trim() - : String(worldEntryOrId?.id || "").trim(); - const worldDirRel = typeof worldEntryOrId === "string" - ? defaultWorldDirRel(worldId) - : String(worldEntryOrId?.worldDir || defaultWorldDirRel(worldId)); - const worldDirAbs = toContentAbs(worldDirRel); - const chunksDirRel = `${worldDirRel}/chunks`; - return { - worldId, - worldDirRel, - worldDirAbs, - worldJsonRel: `${worldDirRel}/world.json`, - worldJsonAbs: path.join(worldDirAbs, "world.json"), - bookmarksRel: `${worldDirRel}/bookmarks.json`, - bookmarksAbs: path.join(worldDirAbs, "bookmarks.json"), - chunksDirRel, - chunksDirAbs: path.join(worldDirAbs, "chunks"), - }; -} - -function normalizeWorldIndexEntry(entry) { - const id = sanitizeWorldId(entry?.id || ""); - return { - id, - name: String(entry?.name || id || "World"), - worldDir: String(entry?.worldDir || defaultWorldDirRel(id)), - }; + return buildWorldStoragePaths(contentRoot, worldEntryOrId); } function readWorldIndexPayload() { const fallback = { schemaVersion: 1, worlds: [] }; const payload = readJsonSafe(worldsIndexPath, fallback); - const worlds = Array.isArray(payload?.worlds) - ? payload.worlds - .filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry)) - .map((entry) => normalizeWorldIndexEntry(entry)) - : []; - return { - schemaVersion: typeof payload?.schemaVersion === "number" ? payload.schemaVersion : 1, - worlds, - }; + return normalizeWorldIndexPayload(payload); } function normalizeWorldDefinitionPayload(payload, fallbackId = "") { @@ -1155,16 +1095,6 @@ function readWorldDefinitionPayload(worldId) { ); } -function normalizeWorldBookmark(entry, index = 0) { - const fallbackId = `bookmark_${index + 1}`; - return { - id: String(entry?.id || fallbackId).trim() || fallbackId, - label: String(entry?.label || entry?.id || fallbackId).trim() || fallbackId, - x: Math.floor(Number(entry?.x) || 0), - y: Math.floor(Number(entry?.y) || 0), - }; -} - function readWorldBookmarksPayload(worldId) { const normalizedId = sanitizeWorldId(worldId); const storage = getWorldStoragePaths(normalizedId); @@ -2164,59 +2094,11 @@ function injectNpcNodeDescriptions(payload, meta) { } function validatePayload(payload, type, rootKey) { - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { - return "Payload must be an object"; - } - if (typeof payload.schemaVersion !== "number") { - return "schemaVersion must be a number"; - } - const allowedTopLevel = new Set(["schemaVersion", rootKey]); - const unknownTopLevel = Object.keys(payload).filter((key) => !allowedTopLevel.has(key)); - if (unknownTopLevel.length > 0) { - return `Unsupported top-level keys for ${type}: ${unknownTopLevel.join(", ")}`; - } - if (!Array.isArray(payload[rootKey])) { - return `Missing array root: ${rootKey}`; - } - - const idKey = REQUIRED_ID_KEY_BY_TYPE[type]; - if (!idKey) { - return null; - } - - const list = payload[rootKey]; - for (let index = 0; index < list.length; index += 1) { - const entry = list[index]; - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - return `${rootKey}[${index}] must be an object`; - } - const idValue = String(entry[idKey] ?? "").trim(); - if (!idValue) { - return `${rootKey}[${index}] is missing required key: ${idKey}`; - } - } - - return null; + return validatePayloadShape(payload, type, rootKey, REQUIRED_ID_KEY_BY_TYPE); } function validateCatalogMetaPayload(payload) { - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { - return "Catalog payload must be an object"; - } - if (typeof payload.schemaVersion !== "number") { - return "schemaVersion must be a number"; - } - const allowedTopLevel = new Set(["schemaVersion", ...FROZEN_CATALOG_KEYS]); - const unknownTopLevel = Object.keys(payload).filter((key) => !allowedTopLevel.has(key)); - if (unknownTopLevel.length > 0) { - return `Unsupported catalog keys: ${unknownTopLevel.join(", ")}`; - } - for (const key of FROZEN_CATALOG_KEYS) { - if (!Array.isArray(payload[key])) { - return `${key} must be an array`; - } - } - return null; + return validateCatalogMetaPayloadShape(payload, FROZEN_CATALOG_KEYS); } function writeJsonAtomic(fullPath, data) { diff --git a/server/contentTransforms.d.ts b/server/contentTransforms.d.ts new file mode 100644 index 0000000..e5acf28 --- /dev/null +++ b/server/contentTransforms.d.ts @@ -0,0 +1,3 @@ +export function normalizeBackgroundTileId(value: unknown, idToSymbol?: Map | null): string; +export function areRowsOnlyFillChar(rows: unknown, fillChar?: string): boolean; +export function resolveContentPath(contentRoot: string, relativePath: string): string; diff --git a/server/contentTransforms.js b/server/contentTransforms.js new file mode 100644 index 0000000..9465cb8 --- /dev/null +++ b/server/contentTransforms.js @@ -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); +} diff --git a/server/validation.d.ts b/server/validation.d.ts new file mode 100644 index 0000000..eee0347 --- /dev/null +++ b/server/validation.d.ts @@ -0,0 +1,10 @@ +export function validatePayload( + payload: unknown, + type: string, + rootKey: string, + requiredIdKeyByType: Record, +): string | null; +export function validateCatalogMetaPayload( + payload: unknown, + frozenCatalogKeys: string[], +): string | null; diff --git a/server/validation.js b/server/validation.js new file mode 100644 index 0000000..008eae1 --- /dev/null +++ b/server/validation.js @@ -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; +} diff --git a/server/worldTransforms.d.ts b/server/worldTransforms.d.ts new file mode 100644 index 0000000..ef7fbba --- /dev/null +++ b/server/worldTransforms.d.ts @@ -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; +}; diff --git a/server/worldTransforms.js b/server/worldTransforms.js new file mode 100644 index 0000000..ebd1af9 --- /dev/null +++ b/server/worldTransforms.js @@ -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), + }; +} diff --git a/src/server/contentTransforms.test.ts b/src/server/contentTransforms.test.ts new file mode 100644 index 0000000..d4062a8 --- /dev/null +++ b/src/server/contentTransforms.test.ts @@ -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"); + }); +}); diff --git a/src/server/validation.test.ts b/src/server/validation.test.ts new file mode 100644 index 0000000..7d3e04d --- /dev/null +++ b/src/server/validation.test.ts @@ -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"); + }); +}); diff --git a/src/server/worldTransforms.test.ts b/src/server/worldTransforms.test.ts new file mode 100644 index 0000000..ba8fafe --- /dev/null +++ b/src/server/worldTransforms.test.ts @@ -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, + }); + }); +}); From 656f4562c1927eb81eae672716e60e0d5ae995ee Mon Sep 17 00:00:00 2001 From: super-jalawii Date: Sat, 27 Jun 2026 11:10:27 -0400 Subject: [PATCH 5/6] restore content editor launcher entrypoint --- docs/refactor/arc-1-smoke-checklist.md | 75 ++++++++++++++++++++++++++ src/WorldshaperLauncher.tsx | 13 +++++ src/contentMain.tsx | 11 ++++ vite.config.ts | 1 + worldshaper-content.html | 13 +++++ 5 files changed, 113 insertions(+) create mode 100644 docs/refactor/arc-1-smoke-checklist.md create mode 100644 src/contentMain.tsx create mode 100644 worldshaper-content.html diff --git a/docs/refactor/arc-1-smoke-checklist.md b/docs/refactor/arc-1-smoke-checklist.md new file mode 100644 index 0000000..f3bbb8f --- /dev/null +++ b/docs/refactor/arc-1-smoke-checklist.md @@ -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 diff --git a/src/WorldshaperLauncher.tsx b/src/WorldshaperLauncher.tsx index 22d1196..0cec1cb 100644 --- a/src/WorldshaperLauncher.tsx +++ b/src/WorldshaperLauncher.tsx @@ -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() { + diff --git a/src/contentMain.tsx b/src/contentMain.tsx new file mode 100644 index 0000000..9f2cb63 --- /dev/null +++ b/src/contentMain.tsx @@ -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( + + + , +); diff --git a/vite.config.ts b/vite.config.ts index 6d59436..e66638d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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"), diff --git a/worldshaper-content.html b/worldshaper-content.html new file mode 100644 index 0000000..bc30fa2 --- /dev/null +++ b/worldshaper-content.html @@ -0,0 +1,13 @@ + + + + + + + Worldshaper Content Editor + + +
+ + + From 17c64e1fbe665c5664260b1fc7bfdf560767f267 Mon Sep 17 00:00:00 2001 From: Andraxion Date: Sat, 27 Jun 2026 16:34:03 -0400 Subject: [PATCH 6/6] Improve VPS delivery and simplify editor history --- package-lock.json | 64 ++ package.json | 2 +- server.js | 64 +- src/worldshaperStudio/historyController.ts | 706 ++++-------------- src/worldshaperStudio/runtime.ts | 11 +- .../worldOverviewWindowController.ts | 31 +- 6 files changed, 283 insertions(+), 595 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5f18269..a2709c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "worldshaper", "version": "0.0.3", "dependencies": { + "compression": "^1.8.1", "express": "^4.19.2", "pixi.js": "^8.19.0", "react": "^19.2.6", @@ -1442,6 +1443,60 @@ ], "license": "CC-BY-4.0" }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2869,6 +2924,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index b2444b2..560d895 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "preview": "vite preview" }, "dependencies": { + "compression": "^1.8.1", "express": "^4.19.2", "pixi.js": "^8.19.0", "react": "^19.2.6", @@ -36,4 +37,3 @@ "vite": "^8.0.12" } } - diff --git a/server.js b/server.js index caf42fc..ad59e8a 100644 --- a/server.js +++ b/server.js @@ -1,4 +1,5 @@ import express from "express"; +import compression from "compression"; import { spawn } from "child_process"; import fs from "fs"; import path from "path"; @@ -999,8 +1000,25 @@ function createDefaultColorCatalogEntries() { }); } +app.use(compression({ threshold: 1024 })); app.use(express.json({ limit: "10mb" })); -app.use(express.static(path.join(__dirname, "dist"))); +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"); + } + }, +})); function resolveContent(type) { const entry = contentMap[type]; @@ -1325,6 +1343,33 @@ 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) { @@ -3505,6 +3550,19 @@ 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; @@ -3524,8 +3582,8 @@ app.get("/api/world/:worldId/overview", (req, res) => { maxTileX: ((maxChunkX + 1) * chunkWidth) - 1, maxTileY: ((maxChunkY + 1) * chunkHeight) - 1, }, - chunkCount: chunks.length, - chunks, + chunkCount: overviewChunks.length, + chunks: overviewChunks, }); } catch (err) { res.status(500).json({ diff --git a/src/worldshaperStudio/historyController.ts b/src/worldshaperStudio/historyController.ts index 1be408c..7c1f101 100644 --- a/src/worldshaperStudio/historyController.ts +++ b/src/worldshaperStudio/historyController.ts @@ -1,24 +1,14 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment, no-empty */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ // @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 = 40; - const MAX_PERSISTED_HISTORY_CHARS = 1_500_000; - const OPERATION_CHECKPOINT_INTERVAL = 12; + const MAX_HISTORY_ENTRIES = 2; 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; @@ -29,102 +19,26 @@ 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; + return true; } 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; - } + return null; } - 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 applyHistorySnapshot() { + return false; } function captureState() { return { - width: Number(documentScope.width) || 1, - height: Number(documentScope.height) || 1, + width: Math.max(1, Number(documentScope.width) || 1), + height: Math.max(1, Number(documentScope.height) || 1), mapName: String(documentScope.mapName || scope.mapId || ""), backgroundColor: documentScope.normalizeMapBackgroundColor(documentScope.backgroundColor), backgroundTileId: String(documentScope.backgroundTileId || "").trim(), @@ -142,6 +56,35 @@ 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; @@ -185,9 +128,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(); @@ -202,7 +145,7 @@ export function createHistoryController(scope) { scope.applyWorldChunkBackgroundState(state?.worldChunkBackgrounds || {}); } if (typeof scope.applyWorldBookmarkState === "function" && scope.isWorldModeActive?.()) { - scope.applyWorldBookmarkState(state?.worldBookmarks || []); + scope.applyWorldBookmarkState(Array.isArray(state?.worldBookmarks) ? state.worldBookmarks : []); } if (typeof scope.rebuildVisibleWorldChunksFromDocument === "function" && typeof scope.isWorldModeActive === "function" && scope.isWorldModeActive()) { scope.rebuildVisibleWorldChunksFromDocument(); @@ -212,388 +155,25 @@ export function createHistoryController(scope) { } } - 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; - } - 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) { + const targetEntry = scope.historyEntries[normalizedTargetIndex] || null; + if (!targetEntry?.state) { 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(); + applyState(targetEntry.state); return true; } function getStateSignature(state) { - const layerSig = scope.cloneLayers(state.layers) - .sort((a, b) => a.layer - b.layer) + const layerSig = scope.cloneLayers(Array.isArray(state?.layers) ? state.layers : []) + .sort((a, b) => Number(a.layer) - Number(b.layer)) .map((layer) => ({ - layer: layer.layer, + layer: Number(layer.layer) || 0, name: typeof layer.name === "string" ? layer.name : "", - rows: scope.normalizeRows(layer.rows, layer.layer === 0 ? "." : " "), + rows: scope.normalizeRows(layer.rows, Number(layer.layer) === 0 ? "." : " "), })); - const heightLayerSig = scope.cloneHeightLayers(state.heightLayers) + const heightLayerSig = scope.cloneHeightLayers(Array.isArray(state?.heightLayers) ? state.heightLayers : []) .sort((a, b) => String(a.id || "").localeCompare(String(b.id || ""))) .map((entry) => ({ id: String(entry.id || ""), @@ -603,30 +183,38 @@ export function createHistoryController(scope) { y: Number(entry.y) || 0, rows: Array.isArray(entry.rows) ? entry.rows.map((row) => String(row || "")) : [], })); - const npcSig = scope.cloneNpcOverlays(state.npcs) - .sort((a, b) => a.id.localeCompare(b.id)) + const npcSig = scope.cloneNpcOverlays(Array.isArray(state?.npcs) ? state.npcs : []) + .sort((a, b) => String(a.id || "").localeCompare(String(b.id || ""))) .map((entry) => ({ - id: entry.id, + id: String(entry.id || ""), layer: Number(entry.layer) || 0, - name: entry.name, - spriteId: entry.spriteId, - x: entry.x, - y: entry.y, + name: String(entry.name || ""), + spriteId: String(entry.spriteId || ""), + x: Number(entry.x) || 0, + y: Number(entry.y) || 0, })); return JSON.stringify({ - 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)), + 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)), layerSig, heightLayerSig, npcSig, worldChunkBackgrounds: state && state.worldChunkBackgrounds && typeof state.worldChunkBackgrounds === "object" && !Array.isArray(state.worldChunkBackgrounds) ? state.worldChunkBackgrounds : {}, - editorUi: scope.cloneEditorUiState(state.editorUi || {}), + 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 || {}), }); } @@ -642,38 +230,51 @@ export function createHistoryController(scope) { } function renderHistoryPreview() { - const selectedEntry = scope.historyEntries[scope.historySelectionIndex] || null; - if (!selectedEntry) { - scope.historyPreviewEl.innerHTML = '

Change Preview

Select a history entry to inspect it.
'; + if (!scope.historyPreviewEl) { + return; + } + const selectedEntry = scope.historyEntries[scope.historySelectionIndex] || null; + if (!selectedEntry) { + scope.historyPreviewEl.innerHTML = '

Undo / Redo

Single-step history is empty.
'; return; } - const details = Array.isArray(selectedEntry.details) ? selectedEntry.details : []; const detailHtml = details.length > 0 ? "
    " + details.map((detail) => "
  • " + detail + "
  • ").join("") + "
" : '
No additional details recorded.
'; - const currentText = scope.historySelectionIndex === scope.historyIndex ? "Current state" : "Selected step " + selectedEntry.seq; + const title = scope.historySelectionIndex === scope.historyIndex ? "Current state" : "Single undo step"; scope.historyPreviewEl.innerHTML = - "

" + currentText + "

" + + "

" + title + "

" + '
' + formatHistoryLabel(selectedEntry) + "
" + detailHtml + - ''; - - 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); - }); + '
Only one undo / redo step is kept in memory.
'; } function renderHistoryList() { - scope.historyListEl.innerHTML = ""; + if (scope.historyListEl) { + scope.historyListEl.innerHTML = ""; + scope.historyEntries.forEach((entry, index) => { + if (index === scope.historyIndex) { + return; + } + const row = document.createElement("button"); + row.type = "button"; + row.className = "history-row" + (index === scope.historySelectionIndex ? " active" : ""); + const timeText = new Date(entry.createdAt).toLocaleTimeString(); + row.innerHTML = + "" + String(entry.seq) + ". " + formatHistoryLabel(entry) + "" + + '' + timeText + ""; + row.addEventListener("click", () => { + if (index === scope.historySelectionIndex) { + return; + } + scope.historySelectionIndex = index; + renderHistoryList(); + renderHistoryPreview(); + }); + scope.historyListEl.appendChild(row); + }); + } if (scope.historyCurrentEl) { const currentEntry = scope.historyEntries[scope.historyIndex] || null; scope.historyCurrentEl.innerHTML = currentEntry @@ -686,34 +287,13 @@ export function createHistoryController(scope) { ) : '
Current State
No history yet.
'; } - scope.historyEntries.forEach((entry, index) => { - if (index === scope.historyIndex) { - return; - } - const row = document.createElement("button"); - row.type = "button"; - row.className = "history-row" + (index === scope.historySelectionIndex ? " active" : ""); - const timeText = new Date(entry.createdAt).toLocaleTimeString(); - row.innerHTML = - "" + String(entry.seq) + ". " + formatHistoryLabel(entry) + "" + - '' + timeText + ""; - row.addEventListener("click", () => { - if (index === scope.historySelectionIndex) { - return; - } - scope.historySelectionIndex = index; - renderHistoryList(); - renderHistoryPreview(); - }); - scope.historyListEl.appendChild(row); - }); } function refreshToolbarState(preserveCurrentStatus) { const canUndo = scope.historyIndex > 0; const canRedo = scope.historyIndex < scope.historyEntries.length - 1; - const currentHistoryId = scope.historyEntries[scope.historyIndex] ? scope.historyEntries[scope.historyIndex].id : 0; - const isDirtyFromSaved = currentHistoryId !== scope.lastSavedHistoryId; + const currentHistoryId = scope.historyEntries[scope.historyIndex] ? Number(scope.historyEntries[scope.historyIndex].id) || 0 : 0; + const isDirtyFromSaved = currentHistoryId !== (Number(scope.lastSavedHistoryId) || 0); scope.undoBtn.disabled = scope.isSaving || !canUndo; scope.redoBtn.disabled = scope.isSaving || !canRedo; @@ -725,13 +305,12 @@ export function createHistoryController(scope) { if (preserveCurrentStatus) { return; } - if (scope.isSaving) { scope.setStatus("Saving...", false); } else if (canRedo) { - scope.setStatus("History branch active. New edits will replace future steps.", false); + scope.setStatus("Redo is available. A new edit will replace it.", false); } else if (isDirtyFromSaved) { - scope.setStatus("Unsaved history changes.", false); + scope.setStatus("Unsaved changes.", false); } else { scope.setStatus("All changes saved.", false); } @@ -739,20 +318,10 @@ export function createHistoryController(scope) { function registerHistory(label, before, after, details, options) { const config = options && typeof options === "object" ? options : {}; - 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 nextState = cloneHistoryState(config.nextState || captureState()); const currentEntry = scope.historyEntries[scope.historyIndex] || null; - const currentState = currentEntry && currentEntry.state - ? currentEntry.state - : captureHistoryStateAtIndex(scope.historyIndex); - if (!config.skipStateCheck && nextState && currentState && getStateSignature(nextState) === getStateSignature(currentState)) { + const currentState = currentEntry?.state ? cloneHistoryState(currentEntry.state) : captureState(); + if (!config.skipStateCheck && getStateSignature(nextState) === getStateSignature(currentState)) { return; } @@ -760,25 +329,6 @@ 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, @@ -787,47 +337,47 @@ export function createHistoryController(scope) { before, after, details: Array.isArray(details) ? details : [], - state: nextState || checkpointState, - operation, + state: nextState, }; 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; + return false; } scope.historyIndex -= 1; scope.historySelectionIndex = scope.historyIndex; - restoreToHistoryIndex(scope.historyIndex); + const restored = restoreToHistoryIndex(scope.historyIndex); schedulePersistHistoryState(); refreshToolbarState(); - scope.setStatus("Undo to step " + scope.historyEntries[scope.historyIndex].seq + ".", false); + if (restored) { + scope.setStatus("Undid the last change.", false); + } + return restored; } function redo() { if (scope.historyIndex >= scope.historyEntries.length - 1) { - return; + return false; } scope.historyIndex += 1; scope.historySelectionIndex = scope.historyIndex; - restoreToHistoryIndex(scope.historyIndex); + const restored = restoreToHistoryIndex(scope.historyIndex); schedulePersistHistoryState(); refreshToolbarState(); - scope.setStatus("Redo to step " + scope.historyEntries[scope.historyIndex].seq + ".", false); + if (restored) { + scope.setStatus("Redid the last change.", false); + } + return restored; } return { @@ -837,7 +387,7 @@ export function createHistoryController(scope) { applyHistorySnapshot, captureState, applyState, - applyOperation, + applyOperation: () => false, restoreToHistoryIndex, getStateSignature, formatCellCoord, diff --git a/src/worldshaperStudio/runtime.ts b/src/worldshaperStudio/runtime.ts index 3c6c8d0..3105e4d 100644 --- a/src/worldshaperStudio/runtime.ts +++ b/src/worldshaperStudio/runtime.ts @@ -4513,11 +4513,12 @@ export function startWorldshaperStudio(bootstrap: WorldshaperStudioBootstrap, in scope.historySelectionIndex = 0; scope.lastSavedHistoryId = initialEntry.id; scope.nextHistoryId = initialEntry.id + 1; - - const restoredHistory = scope.restoreHistoryState(); - if (!scope.applyHistorySnapshot(restoredHistory)) { - scope.persistHistoryState(); - } + try { + if (currentHistoryStorageKey) { + window.localStorage.removeItem(currentHistoryStorageKey); + } + } catch {} + scope.persistHistoryState(); } function refreshUiForLoadedMap() { diff --git a/src/worldshaperStudio/worldOverviewWindowController.ts b/src/worldshaperStudio/worldOverviewWindowController.ts index 7cd468a..7346af1 100644 --- a/src/worldshaperStudio/worldOverviewWindowController.ts +++ b/src/worldshaperStudio/worldOverviewWindowController.ts @@ -46,7 +46,9 @@ type OverviewChunk = { height?: number; backgroundTileId?: string; roomLayers?: OverviewRoomLayer[]; + surfaceRows?: string[]; instances?: unknown[]; + instanceCount?: number; }; type OverviewPayload = { @@ -860,7 +862,9 @@ 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 : 0; + const entityCount = Array.isArray(chunk?.instances) + ? chunk.instances.length + : Math.max(0, Math.floor(Number(chunk?.instanceCount) || 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") + ".", @@ -1395,13 +1399,15 @@ export function createWorldOverviewWindowController(scope: WorldOverviewScope) { return String(entry?.symbol || ".").charAt(0) || "."; } - function getTopVisibleSymbol(chunk: OverviewChunk | null | undefined, localX: number, localY: number) { + function getTopVisibleSymbol( + chunk: OverviewChunk | null | undefined, + sortedRoomLayers: OverviewRoomLayer[], + 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 || ""; - roomLayers.forEach((layer: OverviewRoomLayer) => { + sortedRoomLayers.forEach((layer: OverviewRoomLayer) => { const layerNumber = Number(layer?.layer) || 0; const fillChar = layerNumber === 0 ? "." : " "; const row = Array.isArray(layer?.rows) ? String(layer.rows[localY] || "") : ""; @@ -1433,7 +1439,8 @@ export function createWorldOverviewWindowController(scope: WorldOverviewScope) { } function buildChunkSurfaceSignature(chunk: OverviewChunk | null | undefined) { - const roomLayerSig = (Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : []) + const surfaceSig = Array.isArray(chunk?.surfaceRows) ? chunk.surfaceRows.join("|") : ""; + const roomLayerSig = surfaceSig || (Array.isArray(chunk?.roomLayers) ? chunk.roomLayers : []) .map((layer: OverviewRoomLayer) => String(Number(layer?.layer) || 0) + ":" + (Array.isArray(layer?.rows) ? layer.rows.join("|") : "")) .join("~"); return [ @@ -1459,10 +1466,18 @@ 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 color = getSymbolColor(getTopVisibleSymbol(chunk, localX, localY), chunk); + const visibleSymbol = surfaceRow + ? (String(surfaceRow.charAt(localX) || ".").charAt(0) || ".") + : getTopVisibleSymbol(chunk, sortedRoomLayers, localX, localY); + const color = getSymbolColor(visibleSymbol, chunk); const pixelIndex = (localY * chunkWidth + localX) * 4; imageData.data[pixelIndex] = color[0]; imageData.data[pixelIndex + 1] = color[1];