From a8cb7d65f424f69aa2f52513c496850b41646966 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 10:49:31 +0200 Subject: [PATCH] Phase 18: internet realtime collaboration (GM-authoritative) + server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shared Zod wire protocol (src/lib/sync/messages.ts): hosted/joined/snapshot/ mapImage/error; player-safe projections only (enemy HP masked on the GM before broadcast via src/lib/combat/playerProjection.ts). - wsSync adapter (src/lib/sync/wsSync.ts) behind the existing SyncAdapter seam: GM hostSession + debounced snapshot broadcast (useSessionBroadcaster), player joinSession into an ephemeral playerSessionStore, exponential-backoff reconnect with seamless room resume (GM secret). localSync remains the offline default. - buildSnapshot (src/lib/sync/snapshot.ts) reuses the same projection as the local /play view, guaranteeing parity. Player view refactored into shared PlayerBoards; /play?room=CODE = networked read-only mode. - SessionControl in the header: Host (optional password) → shareable room code/link. - Server (server/): Fastify + @fastify/websocket + @fastify/static, in-memory GM-authoritative rooms (crypto room id/secret hashed, optional join password, TTL sweep, players strictly read-only), origin allowlist, per-frame size cap + rate limit, Zod validation. esbuild bundle (server/build.mjs), tsconfig.server.json. - Dockerfile (multi-stage) + deploy/ttrpg.compose.yml (own project on external 'proxy' net, Traefik labels for ttrpg.briggen.dev, non-root, no-new-privileges). CSP connect-src now allows same-origin wss:. Tests: protocol round-trip, room hub (auth/password/resume/TTL/read-only), enemy masking, Fastify+ws integration (host→join→snapshot, player push rejected), and a two-context Playwright realtime spec (separate config) — GM hosts, player device sees live combat with masked enemy HP. Co-Authored-By: Claude Opus 4.8 (1M context) --- .dockerignore | 10 ++ Dockerfile | 21 +++ bun.lock | 179 +++++++++++++++++++- deploy/ttrpg.compose.yml | 30 ++++ e2e-realtime/realtime.spec.ts | 43 +++++ eslint.config.js | 3 +- package.json | 7 + playwright.realtime.config.ts | 19 +++ server/build.mjs | 17 ++ server/package.json | 15 ++ server/src/index.ts | 72 ++++++++ server/src/integration.test.ts | 57 +++++++ server/src/rooms.test.ts | 82 ++++++++++ server/src/rooms.ts | 145 ++++++++++++++++ src/app/RootLayout.tsx | 6 + src/features/play/SessionControl.tsx | 42 +++++ src/features/play/useSessionBroadcaster.ts | 37 +++++ src/features/player/PlayerBoards.tsx | 77 +++++++++ src/features/player/PlayerMapView.tsx | 2 +- src/features/player/PlayerViewPage.tsx | 182 +++++++++------------ src/lib/combat/playerProjection.test.ts | 33 ++++ src/lib/sync/messages.test.ts | 28 ++++ src/lib/sync/messages.ts | 84 ++++++++++ src/lib/sync/snapshot.ts | 42 +++++ src/lib/sync/wsSync.ts | 120 ++++++++++++++ src/stores/playerSessionStore.ts | 20 +++ src/stores/sessionStore.ts | 25 +++ tsconfig.app.tsbuildinfo | 2 +- tsconfig.server.json | 18 ++ vite.config.ts | 2 +- vitest.config.ts | 2 +- 31 files changed, 1304 insertions(+), 118 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 deploy/ttrpg.compose.yml create mode 100644 e2e-realtime/realtime.spec.ts create mode 100644 playwright.realtime.config.ts create mode 100644 server/build.mjs create mode 100644 server/package.json create mode 100644 server/src/index.ts create mode 100644 server/src/integration.test.ts create mode 100644 server/src/rooms.test.ts create mode 100644 server/src/rooms.ts create mode 100644 src/features/play/SessionControl.tsx create mode 100644 src/features/play/useSessionBroadcaster.ts create mode 100644 src/features/player/PlayerBoards.tsx create mode 100644 src/lib/combat/playerProjection.test.ts create mode 100644 src/lib/sync/messages.test.ts create mode 100644 src/lib/sync/messages.ts create mode 100644 src/lib/sync/snapshot.ts create mode 100644 src/lib/sync/wsSync.ts create mode 100644 src/stores/playerSessionStore.ts create mode 100644 src/stores/sessionStore.ts create mode 100644 tsconfig.server.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..99ae21a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +dev-dist +server/dist +server/node_modules +test-results +playwright-report +.git +e2e +**/*.test.ts diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2031747 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# --- build stage: bundle the PWA + the realtime server --- +FROM oven/bun:1 AS build +WORKDIR /app +COPY package.json bun.lock* bun.lockb* ./ +RUN bun install +COPY . . +RUN bun run build # tsc -b && vite build → /app/dist +RUN bun run build:server # esbuild → /app/server/dist/index.js + +# --- runtime stage: small node image, non-root, serves dist + /ws --- +FROM node:20-slim AS runtime +ENV NODE_ENV=production STATIC_DIR=/app/dist PORT=8787 +WORKDIR /app/server +COPY server/package.json ./ +RUN npm install --omit=dev --no-audit --no-fund +COPY --from=build /app/server/dist ./dist +COPY --from=build /app/dist /app/dist +RUN chown -R node:node /app +USER node +EXPOSE 8787 +CMD ["node", "dist/index.js"] diff --git a/bun.lock b/bun.lock index 595300e..944cf0c 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,8 @@ }, "devDependencies": { "@eslint/js": "^9.17.0", + "@fastify/static": "^9.1.3", + "@fastify/websocket": "^11.2.0", "@playwright/test": "^1.49.1", "@tailwindcss/vite": "^4.0.0", "@testing-library/jest-dom": "^6.6.3", @@ -30,11 +32,14 @@ "@types/node": "^22.10.5", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", + "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.3.4", + "esbuild": "^0.28.0", "eslint": "^9.17.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.16", "fake-indexeddb": "^6.0.0", + "fastify": "^5.8.5", "globals": "^15.14.0", "jsdom": "^25.0.1", "tailwindcss": "^4.0.0", @@ -320,6 +325,26 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@fastify/accept-negotiator": ["@fastify/accept-negotiator@2.0.1", "", {}, "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ=="], + + "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], + + "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="], + + "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="], + + "@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="], + + "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="], + + "@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="], + + "@fastify/send": ["@fastify/send@4.1.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "escape-html": "~1.0.3", "fast-decode-uri-component": "^1.0.1", "http-errors": "^2.0.0", "mime": "^3" } }, "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw=="], + + "@fastify/static": ["@fastify/static@9.1.3", "", { "dependencies": { "@fastify/accept-negotiator": "^2.0.0", "@fastify/send": "^4.0.0", "content-disposition": "^1.0.1", "fastify-plugin": "^5.0.0", "fastq": "^1.17.1", "glob": "^13.0.0" } }, "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ=="], + + "@fastify/websocket": ["@fastify/websocket@11.2.0", "", { "dependencies": { "duplexify": "^4.1.3", "fastify-plugin": "^5.0.0", "ws": "^8.16.0" } }, "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], @@ -344,6 +369,10 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], + + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], @@ -488,6 +517,8 @@ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/type-utils": "8.60.1", "@typescript-eslint/utils": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.60.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA=="], @@ -524,6 +555,8 @@ "@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="], + "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -532,6 +565,8 @@ "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -554,8 +589,12 @@ "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "avvio": ["avvio@9.2.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="], + "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="], "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.14.2", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8", "core-js-compat": "^3.48.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g=="], @@ -604,8 +643,12 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], "core-js-compat": ["core-js-compat@3.49.0", "", { "dependencies": { "browserslist": "^4.28.1" } }, "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA=="], @@ -644,6 +687,8 @@ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -658,10 +703,14 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "duplexify": ["duplexify@4.1.3", "", { "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="], + "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], "electron-to-chromium": ["electron-to-chromium@1.5.368", "", {}, "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "enhanced-resolve": ["enhanced-resolve@5.23.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -684,6 +733,8 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], @@ -714,20 +765,34 @@ "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="], + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + "fast-json-stringify": ["fast-json-stringify@6.4.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ=="], + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="], + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + "fastify": ["fastify@5.8.5", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.14.0 || ^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q=="], + + "fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], + "find-my-way": ["find-my-way@9.6.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], @@ -764,7 +829,7 @@ "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], - "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -792,6 +857,8 @@ "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -808,8 +875,12 @@ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "ipaddr.js": ["ipaddr.js@2.4.0", "", {}, "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ=="], + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], @@ -890,6 +961,8 @@ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + "json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], @@ -906,6 +979,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -940,7 +1015,7 @@ "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], @@ -948,6 +1023,8 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -974,6 +1051,10 @@ "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], @@ -1004,6 +1085,12 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], + + "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], @@ -1018,8 +1105,12 @@ "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], @@ -1028,6 +1119,10 @@ "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], @@ -1050,23 +1145,37 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "rollup": ["rollup@4.61.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.61.1", "@rollup/rollup-android-arm64": "4.61.1", "@rollup/rollup-darwin-arm64": "4.61.1", "@rollup/rollup-darwin-x64": "4.61.1", "@rollup/rollup-freebsd-arm64": "4.61.1", "@rollup/rollup-freebsd-x64": "4.61.1", "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", "@rollup/rollup-linux-arm-musleabihf": "4.61.1", "@rollup/rollup-linux-arm64-gnu": "4.61.1", "@rollup/rollup-linux-arm64-musl": "4.61.1", "@rollup/rollup-linux-loong64-gnu": "4.61.1", "@rollup/rollup-linux-loong64-musl": "4.61.1", "@rollup/rollup-linux-ppc64-gnu": "4.61.1", "@rollup/rollup-linux-ppc64-musl": "4.61.1", "@rollup/rollup-linux-riscv64-gnu": "4.61.1", "@rollup/rollup-linux-riscv64-musl": "4.61.1", "@rollup/rollup-linux-s390x-gnu": "4.61.1", "@rollup/rollup-linux-x64-gnu": "4.61.1", "@rollup/rollup-linux-x64-musl": "4.61.1", "@rollup/rollup-openbsd-x64": "4.61.1", "@rollup/rollup-openharmony-arm64": "4.61.1", "@rollup/rollup-win32-arm64-msvc": "4.61.1", "@rollup/rollup-win32-ia32-msvc": "4.61.1", "@rollup/rollup-win32-x64-gnu": "4.61.1", "@rollup/rollup-win32-x64-msvc": "4.61.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA=="], "rrweb-cssom": ["rrweb-cssom@0.7.1", "", {}, "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg=="], "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "safe-regex2": ["safe-regex2@5.1.1", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], + + "semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="], "serialize-javascript": ["serialize-javascript@7.0.5", "", {}, "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw=="], @@ -1074,12 +1183,16 @@ "seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1098,18 +1211,26 @@ "smob": ["smob@1.6.2", "", {}, "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw=="], + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + "source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + "stream-shift": ["stream-shift@1.0.3", "", {}, "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="], + "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], "string.prototype.trim": ["string.prototype.trim@1.2.11", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-object-atoms": "^1.1.2", "has-property-descriptors": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w=="], @@ -1118,6 +1239,8 @@ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "stringify-object": ["stringify-object@3.3.0", "", { "dependencies": { "get-own-enumerable-property-symbols": "^3.0.0", "is-obj": "^1.0.1", "is-regexp": "^1.0.0" } }, "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw=="], "strip-comments": ["strip-comments@2.0.1", "", {}, "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw=="], @@ -1144,6 +1267,8 @@ "terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="], + "thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], @@ -1160,6 +1285,10 @@ "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + "toad-cache": ["toad-cache@3.7.1", "", {}, "sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], @@ -1208,6 +1337,8 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], "vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="], @@ -1272,6 +1403,8 @@ "workbox-window": ["workbox-window@7.4.1", "", { "dependencies": { "@types/trusted-types": "^2.0.2", "workbox-core": "7.4.1" } }, "sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], @@ -1290,10 +1423,24 @@ "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-regexp-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@fastify/ajv-compiler/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -1316,17 +1463,21 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.8.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ=="], - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "cssstyle/rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + "fast-json-stringify/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "path-scurry/lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], + "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], @@ -1338,6 +1489,8 @@ "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "vite-node/vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], @@ -1346,12 +1499,20 @@ "workbox-build/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "workbox-build/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + "workbox-build/pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="], "@apideck/better-ajv-errors/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@fastify/ajv-compiler/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "fast-json-stringify/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "glob/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], @@ -1418,6 +1579,8 @@ "workbox-build/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "workbox-build/glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -1513,5 +1676,9 @@ "vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], "vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "workbox-build/glob/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + + "workbox-build/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/deploy/ttrpg.compose.yml b/deploy/ttrpg.compose.yml new file mode 100644 index 0000000..7241e51 --- /dev/null +++ b/deploy/ttrpg.compose.yml @@ -0,0 +1,30 @@ +# Self-contained compose project for ttrpg.briggen.dev. +# Deploy: rsync the repo to /root/briggen-dev/ttrpg/, place this file there as +# docker-compose.yml, then `docker compose up -d --build`. +# Joins the EXISTING external `proxy` network — the shared Traefik stack is untouched. +name: ttrpg + +services: + ttrpg: + build: . + container_name: ttrpg + restart: unless-stopped + environment: + - PORT=8787 + - STATIC_DIR=/app/dist + - NODE_ENV=production + - ALLOWED_ORIGINS=https://ttrpg.briggen.dev + security_opt: + - no-new-privileges:true + labels: + - "traefik.enable=true" + - "traefik.http.routers.ttrpg.rule=Host(`ttrpg.briggen.dev`)" + - "traefik.http.routers.ttrpg.entrypoints=websecure" + - "traefik.http.routers.ttrpg.tls.certresolver=letsencrypt" + - "traefik.http.services.ttrpg.loadbalancer.server.port=8787" + networks: + - proxy + +networks: + proxy: + external: true diff --git a/e2e-realtime/realtime.spec.ts b/e2e-realtime/realtime.spec.ts new file mode 100644 index 0000000..729f4bc --- /dev/null +++ b/e2e-realtime/realtime.spec.ts @@ -0,0 +1,43 @@ +import { test, expect, type BrowserContext } from '@playwright/test'; + +async function fresh(ctx: BrowserContext) { + const page = await ctx.newPage(); + await page.goto('/'); + await page.evaluate(() => { indexedDB.deleteDatabase('ttrpg-manager'); localStorage.clear(); }); + await page.reload(); + return page; +} + +test('GM hosts a session; a player on another device sees live combat', async ({ browser }) => { + const gmCtx = await browser.newContext(); + const playerCtx = await browser.newContext(); + + // GM: load the sample campaign and host a session. + const gm = await fresh(gmCtx); + gm.on('dialog', (d) => d.dismiss()); // dismiss the optional-password prompt + await gm.getByLabel('Settings').click(); + await gm.getByRole('button', { name: 'Load sample campaign' }).click(); + await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); + await gm.getByRole('button', { name: '📡 Host' }).click(); + const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? ''; + expect(code).toHaveLength(6); + + // Player: join via the room code on a separate context (device). + const player = await playerCtx.newPage(); + await player.goto(`/play?room=${code}`); + await expect(player.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); + await expect(player.getByText('Lia the Brave')).toBeVisible(); // party synced + + // GM starts the seeded encounter → player sees initiative live, enemy HP masked. + await gm.getByLabel('Primary').getByRole('link', { name: 'Combat' }).click(); + await gm.getByText('Goblin Ambush').click(); + await gm.getByRole('button', { name: 'Start combat' }).click(); + + await expect(player.getByText(/Round \d+/)).toBeVisible(); + await expect(player.getByText('Goblin').first()).toBeVisible(); + // enemy exact HP must NOT be shown to players + await expect(player.getByText('7/7')).toHaveCount(0); + + await gmCtx.close(); + await playerCtx.close(); +}); diff --git a/eslint.config.js b/eslint.config.js index 01466e8..148ee1c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,8 @@ import reactRefresh from 'eslint-plugin-react-refresh'; import tseslint from 'typescript-eslint'; export default tseslint.config( - { ignores: ['dist', 'dev-dist', 'src/data/srd/generated', 'playwright-report', 'test-results'] }, + { ignores: ['dist', 'dev-dist', 'server/dist', 'src/data/srd/generated', 'playwright-report', 'test-results'] }, + { files: ['server/**/*.ts', 'server/build.mjs'], languageOptions: { globals: globals.node } }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], diff --git a/package.json b/package.json index 72abe3f..4edd976 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", + "typecheck:server": "tsc --noEmit -p tsconfig.server.json", + "build:server": "node server/build.mjs", "data:build": "tsx scripts/build-srd.ts" }, "dependencies": { @@ -31,6 +33,8 @@ }, "devDependencies": { "@eslint/js": "^9.17.0", + "@fastify/static": "^9.1.3", + "@fastify/websocket": "^11.2.0", "@playwright/test": "^1.49.1", "@tailwindcss/vite": "^4.0.0", "@testing-library/jest-dom": "^6.6.3", @@ -40,11 +44,14 @@ "@types/node": "^22.10.5", "@types/react": "^19.0.2", "@types/react-dom": "^19.0.2", + "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.3.4", + "esbuild": "^0.28.0", "eslint": "^9.17.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.16", "fake-indexeddb": "^6.0.0", + "fastify": "^5.8.5", "globals": "^15.14.0", "jsdom": "^25.0.1", "tailwindcss": "^4.0.0", diff --git a/playwright.realtime.config.ts b/playwright.realtime.config.ts new file mode 100644 index 0000000..7b3d6d4 --- /dev/null +++ b/playwright.realtime.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Realtime e2e against the BUILT server (serves dist + /ws on one origin). + * Run: bun run build && bun run build:server && bunx playwright test -c playwright.realtime.config.ts + */ +export default defineConfig({ + testDir: './e2e-realtime', + fullyParallel: false, + reporter: 'line', + use: { baseURL: 'http://localhost:8799', trace: 'off' }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'STATIC_DIR=dist PORT=8799 node server/dist/index.js', + url: 'http://localhost:8799/healthz', + reuseExistingServer: true, + timeout: 30_000, + }, +}); diff --git a/server/build.mjs b/server/build.mjs new file mode 100644 index 0000000..0b3d402 --- /dev/null +++ b/server/build.mjs @@ -0,0 +1,17 @@ +import { build } from 'esbuild'; +import { fileURLToPath } from 'node:url'; + +// Bundle the server + its shared `@/lib/sync` imports into one ESM file. +// npm deps (fastify, zod, …) stay external and are installed at runtime. +await build({ + entryPoints: ['server/src/index.ts'], + outfile: 'server/dist/index.js', + bundle: true, + platform: 'node', + format: 'esm', + target: 'node20', + packages: 'external', + banner: { js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);" }, + alias: { '@': fileURLToPath(new URL('../src', import.meta.url)) }, +}); +console.log('server bundled → server/dist/index.js'); diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..5e4f3e1 --- /dev/null +++ b/server/package.json @@ -0,0 +1,15 @@ +{ + "name": "ttrpg-server", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "start": "node dist/index.js" + }, + "dependencies": { + "@fastify/static": "^9.1.3", + "@fastify/websocket": "^11.2.0", + "fastify": "^5.8.5", + "zod": "^3.25.76" + } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..26fa897 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,72 @@ +import path from 'node:path'; +import Fastify from 'fastify'; +import fastifyWebsocket from '@fastify/websocket'; +import fastifyStatic from '@fastify/static'; +import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages'; +import { RoomHub, type Sender } from './rooms'; + +const PORT = Number(process.env.PORT ?? 8787); +const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist')); +const MAX_PAYLOAD = 12 * 1024 * 1024; // 12 MB (map images) +const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean); + +// per-socket token bucket: 80 messages / 10s +const RATE = { capacity: 80, refillMs: 10_000 }; + +export function buildServer() { + const app = Fastify({ bodyLimit: MAX_PAYLOAD }); + const hub = new RoomHub(); + const sweeper = setInterval(() => hub.sweep(), 5 * 60 * 1000); + app.addHook('onClose', async () => clearInterval(sweeper)); + + void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } }); + + void app.register(async (instance) => { + instance.get('/ws', { websocket: true }, (socket, req) => { + // Anti-CSWSH: reject cross-origin upgrades when an allowlist is configured. + const origin = req.headers.origin; + if (ALLOWED_ORIGINS.length && origin && !ALLOWED_ORIGINS.includes(origin)) { + socket.close(1008, 'origin'); + return; + } + const sender: Sender = { send: (msg: ServerMessage) => { try { socket.send(JSON.stringify(msg)); } catch { /* closed */ } } }; + let tokens = RATE.capacity; + const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs); + + socket.on('message', (raw: Buffer) => { + if (tokens-- <= 0) { socket.close(1008, 'rate'); return; } + let parsed; + try { parsed = clientMessageSchema.safeParse(JSON.parse(raw.toString())); } catch { return; } + if (!parsed.success) return; + const m = parsed.data; + switch (m.t) { + case 'host': hub.host(sender, m.password, m.resume); break; + case 'join': hub.join(sender, m.joinCode, m.password); break; + case 'state': hub.state(sender, m.gmSecret, m.snapshot); break; + case 'image': hub.image(sender, m.gmSecret, m.id, m.dataUrl); break; + case 'requestImage': hub.requestImage(sender, m.id); break; + } + }); + socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); }); + }); + }); + + app.get('/healthz', async () => ({ ok: true, rooms: hub.roomCount() })); + + // Serve the built SPA with history fallback (so /play?room=... deep-links work). + void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false }); + app.setNotFoundHandler((req, reply) => { + if (req.raw.url?.startsWith('/ws')) return reply.code(426).send('Upgrade Required'); + return reply.sendFile('index.html'); + }); + + return app; +} + +// Start unless imported by a test. +if (process.env.NODE_ENV !== 'test') { + const app = buildServer(); + app.listen({ port: PORT, host: '0.0.0.0' }) + .then((addr) => console.log(`ttrpg server on ${addr} (static: ${STATIC_DIR})`)) + .catch((e) => { console.error(e); process.exit(1); }); +} diff --git a/server/src/integration.test.ts b/server/src/integration.test.ts new file mode 100644 index 0000000..0e8c09e --- /dev/null +++ b/server/src/integration.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment node +import { describe, it, expect, afterAll } from 'vitest'; +import { WebSocket } from 'ws'; +import type { AddressInfo } from 'node:net'; +import { buildServer } from './index'; +import type { ClientMessage, ServerMessage } from '@/lib/sync/messages'; + +const app = buildServer(); +let base = ''; + +async function start(): Promise { + await app.listen({ port: 0, host: '127.0.0.1' }); + const addr = app.server.address() as AddressInfo; + base = `ws://127.0.0.1:${addr.port}/ws`; +} +afterAll(async () => { await app.close(); }); + +function open(): Promise { + const ws = new WebSocket(base); + return new Promise((res, rej) => { ws.on('open', () => res(ws)); ws.on('error', rej); }); +} +function send(ws: WebSocket, msg: ClientMessage): void { ws.send(JSON.stringify(msg)); } +function next(ws: WebSocket, t: ServerMessage['t']): Promise { + return new Promise((resolve) => { + const onMsg = (raw: Buffer | ArrayBuffer | Buffer[]) => { + const m = JSON.parse(raw.toString()) as ServerMessage; + if (m.t === t) { ws.off('message', onMsg); resolve(m); } + }; + ws.on('message', onMsg); + }); +} +const snap = { campaignName: 'Live', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null }; + +describe('realtime server (integration)', () => { + it('host → join → snapshot flow; players cannot push state', async () => { + await start(); + const gm = await open(); + send(gm, { t: 'host', campaignId: 'c' }); + const hosted = await next(gm, 'hosted') as Extract; + expect(hosted.joinCode).toBeTruthy(); + + const player = await open(); + send(player, { t: 'join', joinCode: hosted.joinCode }); + await next(player, 'joined'); + + send(gm, { t: 'state', gmSecret: hosted.gmSecret, snapshot: snap }); + const got = await next(player, 'snapshot') as Extract; + expect(got.snapshot.campaignName).toBe('Live'); + + // A player pushing state is rejected. + send(player, { t: 'state', gmSecret: 'forged', snapshot: snap }); + const err = await next(player, 'error') as Extract; + expect(err.code).toBe('forbidden'); + + gm.close(); player.close(); + }); +}); diff --git a/server/src/rooms.test.ts b/server/src/rooms.test.ts new file mode 100644 index 0000000..54d9f46 --- /dev/null +++ b/server/src/rooms.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { RoomHub, type Sender } from './rooms'; +import type { ServerMessage, Snapshot } from '@/lib/sync/messages'; + +function fake(): Sender & { msgs: ServerMessage[] } { + const msgs: ServerMessage[] = []; + return { msgs, send: (m) => msgs.push(m) }; +} +const snap: Snapshot = { campaignName: 'C', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null }; +function lastOf(s: ReturnType, t: T): Extract | undefined { + return [...s.msgs].reverse().find((m): m is Extract => m.t === t); +} + +describe('RoomHub', () => { + it('hosts a room and lets a player join + receive snapshots; GM is authoritative', () => { + const hub = new RoomHub(); + const gm = fake(); + hub.host(gm); + const hosted = lastOf(gm, 'hosted')!; + expect(hosted).toBeTruthy(); + const code = (hosted as Extract).joinCode; + const secret = (hosted as Extract).gmSecret; + + const player = fake(); + hub.join(player, code); + expect(lastOf(player, 'joined')).toBeTruthy(); + + hub.state(gm, secret, snap); + expect(lastOf(player, 'snapshot')).toMatchObject({ snapshot: { campaignName: 'C' } }); + }); + + it('rejects state from a non-GM and from a wrong secret', () => { + const hub = new RoomHub(); + const gm = fake(); hub.host(gm); + const player = fake(); + const code = (lastOf(gm, 'hosted') as Extract).joinCode; + hub.join(player, code); + + hub.state(player, 'whatever', snap); + expect(lastOf(player, 'error')?.code).toBe('forbidden'); + hub.state(gm, 'wrong-secret', snap); + expect(lastOf(gm, 'error')?.code).toBe('forbidden'); + }); + + it('enforces an optional join password', () => { + const hub = new RoomHub(); + const gm = fake(); hub.host(gm, 'sesame'); + const code = (lastOf(gm, 'hosted') as Extract).joinCode; + const p1 = fake(); hub.join(p1, code, 'nope'); + expect(lastOf(p1, 'error')?.code).toBe('bad-password'); + const p2 = fake(); hub.join(p2, code, 'sesame'); + expect(lastOf(p2, 'joined')).toBeTruthy(); + }); + + it('reports no-room for a bad code, and resumes a room by GM secret', () => { + const hub = new RoomHub(); + const stray = fake(); hub.join(stray, 'ZZZZZZ'); + expect(lastOf(stray, 'error')?.code).toBe('no-room'); + + const gm = fake(); hub.host(gm); + const hosted = lastOf(gm, 'hosted') as Extract; + const gm2 = fake(); hub.host(gm2, undefined, hosted.gmSecret); + const resumed = lastOf(gm2, 'hosted') as Extract; + expect(resumed.roomId).toBe(hosted.roomId); + expect(hub.roomCount()).toBe(1); + }); + + it('caches + serves images and evicts idle rooms on sweep', () => { + let t = 0; + const hub = new RoomHub(() => t); + const gm = fake(); hub.host(gm); + const hosted = lastOf(gm, 'hosted') as Extract; + hub.image(gm, hosted.gmSecret, 'map1', 'data:abc'); + const player = fake(); hub.join(player, hosted.joinCode); + hub.requestImage(player, 'map1'); + expect(lastOf(player, 'mapImage')).toMatchObject({ id: 'map1', dataUrl: 'data:abc' }); + + t += 7 * 60 * 60 * 1000; // past TTL + hub.sweep(); + expect(hub.roomCount()).toBe(0); + }); +}); diff --git a/server/src/rooms.ts b/server/src/rooms.ts new file mode 100644 index 0000000..21578d7 --- /dev/null +++ b/server/src/rooms.ts @@ -0,0 +1,145 @@ +import crypto from 'node:crypto'; +import type { ServerMessage, Snapshot } from '@/lib/sync/messages'; + +export interface Sender { + send: (msg: ServerMessage) => void; +} + +interface Room { + roomId: string; + joinCode: string; + gmSecretHash: string; + passwordHash: string | null; + gm: Sender | null; + players: Set; + snapshot: Snapshot | null; + images: Map; + lastActivity: number; +} + +const JOIN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no ambiguous chars +const ROOM_TTL_MS = 6 * 60 * 60 * 1000; // 6h idle + +function sha256(s: string): string { + return crypto.createHash('sha256').update(s).digest('hex'); +} +function timingEqual(a: string, b: string): boolean { + const ab = Buffer.from(a), bb = Buffer.from(b); + return ab.length === bb.length && crypto.timingSafeEqual(ab, bb); +} + +/** + * In-memory, GM-authoritative room registry. No persistence. The GM is the only + * writer; players are read-only and the hub enforces it. `now` is injectable for tests. + */ +export class RoomHub { + private rooms = new Map(); + private byCode = new Map(); + private conns = new Map(); + constructor(private now: () => number = () => Date.now()) {} + + private mintJoinCode(): string { + for (let attempt = 0; attempt < 50; attempt++) { + let code = ''; + const bytes = crypto.randomBytes(6); + for (let i = 0; i < 6; i++) code += JOIN_ALPHABET[bytes[i]! % JOIN_ALPHABET.length]; + if (!this.byCode.has(code)) return code; + } + return crypto.randomBytes(8).toString('hex').toUpperCase().slice(0, 6); + } + + host(socket: Sender, password?: string, resume?: string): void { + // Resume an existing room if the GM presents its secret (seamless reconnect). + if (resume) { + const hash = sha256(resume); + for (const room of this.rooms.values()) { + if (timingEqual(room.gmSecretHash, hash)) { + room.gm = socket; + room.lastActivity = this.now(); + this.conns.set(socket, { roomId: room.roomId, role: 'gm' }); + socket.send({ t: 'hosted', roomId: room.roomId, joinCode: room.joinCode, gmSecret: resume }); + return; + } + } + } + const roomId = crypto.randomUUID(); + const joinCode = this.mintJoinCode(); + const gmSecret = crypto.randomBytes(32).toString('base64url'); + const room: Room = { + roomId, joinCode, gmSecretHash: sha256(gmSecret), + passwordHash: password ? sha256(password) : null, + gm: socket, players: new Set(), snapshot: null, images: new Map(), lastActivity: this.now(), + }; + this.rooms.set(roomId, room); + this.byCode.set(joinCode, roomId); + this.conns.set(socket, { roomId, role: 'gm' }); + socket.send({ t: 'hosted', roomId, joinCode, gmSecret }); + } + + join(socket: Sender, joinCode: string, password?: string): void { + const roomId = this.byCode.get(joinCode.trim().toUpperCase()); + const room = roomId ? this.rooms.get(roomId) : undefined; + if (!room) { socket.send({ t: 'error', code: 'no-room', message: 'No session with that code.' }); return; } + if (room.passwordHash && (!password || !timingEqual(room.passwordHash, sha256(password)))) { + socket.send({ t: 'error', code: 'bad-password', message: 'Wrong session password.' }); + return; + } + room.players.add(socket); + room.lastActivity = this.now(); + this.conns.set(socket, { roomId: room.roomId, role: 'player' }); + socket.send({ t: 'joined', roomId: room.roomId }); + if (room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot }); + } + + state(socket: Sender, gmSecret: string, snapshot: Snapshot): void { + const room = this.gmRoom(socket, gmSecret); + if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; } + room.snapshot = snapshot; + room.lastActivity = this.now(); + for (const p of room.players) p.send({ t: 'snapshot', snapshot }); + } + + image(socket: Sender, gmSecret: string, id: string, dataUrl: string): void { + const room = this.gmRoom(socket, gmSecret); + if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; } + room.images.set(id, dataUrl); + room.lastActivity = this.now(); + for (const p of room.players) p.send({ t: 'mapImage', id, dataUrl }); + } + + requestImage(socket: Sender, id: string): void { + const conn = this.conns.get(socket); + const room = conn ? this.rooms.get(conn.roomId) : undefined; + const dataUrl = room?.images.get(id); + if (dataUrl) socket.send({ t: 'mapImage', id, dataUrl }); + } + + disconnect(socket: Sender): void { + const conn = this.conns.get(socket); + if (!conn) return; + const room = this.rooms.get(conn.roomId); + if (room) { + if (conn.role === 'gm' && room.gm === socket) room.gm = null; + room.players.delete(socket); + } + this.conns.delete(socket); + } + + /** Evict rooms idle past the TTL. Call periodically. */ + sweep(): void { + const cutoff = this.now() - ROOM_TTL_MS; + for (const [id, room] of this.rooms) { + if (room.lastActivity < cutoff) { this.byCode.delete(room.joinCode); this.rooms.delete(id); } + } + } + + roomCount(): number { return this.rooms.size; } + + private gmRoom(socket: Sender, gmSecret: string): Room | null { + const conn = this.conns.get(socket); + if (!conn || conn.role !== 'gm') return null; + const room = this.rooms.get(conn.roomId); + if (!room || room.gm !== socket || !timingEqual(room.gmSecretHash, sha256(gmSecret))) return null; + return room; + } +} diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index 3802def..9631d90 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -8,6 +8,8 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { RollTray } from '@/components/ui/RollTray'; import { Select } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; +import { SessionControl } from '@/features/play/SessionControl'; +import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster'; const NAV = [ { to: '/', label: 'Campaigns', exact: true }, @@ -56,6 +58,9 @@ function ThemeToggle() { export function RootLayout() { const pathname = useRouterState({ select: (s) => s.location.pathname }); const [paletteOpen, setPaletteOpen] = useState(false); + const activeCampaign = useActiveCampaign(); + // While the GM is hosting, mirror state to players (inert unless hosting). + useSessionBroadcaster(activeCampaign ?? null); // Global Ctrl/Cmd+K opens the command palette. useEffect(() => { @@ -93,6 +98,7 @@ export function RootLayout() { })}
+ diff --git a/src/features/play/SessionControl.tsx b/src/features/play/SessionControl.tsx new file mode 100644 index 0000000..535e1d4 --- /dev/null +++ b/src/features/play/SessionControl.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; +import { useSessionStore } from '@/stores/sessionStore'; +import { hostSession, stopSession } from '@/lib/sync/wsSync'; +import { useActiveCampaign } from '@/features/campaigns/hooks'; +import { Button } from '@/components/ui/Button'; + +/** Header control: GM hosts a live session and shares the join code/link. */ +export function SessionControl() { + const campaign = useActiveCampaign(); + const role = useSessionStore((s) => s.role); + const status = useSessionStore((s) => s.status); + const joinCode = useSessionStore((s) => s.joinCode); + const [copied, setCopied] = useState(false); + + if (role === 'player') return null; // players don't host + + if (role === 'off') { + if (!campaign) return null; + return ( + + ); + } + + const link = joinCode ? `${location.origin}/play?room=${joinCode}` : ''; + return ( +
+ {status === 'connected' ? 'Live' : status}: + + +
+ ); +} diff --git a/src/features/play/useSessionBroadcaster.ts b/src/features/play/useSessionBroadcaster.ts new file mode 100644 index 0000000..a023917 --- /dev/null +++ b/src/features/play/useSessionBroadcaster.ts @@ -0,0 +1,37 @@ +import { useEffect } from 'react'; +import type { Campaign } from '@/lib/schemas'; +import { useDebouncedCallback } from '@/lib/useDebouncedCallback'; +import { useSessionStore } from '@/stores/sessionStore'; +import { useUiStore } from '@/stores/uiStore'; +import { buildSnapshot } from '@/lib/sync/snapshot'; +import { pushSnapshot, pushImage } from '@/lib/sync/wsSync'; +import { useCharacters } from '@/features/characters/hooks'; +import { useEncounters } from '@/features/combat/hooks'; +import { useMaps, useCalendar } from '@/features/world/hooks'; + +/** + * While the GM is hosting, mirror local state to the room: a debounced + * player-safe snapshot on any change, plus the active map image (sent once per id). + * Inert unless role === 'gm'. Mounted once in the app shell. + */ +export function useSessionBroadcaster(campaign: Campaign | null): void { + const role = useSessionStore((s) => s.role); + const status = useSessionStore((s) => s.status); + const cid = campaign?.id ?? ''; + const characters = useCharacters(cid); + const encounters = useEncounters(cid); + const maps = useMaps(cid); + const calendar = useCalendar(cid); + const activeEncounterId = useUiStore((s) => s.activeEncounterId); + const activeMapId = useUiStore((s) => s.activeMapId); + + const debouncedPush = useDebouncedCallback((s: ReturnType) => pushSnapshot(s), 250); + + useEffect(() => { + if (role !== 'gm' || status !== 'connected' || !campaign) return; + const snapshot = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId }); + debouncedPush(snapshot); + const activeMap = maps.find((m) => m.id === activeMapId); + if (activeMap?.image) pushImage(activeMap.id, activeMap.image); + }, [role, status, campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, debouncedPush]); +} diff --git a/src/features/player/PlayerBoards.tsx b/src/features/player/PlayerBoards.tsx new file mode 100644 index 0000000..e19c82f --- /dev/null +++ b/src/features/player/PlayerBoards.tsx @@ -0,0 +1,77 @@ +import type { Snapshot } from '@/lib/sync/messages'; +import type { PlayerMap } from '@/lib/map'; +import { EmptyState } from '@/components/ui/Page'; +import { cn } from '@/lib/cn'; +import { PlayerMapView } from './PlayerMapView'; + +/** Presentational player view: battle map + party + initiative, from a snapshot. */ +export function PlayerBoards({ snapshot, image }: { snapshot: Snapshot; image?: string }) { + const { party, encounter, map } = snapshot; + return ( + <> + {map && ( +
+

Battle map

+ +
+ )} + +
+
+

Party

+ {party.length === 0 ? ( +

No player characters.

+ ) : ( +
+ {party.map((c) => { + const pct = c.hp.max > 0 ? Math.max(0, Math.min(100, (c.hp.current / c.hp.max) * 100)) : 0; + return ( +
+
+ {c.name} + Lv {c.level} · AC {c.ac} +
+
+
50 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${pct}%` }} /> +
+
{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}
+ {c.conditions.length > 0 && ( +
+ {c.conditions.map((cond, i) => ( + {cond.name}{cond.value ? ` ${cond.value}` : ''} + ))} +
+ )} +
+ ); + })} +
+ )} +
+ +
+

+ Initiative {encounter ? `· Round ${encounter.round}` : ''} +

+ {!encounter ? ( + + ) : ( +
    + {encounter.combatants.map((c) => ( +
  1. + {c.initiative} + {c.name}{c.isCurrent && ◀ turn} + {c.hp ? ( + {c.hp.current}/{c.hp.max} HP + ) : c.status ? ( + {c.status.label} + ) : null} +
  2. + ))} +
+ )} +
+
+ + ); +} diff --git a/src/features/player/PlayerMapView.tsx b/src/features/player/PlayerMapView.tsx index 5ab971d..de48786 100644 --- a/src/features/player/PlayerMapView.tsx +++ b/src/features/player/PlayerMapView.tsx @@ -2,7 +2,7 @@ import type { PlayerMap } from '@/lib/map'; import { MapCanvas, type CanvasToken } from '@/features/world/map/MapCanvas'; /** Read-only render of a player-facing map projection + its image. */ -export function PlayerMapView({ map, image, maxWidth }: { map: PlayerMap; image?: string; maxWidth?: number }) { +export function PlayerMapView({ map, image, maxWidth }: { map: PlayerMap; image?: string | undefined; maxWidth?: number | undefined }) { const tokens: CanvasToken[] = map.tokens.map((t) => ({ id: t.id, label: t.label, color: t.color, col: t.col, row: t.row, size: t.size, kind: t.kind, hp: t.hp, conditions: t.conditions, diff --git a/src/features/player/PlayerViewPage.tsx b/src/features/player/PlayerViewPage.tsx index 64228ac..a51feaa 100644 --- a/src/features/player/PlayerViewPage.tsx +++ b/src/features/player/PlayerViewPage.tsx @@ -1,129 +1,97 @@ +import { useEffect, useRef, useState } from 'react'; import type { Campaign } from '@/lib/schemas'; -import { getSystem } from '@/lib/rules'; import { localSync } from '@/lib/sync'; -import { enemyStatus } from '@/lib/combat/playerProjection'; -import { toPlayerProjection } from '@/lib/map'; +import { buildSnapshot } from '@/lib/sync/snapshot'; +import { joinSession, stopSession } from '@/lib/sync/wsSync'; import { useUiStore } from '@/stores/uiStore'; +import { useSessionStore } from '@/stores/sessionStore'; +import { usePlayerSessionStore } from '@/stores/playerSessionStore'; import { useCharacters } from '@/features/characters/hooks'; import { useEncounters } from '@/features/combat/hooks'; import { useCalendar, useMaps } from '@/features/world/hooks'; import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Button } from '@/components/ui/Button'; -import { cn } from '@/lib/cn'; -import { PlayerMapView } from './PlayerMapView'; +import { PlayerBoards } from './PlayerBoards'; -export function PlayerViewPage() { - return {(c) => }; +function roomParam(): string | null { + return new URLSearchParams(window.location.search).get('room'); } -function PlayerView({ campaign }: { campaign: Campaign }) { +export function PlayerViewPage() { + // A player joining via link (?room=CODE) has no local campaign — networked mode. + if (roomParam()) return ; + return {(c) => }; +} + +function Shell({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) { + const enterFullscreen = () => void document.documentElement.requestFullscreen?.().catch(() => {}); + return ( + +
+
+

{title}

+

{subtitle}

+
+ +
+ {children} +
+ ); +} + +/** Local single-device player view (GM's own screen). */ +function LocalPlayerView({ campaign }: { campaign: Campaign }) { const characters = useCharacters(campaign.id); const encounters = useEncounters(campaign.id); const calendar = useCalendar(campaign.id); const maps = useMaps(campaign.id); const activeEncounterId = useUiStore((s) => s.activeEncounterId); const activeMapId = useUiStore((s) => s.activeMapId); - const activeMap = maps.find((mp) => mp.id === activeMapId) ?? null; - const sys = getSystem(campaign.system); - const pcs = characters.filter((c) => c.kind === 'pc'); - const encounter = - encounters.find((e) => e.id === activeEncounterId && e.status === 'active') ?? - encounters.find((e) => e.status === 'active'); - - const enterFullscreen = () => void document.documentElement.requestFullscreen?.().catch(() => {}); + const snapshot = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId }); + const image = maps.find((m) => m.id === activeMapId)?.image; return ( - -
-
-

{campaign.name}

-

- Player View{calendar ? ` · in-world day ${calendar.currentDay}` : ''} ·{' '} - {localSync.status} -

-
- -
- - {activeMap && ( -
-

Battle map

- -
- )} - -
- {/* Party */} -
-

Party

- {pcs.length === 0 ? ( -

No player characters.

- ) : ( -
- {pcs.map((c) => { - const pct = c.hp.max > 0 ? Math.max(0, Math.min(100, (c.hp.current / c.hp.max) * 100)) : 0; - return ( -
-
- {c.name} - - Lv {c.level} · AC {sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })} - -
-
-
50 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${pct}%` }} /> -
-
{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}
- {c.conditions.length > 0 && ( -
- {c.conditions.map((cond, i) => ( - {cond.name}{cond.value ? ` ${cond.value}` : ''} - ))} -
- )} -
- ); - })} -
- )} -
- - {/* Initiative */} -
-

