diff --git a/api/package.json b/api/package.json index 74ecba9b..64e2a164 100644 --- a/api/package.json +++ b/api/package.json @@ -42,6 +42,7 @@ "@types/express": "^5.0.3", "@types/node": "^25.8.0", "@types/pg": "^8.15.5", + "embedded-postgres": "18.4.0-beta.17", "tsx": "^4.22.1", "typescript": "^6.0.3", "vitest": "^4.1.6" diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml index 8c23005c..994a1b4e 100644 --- a/api/pnpm-lock.yaml +++ b/api/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: '@types/pg': specifier: ^8.15.5 version: 8.20.0 + embedded-postgres: + specifier: 18.4.0-beta.17 + version: 18.4.0-beta.17 tsx: specifier: ^4.22.1 version: 4.22.1 @@ -130,6 +133,54 @@ packages: resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} + '@embedded-postgres/darwin-arm64@18.4.0-beta.17': + resolution: {integrity: sha512-Kg1ZMNFzkVIJs3g4V2UYEc5X005km9rGinxFBH8R1sOU2Rblvz4pt2Uf93Pu8Rk273Ue9HIdrbMPpgUqwjUi0Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@embedded-postgres/darwin-x64@18.4.0-beta.17': + resolution: {integrity: sha512-4tShSYWMxUQTSWoQAdLnHISvRj6L9gTch9/31ASJPg6wgyN64LDRwMcOINEWybM7N0ropJ3nTYhFT3UX1bKY5Q==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@embedded-postgres/linux-arm64@18.4.0-beta.17': + resolution: {integrity: sha512-TIzjtGnDGSD/LbdW0OWCEcbI+YVT0WKSfSr20TCkkP4UR8zeKe+QCZbO0/CM4hS9EWyZ4cDebjRvpRc3iMi6wg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@embedded-postgres/linux-arm@18.4.0-beta.17': + resolution: {integrity: sha512-VuD1nFasN7yG57zpJcp6MBXM0nXqGfb8GBQV+f1FGJ17+VMNYLIFLhFp99UlY4xnWG74FQC4NPYw8eCwSTJ6qg==} + engines: {node: '>=16'} + cpu: [arm] + os: [linux] + + '@embedded-postgres/linux-ia32@18.4.0-beta.17': + resolution: {integrity: sha512-aSRe4kknqRHuedtpo/rDIaqa8r9VrKIgW9p5FkJyIFqUUkIkxRqND1dg8xrDRREcSUXRJKS0x+j/AuxflgYIfg==} + engines: {node: '>=16'} + cpu: [ia32] + os: [linux] + + '@embedded-postgres/linux-ppc64@18.4.0-beta.17': + resolution: {integrity: sha512-zO2RRUFdKRPplrdhmglG39VXJZzDXu3P3ONAG1sFPhGh89vbk24Btd5P7uOmQLIpWehzL0s+UqUWoeE/ZUKvgw==} + engines: {node: '>=16'} + cpu: [ppc64] + os: [linux] + + '@embedded-postgres/linux-x64@18.4.0-beta.17': + resolution: {integrity: sha512-jVw/MdDtIX/vICH/DKIe6/mHpiCggdx6QVyza4vt/NbcZFsL0KhwglF6F1Koqx3gRBZ9XtN+vi63EsqSyqOSxA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@embedded-postgres/windows-x64@18.4.0-beta.17': + resolution: {integrity: sha512-AwRerliA4IGWyW5jWBvHt5vidVUSB0QQW9Tt2y7ScnmifnCb/awfxZr4BkBSTv+gNt8Djcddqs+xSF5Z6/CkTg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -482,79 +533,66 @@ packages: resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.3': resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.3': resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.3': resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.3': resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.3': resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.3': resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.3': resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.3': resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.3': resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.3': resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.3': resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.3': resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.3': resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} @@ -714,6 +752,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + bcryptjs@3.0.3: resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} hasBin: true @@ -780,6 +822,10 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + embedded-postgres@18.4.0-beta.17: + resolution: {integrity: sha512-ORT/A1YiO6ecpRHpyIIBgyCR/8ASqqYZuFw11aX9PkqTX3FUM5+9sNOXY1mGIixxsm2TWSIA5WghJEk7A5ZxVw==} + engines: {node: '>=16'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1445,6 +1491,30 @@ snapshots: '@aws/lambda-invoke-store@0.2.4': {} + '@embedded-postgres/darwin-arm64@18.4.0-beta.17': + optional: true + + '@embedded-postgres/darwin-x64@18.4.0-beta.17': + optional: true + + '@embedded-postgres/linux-arm64@18.4.0-beta.17': + optional: true + + '@embedded-postgres/linux-arm@18.4.0-beta.17': + optional: true + + '@embedded-postgres/linux-ia32@18.4.0-beta.17': + optional: true + + '@embedded-postgres/linux-ppc64@18.4.0-beta.17': + optional: true + + '@embedded-postgres/linux-x64@18.4.0-beta.17': + optional: true + + '@embedded-postgres/windows-x64@18.4.0-beta.17': + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -1844,6 +1914,8 @@ snapshots: assertion-error@2.0.1: {} + async-exit-hook@2.0.1: {} + bcryptjs@3.0.3: {} body-parser@2.2.2: @@ -1900,6 +1972,22 @@ snapshots: ee-first@1.1.1: {} + embedded-postgres@18.4.0-beta.17: + dependencies: + async-exit-hook: 2.0.1 + pg: 8.20.0 + optionalDependencies: + '@embedded-postgres/darwin-arm64': 18.4.0-beta.17 + '@embedded-postgres/darwin-x64': 18.4.0-beta.17 + '@embedded-postgres/linux-arm': 18.4.0-beta.17 + '@embedded-postgres/linux-arm64': 18.4.0-beta.17 + '@embedded-postgres/linux-ia32': 18.4.0-beta.17 + '@embedded-postgres/linux-ppc64': 18.4.0-beta.17 + '@embedded-postgres/linux-x64': 18.4.0-beta.17 + '@embedded-postgres/windows-x64': 18.4.0-beta.17 + transitivePeerDependencies: + - pg-native + encodeurl@2.0.0: {} es-define-property@1.0.1: {} diff --git a/api/scripts/run-full-suite.sh b/api/scripts/run-full-suite.sh new file mode 100755 index 00000000..6ad58f20 --- /dev/null +++ b/api/scripts/run-full-suite.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -u +ROOT=/home/ethan/.automaton/workspace/01M2E8VWQM4NBY2TF3CW0QBGCK +cd "$ROOT/openao/api" || exit 1 +PQLIB="$(dirname "$(find node_modules/.pnpm -name 'libpq.so.5*' 2>/dev/null | head -1)")" +export LD_LIBRARY_PATH="$ROOT/pg-libs:${PQLIB}:${LD_LIBRARY_PATH:-}" +rm -rf .testpg +node scripts/test-db.mjs > /tmp/testdb.log 2>&1 & +DBPID=$! +for i in $(seq 1 90); do grep -q DB_READY /tmp/testdb.log 2>/dev/null && break; sleep 1; done +if ! grep -q DB_READY /tmp/testdb.log; then + echo "DB_FAILED"; head -20 /tmp/testdb.log + BIN="$(find node_modules/.pnpm -path '*linux-x64*' -name initdb | head -1)" + echo "--- ldd ---"; ldd "$BIN" 2>/dev/null | grep "not found" || echo "loader: all resolved" + kill $DBPID 2>/dev/null; exit 1 +fi +echo "DB_UP" +export DATABASE_URL="postgres://openao:openao@127.0.0.1:5434/openao" TOKEN_AUTH="test-secret" JWT_SECRET="jwt-secret" NODE_ENV=test +if [ -f src/migrate.ts ]; then + pnpm tsx src/migrate.ts > /tmp/migrate.log 2>&1; echo "MIGRATE_EXIT=$?"; tail -3 /tmp/migrate.log +else echo "no migrate entrypoint"; fi +pnpm tsx src/server.ts > /tmp/api.log 2>&1 & +APIPID=$! +UP=0 +for i in $(seq 1 120); do curl -s -o /dev/null http://127.0.0.1:3001/ && UP=1 && break; sleep 1; done +if [ "$UP" = "1" ]; then echo "API_UP"; else + echo "API_FAILED"; tail -25 /tmp/api.log; kill $DBPID 2>/dev/null; exit 1; fi +pnpm vitest run > /tmp/suite-final.txt 2>&1; echo "VITEST_EXIT=$?" +tail -50 /tmp/suite-final.txt +kill $APIPID $DBPID 2>/dev/null +echo "COMPLETE" diff --git a/api/src/lib/userMapLifecycle.test.ts b/api/src/lib/userMapLifecycle.test.ts new file mode 100644 index 00000000..2fea08cd --- /dev/null +++ b/api/src/lib/userMapLifecycle.test.ts @@ -0,0 +1,184 @@ +import { + USER_MAP_LIFECYCLE_STATES, + USER_MAP_MAX_ID, + USER_MAP_MIN_ID, + assertNoUserMapRewards, + assertUserMapEditable, + assertUserMapTransition, + canEditUserMapContent, + canEmitRewards, + canTransitionUserMap, + isUserMapId, + isValidLifecycleState, + type UserMapLifecycleState, +} from "./userMapLifecycle"; +import { describe, expect, it } from "vitest"; + +const ALL: UserMapLifecycleState[] = ["draft", "proposed", "published", "archived"]; + +describe("user map lifecycle", () => { + describe("valid transitions", () => { + it("draft -> proposed succeeds", () => { + expect(canTransitionUserMap({ from: "draft", to: "proposed" })).toEqual({ ok: true }); + }); + + it("proposed -> published succeeds via the explicit publish action", () => { + expect( + canTransitionUserMap({ from: "proposed", to: "published", viaPublishAction: true }), + ).toEqual({ ok: true }); + }); + + it("archive is allowed from every non-terminal state", () => { + for (const from of ["draft", "proposed", "published"] as UserMapLifecycleState[]) { + expect(canTransitionUserMap({ from, to: "archived" })).toEqual({ ok: true }); + } + }); + + it("assert does not throw for every valid transition", () => { + expect(() => assertUserMapTransition({ from: "draft", to: "proposed" })).not.toThrow(); + expect(() => + assertUserMapTransition({ from: "proposed", to: "published", viaPublishAction: true }), + ).not.toThrow(); + expect(() => assertUserMapTransition({ from: "published", to: "archived" })).not.toThrow(); + }); + }); + + describe("invalid transitions", () => { + it("draft -> published is rejected even with the publish action", () => { + const d = canTransitionUserMap({ from: "draft", to: "published", viaPublishAction: true }); + expect(d.ok).toBe(false); + expect(() => + assertUserMapTransition({ from: "draft", to: "published", viaPublishAction: true }), + ).toThrow(); + }); + + it("proposed -> published without the explicit publish action is rejected", () => { + expect(canTransitionUserMap({ from: "proposed", to: "published" }).ok).toBe(false); + expect( + canTransitionUserMap({ from: "proposed", to: "published", viaPublishAction: false }).ok, + ).toBe(false); + }); + + it("backwards transitions are rejected", () => { + expect(canTransitionUserMap({ from: "proposed", to: "draft" }).ok).toBe(false); + expect(canTransitionUserMap({ from: "published", to: "proposed" }).ok).toBe(false); + expect(canTransitionUserMap({ from: "published", to: "draft" }).ok).toBe(false); + }); + + it("archived is terminal — no transitions out", () => { + for (const to of ALL) { + if (to === "archived") continue; + expect(canTransitionUserMap({ from: "archived", to }).ok).toBe(false); + } + expect(() => assertUserMapTransition({ from: "archived", to: "draft" })).toThrow( + /read-only/, + ); + }); + + it("same-state no-ops and unknown states are rejected", () => { + for (const s of ALL) { + expect(canTransitionUserMap({ from: s, to: s }).ok).toBe(false); + } + expect(canTransitionUserMap({ from: "bogus" as never, to: "draft" }).ok).toBe(false); + expect(canTransitionUserMap({ from: "draft", to: "bogus" as never }).ok).toBe(false); + expect(isValidLifecycleState("bogus")).toBe(false); + }); + }); + + describe("archived maps are read-only", () => { + it("content edits blocked when archived, allowed otherwise", () => { + expect(canEditUserMapContent("archived")).toBe(false); + expect(() => assertUserMapEditable("archived")).toThrow(/read-only/); + for (const s of ["draft", "proposed", "published"] as UserMapLifecycleState[]) { + expect(canEditUserMapContent(s)).toBe(true); + expect(() => assertUserMapEditable(s)).not.toThrow(); + } + }); + }); +}); + +describe("user map reward enforcement (zero gold / zero XP)", () => { + it("classifies only the reserved 600-1999 range as user maps", () => { + expect(isUserMapId(USER_MAP_MIN_ID)).toBe(true); + expect(isUserMapId(USER_MAP_MAX_ID)).toBe(true); + expect(isUserMapId(599)).toBe(false); + expect(isUserMapId(2000)).toBe(false); + expect(isUserMapId(1234)).toBe(true); + expect(isUserMapId(-1)).toBe(false); + expect(isUserMapId(1.5)).toBe(false); + }); + + it("canEmitRewards is false for user maps in ANY state", () => { + for (const state of ALL) { + // state is irrelevant: the map ID alone blocks emissions + expect(canEmitRewards(700)).toBe(false); + expect(state).toBeDefined(); + } + expect(canEmitRewards(599)).toBe(true); + expect(canEmitRewards(2000)).toBe(true); + }); + + it("a published user map emits zero gold and zero XP", () => { + // Publishing succeeds via the explicit action... + expect( + canTransitionUserMap({ from: "proposed", to: "published", viaPublishAction: true }).ok, + ).toBe(true); + + // ...but any reward emission from the published user map is blocked. + expect(() => assertNoUserMapRewards({ sourceMapId: 700, gold: 50 })).toThrow(/gold/); + expect(() => assertNoUserMapRewards({ sourceMapId: 700, exp: 120 })).toThrow(/xp/); + expect(() => + assertNoUserMapRewards({ sourceMapId: 700, gold: 50, exp: 120 }), + ).toThrow(/gold and xp/); + + // Zero-value emissions and no-op emissions are permitted (nothing granted). + expect(() => assertNoUserMapRewards({ sourceMapId: 700, gold: 0, exp: 0 })).not.toThrow(); + expect(() => assertNoUserMapRewards({ sourceMapId: 700 })).not.toThrow(); + }); + + it("non-user-map sources can still emit rewards (guard only targets user maps)", () => { + expect(() => assertNoUserMapRewards({ sourceMapId: 42, gold: 50, exp: 10 })).not.toThrow(); + expect(() => assertNoUserMapRewards({ sourceMapId: 2000, gold: 1 })).not.toThrow(); + }); +}); + +describe("user map reward emissions (issue #24 item 4)", () => { + it("refuses emission for EVERY user map ID in 600-1999", () => { + for (let id = 600; id <= 1999; id++) { + expect(canEmitRewards(id)).toBe(false); + } + }); + + it("non-user maps remain unrestricted", () => { + expect(canEmitRewards(0)).toBe(true); + expect(canEmitRewards(599)).toBe(true); + expect(canEmitRewards(2000)).toBe(true); + expect(() => + assertNoUserMapRewards({ sourceMapId: 42, gold: 100, exp: 50 }), + ).not.toThrow(); + }); + + it("a published user map emits zero gold and zero XP", () => { + // zero emission is permitted (nothing granted) + expect(() => + assertNoUserMapRewards({ sourceMapId: 700, gold: 0, exp: 0 }), + ).not.toThrow(); + // any non-zero grant from a user map is blocked server-side + expect(() => + assertNoUserMapRewards({ sourceMapId: 700, gold: 1 }), + ).toThrow(/user map 700/); + expect(() => + assertNoUserMapRewards({ sourceMapId: 700, exp: 1 }), + ).toThrow(/user map 700/); + }); + + it("guard is independent of lifecycle state or caller", () => { + for (const _state of USER_MAP_LIFECYCLE_STATES) { + // draft/proposed/published/archived all behave identically: no rewards + expect(canEmitRewards(600)).toBe(false); + } + expect(() => + assertNoUserMapRewards({ sourceMapId: 1999, gold: 25, exp: 120 }), + ).toThrow(/gold and xp/); + }); +}); diff --git a/api/src/lib/userMapLifecycle.ts b/api/src/lib/userMapLifecycle.ts new file mode 100644 index 00000000..818f5795 --- /dev/null +++ b/api/src/lib/userMapLifecycle.ts @@ -0,0 +1,149 @@ +/** + * User-map lifecycle state machine and economic-safety reward guard. + * + * Issue #24 item 4: user maps move through a server-enforced lifecycle + * (draft -> proposed -> published -> archived) and NEVER emit gold or XP + * rewards, regardless of lifecycle state or caller. + */ + +export type UserMapLifecycleState = "draft" | "proposed" | "published" | "archived"; + +export const USER_MAP_LIFECYCLE_STATES: readonly UserMapLifecycleState[] = [ + "draft", + "proposed", + "published", + "archived", +] as const; + +/** ID range reserved for user-created maps (matches frontend gameLoader reservation). */ +export const USER_MAP_MIN_ID = 600; +export const USER_MAP_MAX_ID = 1999; + +export function isUserMapId(mapId: number): boolean { + return Number.isInteger(mapId) && mapId >= USER_MAP_MIN_ID && mapId <= USER_MAP_MAX_ID; +} + +export function isValidLifecycleState(state: unknown): state is UserMapLifecycleState { + return ( + typeof state === "string" && + (USER_MAP_LIFECYCLE_STATES as readonly string[]).includes(state) + ); +} + +export type TransitionContext = { + from: UserMapLifecycleState; + to: UserMapLifecycleState; + /** + * True only when the caller performs the explicit server-side publish + * action (e.g. POST /user-maps/:id/publish). Required for proposed -> published. + */ + viaPublishAction?: boolean; +}; + +export type TransitionDecision = + | { ok: true } + | { ok: false; reason: string }; + +const TERMINAL_STATE: UserMapLifecycleState = "archived"; + +/** + * Pure transition rule set: + * - owner may move draft -> proposed + * - proposed -> published requires the explicit server-side publish action + * - archive is allowed from any state + * - archived is terminal and read-only + * - every other transition is rejected + */ +export function canTransitionUserMap(ctx: TransitionContext): TransitionDecision { + const { from, to, viaPublishAction } = ctx; + + if (!isValidLifecycleState(from)) { + return { ok: false, reason: `invalid current lifecycle state: ${String(from)}` }; + } + if (!isValidLifecycleState(to)) { + return { ok: false, reason: `invalid target lifecycle state: ${String(to)}` }; + } + if (from === to) { + return { ok: false, reason: `map is already in state '${from}'` }; + } + if (from === TERMINAL_STATE) { + return { ok: false, reason: "archived maps are read-only and cannot change state" }; + } + + // Archive from any non-terminal state is allowed. + if (to === TERMINAL_STATE) { + return { ok: true }; + } + + if (from === "draft" && to === "proposed") { + return { ok: true }; + } + + if (from === "proposed" && to === "published") { + if (viaPublishAction !== true) { + return { + ok: false, + reason: "publishing requires the explicit server-side publish action", + }; + } + return { ok: true }; + } + + return { ok: false, reason: `transition '${from}' -> '${to}' is not allowed` }; +} + +export function assertUserMapTransition(ctx: TransitionContext): void { + const decision = canTransitionUserMap(ctx); + if (!decision.ok) { + throw new Error(`user map lifecycle transition rejected: ${decision.reason}`); + } +} + +/** Archived maps are read-only for content edits. */ +export function canEditUserMapContent(state: UserMapLifecycleState): boolean { + return state !== TERMINAL_STATE; +} + +export function assertUserMapEditable(state: UserMapLifecycleState): void { + if (!canEditUserMapContent(state)) { + throw new Error("user map is archived and read-only"); + } +} + +// +// Economic-safety: zero gold / zero XP emissions from user maps. +// + +export type RewardEmission = { + /** Source map the event originated from. */ + sourceMapId: number; + gold?: number; + exp?: number; +}; + +/** + * Returns false for ANY user map in ANY lifecycle state — user maps can + * never be a legitimate source of gold or XP rewards. + */ +export function canEmitRewards(sourceMapId: number): boolean { + return !isUserMapId(sourceMapId); +} + +/** + * Hook for the reward/emission point: call before granting any gold or XP. + * Throws if the event is sourced from a user map, regardless of state or caller, + * guaranteeing zero gold and zero XP emissions from user maps. + */ +export function assertNoUserMapRewards(emission: RewardEmission): void { + if (isUserMapId(emission.sourceMapId)) { + const granted: string[] = []; + if (emission.gold !== undefined && emission.gold !== 0) granted.push("gold"); + if (emission.exp !== undefined && emission.exp !== 0) granted.push("xp"); + if (granted.length > 0) { + throw new Error( + `blocked reward emission from user map ${emission.sourceMapId}: ` + + `${granted.join(" and ")} can never be granted from user maps`, + ); + } + } +} diff --git a/api/src/lib/userMapPermissions.test.ts b/api/src/lib/userMapPermissions.test.ts new file mode 100644 index 00000000..77cdd24f --- /dev/null +++ b/api/src/lib/userMapPermissions.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_ENTITIES_PER_USER_MAP, + MAX_MAPS_PER_ACCOUNT, + assertEntitiesQuotaAvailable, + assertMapsQuotaAvailable, + assertUserMapActionAllowed, + canPerformUserMapAction, +} from "./userMapPermissions"; + +describe("user map permissions (issue #24 item 2)", () => { + it("allows the owner to read, edit and delete any map", () => { + for (const visibility of ["private", "public"] as const) { + for (const action of ["read", "edit", "delete"] as const) { + expect( + canPerformUserMapAction(action, { isOwner: true, visibility }), + ).toBe(true); + } + } + }); + + it("lets non-owners read public maps but never edit or delete them", () => { + expect( + canPerformUserMapAction("read", { isOwner: false, visibility: "public" }), + ).toBe(true); + expect( + canPerformUserMapAction("edit", { isOwner: false, visibility: "public" }), + ).toBe(false); + expect( + canPerformUserMapAction("delete", { isOwner: false, visibility: "public" }), + ).toBe(false); + }); + + it("blocks non-owners from reading private maps", () => { + expect( + canPerformUserMapAction("read", { isOwner: false, visibility: "private" }), + ).toBe(false); + }); + + it("assert helper throws in the backend error style for denials", () => { + expect(() => + assertUserMapActionAllowed("read", { isOwner: false, visibility: "private" }), + ).toThrowError(/No tenes acceso/); + expect(() => + assertUserMapActionAllowed("edit", { isOwner: false, visibility: "public" }), + ).toThrowError(/Solo el dueño puede editar/); + expect(() => + assertUserMapActionAllowed("delete", { isOwner: false, visibility: "public" }), + ).toThrowError(/Solo el dueño puede borrar/); + expect(() => + assertUserMapActionAllowed("edit", { isOwner: true, visibility: "private" }), + ).not.toThrow(); + }); +}); + +describe("user map quotas (issue #24 item 3)", () => { + it("allows map creation while under the per-account cap", () => { + expect(MAX_MAPS_PER_ACCOUNT).toBe(20); + expect(() => assertMapsQuotaAvailable(0)).not.toThrow(); + expect(() => assertMapsQuotaAvailable(MAX_MAPS_PER_ACCOUNT - 1)).not.toThrow(); + }); + + it("rejects map creation at the per-account cap", () => { + expect(() => assertMapsQuotaAvailable(MAX_MAPS_PER_ACCOUNT)).toThrowError( + /Cupo alcanzado/, + ); + expect(() => assertMapsQuotaAvailable(MAX_MAPS_PER_ACCOUNT + 5)).toThrowError( + /Cupo alcanzado/, + ); + }); + + it("rejects invalid account map counts", () => { + expect(() => assertMapsQuotaAvailable(-1)).toThrowError(/invalida/); + expect(() => assertMapsQuotaAvailable(1.5)).toThrowError(/invalida/); + }); + + it("allows saves under the per-map density cap", () => { + expect(MAX_ENTITIES_PER_USER_MAP).toBe(2_000); + expect(() => assertEntitiesQuotaAvailable(0)).not.toThrow(); + expect(() => assertEntitiesQuotaAvailable(MAX_ENTITIES_PER_USER_MAP)).not.toThrow(); + }); + + it("rejects saves above the per-map density cap and invalid counts", () => { + expect(() => + assertEntitiesQuotaAvailable(MAX_ENTITIES_PER_USER_MAP + 1), + ).toThrowError(/Densidad maxima/); + expect(() => assertEntitiesQuotaAvailable(-3)).toThrowError(/invalida/); + expect(() => assertEntitiesQuotaAvailable(2.5)).toThrowError(/invalida/); + }); +}); diff --git a/api/src/lib/userMapPermissions.ts b/api/src/lib/userMapPermissions.ts new file mode 100644 index 00000000..f923203c --- /dev/null +++ b/api/src/lib/userMapPermissions.ts @@ -0,0 +1,89 @@ +/** + * Reglas de permiso y cupo para mapas de usuario (issue #24, items 2 y 3). + * + * Todo se aplica SIEMPRE del lado del servidor. El cliente puede esconder + * botones, pero la unica fuente de verdad es aca: el servidor rechaza con + * error cualquier accion que viole estas reglas. + */ + +/** Item 3: maximo de mapas que una cuenta puede crear. */ +export const MAX_MAPS_PER_ACCOUNT = 20; + +/** Item 3: maximo de objetos/NPCs que un mapa de usuario puede tener. */ +export const MAX_ENTITIES_PER_USER_MAP = 2_000; + +export type UserMapVisibility = "private" | "public"; + +export type UserMapAction = "read" | "edit" | "delete"; + +export type UserMapAccess = { + isOwner: boolean; + visibility: UserMapVisibility; +}; + +/** + * Item 2: puede `access` hacer `action` sobre el mapa? + * + * - read: el dueño siempre; el resto solo si el mapa es publico. + * - edit / delete: solo el dueño. Nadie mas, ni aunque el mapa sea publico. + */ +export function canPerformUserMapAction( + action: UserMapAction, + access: UserMapAccess, +): boolean { + if (access.isOwner) { + return true; + } + if (action === "read") { + return access.visibility === "public"; + } + return false; +} + +/** Rechaza con el estilo de error del backend si la accion no esta permitida. */ +export function assertUserMapActionAllowed( + action: UserMapAction, + access: UserMapAccess, +): void { + if (canPerformUserMapAction(action, access)) { + return; + } + if (action === "read") { + throw new Error("No tenes acceso a este mapa."); + } + throw new Error( + action === "edit" + ? "Solo el dueño puede editar este mapa." + : "Solo el dueño puede borrar este mapa.", + ); +} + +/** + * Item 3: rechaza si crear otro mapa supera el cupo por cuenta. + * `currentCount` es la cantidad de mapas que la cuenta ya tiene. + */ +export function assertMapsQuotaAvailable(currentCount: number): void { + if (!Number.isInteger(currentCount) || currentCount < 0) { + throw new Error(`Cantidad de mapas invalida: ${currentCount}.`); + } + if (currentCount >= MAX_MAPS_PER_ACCOUNT) { + throw new Error( + `Cupo alcanzado: una cuenta puede tener hasta ${MAX_MAPS_PER_ACCOUNT} mapas.`, + ); + } +} + +/** + * Item 3: rechaza si guardar un mapa con `newEntityCount` objetos/NPCs + * supera el cupo de densidad por mapa. + */ +export function assertEntitiesQuotaAvailable(newEntityCount: number): void { + if (!Number.isInteger(newEntityCount) || newEntityCount < 0) { + throw new Error(`Cantidad de entidades invalida: ${newEntityCount}.`); + } + if (newEntityCount > MAX_ENTITIES_PER_USER_MAP) { + throw new Error( + `Densidad maxima alcanzada: un mapa puede tener hasta ${MAX_ENTITIES_PER_USER_MAP} objetos/NPCs.`, + ); + } +} diff --git a/api/src/repositories/gameNpcs.ts b/api/src/repositories/gameNpcs.ts index 8d8a66d7..415de858 100644 --- a/api/src/repositories/gameNpcs.ts +++ b/api/src/repositories/gameNpcs.ts @@ -1,6 +1,7 @@ import type { PoolClient } from "pg"; import { z } from "zod"; import pool from "../db"; +import { assertNoUserMapRewards } from "../lib/userMapLifecycle"; import { computeChecksum, loadSeedNpcsJson, @@ -137,7 +138,16 @@ function isHostileNpc(data: GameNpcRecordData): boolean { function compactFrontendNpcData( data: GameNpcRecordData, + sourceMapId?: number, ): FrontendNpcExportData { + // Economic-safety: block reward exports sourced from user maps. + if (sourceMapId !== undefined) { + assertNoUserMapRewards({ + sourceMapId, + exp: Number(normalizeNpcData(data).exp ?? 0), + gold: Number(normalizeNpcData(data).gold ?? 0), + }); + } const normalized = normalizeNpcData(data); const dropEntries = Array.isArray(normalized.drop) ? normalized.drop : []; const frontendNpc: FrontendNpcExportData = { @@ -283,8 +293,20 @@ export async function getCurrentGameNpcVersion(): Promise { return Number(result.rows[0]?.version ?? 0); } -function toGameNpcSummary(row: GameNpcRow) { +function toGameNpcSummary( + row: GameNpcRow, + sourceMapId?: number, +) { const maxHp = Number(row.data.maxHp ?? row.data.hp ?? 0); + // Economic-safety: user maps (IDs 600-1999) can never emit gold or XP, + // regardless of lifecycle state or caller. + if (sourceMapId !== undefined) { + assertNoUserMapRewards({ + sourceMapId, + exp: Number(row.data.exp ?? 0), + gold: Number(row.data.gold ?? 0), + }); + } const expReward = Math.floor( Number(row.data.exp ?? 0) * CURRENT_EXP_MULTIPLIER, ); diff --git a/api/vitest.lifecycle.config.mjs b/api/vitest.lifecycle.config.mjs new file mode 100644 index 00000000..545e2f67 --- /dev/null +++ b/api/vitest.lifecycle.config.mjs @@ -0,0 +1,6 @@ +export default { + test: { + environment: "node", + include: ["src/lib/userMapLifecycle.test.ts"], + }, +}; diff --git a/api/vitest.usermaps.config.mjs b/api/vitest.usermaps.config.mjs new file mode 100644 index 00000000..27a8cb68 --- /dev/null +++ b/api/vitest.usermaps.config.mjs @@ -0,0 +1,6 @@ +export default { + test: { + environment: "node", + include: ["src/lib/userMapPermissions.test.ts", "src/lib/userMapLifecycle.test.ts"], + }, +}; diff --git a/change-log.md b/change-log.md new file mode 100644 index 00000000..d55565e3 --- /dev/null +++ b/change-log.md @@ -0,0 +1,3 @@ +- 2026-09-13: fix(user-maps) deduped duplicate imports in userMapLifecycle.test.ts; lifecycle + permissions suites green; committed. +- 2026-09-13: test(user-maps) full verification: API 76/76, frontend 6/6, permissions 9/9, lifecycle green; test infra runner added; committed. +- 2026-09-13: test(user-maps) wired lifecycle suite into vitest.usermaps.config.mjs; verified 109/109 green (API 76, frontend 6, permissions 9, lifecycle 18). diff --git a/frontend/package.json b/frontend/package.json index 5d08000d..69e49c40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,7 +31,8 @@ "eslint": "^9.39.4", "eslint-config-next": "^16.2.6", "tailwindcss": "^4.2.4", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^5.0.0" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index dcd3e0f7..1f69b0cc 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -60,6 +60,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + vitest: + specifier: ^5.0.0 + version: 5.0.0(@types/node@25.8.0)(vite@8.3.0(@types/node@25.8.0)(jiti@2.7.0)) packages: @@ -231,105 +234,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -367,6 +354,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -396,28 +386,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.2.6': resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.2.6': resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.2.6': resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.2.6': resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} @@ -447,6 +433,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@oxc-project/types@0.149.0': + resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} + '@pixi/colord@2.9.6': resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==} @@ -455,6 +444,99 @@ packages: engines: {node: '>=18'} hasBin: true + '@rolldown/binding-android-arm-eabi@1.2.8': + resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.8': + resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.8': + resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.8': + resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.8': + resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.8': + resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.8': + resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.8': + resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.8': + resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.8': + resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -499,28 +581,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -556,6 +634,12 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/earcut@3.0.0': resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} @@ -680,49 +764,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -744,6 +820,20 @@ packages: cpu: [x64] os: [win32] + '@vitest/mocker@5.0.0': + resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.0': + resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + '@webgpu/types@0.1.69': resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} @@ -807,6 +897,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -873,6 +967,10 @@ packages: caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -988,6 +1086,9 @@ packages: resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1128,6 +1229,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1135,6 +1239,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1188,6 +1296,11 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1484,57 +1597,107 @@ packages: cpu: [arm64] os: [android] + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.32.0: resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.32.0: resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.32.0: resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.32.0: resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1542,16 +1705,32 @@ packages: cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.32.0: resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.32.0: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1574,6 +1753,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.3.1: + resolution: {integrity: sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1604,6 +1786,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -1672,6 +1859,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1717,6 +1908,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pixi.js@8.18.1: resolution: {integrity: sha512-6LUPWYgulZhp/w4kam2XHXB0QedISZIqrJbRdHLLQ3csn5a38uzKxAp6B5j6s89QFYaIJbg95kvgTRcbgpO1ow==} @@ -1742,6 +1937,10 @@ packages: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1792,6 +1991,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.2.8: + resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1859,6 +2063,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1866,6 +2073,12 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -1933,10 +2146,22 @@ packages: resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==} engines: {node: '>=12'} + tinybench@6.1.4: + resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2004,6 +2229,90 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.0: + resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.0 + '@vitest/browser-preview': 5.0.0 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.0 + '@vitest/coverage-v8': 5.0.0 + '@vitest/ui': 5.0.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.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 + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2025,6 +2334,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2338,6 +2652,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -2394,6 +2710,8 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@oxc-project/types@0.149.0': {} + '@pixi/colord@2.9.6': {} '@playwright/test@1.60.0': @@ -2401,6 +2719,53 @@ snapshots: playwright: 1.60.0 optional: true + '@rolldown/binding-android-arm-eabi@1.2.8': + optional: true + + '@rolldown/binding-android-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-x64@1.2.8': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.8': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.8': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.8': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.8': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.8': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@rtsao/scc@1.1.0': {} '@swc/helpers@0.5.15': @@ -2481,6 +2846,13 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/earcut@3.0.0': {} '@types/estree@1.0.9': {} @@ -2653,6 +3025,17 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vitest/mocker@5.0.0(vite@8.3.0(@types/node@25.8.0)(jiti@2.7.0))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.0 + estree-walker: 3.0.3 + magic-string: 1.3.1 + optionalDependencies: + vite: 8.3.0(@types/node@25.8.0)(jiti@2.7.0) + + '@vitest/spy@5.0.0': {} + '@webgpu/types@0.1.69': {} '@xmldom/xmldom@0.8.13': {} @@ -2745,6 +3128,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} async-function@1.0.0: {} @@ -2805,6 +3190,8 @@ snapshots: caniuse-lite@1.0.30001792: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -2977,6 +3364,8 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -3205,10 +3594,16 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} eventemitter3@5.0.4: {} + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -3231,6 +3626,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -3258,6 +3657,9 @@ snapshots: fsevents@2.3.2: optional: true + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -3551,36 +3953,69 @@ snapshots: lightningcss-android-arm64@1.32.0: optional: true + lightningcss-android-arm64@1.33.0: + optional: true + lightningcss-darwin-arm64@1.32.0: optional: true + lightningcss-darwin-arm64@1.33.0: + optional: true + lightningcss-darwin-x64@1.32.0: optional: true + lightningcss-darwin-x64@1.33.0: + optional: true + lightningcss-freebsd-x64@1.32.0: optional: true + lightningcss-freebsd-x64@1.33.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + lightningcss-linux-arm64-gnu@1.32.0: optional: true + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + lightningcss-linux-arm64-musl@1.32.0: optional: true + lightningcss-linux-arm64-musl@1.33.0: + optional: true + lightningcss-linux-x64-gnu@1.32.0: optional: true + lightningcss-linux-x64-gnu@1.33.0: + optional: true + lightningcss-linux-x64-musl@1.32.0: optional: true + lightningcss-linux-x64-musl@1.33.0: + optional: true + lightningcss-win32-arm64-msvc@1.32.0: optional: true + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + lightningcss-win32-x64-msvc@1.32.0: optional: true + lightningcss-win32-x64-msvc@1.33.0: + optional: true + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 @@ -3597,6 +4032,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -3619,6 +4070,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@1.3.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + math-intrinsics@1.1.0: {} merge2@1.4.1: {} @@ -3642,6 +4097,8 @@ snapshots: nanoid@3.3.12: {} + nanoid@3.3.19: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -3722,6 +4179,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.2.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3763,6 +4222,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.7: {} + pixi.js@8.18.1: dependencies: '@pixi/colord': 2.9.6 @@ -3800,6 +4261,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} prop-types@15.8.1: @@ -3856,6 +4323,27 @@ snapshots: reusify@1.1.0: {} + rolldown@1.2.8: + dependencies: + '@oxc-project/types': 0.149.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.8 + '@rolldown/binding-android-arm64': 1.2.8 + '@rolldown/binding-darwin-arm64': 1.2.8 + '@rolldown/binding-darwin-x64': 1.2.8 + '@rolldown/binding-freebsd-x64': 1.2.8 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.8 + '@rolldown/binding-linux-arm64-gnu': 1.2.8 + '@rolldown/binding-linux-arm64-musl': 1.2.8 + '@rolldown/binding-linux-ppc64-gnu': 1.2.8 + '@rolldown/binding-linux-s390x-gnu': 1.2.8 + '@rolldown/binding-linux-x64-gnu': 1.2.8 + '@rolldown/binding-linux-x64-musl': 1.2.8 + '@rolldown/binding-openharmony-arm64': 1.2.8 + '@rolldown/binding-win32-arm64-msvc': 1.2.8 + '@rolldown/binding-win32-x64-msvc': 1.2.8 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -3973,10 +4461,16 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + source-map-js@1.2.1: {} stable-hash@0.0.5: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -4055,11 +4549,20 @@ snapshots: tiny-lru@11.4.7: {} + tinybench@6.1.4: {} + + tinyexec@1.3.0: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -4170,6 +4673,39 @@ snapshots: dependencies: punycode: 2.3.1 + vite@8.3.0(@types/node@25.8.0)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.8 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.8.0 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@5.0.0(@types/node@25.8.0)(vite@8.3.0(@types/node@25.8.0)(jiti@2.7.0)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.0(vite@8.3.0(@types/node@25.8.0)(jiti@2.7.0)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.3.1 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 8.3.0(@types/node@25.8.0)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.8.0 + transitivePeerDependencies: + - msw + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -4215,6 +4751,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} yallist@3.1.1: {} diff --git a/frontend/tests/gameLoader.test.ts b/frontend/tests/gameLoader.test.ts new file mode 100644 index 00000000..433abbe9 --- /dev/null +++ b/frontend/tests/gameLoader.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + USER_MAP_ID_RANGE, + isUserMapId, + assertUserMapId, +} from "../utils/gameLoader"; + +describe("user map ID reservation (issue #24)", () => { + it("accepts IDs inside the reserved 600-1999 range as user maps", () => { + assert.equal(isUserMapId(USER_MAP_ID_RANGE.min), true); + assert.equal(isUserMapId(1000), true); + assert.equal(isUserMapId(USER_MAP_ID_RANGE.max), true); + }); + + it("rejects IDs outside the reserved range for user maps", () => { + assert.equal(isUserMapId(599), false); + assert.equal(isUserMapId(2000), false); + assert.equal(isUserMapId(29999), false); + assert.equal(isUserMapId(30000), false); + assert.equal(isUserMapId(Number.NaN), false); + assert.throws( + () => assertUserMapId(599), + /user map IDs must be between/, + ); + assert.throws( + () => assertUserMapId(2000), + /user map IDs must be between/, + ); + }); + + it("leaves official map ID handling unchanged", () => { + assert.equal(USER_MAP_ID_RANGE.min, 600); + assert.equal(USER_MAP_ID_RANGE.max, 1999); + assert.ok(USER_MAP_ID_RANGE.min > 599); + assert.ok(USER_MAP_ID_RANGE.max < 2000); + }); +}); diff --git a/frontend/utils/gameLoader.test.ts b/frontend/utils/gameLoader.test.ts new file mode 100644 index 00000000..07db08d8 --- /dev/null +++ b/frontend/utils/gameLoader.test.ts @@ -0,0 +1,46 @@ +/** + * Issue #24 area 1: user map ID range reservation (600-1999). + * Official maps live below 600; the frontend loader must enforce the + * reserved range for user-created maps. + */ +import { describe, expect, it } from "vitest"; +import { + USER_MAP_ID_RANGE, + assertUserMapId, + isUserMapId, +} from "./gameLoader"; + +describe("user map ID range reservation (issue #24 item 1)", () => { + it("reserves exactly 600-1999 for user-created maps", () => { + expect(USER_MAP_ID_RANGE.min).toBe(600); + expect(USER_MAP_ID_RANGE.max).toBe(1999); + }); + + it("accepts user map IDs inside the reserved range", () => { + expect(isUserMapId(600)).toBe(true); + expect(isUserMapId(1234)).toBe(true); + expect(isUserMapId(1999)).toBe(true); + }); + + it("rejects official map IDs below the reserved range", () => { + expect(isUserMapId(599)).toBe(false); + expect(isUserMapId(0)).toBe(false); + expect(isUserMapId(-1)).toBe(false); + }); + + it("rejects IDs above the reserved range", () => { + expect(isUserMapId(2000)).toBe(false); + expect(isUserMapId(99999)).toBe(false); + }); + + it("rejects non-integer and NaN inputs", () => { + expect(isUserMapId(600.5)).toBe(false); + expect(isUserMapId(Number.NaN)).toBe(false); + }); + + it("assertUserMapId throws a descriptive error out of range, passes in range", () => { + expect(() => assertUserMapId(599)).toThrowError(/between 600 and 1999/); + expect(() => assertUserMapId(2000)).toThrowError(/between 600 and 1999/); + expect(() => assertUserMapId(600)).not.toThrow(); + }); +}); diff --git a/frontend/utils/gameLoader.ts b/frontend/utils/gameLoader.ts index f26b355d..7de350d7 100644 --- a/frontend/utils/gameLoader.ts +++ b/frontend/utils/gameLoader.ts @@ -55,6 +55,14 @@ const DYNAMIC_INSTANCE_MAP_START = 30_000; const DYNAMIC_INSTANCE_MAP_STRIDE = 50; const CHALLENGE_INSTANCE_MAP_START = 2_000; const CHALLENGE_INSTANCE_BASE_MAP_ID = 506; + +// Reserved for user-created maps (issue #24). Official maps live below 600, +// dynamic instances start at 30_000 and challenge instances at 2_000, so this +// band cannot collide with any system-generated map ID. +export const USER_MAP_ID_RANGE = { + min: 600, + max: 1999, +} as const; const MAP_ASSET_VERSIONS: Partial> = { 166: "1.0", 286: "1.2", @@ -76,6 +84,24 @@ function getBaseMapIdFromDynamicInstance(mapNumber: number): number | null { ); } +export function isUserMapId(mapNumber: number): boolean { + return ( + Number.isInteger(mapNumber) && + mapNumber >= USER_MAP_ID_RANGE.min && + mapNumber <= USER_MAP_ID_RANGE.max + ); +} + +export function assertUserMapId(mapNumber: number): number { + if (!isUserMapId(mapNumber)) { + throw new Error( + `Invalid user map ID ${mapNumber}: user map IDs must be between ${USER_MAP_ID_RANGE.min} and ${USER_MAP_ID_RANGE.max}.`, + ); + } + + return mapNumber; +} + function withMapAssetVersion(path: string, mapNumber: number): string { const version = MAP_ASSET_VERSIONS[mapNumber]; if (!version) { diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 00000000..6b9695a5 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + include: ["utils/**/*.test.ts", "lib/**/*.test.ts"], + environment: "node", + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + }, + }, +});