Phase 18: internet realtime collaboration (GM-authoritative) + server

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 10:49:31 +02:00
parent fb459ad92c
commit a8cb7d65f4
31 changed files with 1304 additions and 118 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
dist
dev-dist
server/dist
server/node_modules
test-results
playwright-report
.git
e2e
**/*.test.ts
+21
View File
@@ -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"]
+173 -6
View File
@@ -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=="],
}
}
+30
View File
@@ -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
+43
View File
@@ -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();
});
+2 -1
View File
@@ -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}'],
+7
View File
@@ -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",
+19
View File
@@ -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,
},
});
+17
View File
@@ -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');
+15
View File
@@ -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"
}
}
+72
View File
@@ -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); });
}
+57
View File
@@ -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<void> {
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<WebSocket> {
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<ServerMessage> {
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<ServerMessage, { t: 'hosted' }>;
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<ServerMessage, { t: 'snapshot' }>;
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<ServerMessage, { t: 'error' }>;
expect(err.code).toBe('forbidden');
gm.close(); player.close();
});
});
+82
View File
@@ -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<T extends ServerMessage['t']>(s: ReturnType<typeof fake>, t: T): Extract<ServerMessage, { t: T }> | undefined {
return [...s.msgs].reverse().find((m): m is Extract<ServerMessage, { t: T }> => 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<ServerMessage, { t: 'hosted' }>).joinCode;
const secret = (hosted as Extract<ServerMessage, { t: 'hosted' }>).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<ServerMessage, { t: 'hosted' }>).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<ServerMessage, { t: 'hosted' }>).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<ServerMessage, { t: 'hosted' }>;
const gm2 = fake(); hub.host(gm2, undefined, hosted.gmSecret);
const resumed = lastOf(gm2, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
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<ServerMessage, { t: 'hosted' }>;
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);
});
});
+145
View File
@@ -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<Sender>;
snapshot: Snapshot | null;
images: Map<string, string>;
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<string, Room>();
private byCode = new Map<string, string>();
private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player' }>();
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;
}
}
+6
View File
@@ -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() {
})}
</nav>
<div className="ml-auto flex items-center gap-2">
<SessionControl />
<Button size="sm" variant="ghost" onClick={() => setPaletteOpen(true)} title="Command palette (Ctrl/Cmd+K)" aria-label="Open command palette">
K
</Button>
+42
View File
@@ -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 (
<Button size="sm" variant="ghost" title="Host a live session for players"
onClick={() => {
const pw = window.prompt('Optional player password (leave blank for none):')?.trim();
hostSession(campaign.id, pw || undefined);
}}
>📡 Host</Button>
);
}
const link = joinCode ? `${location.origin}/play?room=${joinCode}` : '';
return (
<div className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent/5 px-2 py-1 text-xs">
<span className="text-muted">{status === 'connected' ? 'Live' : status}:</span>
<button
data-testid="join-code"
className="font-mono font-semibold text-accent"
title="Copy player link"
onClick={() => { if (link) void navigator.clipboard?.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }}
>{joinCode}{copied ? ' ✓' : ''}</button>
<Button size="sm" variant="ghost" className="h-6 px-1 text-danger" onClick={() => stopSession()} title="Stop session"></Button>
</div>
);
}
@@ -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<typeof buildSnapshot>) => 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]);
}
+77
View File
@@ -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 && (
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Battle map</h2>
<PlayerMapView map={map as PlayerMap} image={image} maxWidth={1100} />
</section>
)}
<div className="grid gap-6 lg:grid-cols-2">
<section>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Party</h2>
{party.length === 0 ? (
<p className="text-sm text-muted">No player characters.</p>
) : (
<div className="space-y-2">
{party.map((c) => {
const pct = c.hp.max > 0 ? Math.max(0, Math.min(100, (c.hp.current / c.hp.max) * 100)) : 0;
return (
<div key={c.id} className="rounded-lg border border-line bg-panel p-3">
<div className="flex items-baseline justify-between">
<span className="font-display text-lg font-semibold text-ink">{c.name}</span>
<span className="text-sm text-muted">Lv {c.level} · AC {c.ac}</span>
</div>
<div className="mt-2 h-3 overflow-hidden rounded-full bg-surface">
<div className={cn('h-full', pct > 50 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${pct}%` }} />
</div>
<div className="mt-1 text-right text-xs text-muted">{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</div>
{c.conditions.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{c.conditions.map((cond, i) => (
<span key={i} className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
))}
</div>
)}
</div>
);
})}
</div>
)}
</section>
<section>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">
Initiative {encounter ? `· Round ${encounter.round}` : ''}
</h2>
{!encounter ? (
<EmptyState title="No active combat" hint="When the GM starts an encounter, the turn order shows here." />
) : (
<ol className="space-y-1">
{encounter.combatants.map((c) => (
<li key={c.id} className={cn('flex items-center gap-3 rounded-md border px-3 py-2', c.isCurrent ? 'border-accent bg-elevated ring-1 ring-accent/40' : 'border-line bg-panel')}>
<span className="w-8 text-center font-display text-lg font-semibold text-accent">{c.initiative}</span>
<span className="flex-1 text-ink">{c.name}{c.isCurrent && <span className="ml-2 text-xs text-accent"> turn</span>}</span>
{c.hp ? (
<span className="text-sm text-muted">{c.hp.current}/{c.hp.max} HP</span>
) : c.status ? (
<span className={cn('text-sm font-medium', c.status.cls)}>{c.status.label}</span>
) : null}
</li>
))}
</ol>
)}
</section>
</div>
</>
);
}
+1 -1
View File
@@ -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,
+75 -107
View File
@@ -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 <RequireCampaign>{(c) => <PlayerView campaign={c} />}</RequireCampaign>;
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 <NetworkedPlayerView room={roomParam()!} />;
return <RequireCampaign>{(c) => <LocalPlayerView campaign={c} />}</RequireCampaign>;
}
function Shell({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
const enterFullscreen = () => void document.documentElement.requestFullscreen?.().catch(() => {});
return (
<Page>
<div className="mb-6 flex items-end justify-between">
<div>
<h1 className="font-display text-3xl font-bold text-accent">{title}</h1>
<p className="text-sm text-muted">{subtitle}</p>
</div>
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden"> Fullscreen</Button>
</div>
{children}
</Page>
);
}
/** 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 (
<Page>
<div className="mb-6 flex items-end justify-between">
<div>
<h1 className="font-display text-3xl font-bold text-accent">{campaign.name}</h1>
<p className="text-sm text-muted">
Player View{calendar ? ` · in-world day ${calendar.currentDay}` : ''} ·{' '}
<span className="uppercase">{localSync.status}</span>
</p>
</div>
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden"> Fullscreen</Button>
</div>
{activeMap && (
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Battle map</h2>
<PlayerMapView map={toPlayerProjection(activeMap)} image={activeMap.image} maxWidth={1100} />
</section>
)}
<div className="grid gap-6 lg:grid-cols-2">
{/* Party */}
<section>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Party</h2>
{pcs.length === 0 ? (
<p className="text-sm text-muted">No player characters.</p>
) : (
<div className="space-y-2">
{pcs.map((c) => {
const pct = c.hp.max > 0 ? Math.max(0, Math.min(100, (c.hp.current / c.hp.max) * 100)) : 0;
return (
<div key={c.id} className="rounded-lg border border-line bg-panel p-3">
<div className="flex items-baseline justify-between">
<span className="font-display text-lg font-semibold text-ink">{c.name}</span>
<span className="text-sm text-muted">
Lv {c.level} · AC {sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })}
</span>
</div>
<div className="mt-2 h-3 overflow-hidden rounded-full bg-surface">
<div className={cn('h-full', pct > 50 ? 'bg-success' : pct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${pct}%` }} />
</div>
<div className="mt-1 text-right text-xs text-muted">{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</div>
{c.conditions.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{c.conditions.map((cond, i) => (
<span key={i} className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
))}
</div>
)}
</div>
);
})}
</div>
)}
</section>
{/* Initiative */}
<section>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">
Initiative {encounter ? `· Round ${encounter.round}` : ''}
</h2>
{!encounter ? (
<EmptyState title="No active combat" hint="When the GM starts an encounter, the turn order shows here." />
) : (
<ol className="space-y-1">
{encounter.combatants.map((c, idx) => {
const isCurrent = idx === encounter.turnIndex;
const isPC = c.kind === 'pc';
const status = enemyStatus(c);
return (
<li
key={c.id}
className={cn(
'flex items-center gap-3 rounded-md border px-3 py-2',
isCurrent ? 'border-accent bg-elevated ring-1 ring-accent/40' : 'border-line bg-panel',
)}
>
<span className="w-8 text-center font-display text-lg font-semibold text-accent">{c.initiative}</span>
<span className="flex-1 text-ink">{c.name}{isCurrent && <span className="ml-2 text-xs text-accent"> turn</span>}</span>
{isPC ? (
<span className="text-sm text-muted">{c.hp.current}/{c.hp.max} HP</span>
) : (
<span className={cn('text-sm font-medium', status.cls)}>{status.label}</span>
)}
</li>
);
})}
</ol>
)}
</section>
</div>
</Page>
<Shell title={campaign.name} subtitle={`Player View${snapshot.calendarDay !== null ? ` · in-world day ${snapshot.calendarDay}` : ''} · ${localSync.status.toUpperCase()}`}>
<PlayerBoards snapshot={snapshot} {...(image ? { image } : {})} />
</Shell>
);
}
/** 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 (
<Shell title="Joining session…" subtitle={`Room ${room} · ${status}`}>
{error ? <EmptyState title="Couldn't join" hint={error} /> : <p className="text-sm text-muted">Connecting</p>}
</Shell>
);
}
const image = snapshot.mapImageId ? images[snapshot.mapImageId] : undefined;
void joined;
return (
<Shell title={snapshot.campaignName} subtitle={`Player View · live${snapshot.calendarDay !== null ? ` · in-world day ${snapshot.calendarDay}` : ''}`}>
<PlayerBoards snapshot={snapshot} {...(image ? { image } : {})} />
</Shell>
);
}
+33
View File
@@ -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<Combatant, 'hp'> {
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');
});
});
+28
View File
@@ -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);
});
});
+84
View File
@@ -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<typeof snapshotSchema>;
// ---- 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<typeof clientMessageSchema>;
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<typeof serverMessageSchema>;
+42
View File
@@ -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,
};
}
+120
View File
@@ -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<typeof setTimeout> | 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();
}
+20
View File
@@ -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<string, string>;
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<PlayerSessionState>()((set) => ({
snapshot: null,
images: {},
setSnapshot: (snapshot) => set({ snapshot }),
setImage: (id, dataUrl) => set((s) => ({ images: { ...s.images, [id]: dataUrl } })),
reset: () => set({ snapshot: null, images: {} }),
}));
+25
View File
@@ -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<Omit<SessionState, 'set' | 'reset'>>) => void;
reset: () => void;
}
/** Ephemeral (non-persisted) state for a live collaboration session. */
export const useSessionStore = create<SessionState>()((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 }),
}));
File diff suppressed because one or more lines are too long
+18
View File
@@ -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/**/*"]
}
+1 -1
View File
@@ -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'";
+1 -1
View File
@@ -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/**'],
},
});