- Initiative {encounter ? `· Round ${encounter.round}` : ''} -

- {!encounter ? ( - - ) : ( -
    - {encounter.combatants.map((c, idx) => { - const isCurrent = idx === encounter.turnIndex; - const isPC = c.kind === 'pc'; - const status = enemyStatus(c); - return ( -
  1. - {c.initiative} - {c.name}{isCurrent && ◀ turn} - {isPC ? ( - {c.hp.current}/{c.hp.max} HP - ) : ( - {status.label} - )} -
  2. - ); - })} -
- )} -
-
-
+ + + + ); +} + +/** Networked player view — joins a GM's session over the network. */ +function NetworkedPlayerView({ room }: { room: string }) { + const status = useSessionStore((s) => s.status); + const error = useSessionStore((s) => s.error); + const snapshot = usePlayerSessionStore((s) => s.snapshot); + const images = usePlayerSessionStore((s) => s.images); + const [needPw, setNeedPw] = useState(false); + const joined = useRef(false); + + useEffect(() => { + joinSession(room); + return () => stopSession(); + }, [room]); + + useEffect(() => { + if (error?.toLowerCase().includes('password') && !needPw) { + setNeedPw(true); + const pw = window.prompt('This session needs a password:')?.trim(); + if (pw) { joinSession(room, pw); setNeedPw(false); } + } + }, [error, needPw, room]); + + if (!snapshot) { + return ( + + {error ? :

Connecting…

} +
+ ); + } + const image = snapshot.mapImageId ? images[snapshot.mapImageId] : undefined; + void joined; + return ( + + + ); } diff --git a/src/lib/combat/playerProjection.test.ts b/src/lib/combat/playerProjection.test.ts new file mode 100644 index 0000000..3ac2255 --- /dev/null +++ b/src/lib/combat/playerProjection.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { enemyStatus, toPlayerEncounter } from './playerProjection'; +import type { Combatant, Encounter } from '@/lib/schemas'; + +function mob(hp: { current: number; max: number }): Pick { + return { hp: { ...hp, temp: 0 } }; +} + +describe('enemyStatus', () => { + it('maps HP fraction to a masked band', () => { + expect(enemyStatus(mob({ current: 20, max: 20 })).label).toBe('Healthy'); + expect(enemyStatus(mob({ current: 10, max: 20 })).label).toBe('Bloodied'); + expect(enemyStatus(mob({ current: 0, max: 20 })).label).toBe('Down'); + }); +}); + +describe('toPlayerEncounter', () => { + it('keeps PC HP but masks enemy HP as a status', () => { + const e: Encounter = { + id: 'e', campaignId: 'c', name: 'Fight', status: 'active', round: 2, turnIndex: 0, + combatants: [ + { id: 'pc', name: 'Hero', kind: 'pc', initiative: 15, initBonus: 0, ac: 16, hp: { current: 9, max: 18, temp: 0 }, conditions: [], notes: '' }, + { id: 'm', name: 'Ogre', kind: 'monster', initiative: 8, initBonus: 0, ac: 13, hp: { current: 30, max: 60, temp: 0 }, conditions: [], notes: '' }, + ], + log: [], createdAt: '', updatedAt: '', + }; + const p = toPlayerEncounter(e); + expect(p.combatants[0]).toMatchObject({ name: 'Hero', isCurrent: true, hp: { current: 9, max: 18 } }); + expect(p.combatants[0]!.status).toBeUndefined(); + expect(p.combatants[1]!.hp).toBeUndefined(); + expect(p.combatants[1]!.status?.label).toBe('Bloodied'); + }); +}); diff --git a/src/lib/sync/messages.test.ts b/src/lib/sync/messages.test.ts new file mode 100644 index 0000000..f160189 --- /dev/null +++ b/src/lib/sync/messages.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { clientMessageSchema, serverMessageSchema, snapshotSchema, type Snapshot } from './messages'; + +const snap: Snapshot = { campaignName: 'C', calendarDay: 3, party: [], encounter: null, map: null, mapImageId: null }; + +describe('sync protocol', () => { + it('round-trips client messages and rejects malformed', () => { + expect(clientMessageSchema.safeParse({ t: 'join', joinCode: 'ABC123' }).success).toBe(true); + expect(clientMessageSchema.safeParse({ t: 'state', gmSecret: 's', snapshot: snap }).success).toBe(true); + expect(clientMessageSchema.safeParse({ t: 'nope' }).success).toBe(false); + expect(clientMessageSchema.safeParse({ t: 'state', snapshot: snap }).success).toBe(false); // missing gmSecret + }); + it('round-trips server messages', () => { + expect(serverMessageSchema.safeParse({ t: 'hosted', roomId: 'r', joinCode: 'C', gmSecret: 's' }).success).toBe(true); + expect(serverMessageSchema.safeParse({ t: 'snapshot', snapshot: snap }).success).toBe(true); + }); + it('validates a full snapshot with masked enemy', () => { + const full: Snapshot = { + campaignName: 'X', calendarDay: null, + party: [{ id: 'a', name: 'Hero', level: 3, ac: 15, hp: { current: 10, max: 20, temp: 0 }, conditions: [] }], + encounter: { id: 'e', name: 'Fight', round: 1, combatants: [ + { id: 'm', name: 'Goblin', kind: 'monster', initiative: 12, isCurrent: false, status: { label: 'Bloodied', cls: 'text-warning' } }, + ] }, + map: null, mapImageId: null, + }; + expect(snapshotSchema.safeParse(full).success).toBe(true); + }); +}); diff --git a/src/lib/sync/messages.ts b/src/lib/sync/messages.ts new file mode 100644 index 0000000..520a064 --- /dev/null +++ b/src/lib/sync/messages.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; +import { int, num, hpSchema } from '@/lib/schemas/common'; + +/** + * Wire protocol shared by the client and the realtime server. Everything is + * JSON-safe and Zod-validated on both ends. The GM is the sole authoritative + * writer; players are read-only. + */ + +const condSchema = z.object({ name: z.string(), value: int.optional() }); + +// ---- player-facing projections (the only campaign data that leaves the GM) ---- + +export const playerCharacterSchema = z.object({ + id: z.string(), + name: z.string(), + level: int, + ac: int, + hp: hpSchema, + conditions: z.array(condSchema).default([]), +}); + +export const playerCombatantSchema = z.object({ + id: z.string(), + name: z.string(), + kind: z.enum(['pc', 'npc', 'monster']), + initiative: int, + isCurrent: z.boolean(), + hp: hpSchema.optional(), + status: z.object({ label: z.string(), cls: z.string() }).optional(), +}); + +export const playerEncounterSchema = z.object({ + id: z.string(), + name: z.string(), + round: int, + combatants: z.array(playerCombatantSchema), +}); + +const playerTokenSchema = z.object({ + id: z.string(), label: z.string(), color: z.string(), col: int, row: int, size: int, + kind: z.enum(['pc', 'npc', 'monster', 'object']), + hp: hpSchema.optional(), + conditions: z.array(condSchema).default([]), +}); +const playerDrawingSchema = z.object({ + id: z.string(), kind: z.enum(['freehand', 'line', 'rect', 'circle', 'arrow', 'text']), + points: z.array(z.object({ x: num, y: num })), color: z.string(), width: num, text: z.string().optional(), +}); +export const playerMapSchema = z.object({ + id: z.string(), name: z.string(), gridSize: int, showGrid: z.boolean(), fogEnabled: z.boolean(), + revealed: z.array(z.string()), gridType: z.enum(['square', 'hex']), gridUnit: z.object({ feet: int }), + tokens: z.array(playerTokenSchema), drawings: z.array(playerDrawingSchema), hasImage: z.boolean(), +}); + +export const snapshotSchema = z.object({ + campaignName: z.string(), + calendarDay: int.nullable(), + party: z.array(playerCharacterSchema), + encounter: playerEncounterSchema.nullable(), + map: playerMapSchema.nullable(), + mapImageId: z.string().nullable(), +}); +export type Snapshot = z.infer; + +// ---- messages ---- + +export const clientMessageSchema = z.discriminatedUnion('t', [ + z.object({ t: z.literal('host'), campaignId: z.string(), password: z.string().max(128).optional(), resume: z.string().optional() }), + z.object({ t: z.literal('join'), joinCode: z.string().max(16), password: z.string().max(128).optional() }), + z.object({ t: z.literal('state'), gmSecret: z.string(), snapshot: snapshotSchema }), + z.object({ t: z.literal('image'), gmSecret: z.string(), id: z.string(), dataUrl: z.string() }), + z.object({ t: z.literal('requestImage'), id: z.string() }), +]); +export type ClientMessage = z.infer; + +export const serverMessageSchema = z.discriminatedUnion('t', [ + z.object({ t: z.literal('hosted'), roomId: z.string(), joinCode: z.string(), gmSecret: z.string() }), + z.object({ t: z.literal('joined'), roomId: z.string() }), + z.object({ t: z.literal('snapshot'), snapshot: snapshotSchema }), + z.object({ t: z.literal('mapImage'), id: z.string(), dataUrl: z.string() }), + z.object({ t: z.literal('error'), code: z.string(), message: z.string() }), +]); +export type ServerMessage = z.infer; diff --git a/src/lib/sync/snapshot.ts b/src/lib/sync/snapshot.ts new file mode 100644 index 0000000..482011a --- /dev/null +++ b/src/lib/sync/snapshot.ts @@ -0,0 +1,42 @@ +import type { BattleMap, Calendar, Campaign, Character, Encounter } from '@/lib/schemas'; +import { getSystem } from '@/lib/rules'; +import { toPlayerEncounter } from '@/lib/combat/playerProjection'; +import { toPlayerProjection } from '@/lib/map'; +import type { Snapshot } from './messages'; + +/** Build the player-safe session snapshot from the GM's local state. */ +export function buildSnapshot(input: { + campaign: Campaign; + characters: Character[]; + encounters: Encounter[]; + maps: BattleMap[]; + calendar: Calendar | null; + activeEncounterId: string | null; + activeMapId: string | null; +}): Snapshot { + const { campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId } = input; + const sys = getSystem(campaign.system); + + const party = characters.filter((c) => c.kind === 'pc').map((c) => ({ + id: c.id, + name: c.name, + level: c.level, + ac: sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus }), + hp: c.hp, + conditions: c.conditions.map((x) => ({ name: x.name, ...(x.value !== undefined ? { value: x.value } : {}) })), + })); + + const activeEnc = encounters.find((e) => e.id === activeEncounterId && e.status === 'active') + ?? encounters.find((e) => e.status === 'active') ?? null; + const activeMap = maps.find((m) => m.id === activeMapId) ?? null; + const projection = activeMap ? toPlayerProjection(activeMap) : null; + + return { + campaignName: campaign.name, + calendarDay: calendar ? calendar.currentDay : null, + party, + encounter: activeEnc ? toPlayerEncounter(activeEnc) : null, + map: projection, + mapImageId: activeMap && activeMap.image ? activeMap.id : null, + }; +} diff --git a/src/lib/sync/wsSync.ts b/src/lib/sync/wsSync.ts new file mode 100644 index 0000000..595eb9c --- /dev/null +++ b/src/lib/sync/wsSync.ts @@ -0,0 +1,120 @@ +import { useSessionStore } from '@/stores/sessionStore'; +import { usePlayerSessionStore } from '@/stores/playerSessionStore'; +import { clientMessageSchema, serverMessageSchema, type ClientMessage, type Snapshot } from './messages'; + +/** + * WebSocket transport for live sessions. The GM hosts and pushes player-safe + * snapshots; players join read-only and render received state. Reconnects with + * backoff and re-hosts/re-joins automatically. No-op-safe when offline. + */ + +let ws: WebSocket | null = null; +let gmSecret = ''; +let intent: { kind: 'gm'; campaignId: string; password?: string } | { kind: 'player'; joinCode: string; password?: string } | null = null; +let lastSnapshot: Snapshot | null = null; +let lastImage: { id: string; dataUrl: string } | null = null; +let reconnectMs = 1000; +let reconnectTimer: ReturnType | null = null; +let manualStop = false; + +function wsUrl(): string { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + return `${proto}://${location.host}/ws`; +} + +function send(msg: ClientMessage): void { + if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(clientMessageSchema.parse(msg))); +} + +function connect(): void { + if (!intent) return; + useSessionStore.getState().set({ status: 'connecting', error: null }); + let socket: WebSocket; + try { + socket = new WebSocket(wsUrl()); + } catch { + useSessionStore.getState().set({ status: 'error', error: 'Could not open connection.' }); + return; + } + ws = socket; + + socket.onopen = () => { + reconnectMs = 1000; + if (!intent) return; + if (intent.kind === 'gm') send({ t: 'host', campaignId: intent.campaignId, ...(intent.password ? { password: intent.password } : {}), ...(gmSecret ? { resume: gmSecret } : {}) }); + else send({ t: 'join', joinCode: intent.joinCode, ...(intent.password ? { password: intent.password } : {}) }); + }; + + socket.onmessage = (ev) => { + let parsed; + try { parsed = serverMessageSchema.safeParse(JSON.parse(String(ev.data))); } catch { return; } + if (!parsed.success) return; + const msg = parsed.data; + const session = useSessionStore.getState(); + if (msg.t === 'hosted') { + gmSecret = msg.gmSecret; + session.set({ role: 'gm', status: 'connected', roomId: msg.roomId, joinCode: msg.joinCode, error: null }); + // (re)send current state on (re)connect + if (lastSnapshot) send({ t: 'state', gmSecret, snapshot: lastSnapshot }); + if (lastImage) send({ t: 'image', gmSecret, id: lastImage.id, dataUrl: lastImage.dataUrl }); + } else if (msg.t === 'joined') { + session.set({ role: 'player', status: 'connected', roomId: msg.roomId, error: null }); + } else if (msg.t === 'snapshot') { + usePlayerSessionStore.getState().setSnapshot(msg.snapshot); + // fetch the map image if we don't have it cached + const id = msg.snapshot.mapImageId; + if (id && !usePlayerSessionStore.getState().images[id]) send({ t: 'requestImage', id }); + } else if (msg.t === 'mapImage') { + usePlayerSessionStore.getState().setImage(msg.id, msg.dataUrl); + } else if (msg.t === 'error') { + session.set({ status: 'error', error: msg.message }); + if (msg.code === 'no-room' || msg.code === 'bad-password') manualStop = true; + } + }; + + socket.onclose = () => { + ws = null; + if (manualStop || !intent) return; + useSessionStore.getState().set({ status: 'connecting' }); + reconnectTimer = setTimeout(connect, reconnectMs); + reconnectMs = Math.min(15000, reconnectMs * 2); + }; + socket.onerror = () => { try { socket.close(); } catch { /* ignore */ } }; +} + +export function hostSession(campaignId: string, password?: string): void { + manualStop = false; + intent = { kind: 'gm', campaignId, ...(password ? { password } : {}) }; + connect(); +} + +export function joinSession(joinCode: string, password?: string): void { + manualStop = false; + intent = { kind: 'player', joinCode: joinCode.trim().toUpperCase(), ...(password ? { password } : {}) }; + usePlayerSessionStore.getState().reset(); + connect(); +} + +export function pushSnapshot(snapshot: Snapshot): void { + lastSnapshot = snapshot; + if (useSessionStore.getState().role === 'gm') send({ t: 'state', gmSecret, snapshot }); +} + +export function pushImage(id: string, dataUrl: string): void { + if (lastImage?.id === id) return; + lastImage = { id, dataUrl }; + if (useSessionStore.getState().role === 'gm') send({ t: 'image', gmSecret, id, dataUrl }); +} + +export function stopSession(): void { + manualStop = true; + intent = null; + gmSecret = ''; + lastSnapshot = null; + lastImage = null; + if (reconnectTimer) clearTimeout(reconnectTimer); + try { ws?.close(); } catch { /* ignore */ } + ws = null; + useSessionStore.getState().reset(); + usePlayerSessionStore.getState().reset(); +} diff --git a/src/stores/playerSessionStore.ts b/src/stores/playerSessionStore.ts new file mode 100644 index 0000000..ec75099 --- /dev/null +++ b/src/stores/playerSessionStore.ts @@ -0,0 +1,20 @@ +import { create } from 'zustand'; +import type { Snapshot } from '@/lib/sync/messages'; + +interface PlayerSessionState { + snapshot: Snapshot | null; + /** map image data URLs keyed by map id, cached so they don't re-stream */ + images: Record; + setSnapshot: (s: Snapshot) => void; + setImage: (id: string, dataUrl: string) => void; + reset: () => void; +} + +/** Ephemeral store holding what a joined player currently sees (never persisted). */ +export const usePlayerSessionStore = create()((set) => ({ + snapshot: null, + images: {}, + setSnapshot: (snapshot) => set({ snapshot }), + setImage: (id, dataUrl) => set((s) => ({ images: { ...s.images, [id]: dataUrl } })), + reset: () => set({ snapshot: null, images: {} }), +})); diff --git a/src/stores/sessionStore.ts b/src/stores/sessionStore.ts new file mode 100644 index 0000000..8e21c4b --- /dev/null +++ b/src/stores/sessionStore.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand'; + +export type SessionRole = 'off' | 'gm' | 'player'; +export type SessionStatus = 'idle' | 'connecting' | 'connected' | 'error'; + +interface SessionState { + role: SessionRole; + status: SessionStatus; + roomId: string | null; + joinCode: string | null; + error: string | null; + set: (patch: Partial>) => void; + reset: () => void; +} + +/** Ephemeral (non-persisted) state for a live collaboration session. */ +export const useSessionStore = create()((set) => ({ + role: 'off', + status: 'idle', + roomId: null, + joinCode: null, + error: null, + set: (patch) => set(patch), + reset: () => set({ role: 'off', status: 'idle', roomId: null, joinCode: null, error: null }), +})); diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 1f0bcfd..bc9b46a 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/player/PlayerMapView.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/features/world/map/MapCanvas.tsx","./src/features/world/map/MapEditor.tsx","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/combat/playerProjection.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/map/distance.ts","./src/lib/map/fog.ts","./src/lib/map/grid.ts","./src/lib/map/index.ts","./src/lib/map/map.test.ts","./src/lib/map/projection.ts","./src/lib/map/shapes.ts","./src/lib/map/types.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/play/SessionControl.tsx","./src/features/play/useSessionBroadcaster.ts","./src/features/player/PlayerBoards.tsx","./src/features/player/PlayerMapView.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/features/world/map/MapCanvas.tsx","./src/features/world/map/MapEditor.tsx","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/combat/playerProjection.test.ts","./src/lib/combat/playerProjection.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/map/distance.ts","./src/lib/map/fog.ts","./src/lib/map/grid.ts","./src/lib/map/index.ts","./src/lib/map/map.test.ts","./src/lib/map/projection.ts","./src/lib/map/shapes.ts","./src/lib/map/types.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/lib/sync/messages.test.ts","./src/lib/sync/messages.ts","./src/lib/sync/snapshot.ts","./src/lib/sync/wsSync.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/playerSessionStore.ts","./src/stores/rollStore.ts","./src/stores/sessionStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..ecdbdc3 --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2022", + "lib": ["ES2022"], + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "baseUrl": ".", + "paths": { "@/*": ["./src/*"] }, + "types": ["node", "vitest/globals"] + }, + "include": ["server/src/**/*"] +} diff --git a/vite.config.ts b/vite.config.ts index d4717ab..47e359c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,7 +11,7 @@ import { fileURLToPath, URL } from 'node:url'; // models (Ollama/LM Studio). Everything else stays locked to 'self'. const PROD_CSP = "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; " + - "script-src 'self'; connect-src 'self' https: http://localhost:* http://127.0.0.1:*; " + + "script-src 'self'; connect-src 'self' https: wss: http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:*; " + "font-src 'self' data:; worker-src 'self' blob:; " + "manifest-src 'self'; object-src 'none'; base-uri 'self'"; diff --git a/vitest.config.ts b/vitest.config.ts index 31f92a0..e868fcf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,6 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], css: false, - exclude: ['**/node_modules/**', '**/e2e/**', '**/dist/**'], + exclude: ['**/node_modules/**', '**/e2e/**', '**/e2e-realtime/**', '**/dist/**'], }, });