Merge deployed server fork (ttrpg.briggen.dev) with the audit-fix branch

The production site ran a divergent, more-evolved fork from /root/briggen-dev/ttrpg
that never made it back to git (3D physical dice + themes, extended dice notation,
world wiki with wikilinks/backlinks/GM-only visibility, quest hierarchies, session
replay, route-level code splitting, extra server hardening, useResyncedState). This
merges that fork (reconstructed from its true fork point 235cdec) with the local
262-finding audit-fix pass, resolving 104 conflicts across every subsystem.

Policy: the fork's architecture is the baseline (every prod feature kept); the audit's
correctness fixes are ported into it (no regressions). Hard invariants held:
- No auto-rolled dice — every roll originates from an explicit user click (NpcsPage,
  compendium, tracker, director all add combatants at initiative 0).
- One append-only Dexie history (v14–v19) that converges databases from EITHER fork
  idempotently; v19 re-runs both sides' character backfills.
- Unified client/server wire protocol (union of both message sets).

Notable reconciliations: PF2e AC/Class DC/armor/agile/striking folded into the fork's
gear model; wave-caster/psychic slot tables + Magus/Summoner never-Legendary ladder
verified against AoN; concentration split into boolean + concentratingOn; dual offline
indicators deduped; Node-25 localStorage shadowing fixed in the test setup.

Verified green: tsc (client+server), eslint, 1312 unit, 42 e2e, 5 realtime e2e,
client+server builds; 3D dice confirmed live (WebGL canvas mounts) in the merged build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 01:56:37 +02:00
co-authored by Claude Fable 5
329 changed files with 31808 additions and 2593 deletions
+6
View File
@@ -8,3 +8,9 @@ playwright-report
.git
e2e
**/*.test.ts
# Large source SRD dumps not imported at runtime (the app loads the slim
# generated variants; only weapons-full.json is imported). Keep them in the repo
# but out of the Docker build context to slim it (~15 MB) — T-167.
src/data/srd/monsters-full.json
src/data/srd/spells-full.json
src/data/srd/magicitems-full.json
+4
View File
@@ -14,3 +14,7 @@ test-results
# Transient harness lock (not part of the project)
.claude/scheduled_tasks.lock
# Local runtime + build artifacts
/data/
*.tsbuildinfo
+123
View File
@@ -0,0 +1,123 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
A local-first PWA campaign manager for **D&D 5e** and **Pathfinder 2e**. Primary data lives in
IndexedDB on the device; the app works fully offline with no account. On top of that there is an
**optional** Fastify server (`server/`) that adds three networked features: live multiplayer
sessions (WebSocket), encrypted-blob cloud backup, and shared cloud campaigns. The browser app and
the server share TypeScript modules (notably `src/lib/sync/messages.ts`) via the `@` alias.
The package manager is **bun**. The README's "no server" framing describes the default offline mode;
the server and realtime sync are implemented (see `server/` and `src/lib/sync/wsSync.ts`).
## Commands
```bash
bun install
bun run dev # vite dev server at http://localhost:5173
bun run build # tsc -b (typecheck) + vite build → dist/
bun run build:server # esbuild bundle → server/dist/index.js
bun run lint # eslint
bun run typecheck # tsc -b --noEmit (client)
bun run typecheck:server # tsc --noEmit -p tsconfig.server.json
bun run test # vitest run (all unit tests, jsdom)
bun run test:watch
bunx vitest run path/to/file.test.ts # single unit test file
bunx vitest run -t "name of test" # single test by name
bun run test:e2e # playwright against dev server (e2e/)
bunx playwright test e2e/combat-depth.spec.ts # single e2e file
```
Realtime e2e runs against the **built** server (one origin serving `dist/` + `/ws`):
```bash
bun run build && bun run build:server
bunx playwright test -c playwright.realtime.config.ts # e2e-realtime/
```
## Architecture
### The rules-system seam (most important)
`src/lib/rules/types.ts` defines the `RulesSystem` interface — the single contract for all
system-specific math (ability mods, skills, saves, AC, proficiency, spell DCs, weapon attacks,
rest options, carrying capacity). `5e` and `pf2e` each implement it under `src/lib/rules/dnd5e/`
and `src/lib/rules/pf2e/`. Get an implementation via `getSystem(systemId)`.
**Feature and store code must never branch on `if (system === '5e')`.** Call the `RulesSystem`
methods instead. Adding a new system should be a self-contained module that implements the interface.
### Data layer: schemas → repositories → Dexie
- `src/lib/schemas/` — Zod schemas for every entity. Numbers reject `NaN`/`Infinity`. This is the
validation boundary; entities are parsed on write.
- `src/lib/db/repositories.ts` — the **only** way features touch the database. Repos validate with
Zod on write, stamp `updatedAt`, and do **cascade deletes inside Dexie transactions** (deleting a
campaign atomically removes all its children). Do not call `db.*` tables directly from features.
- `src/lib/db/db.ts` — the single Dexie instance. Schema versions are **strictly additive**: to
change shape, bump `version(n)` and add an `.upgrade()` migration that backfills — **never edit a
past `version()` block in place.** New optional fields need no migration (rows still parse).
These choices exist to kill the failure modes of the previous (deleted) implementation, catalogued
in `docs/OLD_BUG_HUNT_REPORT.md`: silent data loss, turn-order corruption, missing cascade deletes,
NaN inputs.
### Pure engines
`src/lib/combat/` (turn-order + HP), `src/lib/dice/` (notation parser + seedable roller), and the
rules math are pure and unit-tested. Add/remove/reorder combatants must never corrupt whose turn it
is — preserve that invariant and its tests.
### State & UI
- `src/stores/` — Zustand stores; `persist` middleware for state that must survive reload (e.g.
`sessionStore`'s join intent). Dexie data is read reactively via `dexie-react-hooks` `liveQuery`,
not duplicated into stores.
- `src/features/<feature>/` — UI per domain (campaigns, characters, combat, dice, compendium, world,
play, player, assistant, cloud, settings). `src/components/ui/` holds design-system primitives.
- Routing is code-defined in `src/router.tsx` (TanStack Router) — add a route there.
- `src/lib/compendium/` lazy-loads SRD JSON from `src/data/srd/` so it stays out of the main bundle.
### Sync: two seams
- `src/lib/sync/index.ts` — `SyncAdapter`. `localSync` is a no-op (same-device sharing is automatic
via `liveQuery`).
- `src/lib/sync/wsSync.ts` — the real WebSocket transport for **live sessions**: the GM hosts and
pushes player-safe **snapshots** (`snapshot.ts` projects only player-visible state, e.g. fog, GM-only
tokens, hidden HP), players join read-only, with reconnect/backoff. Message shapes are Zod schemas
in `messages.ts`, **shared with the server**.
### Server (`server/src/`)
Fastify app (`index.ts`) bundled by esbuild (`server/build.mjs`). In production it also serves the
built PWA from `STATIC_DIR`. Three concerns, each its own module + test:
`rooms.ts` (live-session room hub over `/ws`), `accounts.ts` (auth + per-user cloud backup blob),
`campaigns.ts` (shared cloud campaigns with invite codes / roles). Config is env-driven: `PORT`,
`STATIC_DIR`, `DATA_DIR`, `ALLOWED_ORIGINS`, `ADMIN_USERS`. There is per-IP and per-socket rate
limiting. Client-side counterparts live in `src/lib/cloud/`.
### LLM assistant (bring-your-own-key)
`src/lib/llm/client.ts` calls the user's configured provider (Anthropic/OpenAI/OpenRouter/local
Ollama/LM Studio) directly from the browser — robust JSON extraction handles fenced/partial replies.
`src/lib/assistant/` builds prompts/context and post-processes (advisors, level-up, encounter
suggestions). The build-time CSP in `vite.config.ts` deliberately allows `connect-src https:` +
localhost so these calls work; keep that in mind when touching CSP.
## Deployment
`Dockerfile` is multi-stage (bun build → slim node runtime, non-root, serves `dist` + `/ws` on one
port). `docker-compose.yml` deploys to `ttrpg.briggen.dev` behind an external Traefik `proxy`
network. Cloud-sync data persists in the `ttrpg-data` volume at `DATA_DIR=/data`.
## Conventions
- TypeScript strict; import app code via the `@/` alias (maps to `src/`), used by both client and
the server bundle.
- SRD data in `src/data/srd/` is generated, not hand-edited — regenerate with the `scripts/` scrapers
(`fetch_data.py`, `fetch_open5e.ts`, `fetch_pf2e.ts`).
+10 -7
View File
@@ -7,17 +7,20 @@ 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
# --- runtime stage: bun image, non-root, serves dist + /ws ---
# bun (not node:20) because the server now persists via the built-in `bun:sqlite`
# driver (T-129); node:20 has neither bun:sqlite nor node:sqlite (node ≥22.5), so
# the container would crash on startup. The bundle is run with `bun`.
FROM oven/bun:1 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
RUN bun install --production
COPY --from=build /app/server/dist ./dist
COPY --from=build /app/dist /app/dist
# Cloud-sync data dir; create it node-owned so a fresh named volume mounts writable.
# Cloud-sync data dir; create it bun-owned so a fresh named volume mounts writable.
ENV DATA_DIR=/data
RUN mkdir -p /data && chown -R node:node /app /data
USER node
RUN mkdir -p /data && chown -R bun:bun /app /data
USER bun
EXPOSE 8787
CMD ["node", "dist/index.js"]
CMD ["bun", "dist/index.js"]
+50
View File
@@ -0,0 +1,50 @@
# NOTICE — Third-party game content & licenses
This application bundles open/community-licensed tabletop game content. **No
content is removed**; this file records accurate per-source provenance and the
license each dataset is distributed under. If you redistribute this project,
keep this NOTICE and the attributions below.
## Dungeons & Dragons 5e
| Dataset (file) | Source | License |
|---|---|---|
| Monsters (`src/data/srd/monsters-*.json`) | [Open5e](https://open5e.com) — SRD 5.1 | OGL 1.0a / CC-BY-4.0 |
| Spells (`spells-srd.json`) | Open5e — SRD 5.1 | OGL 1.0a / CC-BY-4.0 |
| Magic items (`magicitems-*.json`) | Open5e — SRD 5.1 | OGL 1.0a / CC-BY-4.0 |
| Classes (`classes.json`) | Open5e — SRD 5.1 | OGL 1.0a / CC-BY-4.0 |
| Races (`races.json`) | Open5e v1 (SRD 5.1 + community docs) **and** Open5e v2 `srd-2024` — **SRD 5.2 (2024)** | per-entry `source`/`license`; SRD 5.2 entries are CC-BY-4.0 |
| Backgrounds (`backgrounds.json`) | Open5e v1 (SRD 5.1 + community docs) **and** Open5e v2 `srd-2024` — **SRD 5.2 (2024)** | per-entry `source`/`license`; SRD 5.2 entries are CC-BY-4.0 |
| Feats — pickers (`feats.json`) | Open5e v1 (community docs) **and** Open5e v2 `srd-2024` — **SRD 5.2 (2024)** | per-entry `source`/`license`; SRD 5.2 entries are CC-BY-4.0 |
| Conditions (`conditions.json`) | Open5e — SRD 5.1 | OGL 1.0a / CC-BY-4.0 |
| Equipment / armor (`equipment.json`) | Open5e — SRD 5.1 | OGL 1.0a / CC-BY-4.0 |
| **Feats** (`mpmb-feats.json`) | **[MPMB's Character Record Sheet](https://github.com/morepurplemorebetter/MPMBs-Character-Record-Sheet)** — community dataset, **not** Open5e | per the MPMB project terms |
| Weapons (`weapons-*.json`) | mixed (SRD + non-SRD entries) — see source generator | SRD entries OGL 1.0a; review non-SRD entries before commercial redistribution |
> The SRD 5.1 is © Wizards of the Coast and made available under the OGL 1.0a /
> CC-BY-4.0. "Dungeons & Dragons" and "D&D" are trademarks of Wizards of the
> Coast; this project is unaffiliated and uses only open-licensed reference data.
## Pathfinder 2e
| Dataset | Source | License |
|---|---|---|
| Ancestries, classes, feats, spells, equipment, creatures, etc. (`public/data/pf2e/*.json`) | [Foundry VTT pf2e](https://github.com/foundryvtt/pf2e) system data & [Archives of Nethys](https://2e.aonprd.com) | Paizo ORC License / OGL 1.0a / Paizo Community Use Policy (as applicable per entry) |
> Pathfinder, the Pathfinder logo, and Paizo are trademarks of Paizo Inc.
> Game mechanics are released under the **ORC License**; some flavor/rules text
> is used under Paizo's **Community Use Policy**. This project is published under
> those terms and is not endorsed by Paizo.
## Maps
Universal VTT (`.dd2vtt` / `.uvtt`) import follows the open format used by
Dungeondraft, Foundry VTT, and others. No map assets are bundled.
## Per-entry attribution (roadmap: T-105)
Per-entry `source`/`license` metadata is being added to each dataset so the
compendium can show provenance inline. Until that lands, this file is the
authoritative source-of-record. **No dataset is pruned** — content the audit
flagged as mislabeled (e.g. MPMB feats previously credited to Open5e) is
**re-attributed here, not removed.**
+22 -9
View File
@@ -10,16 +10,17 @@
"@fontsource/spectral": "^5.2.8",
"@tanstack/react-router": "^1.95.0",
"@tanstack/react-virtual": "^3.14.2",
"cannon-es": "^0.20.0",
"clsx": "^2.1.1",
"dexie": "^4.0.10",
"dexie-react-hooks": "^1.1.7",
"dompurify": "^3.2.3",
"fuse.js": "^7.0.0",
"lucide-react": "^1.17.0",
"nanoid": "^5.0.9",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0",
"three": "^0.185.0",
"zod": "^3.24.1",
"zustand": "^5.0.2",
},
@@ -31,11 +32,10 @@
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/dompurify": "^3.0.5",
"@types/node": "^22.10.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/three": "^0.185.0",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.3.4",
"esbuild": "^0.28.0",
@@ -53,6 +53,7 @@
"vite": "^6.0.7",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^2.1.8",
"ws": "^8.21.0",
},
},
},
@@ -259,6 +260,8 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
@@ -497,10 +500,10 @@
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
"@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
"@trickfilm400/rollup-plugin-off-main-thread": ["@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1", "", { "dependencies": { "ejs": "^3.1.10", "json5": "^2.2.3", "magic-string": "^0.30.21", "string.prototype.matchall": "^4.0.12" } }, "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q=="],
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
@@ -511,8 +514,6 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -525,8 +526,14 @@
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/three": ["@types/three@0.185.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-O2Uy8Cj4Nonr8dWUUbifMdPe8B0Mq7EdOHb89S4+kjUw/KhbjTZrUuYlrQ1bpUKG+EP9QJnN7qNxbHGlGoLHMA=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
"@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=="],
@@ -633,6 +640,8 @@
"caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="],
"cannon-es": ["cannon-es@0.20.0", "", {}, "sha512-eZhWTZIkFOnMAJOgfXJa9+b3kVlvG+FX4mdkpePev/w/rP5V8NRquGyEozcjPfEoXUlb+p7d9SUcmDSn14prOA=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -709,8 +718,6 @@
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"dompurify": ["dompurify@3.4.8", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="],
"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=="],
@@ -797,6 +804,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"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=="],
@@ -1035,6 +1044,8 @@
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="],
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
@@ -1281,6 +1292,8 @@
"thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="],
"three": ["three@0.185.0", "", {}, "sha512-+yRrcRO2iZa8uzvNNl0d7cL4huhgKgBvVJ0njcTe8xFqZ6DMAFZdCKDP91SEAuj25bNAj7k1QQdf+srZywVK6w=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
+38
View File
@@ -0,0 +1,38 @@
# 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
- DATA_DIR=/data
- ALLOWED_ORIGINS=https://ttrpg.briggen.dev
# comma-separated usernames that can see the admin panel (set to your account)
- ADMIN_USERS=nilsb
volumes:
- ttrpg-data:/data
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
volumes:
ttrpg-data:
networks:
proxy:
external: true
+173
View File
@@ -0,0 +1,173 @@
# Deep Audit & Remediation Plan — 2026-06-30
> **STATUS — all five waves shipped & live on ttrpg.briggen.dev (`index-BZ__3h9L.js`).**
> Wave 1 (correctness hotfixes), Wave 2 (legal/data), Wave 3 (rules-math) — done earlier.
> **Wave 4 (RulesSystem seam):** added `proficiencyRanks`, `terms`, `creatureRating`,
> `bonusTrainedSkills`, `allowsHpRoll`, `deathAndDying` to the interface + both impls; added
> `loadCreatures(system)` to the compendium; deleted the inline `system==='5e'` branches across
> ~20 feature/engine files (engine death-state, encounter rating, level-up HP, char-build terms,
> proficiency-rank arrays, system-name labels). New `seam.test.ts` (6 tests).
> **Wave 5a tail (3D dice):** reworked `Dice3DStage` — real numerals baked on faces, the rolled
> value's face settles upright to camera (was a random quaternion + floating sprite), per-die
> contact shadow + damped drop; 3D toggle surfaced in Settings + rendered app-wide in the RollTray.
> **Wave 5d (UI polish):** char-sheet not-found state, pinned modal footer, crit-button aria,
> DS `Checkbox` (≈22 sites migrated) + DS `Textarea` for notes, shared `SettingsCard`, click-to-roll
> ability scores, responsive combatant row, mobile nav drawer. **Nav:** added `/player` ("Join a
> Game") to the rail; system labels unified via `getSystem(s).label`. 849 unit tests green.
Generated by a 14-agent local audit workflow (rules-math correctness, engines, data,
dice UX, emoji→icon, UI/UX, architecture). Every **high-severity correctness** claim
was put through a second adversarial agent that tried to refute it from the actual
code; **0 of the high-severity claims were refuted**. 55 findings kept, 72 emoji sites
inventoried.
Severity: 🔴 high · 🟠 medium · ⚪ low. Effort: S / M / L.
---
## Urgent (do first — correctness in a *deployed* app + legal exposure)
| # | Issue | Sev | Eff | Location |
|---|-------|-----|-----|----------|
| 1 | **PF2e PCs are instantly killed by overkill damage** — the 5e massive-damage instant-death rule is applied to *both* systems. PF2e should just increment Dying. | 🔴 | S | `src/lib/combat/engine.ts:519-526` |
| 2 | **Sheet HP "Damage" never clamps at 0** — goes negative, schema `int.min(0)` rejects the write, so it *silently fails to persist*. | 🔴 | S | `src/features/characters/CharacterSheet.tsx:355-357` |
| 3 | **5e Feats tab ships non-SRD copyrighted feats** (PHB/Xanathar/Tasha) with no attribution; the licensed `feats.json` exists but is unused. Copyright exposure on a public deployment. | 🔴 | M | `src/lib/compendium/index.ts:76` |
| 4 | **Map-view damage/heal bypasses death resolution** — writes only `.hp`, discarding Unconscious/Dying cleanup. | 🟠 | S | `src/features/world/map/MapEditor.tsx:212-219` |
---
## Theme 1 — Rules-math correctness (5e + PF2e)
All isolated in the pure rules modules, so a fix at the seam corrects every consumer
(sheet, tracker, snapshot, dashboard) at once.
- 🟠 **PF2e AC never adds armor proficiency** (`level + rank`) — understates AC by up to ~+20 at high level. *(Deliberately scoped out of T-051 to avoid double-counting a hand-entered `armorBonus`; the real fix is a schema field.)* — add `armorProficiencyRank` to the character schema, thread through `ArmorClassInput`/`derivedArmorClass`, add `pf2eProficiency(level, armorRank)` with a 0 fallback; advance the armor rank in `proficiencyProgression.ts`. **Do not touch `dnd5e.armorClass`.** — `src/lib/rules/pf2e/index.ts:90-99`. [M]
- 🟠 **5e variant encumbrance missing the "heavily encumbered" tier** (STR×10, −20 ft). Only two bands exist; a STR 10 PC at 120 lb is reported −10 when RAW is −20. — add a `heavilyEncumbered = str*10*mult` threshold. — `src/lib/rules/equipment.ts:61-62`. [S]
- 🟠 **Spell proficiency ladder assumes uniform 7/15/19 for all full casters** — Bard/Cleric Expert/Master/Legendary Spellcaster levels differ. Verify against Player Core; move divergent classes into a per-class spell map. — `src/lib/rules/pf2e/proficiencyProgression.ts:81`. [M]
- ⚪ **5e Unarmored Defense not applied** (Barbarian 10+DEX+CON, Monk 10+DEX+WIS) — add optional `unarmoredAbility` to the rules input, set from class; never branch on `className` in feature code. — `src/lib/rules/dnd5e/index.ts:100-117`. [M]
- ⚪ **PF2e Bulk ignores creature size** (no `size` on Character → multiplier always ×1) — add optional `size`, backfilled from ancestry. — `src/lib/rules/pf2e/index.ts:121-127`. [M]
- ⚪ **`formatModifier` renders literal "NaN"** for non-finite input — add a `Number.isFinite` guard in the one formatter every modifier render uses. — `src/lib/format.ts:2-4`. [S]
## Theme 2 — Combat HP & death-state resolution
- 🔴 **PF2e overkill instant-death** (urgent #1) — gate the block behind `system === '5e'`. — `engine.ts:519-526`. [S]
- 🔴 **Sheet HP damage clamp** (urgent #2) — `Math.max(0, …)`. — `CharacterSheet.tsx:355-357`. [S]
- 🟠 **Map-view damage/heal** (urgent #4) — route through `engine.damageCombatant(... {system})`, write back the full combatant. — `MapEditor.tsx:212-219`. [S]
- 🟠 **Death/dying branches on the system string *inside the pure engine*** — add an `onReducedToZero` / `instantDeathState` capability to `RulesSystem`, implement per system, call `getSystem(opts.system)`. — `engine.ts:523,528,543`. [M]
- ⚪ **Fully temp-HP-absorbed hit on a downed creature still records a death-save failure** — early-return when no net HP damage lands. — `engine.ts:501-540`. [S]
## Theme 3 — Dice engine + Dice UX overhaul
> Directly addresses the surfaces you flagged: two windows, the opt-in 3D toggle, the "meh" 3D.
- 🔴 **A single roll renders in TWO places on `/dice`** (in-page panel *and* the global `RollTray`) — this is the "both dice windows stay." Adopt one model: `RollTray` = everywhere-affordance, `DicePage` = workbench; suppress the global tray on `/dice`. — `RootLayout.tsx:262` + `DicePage.tsx:202-238`. [S]
- 🟠 **Advantage/Disadvantage is a sticky *global* mode** that silently affects every roll app-wide until manually cleared — make it one-shot (consume+reset after the next roll), or render a persistent ADV/DIS badge in the tray. — `rollStore.ts:16-17,33-36`. [S]
- 🟠 **3D toggle + stage live only on `/dice`** — invisible to rolls from sheets/combat. Surface the toggle in `RollTray`/Settings (store already persists). — `DicePage.tsx:193-199`. [M]
- 🟠 **Macro save/edit can't set DC or system** — so saved checks never report degree-of-success. Add optional DC + system controls. — `macroStore.ts:19-22`. [M]
- 🟠 **3D dice are presentational theater** — the shown number is a floating canvas *label* unrelated to the resting face; no real faces, no physics, settles to a *random* quaternion. **Fix:** precompute each polyhedron's face normals, settle to the quaternion that orients `value`'s face to camera, bake numerals/pips via per-face UV atlas (not a Sprite), add a shadowed ground plane + damped fall. Keep the authoritative value path untouched. — `Dice3DStage.tsx:108-112,221-231,306`. [L]
- ⚪ **`RollTray` never auto-dismisses** and shows only the latest roll — add timed auto-dismiss (~6s, pause on hover) and/or a 3-5 roll ring buffer. — `RollTray.tsx:17-53`. [M]
- ⚪ **3D chunk first-use jank** — keep default-off + code-split, but prefetch the three.js chunk on toggle hover/idle. — `DicePage.tsx:23-25`. [S]
- ⚪ **Dice notation conventions** (judgment calls, not bugs): keep/drop count is validated against the *pre-explosion* die count; `r{n}` is reroll-once and `ro` is rejected (diverges from Roll20/Foundry). Either align to the de-facto standard or surface an inline notation legend. — `notation.ts:246-252`. [S]
## Theme 4 — Data integrity & licensing/attribution
- 🔴 **5e Feats copyright exposure** (urgent #3) — repoint `loadFeats5e` to the licensed `src/data/srd/feats.json`; keep `mpmb-feats.json` for the builder only. — `compendium/index.ts:76`. [M]
- 🟠 **PF2e Actions dataset truncated** (646 of 3950) and activation lines stored as names — re-run `scripts/fetch_pf2e.ts`, fix the scraper; **replace via regeneration, don't delete rows.** — `public/data/pf2e/actions.json`. [M]
- 🟠 **Monster art URLs are external `http://api.open5e.com`** — blocked by production CSP + mixed-content. Fetch+cache the ~12 illustrations locally at build time, or rewrite to https + allow in CSP `img-src`. — `MonsterDetail.tsx:86`. [M]
- 🟠 **Per-entry source/license not surfaced for most 5e categories** (CC-BY-4.0 *requires* it; data fields already exist) — add a Source/License footer to the remaining detail renderers, mirroring `ClassDetail`. — `details.tsx:89`. [M]
- ⚪ **Magic Item detail prints "requires attunement" twice** — render the data value without the literal prefix. — `details.tsx:121`. [S]
## Theme 5 — Architecture: route system logic through the RulesSystem seam
CLAUDE.md forbids feature/store/engine code branching on system id, yet it's forked
inline across ~25+ files. Add the missing seam capabilities once, then delete the branches.
- 🟠 **Char-creation & level-up rules math branch on system in components** — add `getSystem(id).trainedSkillCount(...)` and `.levelUpHp(...)`. — `CreationWizard.tsx:249`, `LevelUpModal.tsx:138`. [M]
- 🟠 **`RANKS_5E`/`RANKS_PF2E` proficiency arrays triple-duplicated** and selected by branch — expose `getSystem(id).proficiencyRanks`. — `CharacterSheet.tsx:33-34` (+2). [S]
- ⚪ **Pervasive terminology / CR-vs-Level branching across ~25 files** — add a terminology surface to `RulesSystem` (`displayName`, `terms.ancestry`, `ratingLabel`, `subclassLabel`). — many sites. [M]
- ⚪ **Per-system creature/data dispatch hard-coded** — add `loadCreatures(system)` + `creatureRating(system, entry)` to the central loader. — 3 sites. [S]
- ⚪ **Silent error swallowing** — roll errors and list loads blanked with no feedback; surface `setError` + retry affordances (RAG `.catch(()=>[])` is fine). — 4 sites. [M]
## Theme 6 — Navigation & information architecture
- 🔴 **Global nav rail omits the entire World/reference feature set** (Assistant, Notes, NPCs, Quests, Calendar, Homebrew) — add a third "World" nav group/hub. — `RootLayout.tsx:31-40`. [M]
- 🟠 **Calendar orphaned from the command palette** — add to `CommandPalette` NAV. — `CommandPalette.tsx:17-33`. [S]
- 🟠 **Same route, different labels** in rail vs dashboard vs title (`/maps`, `/play`) — one canonical name per route. — `RootLayout.tsx:36,39`. [S]
- 🟠 **Player home (`/player`) unreachable** from any in-app nav — add a palette entry / room-code field. — `router.tsx:90-94`. [S]
- ⚪ **System name rendered two ways** ("PF2e" vs "Pathfinder 2e") — render from `SYSTEM_OPTIONS` label. — `RootLayout.tsx:65`. [S]
- ⚪ **Dashboard "Cast & Threats" cards link to the generic NPC list**, not the clicked NPC — `requestWorldFocus('npc', name)` then navigate. — `DashboardPage.tsx:283-286`. [S]
## Theme 7 — UI/UX polish & design-system consistency
- 🟠 Character sheet shows **"Loading…" forever for a missing/deleted character** — resolve `null` + render a not-found state. — `CharacterSheetPage.tsx:10-16`. [S]
- 🟠 **Long modals scroll their footer action out of view** — flex-col panel, `shrink-0` header/footer, scroll only the middle. — `Modal.tsx:72-90`. [S]
- 🟠 **Crit-damage button labeled only with ⚡, no accessible name** — add `aria-label`. — `AttacksSection.tsx:69-75`. [S]
- 🟠 Notes uses a **raw `<textarea>`** instead of the DS primitive. — `CharacterSheet.tsx:315-320`. [S]
- 🟠 **Ability-score generation UI duplicated** between modal and wizard — extract a shared `<AbilityScorePicker>`. — `AbilityGenModal.tsx` + `CreationWizard.tsx:627-664`. [M]
- 🟠 **Settings page mixes three card styles** — export one `SettingsCard`. — `SettingsPage.tsx:34-42`. [S]
- 🟠 **Combatant row crams ~9 control clusters into one flex-wrap line** — wraps chaotically on tablet/mobile; give it a responsive grid + overflow menu. — `EncounterTracker.tsx:800-1027`. [L]
- 🟠 **Mobile nav is an unlabeled 68px icon strip** — toggled drawer with icon+label below `sm`. — `RootLayout.tsx:137`. [M]
- ⚪ Native checkboxes/radios break the design system (11 sites) — add a themed `Checkbox`. [M]
- ⚪ Settings status color from fragile substring matching (a blocker can show green) — store an explicit `tone`. [S]
- ⚪ Two competing section-heading systems — standardize on `.smallcaps`. [S]
- ⚪ Ability scores aren't click-to-roll (saves/skills/attacks are) — wrap in a roll button. [S]
- ⚪ Compendium list has no skeleton/empty affordance while loading. [S]
- ⚪ `<label>` wraps read-only text; glyph-only handout "hide" has no accessible name. [S]
## Theme 8 — Emoji → Lucide icon system
**72 emoji glyph sites** across `src/app`, `src/features/*`, `src/components/ui`, and —
notably — **combat-log event *strings* in `engine.ts`**. They render inconsistently
across platforms and give screen readers junk names (✕ announces as "multiplication
sign", ⚡ as "high voltage"). `lucide-react` is **already a dependency** (v1.17).
**Approach (one effort, not 72 edits):** a central `ICONS` map (semantic name →
Lucide component) + an `<Icon>` wrapper that always sets `aria-hidden` and an accessible
name on interactive controls; codemod the JSX sites. Two special cases:
1. Combat-log strings in `engine.ts` (⚔/⚑/🏰/💀/✨) should carry a **structured icon token** rendered at display time, not raw emoji baked into log text.
2. **Quests and Maps both use the folded-map glyph** today — give Quests a distinct icon (`ScrollText`/`Flag`/`Target`), Maps keeps `Map`.
Representative mappings (full 72-row inventory in the workflow output):
| Emoji | Lucide | Emoji | Lucide | Emoji | Lucide |
|---|---|---|---|---|---|
| ✕ (×17) | `X` | ✓ (×6) | `Check` | 🎲 | `Dices` |
| ⚔ | `Swords` | 🗡️ | `Sword` | 🛡️ | `Shield` |
| 💀 | `Skull` | 🔥 | `Flame` | ✨ | `Sparkles` |
| 🧙 | `Wand2` | 📜 | `ScrollText` | 🗺️ | `Map` |
| 📅 | `Calendar` | 📚 | `Library` | 🧠 | `Brain` |
| 💬 | `MessageCircle` | 📣 | `Megaphone` | 📡 | `Radio` |
| 📨 | `Mail`/`Send` | 🎭 | `Drama` | ⚡ | `Zap` |
| ☑/☐ | `SquareCheck`/`Square` | ⬆/⬇ | `Upload`/`Download` | ⚠ | `TriangleAlert` |
| 🔒 | `Lock` | ⚙ | `Settings` | 🤫 | `EyeOff` |
---
## Recommended sequencing (5 waves)
**Wave 1 — Correctness hotfixes** (small, high-value, isolated): PF2e overkill gate;
sheet HP clamp; map-view damage routing; temp-HP-on-downed edge case; duplicate
"attunement" string; `formatModifier` NaN guard. Run engine/dice unit tests after each.
**Wave 2 — Legal/data exposure** (parallel with Wave 1): repoint the 5e Feats loader to
the licensed dataset *first* (only finding with outside-the-app risk); then regenerate
the truncated PF2e actions dataset and fix monster-art/CSP.
**Wave 3 — Rules-math at the seam**: PF2e armor proficiency in AC; 5e heavily-encumbered
tier; Bulk size field; per-class spell ladder. New optional schema fields → no migration.
**Wave 4 — Shared primitives that later work depends on** (land these before their
dependents to avoid rework): (a) extend `RulesSystem` — death/at-zero capability,
`proficiencyRanks`, terminology getters, `trainedSkillCount`/`levelUpHp`, `loadCreatures`
— then delete the inline branches; (b) the central `Icon` map + `<Icon>` wrapper;
(c) DS `Checkbox` + shared `AbilityScorePicker`; (d) decide the single dice result model.
**Wave 5 — Feature/UX on top of the primitives**: dice UX overhaul (single feed →
one-shot/visible advantage → macro DC+system → 3D toggle surfacing → real-faces 3D render
last, it's L and purely cosmetic); navigation/IA cleanup (consumes terminology getters +
icon set); remaining design-system polish; run the emoji→Lucide codemod once `<Icon>` exists.
**Why this order:** correctness and licensing before any polish; the RulesSystem seam,
Icon component, DS primitives, and unified dice surface are the shared foundations the
dice UX, navigation, and polish all build on.
+833
View File
@@ -0,0 +1,833 @@
# TTRPG Manager — Engineering Backlog
> **STATUS (2026-07-02): substantially implemented — see [`ROADMAP.md`](./ROADMAP.md) for current planning.**
> A code audit found ~160 of the 181 task IDs below present as implemented `T-XXX` markers in
> `src/` + `server/src/`, and every spot-checked P0 confirmed done (T-001/T-003 backup validation,
> T-026/T-027 build correctness, T-120–T-123 server hardening, T-130 optimistic concurrency,
> T-140–T-152 sync robustness, T-160/T-161 offline/durability). This file is kept as the historical
> record of the June 2026 audit; new work items live in ROADMAP.md.
> Becoming a true competitor to D&D Beyond and Pathbuilder 2e.
> Companion to [`COMPETITIVE_STRATEGY_REPORT.md`](./COMPETITIVE_STRATEGY_REPORT.md). This is the verified, actionable backlog.
**How this was built:** 12 subsystem audits → 12 area-verifiers that re-read the actual source to confirm/refute every claim and compute the *exact correct formula* for each rules bug → one synthesis pass into this dependency-ordered backlog. Items the verifiers refuted or found already-implemented are listed under [Verified & Closed](#verified--closed) so nothing is silently dropped.
**Data policy:** Per project decision, **no compendium/SRD data is ever deleted** — even mislabeled content (e.g. MPMB feats tagged Open5e, bulk Archives-of-Nethys text). The licensing task (T-105) is *attribution only*: add accurate per-entry source/license metadata + a NOTICE, keep all data.
**Totals:** 181 tasks across 16 epics. Phase counts — P0: 24 · P1: 43 · P2: 66 · P3: 48.
Legend: 🔴 critical · 🟠 high · 🟡 medium · ⚪ low · effort **S**≈days **M**≈1-3wk **L**≈1-2mo.
---
## Epics
| Epic | Phase | # | Theme |
|---|:--:|:--:|---|
| **Data layer: backup/restore, cascade, referential integrity** | P0 | 11 | Stop silent data loss; validate on every boundary |
| **Server hardening, quotas, auth & scale** | P0 | 17 | Close data-loss/DoS/security holes; later real DB |
| **Realtime sync & player-view robustness** | P0 | 13 | Survive reconnects; validated two-way play; delta sync |
| **Platform: PWA offline, durability, a11y, performance** | P0 | 13 | True offline parity, persistent storage, code health |
| **Dice engine + UI correctness** | P1 | 14 | Unify roll flows; crit math; parser fixes |
| **Character builder & creation correctness** | P1 | 7 | Produce legal characters with full mechanics |
| **Feat & subclass engine (the moat)** | P1 | 2 | Data-driven feats/subclasses with prerequisites |
| **Guided, rules-validated level-up** | P1 | 5 | Class-data-keyed stepper walking every choice |
| **Spellcasting, AC & equipment derivation** | P1 | 4 | Correct slots/DCs; AC & attacks from gear |
| **Rules engine depth (5e + PF2e math)** | P1 | 7 | Correct math across both systems via the seam |
| **Combat tracker automation** | P2 | 21 | Stat-block-driven combat; deaths, concentration, conditions |
| **AI flagship: chat, RAG Q&A, generation, recap** | P2 | 17 | Grounded BYO-key AI competitors lack |
| **Compendium search, fidelity & licensing** | P2 | 14 | Full-text/global search; per-entry attribution (keep data) |
| **Worldbuilding depth: links, calendar, homebrew, secrets** | P1 | 17 | Connected entities, rich text, GM-only visibility |
| **Maps / VTT: vision, lighting, hex, tokens** | P3 | 14 | Dynamic LOS, hex/gridless, system-aware templates |
| **Interop: import/export** | P3 | 5 | DDB import; deeper Pathbuilder; standard exports; PDF |
---
## P0 — Stabilize — data-loss, illegal output, security, offline (must-fix)
_24 tasks._
### Data layer: backup/restore, cascade, referential integrity
- **T-001** 🔴 Schema-validate backup restore before insert `M` `data-integrity`
- restoreBackup bulkAdds raw JSON rows with no Zod parse, bypassing the validate-on-write invariant (I78/I123). Map each table to its schema and safeParse every row before bulkAdd, skipping/reporting failures (or failing the whole restore on any invalid row to avoid partial corruption), and run per-version up-migration on imported rows first.
- **Files:** `src/lib/io/backup.ts`
- **T-002** 🔴 Include sessionLog in backup, cascade delete, and wipe `S` `data-integrity`
- sessionLog is omitted from backup TABLES (lost on round-trip and surviving wipe) and from the campaign cascade transaction, orphaning recap rows (I77/I80/I124). Add 'sessionLog' to TABLES so it exports/restores/clears, and add db.sessionLog to the campaignsRepo.remove transaction scope with a campaignId-scoped delete.
- **Files:** `src/lib/io/backup.ts`, `src/lib/db/repositories.ts`
- **T-003** 🟠 Enforce backup format/version gate + migrate + iterate file tables `M` `data-integrity`
- restoreBackup ignores format/version and iterates the current TABLES list, silently dropping unknown tables; version is hardcoded to 1, decoupled from db.verno=13 (I79/I125 + findings). Reject files whose format!==FORMAT, record the true source schema version (db.verno), branch restore on it to run per-version migrations, and iterate the file's own keys so nothing is dropped.
- **Files:** `src/lib/io/backup.ts`
- **Depends on:** T-001
- **T-004** 🟠 Make repo update() methods re-validate via Zod `M` `data-integrity`
- charactersRepo.update (and peers) call db.*.update(id, patch) without re-parsing through the schema, so out-of-bounds patches persist; this is the root that lets player diffs set arbitrary hp.max/slots (I48). Re-validate the merged record (or the patch) with the entity schema on every update, mirroring insert/importMany.
- **Files:** `src/lib/db/repositories.ts`
### Server hardening, quotas, auth & scale
- **T-120** 🔴 Fix lazy load() race that can clobber all accounts `M` `data-integrity`
- load() sets loaded=true before awaiting readFile and index.ts calls it un-awaited, so a register() during the startup window operates on an empty map and persist() rewrites the store with only the new user, destroying all existing accounts (I11 + finding). Latch a shared in-progress promise before awaiting IO so concurrent callers await the same readFile, and await accounts.load()/cloud.load() before app.listen.
- **Files:** `server/src/accounts.ts`, `server/src/campaigns.ts`, `server/src/index.ts`
- **T-121** 🟠 Offload password hashing off the event loop `M` `security`
- hashPassword uses crypto.scryptSync in register/login, blocking the single-threaded event loop for tens of ms per call and serializing under concurrency (I12). Use async crypto.scrypt (promisified) or a worker pool and await it.
- **Files:** `server/src/accounts.ts`
- **T-122** 🟠 Enforce storage quota + per-object size caps `M` `security`
- saveBlob/putCharacter never check the tracked quotaBytes and the only ceiling is the 48MB body limit, so any authenticated user can exhaust disk (I13). Before writing, compute prospective usage (blobBytes + usageBytes + new size) and reject with 413 over quota, and add a per-object size cap below BODY_LIMIT.
- **Files:** `server/src/index.ts`, `server/src/campaigns.ts`
- **T-123** 🟡 Proxy-aware, per-account rate limiting `M` `security`
- req.ip is the proxy IP without trustProxy so all clients share one 60/min bucket, and ipHits never prunes (memory leak) (I14). Set Fastify trustProxy (or a CIDR), prefer per-account/token keys when authenticated, and periodically prune expired ipHits (or use @fastify/rate-limit).
- **Files:** `server/src/index.ts`
- **T-124** 🟡 Stop swallowing persist failures `S` `data-integrity`
- persist() ends with .catch(()=>{}) so a failed write (ENOSPC/EACCES) is dropped while the handler still returns ok:true, silently diverging memory from disk (I17). Log the error and propagate failure so routes can return 5xx.
- **Files:** `server/src/accounts.ts`, `server/src/campaigns.ts`
- **T-125** 🟡 Cap rooms/players/images in RoomHub `M` `security`
- RoomHub has no cap on rooms, players-per-room, or images (images only grow until the 6h idle sweep), so a GM can drive attacker-controlled memory (I19 + sync finding). Add per-room image count/byte caps with LRU eviction, a per-room player/connection cap, a global room cap, and backpressure; reject host/join/image beyond limits.
- **Files:** `server/src/rooms.ts`, `server/src/index.ts`
### Realtime sync & player-view robustness
- **T-140** 🟠 Durable rejoin token so seats survive reconnects `M` `bug`
- join() mints a new playerId per socket and seats are deleted on disconnect, so a reconnecting player gets a fresh id with no seat and their HP/slot edits are silently dropped while they keep editing (I46). Issue a durable per-client rejoin token (persisted in sessionStore), key seats by it, rebind on reconnect, and reset seatStatus on a dropped seat so the player is prompted to re-claim.
- **Files:** `server/src/rooms.ts`, `src/lib/sync/wsSync.ts`, `src/features/player/PlayerViewPage.tsx`, `src/stores/sessionStore.ts`
- **T-141** 🟠 Persist GM host intent + gmSecret for reload resume `M` `bug`
- gmSecret is a module-level variable and role isn't persisted, so a GM reload can never reach the server resume branch and re-hosting mints a new joinCode, invalidating every shared link (I47). Persist GM host intent + gmSecret + roomId/joinCode and add a GM auto-resume hook that re-hosts with resume=gmSecret.
- **Files:** `src/lib/sync/wsSync.ts`, `src/stores/sessionStore.ts`
- **T-142** 🟠 Scope and clamp player patches GM-side `M` `security`
- partialCharacterDiffSchema accepts full hp/spellcasting/resources/defenses and charactersRepo.update doesn't re-validate, so a crafted playerPatch can set hp.max/slots arbitrarily on the GM device (I48). Narrow the schema to truly player-owned bounded fields (hp.current/temp, slot.current, resource.current, conditions) and clamp each value GM-side against the authoritative sheet; optionally add a GM audit/consent toggle.
- **Files:** `src/lib/sync/wsSync.ts`, `src/lib/sync/messages.ts`
- **Depends on:** T-004
### Platform: PWA offline, durability, a11y, performance
- **T-160** 🔴 Runtime-cache PF2e JSON so the compendium works offline `M` `bug`
- PF2e data is fetched at runtime from public/data/pf2e/*.json but workbox globPatterns excludes JSON and there is no runtimeCaching, so PF2e fails offline while 5e works incidentally; the three largest files also exceed the 6MB precache limit and re-download every visit (I26/I27 + 60MB finding). Add a workbox runtimeCaching entry (CacheFirst/StaleWhileRevalidate + expiration) for /data/pf2e/*.json (or persist parsed datasets to IndexedDB on first load), and add gzip/brotli at the static layer; consider splitting the 20MB creatures.json.
- **Files:** `vite.config.ts`, `src/lib/compendium/index.ts`
- **T-161** 🟠 Request persistent storage for IndexedDB `S` `data-integrity`
- The app never calls navigator.storage.persist(), so the only copy of offline data sits in evictable best-effort storage while ErrorBoundary claims 'Your data is safe' (I28). Call navigator.storage.persist() at startup, check persisted(), and surface the grant state to the user.
- **Files:** `src/main.tsx`, `src/components/ui/ErrorBoundary.tsx`
### Dice engine + UI correctness
- **T-012** 🟠 Fix adv/dis combined with explode/reroll parse crash `S` `bug`
- applyRollMode injects a keep token but leaves '!'/'r{n}' trailing after it (e.g. '2d20kh1!'), which TERM_RE's fixed-order anchored grammar rejects, throwing DiceParseError and crashing the roll (I86). Make applyRollMode capture and re-emit !/r{n} in the order TERM_RE expects (e.g. '2d20!kh1', '2d20r1kh1') or relax TERM_RE to accept modifiers in any order; add tests for adv/dis over '1d20!' and '1d20r1'.
- **Files:** `src/lib/dice/notation.ts`
- **T-013** 🟠 Unify the three roll flows into one role-aware path `M` `bug`
- useRoll, DicePage, and MyCharacterPanel each duplicate roll/persist/broadcast logic; DicePage never pushes to useRollStore (so its rolls miss the global tray), and player rolls from the dice page/sheet call broadcastGmRoll which no-ops for players, so they never reach the table (I87/I91). Route all rolls through one helper that pushes useRollStore, persists via diceRepo, and broadcasts role-aware (player→sendPlayerRoll with active character id, GM→broadcastGmRoll).
- **Files:** `src/features/dice/DicePage.tsx`, `src/lib/useRoll.ts`, `src/features/player/MyCharacterPanel.tsx`, `src/lib/sync/wsSync.ts`
### Character builder & creation correctness
- **T-026** 🔴 PF2e boost-based ability builder; gate 5e methods by system `M` `correctness`
- The Abilities step offers 5e standard-array/point-buy/4d6 for BOTH systems and buildCharacter copies scores verbatim, so every PF2e PC has illegal scores and no boosts are applied (I54/I102 + finding). Add a PF2e boost builder and branch the wizard's Abilities step on system; keep array/point-buy/4d6 only for 5e; have buildCharacter apply the boosts.
- **Correct behavior:** PF2e (Player Core/CRB): all six scores start at 10, then apply boosts in order — Ancestry (its listed boosts + a free boost; legacy ancestries may apply a flaw), Background (one from a choice of two + one free), Class (one to the key ability), then four free boosts each to a different ability. Each boost is +2 if the score is 17 or lower, +1 if 18+. Boosts within one batch must target different abilities; level-1 single ability caps at 18. Standard array [15,14,13,12,10,8], 27-point buy (8-15), and 4d6-drop-lowest are 5e-only and illegal in PF2e.
- **Files:** `src/features/characters/builder/CreationWizard.tsx`, `src/lib/rules/abilityGen.ts`, `src/lib/rules/progression.ts`
- **T-027** 🔴 Apply 5e racial ASI, speed, senses, traits at creation `M` `correctness`
- loadRaces5e returns asi but the wizard only shows it as meta text; finish() applies no racial bonus, leaving raw scores, speed 30, and no senses/traits (I55). Add asi/speed/traits to the Origin interface and apply them in finish().
- **Correct behavior:** 2014 5e PHB: each race grants fixed ability score increases (Hill Dwarf +2 CON/+1 WIS; Mountain Dwarf +2 STR/+2 CON; Variant Human +1 to two of choice), racial speed (e.g. 25 for dwarves/halflings), darkvision/senses, and racial traits — all added to base scores/speed at creation.
- **Files:** `src/features/characters/builder/CreationWizard.tsx`
- **Depends on:** T-030
- **T-029** 🟠 Apply background mechanics (5e and PF2e) `M` `correctness`
- Background is written only into notes; loaders carry skills/tools/languages/feature (5e) and data (PF2e) that never reach skillRanks/abilities/feats (I56). Apply: 5e 2 skill profs + tools/languages + feature + starting gear/gold; PF2e 2 boosts + trained skill + Lore + skill feat.
- **Correct behavior:** 5e background: 2 skill proficiencies, listed tool proficiencies, 2 languages (or tools), a background feature, and starting equipment/gold. PF2e background: 2 ability boosts (one from a choice of two + one free), training in one listed skill + one Lore subcategory, and one skill feat.
- **Files:** `src/features/characters/builder/CreationWizard.tsx`
- **Depends on:** T-030, T-036
### Rules engine depth (5e + PF2e math)
- **T-050** 🟠 Clamp 5e ASI at 20 `S` `correctness`
- applyIncreases for 5e does next[k]+1 with no clamp and the schema has no max, so repeated ASIs push scores past 20 and persist (I95 + finding). Clamp the 5e branch to Math.min(20, next[k]+1); leave PF2e's partial-boost rule unchanged.
- **Correct behavior:** 5e PHB Ability Score Improvement: 'you can't increase an ability score above 20 using this feature' — clamp each affected score at 20. PF2e has no equivalent universal hard cap (its +2-if-<18-else-+1 boost rule already gates growth), so the PF2e branch is correct.
- **Files:** `src/lib/rules/progression.ts`
### Combat tracker automation
- **T-060** 🟠 Fix damage/heal computed from stale render closure `S` `bug`
- EncounterTracker onDamage/onHeal precompute new HP from the render-closure combatant outside the transaction, so two rapid clicks both derive from the same stale HP and the second clobbers the first (I37). Inside the mutate callback, look up the live combatant from the fresh state before applying applyDamage/applyHealing, mirroring MapEditor.
- **Files:** `src/features/combat/EncounterTracker.tsx`
- **T-061** 🟡 Route compendium Add-to-combat through mutate() `S` `bug`
- CompendiumPage AddToCombat does get()+save() (blind put), reintroducing the read-modify-write race that mutate() exists to prevent (I39). Replace with encountersRepo.mutate(activeEncounterId, (e)=>addCombatant(e,{...})).
- **Files:** `src/features/compendium/CompendiumPage.tsx`
### Compendium search, fidelity & licensing
- **T-105** 🟠 Per-entry source/license attribution + NOTICE (keep all data) `M` `licensing`
- 5e feats come from MPMB but are credited to Open5e, PF2e ships bulk Archives-of-Nethys flavor+rules text, and 5e Weapons ship non-SRD items while Settings lumps licenses together (I104/I105 + findings). ATTRIBUTION ONLY: add accurate per-entry source/license metadata across all datasets, add a NOTICE file, and replace the lumped attribution with per-source provenance. Do NOT delete or remove any content.
- **Files:** `src/lib/compendium/index.ts`, `scripts/fetch_pf2e.ts`, `src/features/settings/SettingsPage.tsx`
---
## P1 — Build-engine credibility — legal characters, feats/subclasses, guided level-up, correct rules math
_43 tasks._
### Data layer: backup/restore, cascade, referential integrity
- **T-005** 🟠 Referential cleanup on character delete `M` `data-integrity`
- charactersRepo.remove is a bare delete, leaving combatant.characterId and mapToken.characterId/combatantId dangling so HP rings/portraits resolve a missing character (I81/I127). In a transaction, null the characterId on map tokens and detach from encounter combatants when deleting a character. Note: map delete needs no cleanup (no schema holds a mapId ref) — that half of the claim is refuted.
- **Files:** `src/lib/db/repositories.ts`
- **T-006** 🟡 Enforce HP current<=max cross-field validation `S` `correctness`
- hpSchema has no refine ensuring current<=max and no temp upper bound, so states like current=999/max=10 can persist (I83). Add a superRefine (or repo clamp) enforcing the upper bound while keeping any documented negative dying lower range.
- **Correct behavior:** Both 5e (PHB Hit Points/Healing) and PF2e (CRB 'You can never have more Hit Points than your maximum') cap current HP at max; healing above max is wasted. Temp HP is a separate pool that does not raise max (temp>=0). Negative current is non-standard (5e: 0 then death saves; PF2e: 0 then dying); if a negative lower bound is retained for an internal dying representation, still enforce current<=max as the upper bound.
- **Files:** `src/lib/schemas/common.ts`
### Server hardening, quotas, auth & scale
- **T-126** 🟡 Login brute-force + enumeration + password-length hardening `M` `security`
- Login has no lockout/backoff/captcha, skips scrypt when the username is missing (timing enumeration), and accepts unbounded password length into the blocking hash (I20 + findings). Add per-account/IP attempt counters with backoff/lockout, always compute a constant-time dummy scrypt on missing users, and cap password length (e.g. 256).
- **Files:** `server/src/index.ts`, `server/src/accounts.ts`
- **T-127** ⚪ WS transport hardening: real token bucket + Origin `S` `security`
- The WS 'token bucket' resets to full every 10s (a fixed window) and closes the socket on exceed, killing legitimate sessions, and the Origin allowlist is skipped when the header is absent (I24 + finding). Implement a real refilling bucket that drops/ignores the offending message instead of closing, and reject upgrades with a missing Origin when ALLOWED_ORIGINS is set.
- **Files:** `server/src/index.ts`
### Realtime sync & player-view robustness
- **T-143** 🟠 Request all referenced images on snapshot `M` `bug`
- On a snapshot the client requests only the map background, never portraits (char:<id>), token icons (tok:<id>), or the handout image, so any player who joins/reconnects after the GM pushed images sees none of them (finding). After applying a snapshot, requestImage for every referenced imageId not already cached.
- **Files:** `src/lib/sync/wsSync.ts`
- **T-144** ⚪ Show the roller their own roll in the table feed `S` `bug`
- sendPlayerRoll only transmits and the server skips the sender, so a player never sees their own roll in 'Table rolls' (I50). Optimistically addRoll to playerSessionStore for the roller, mirroring broadcastGmRoll.
- **Files:** `src/lib/sync/wsSync.ts`
- **T-147** ⚪ Replay private handout + chat/roll history on rejoin `M` `feature`
- join() replays only the snapshot; private handouts are fire-and-forget to current playerIds and chat/rolls aren't cached, so a reconnecting player loses their handout and all table history (I52). Cache the last private handout per durable identity and a bounded chat/roll buffer per room, replayed on (re)join.
- **Files:** `server/src/rooms.ts`
- **Depends on:** T-140
### Platform: PWA offline, durability, a11y, performance
- **T-162** 🟡 Handle QuotaExceeded + show storage usage `M` `data-integrity`
- Image data URLs are written to IndexedDB with no QuotaExceededError handling and no usage warning, so heavy map/token/portrait use throws with no feedback (I29). Wrap repo writes to catch QuotaExceededError and surface a message, add a navigator.storage.estimate() usage display in settings, and cap/compress stored image sizes.
- **Files:** `src/lib/db/repositories.ts`, `src/lib/img/resize.ts`
- **T-163** 🟡 Top-level ErrorBoundary around the app shell `S` `bug`
- The only ErrorBoundary wraps <Outlet>, not RouterProvider/RootLayout, which runs shell-level hooks and renders nav/header — if any throw, the whole app white-screens, contradicting the boundary's intent (finding). Wrap RouterProvider (or RootLayout's body) in a top-level boundary.
- **Files:** `src/main.tsx`, `src/app/RootLayout.tsx`
### Dice engine + UI correctness
- **T-014** 🟠 Crit damage automation `S` `correctness`
- Crit is detection-only; AttacksSection rolls plain damage with no doubling (I88). Add a crit damage path off the attack result triggered by a natural-crit (5e) or critical-success degree (PF2e).
- **Correct behavior:** 5e (PHB Critical Hits): roll all damage DICE twice and add modifiers ONCE — 1d8+3 crit = 2d8+3 (not 2d8+6); extra feature dice (sneak attack/smite) are also doubled. PF2e (CRB Critical Hits/Doubling): compute full normal damage incl. all bonuses then DOUBLE the total — 1d8+4 crit = (1d8+4)*2. A crit requires a hit: 5e nat 20 auto-hits; PF2e nat 20 raises degree one step and total>=DC+10 is also a crit.
- **Files:** `src/features/characters/sheet/AttacksSection.tsx`, `src/lib/dice/notation.ts`, `src/lib/dice/check.ts`
- **Depends on:** T-013
- **T-015** 🟡 Limit 5e crit/fumble to attack rolls only `S` `correctness`
- 5e degreeOfSuccess returns critical-success/failure on any natural 20/1 for all d20 checks, overstating the rules for ability checks and saves (finding). Pass a rollType (attack vs check/save) so crit/fumble applies only to attack rolls.
- **Correct behavior:** 5e RAW (PHB): natural 20/1 auto-success/fail and critical hits apply ONLY to attack rolls (death saves have a separate special rule). Ability checks and saving throws have NO nat-20/nat-1 special case: success iff total>=DC, else failure, with no crit. PF2e is the exception that does use nat-20/1 degree shifts — keep its existing logic.
- **Files:** `src/lib/dice/check.ts`
- **T-020** ⚪ Require campaignId on diceRepo.clear `S` `data-integrity`
- clear() with no campaignId calls db.diceRolls.clear(), wiping every campaign's history (I93). Make campaignId required, or rename the global form clearAll() so a missing id cannot nuke all rolls.
- **Files:** `src/lib/db/repositories.ts`
- **T-021** ⚪ Add a global aggregate dice-quantity budget `S` `performance`
- parseDice enforces only per-term limits, so '1000d1000+1000d1000+...' or exploding terms can drive ~10^6 rng calls synchronously and freeze the UI (I94). Sum total declared dice across terms (with an explode-cap-adjusted estimate) and throw DiceParseError above a global budget before rolling.
- **Files:** `src/lib/dice/notation.ts`
### Character builder & creation correctness
- **T-028** 🟠 Apply PF2e ancestry boosts/flaw, heritage, senses `M` `correctness`
- PF2e ancestry land speed/HP are applied but heritage, ancestry ability boosts/flaw, senses, traits, and languages are not (partial feature). Apply ancestry boosts/flaw and heritage mechanics, senses, traits, and languages to the built character.
- **Files:** `src/features/characters/builder/CreationWizard.tsx`
- **Depends on:** T-026, T-030
- **T-030** 🟠 Structured ancestry/race/heritage/background/subclass data `M` `feature`
- Race/ancestry/background mechanics are stored only as free text or display meta, so they cannot be applied (absent must features). Provide structured mechanical fields (ASIs/boosts, speed, senses, traits, granted skills/tools/languages, feature/feat refs) sourced from existing loaders to back the creation tasks.
- **Files:** `src/lib/compendium/index.ts`, `src/lib/schemas/character.ts`
- **T-031** 🟠 Starting equipment/gold at creation `M` `feature`
- finish() writes no inventory and zero currency despite the Review step promising gear (I58). Add starting-equipment selection (class+background gear or starting gold by system) in the wizard and seed currency/inventory in finish().
- **Files:** `src/features/characters/builder/CreationWizard.tsx`, `src/features/characters/sheet/InventorySection.tsx`
- **T-032** 🟡 Constrain builder spell picks by class/tradition + counts `M` `correctness`
- Builder spell filter is name+level-cap only, so a Wizard can pick Cleric spells and counts are advisory (I64). Filter by class list/tradition and enforce cantrip/known/prepared counts; PF2e needs caster detection fixed first so the Spells step appears.
- **Files:** `src/features/characters/builder/CreationWizard.tsx`
- **Depends on:** T-040
### Feat & subclass engine (the moat)
- **T-036** 🟠 Feat data model + prerequisite engine + selection UI `L` `feature`
- There is no feats field on the schema and no feat UI; level-up 'feat' is text only and loadFeats5e is unused (I57). Add featSchema and a feats field (additive Dexie version + .upgrade backfilling feats:[]), add listFeats/checkPrerequisites/applyFeat to RulesSystem with predicates evaluated against the derived character, and build authoring/selection UI in the builder and level-up wired to feat datasets; cap 5e ASI-gained scores at 20 in applyFeat.
- **Files:** `src/lib/rules/types.ts`, `src/lib/schemas/character.ts`, `src/features/characters/builder/CreationWizard.tsx`, `src/features/characters/sheet/LevelUpModal.tsx`, `src/lib/compendium/index.ts`
- **Depends on:** T-026, T-050
- **T-037** 🟠 Subclass mechanics (5e archetype/domain; PF2e doctrine/order/bloodline) `L` `feature`
- Subclass is selectable but stored only as a notes line and grants no mechanics (absent must + finding). Make subclasses grant proficiencies, slots, features, expanded spell lists, and choices, feeding the build and level-up.
- **Files:** `src/lib/rules/dnd5e/index.ts`, `src/lib/rules/pf2e/index.ts`, `src/features/characters/builder/CreationWizard.tsx`
- **Depends on:** T-030, T-036
### Guided, rules-validated level-up
- **T-041** 🟡 Key level-up to class data; read maxLevel from RulesSystem `M` `correctness`
- planLevelUp/getClassDef look up by class name, so unknown classes default to hitDie 8 and caster 'none' (wrong HP/slots for d10/d12/6-HP classes), and the level cap is hardcoded 20 in feature code (I60/I8). Resolve HP/caster/slots from class data and add maxLevel to RulesSystem (20 for both) read via getSystem(); ASIs/boosts already fire for unknown classes so keep that.
- **Correct behavior:** PF2e HP per level is fixed per class: 6 (Sorcerer/Wizard/Witch), 10 (Fighter/Champion/Monk/Ranger etc.), 12 (Barbarian), 8 (most others) — defaulting to 8 mis-computes HP. 5e hit die is d6/d8/d10/d12 per class. Both 5e (PHB) and PF2e (CRB) cap advancement at level 20, so maxLevel=20 for both is the correct value; the defect is architectural (read it from the RulesSystem seam).
- **Files:** `src/lib/rules/progression.ts`, `src/features/assistant/useLevelUpAdvisor.ts`, `src/lib/rules/types.ts`
- **T-042** 🟡 Merge level-up slots preserving spent state `S` `correctness`
- Level-up sets spellcasting.slots = plan.slots wholesale with current===max, discarding manual edits and refilling spent slots (I61). Merge plan.slots into existing slots by rank: set the new max and clamp current=min(oldCurrent,newMax), adding only newly-gained slots.
- **Files:** `src/features/characters/sheet/LevelUpModal.tsx`
- **T-043** 🟡 Expand level-up to walk features, spells, proficiencies `M` `feature`
- Level-up writes only HP/slots/abilities/one skill and never adds class/subclass features, new cantrips/spells known, proficiency increases (PF2e expertise/master, perception), or feat slots (I62). Walk every per-level choice from class/subclass data.
- **Files:** `src/lib/rules/progression.ts`, `src/features/characters/sheet/LevelUpModal.tsx`
- **Depends on:** T-036, T-037, T-041
- **T-044** 🟡 Add PF2e ancestry/skill/general feat tracks to level-up `S` `correctness`
- The PF2e level-up planner emits only ability boosts, skill increases, and class feats, omitting ancestry/skill/general feats — roughly half the choices (I100). Add the missing tracks.
- **Correct behavior:** PF2e progression: Class feats at 1,2,4,6,8,10,12,14,16,18,20 (even at level-up); Ancestry feats at 1,5,9,13,17; Skill feats at every even level 2-20; General feats at 3,7,11,15,19; Skill increases at 3,5,7,9,11,13,15,17,19; Ability boosts (×4) at 1,5,10,15,20.
- **Files:** `src/lib/rules/progression.ts`
- **Depends on:** T-036
### Spellcasting, AC & equipment derivation
- **T-040** 🟠 Fix PF2e caster detection; derive caster/slots from class data `M` `correctness`
- normalizeFoundryClass hardcodes caster:'none' for all PF2e classes so the Spells step never appears, while buildCharacter uses a 15-entry curated table that omits Animist/Magus/Psychic/Summoner — leaving those casters with no slots/ability (I59 + finding). Derive caster type/tradition/ability/slots from the compendium RulesetClass (fix the hardcode), and stop defaulting unknown casters to 'none'.
- **Files:** `src/lib/ruleset/normalize.ts`, `src/lib/rules/progression.ts`
- **T-046** 🟠 Correct PF2e spell slots, cantrips, repertoire, class DC `M` `correctness`
- pf2eSlots fabricates a flat 3-per-rank (wrong at level 1 and at each new top rank), models no cantrips/focus, BuiltCharacter returns spells:[], and class DC is computed nowhere (I65/I96/I103 + findings). Implement per-class slot tables, cantrips, prepared/spontaneous/repertoire/signature spells, focus pool max, and class DC.
- **Correct behavior:** PF2e full-caster slots (Player Core/CRB): rank R unlocks at character level 2R-1 with 2 slots, rising to 3 at level 2R; lower ranks are 3; 10th rank is a single slot gained at level 19-20 only. So L1=1st×2 (not 3), L2=1st×3, L3=1st×3/2nd×2, etc.; each odd level's newest rank is 2 not 3. Cantrips: full casters know ~5, auto-heightened to half level rounded up. Class DC = 10 + proficiency bonus (level + rank bonus: trained 2/expert 4/…) + key-ability modifier. Spontaneous casters' repertoire size and prepared casters' prepared counts come from the class table; Cleric Divine Font adds slots; focus spellcasters have a separate Focus Point pool capped at 3.
- **Files:** `src/lib/rules/pf2e/progression.ts`, `src/lib/rules/pf2e/index.ts`
- **Depends on:** T-040
- **T-047** 🟠 Derive AC and attacks from equipped gear + proficiency `M` `correctness`
- Both systems compute AC as 10 + full DEX + a manual armorBonus, ignoring equipped armor, Dex caps, and PF2e level+proficiency scaling; attacks are fully manual and no weapon/armor proficiency is tracked (I63/I97 + findings). Derive AC from equipped armor/shield, auto-generate attacks from equipped weapons, and track weapon/armor proficiency.
- **Correct behavior:** 5e AC (PHB): Unarmored = 10 + DEX (+shield 2; Barbarian +CON, Monk +WIS); Light = armor base + full DEX; Medium = base + min(DEX,+2); Heavy = base, no DEX. PF2e AC (Player Core): 10 + DEX (capped at the armor's Dex Cap) + proficiency bonus (= level + rank bonus: trained 2/expert 4/master 6/legendary 8; untrained adds neither level nor bonus) + armor item bonus + shield (when Raised). The current 10 + full DEX + manual bonus is correct only for the unarmored case.
- **Files:** `src/lib/rules/pf2e/index.ts`, `src/lib/rules/dnd5e/index.ts`, `src/features/characters/CharacterSheet.tsx`, `src/features/characters/sheet/InventorySection.tsx`, `src/features/characters/sheet/AttacksSection.tsx`
### Rules engine depth (5e + PF2e math)
- **T-051** 🟠 Advance PF2e proficiency ranks with level `M` `correctness`
- saveRanks/perceptionRank/spellcastingRank/weapon ranks are set once at build and never advance, so high-level PF2e spell DC, spell attack, saves, perception/initiative, class DC, and AC are short by up to +6 (finding, the largest PF2e gap after AC). Advance the class-driven proficiency progression by level.
- **Correct behavior:** PF2e: proficiency bonus = character level + rank bonus (trained 2/expert 4/master 6/legendary 8; untrained 0 and no level). Classes raise ranks at fixed milestones (e.g. spellcasting/weapons to expert ~7, master ~15, legendary ~19; saves and perception bump per class table). Every rank read by spellSaveDc/spellAttackBonus/saveModifiers/initiative/class DC/AC must reflect the level+rank bonus, not a frozen 'trained'.
- **Files:** `src/lib/rules/progression.ts`
- **Depends on:** T-041
- **T-052** ⚪ Fix PF2e Refocus to restore +1 focus `S` `correctness`
- The refocus RestOption recovers:['short'] and applyRest sets current=max, so one Refocus refills the whole focus pool; there is no focus-pool max/cap (I101). Give RestOption a step/amount semantic so refocus adds +1 (capped at 3) while daily prep refills.
- **Correct behavior:** PF2e Refocus (Player Core): you recover 1 Focus Point per 10-minute Refocus (more only with specific feats); Focus Points are restored to full only by daily preparations / a night's rest. Pool max is 3. So refocus = +1 capped at 3; 'Rest for the Night'/daily prep = full.
- **Files:** `src/lib/rules/rest.ts`, `src/lib/rules/pf2e/index.ts`
### Combat tracker automation
- **T-062** 🟡 Custom-monster CR/level input; include NPC foes in budget `M` `correctness`
- Hand-added monsters never set cr/level so they are filtered out of the difficulty budget (shows trivial/0 XP), and NPC-kind foes are excluded entirely (I38 + finding). Add a CR (5e) / creature-level (pf2e) field to the custom-combatant form and feed monster- and npc-kind foes into computeBudget.
- **Correct behavior:** 5e (DMG p.82): each monster contributes XP by CR (CR 1/4=50, CR 1=200, per CR_XP table); Adjusted XP = sum(CR_XP) × multiplier(count) where multiplier is 1/1.5/2/2.5/3/4 for 1/2/3-6/7-10/11-14/15+ monsters; compare to summed party thresholds [easy,medium,hard,deadly]. PF2e (GMG): each creature contributes XP by (creatureLevel − partyLevel): −4→10,−3→15,−2→20,−1→30,0→40,+1→60,+2→80,+3→120,+4→160; thresholds Trivial/Low/Moderate/Severe/Extreme = 40/60/80/120/160 adjusted ±10/15/20/30/40 per PC over/under 4. The budget tables are already correct; only the missing cr/level input is the defect.
- **Files:** `src/features/combat/EncounterTracker.tsx`
- **T-063** 🟡 Fix undo: state-tracked depth, mutate-based, with redo `M` `correctness`
- Undo pushes the stale render-time encounter, restores via a non-transactional blind save() (clobbering concurrent writers), has no redo, and reads visibility from a ref during render (I40 + finding). Track undo depth in state, restore via mutate, push to a redo stack, and snapshot the freshest committed encounter.
- **Files:** `src/features/combat/EncounterTracker.tsx`
- **T-064** ⚪ Make previousTurn condition handling symmetric `S` `correctness`
- nextTurn ticks/expires conditions on the new active combatant but previousTurn only moves the pointer, permanently losing expired/ticked condition state (I42). Route 'Prev' through the undo stack so it truly reverses the last nextTurn, or make ticking reversible, or document it as a pure pointer move.
- **Files:** `src/lib/combat/engine.ts`
- **Depends on:** T-063
- **T-065** ⚪ Wire Temp HP control in the tracker `S` `feature`
- setTempHp is implemented and tested but unreachable from the tracker UI (I45). Add a 'Temp' button next to Dmg/Heal that calls mutate with setTempHp on the freshly looked-up combatant.
- **Files:** `src/features/combat/EncounterTracker.tsx`
### AI flagship: chat, RAG Q&A, generation, recap
- **T-092** ⚪ Warn that API key is stored unencrypted; default rememberKey off `S` `security`
- With rememberKey on (default true) the apiKey is written to localStorage in plaintext, readable by XSS or device access (I9). Add an explicit warning near the toggle and consider defaulting rememberKey to false.
- **Files:** `src/stores/assistantStore.ts`, `src/features/settings/AssistantSettings.tsx`
### Compendium search, fidelity & licensing
- **T-106** 🟡 Make PF2e loadClasses fail loudly `S` `bug`
- loadClasses caches an empty array for PF2e on a 404/network error with no throw, so the Classes category and class picker silently render empty instead of an error (I108). Throw on !res.ok like loadPf2e and don't cache failures.
- **Files:** `src/lib/compendium/index.ts`
- **T-107** ⚪ Build filter options from allData (include homebrew) `S` `bug`
- Filter dropdowns are built from f.options(data) while filtering runs against allData, so homebrew-only trait/type/rarity values never appear (I110). Build options from allData.
- **Files:** `src/features/compendium/CompendiumPage.tsx`
- **T-108** ⚪ Zod validation at the SRD load boundary `S` `data-integrity`
- Every loader casts raw JSON with no parse and bad fields silently coerce to NaN/defaults (I107). Add a light Zod schema per dataset (at least name + the numeric fields used for combat/sort) and parse in each loader, surfacing a load error instead of NaN.
- **Files:** `src/lib/compendium/index.ts`, `src/features/compendium/registry.tsx`
- **T-109** ⚪ Keep full race/background/feat descriptions `S` `content`
- The first() helper truncates desc/traits to the first paragraph (~500 chars) for races/backgrounds/feats, degrading the builder pickers (I106). Drop the first()-paragraph cap so pickers show complete text.
- **Files:** `scripts/fetch_open5e.ts`
### Worldbuilding depth: links, calendar, homebrew, secrets
- **T-181** 🟠 Referential integrity for wikilinks on rename/delete `M` `data-integrity`
- Wikilinks resolve by case-insensitive title; renaming a note orphans all [[OldTitle]] references (backlinks vanish, stale links offer to create a duplicate) and deleting leaves dangling links (I68 + finding). Resolve links by stable note id (store [[id|alias]] or a title index) or rewrite all [[oldTitle]] occurrences across siblings inside a repo transaction on rename, and handle delete.
- **Files:** `src/features/world/NotesPage.tsx`, `src/lib/wikilinks.ts`
- **T-184** 🟠 Full homebrew monster stat block + per-system init `M` `correctness`
- Homebrew monsters expose only AC/HP/CR/Dex and homebrewCombatant computes init as Dex mod for all systems, so a homebrew creature has no attacks to make and wrong PF2e initiative (I70). Expand authoring to a full stat block (six abilities, saves, speed, actions/attacks with damage) matching the compendium schema and compute init per system.
- **Correct behavior:** 5e (PHB) initiative = Dexterity modifier — correct as-is for 5e. PF2e (CRB) initiative is a skill roll, normally Perception = Wis mod + proficiency (the PF2e engine already uses Perception). A runnable stat block also needs the six ability scores, saves, speed, and at least one attack/action with to-hit and damage.
- **Files:** `src/features/world/homebrew.ts`, `src/features/compendium/CompendiumPage.tsx`
- **T-180** 🟡 Fix NPC disposition vs life-status mislabel `S` `correctness`
- The dashboard maps Npc.status (alive/dead/unknown) to Hostile/Friendly badges, so a slain ally shows red 'Hostile' and a living villain shows green 'Friendly' (I67). Either relabel the badge to the life-state (Alive/Dead/Unknown) or add an npc.disposition enum and drive the badge from it while showing status separately.
- **Correct behavior:** Not a numeric formula: do not equate dead==hostile or alive==friendly. Render life-state honestly (Alive/Dead/Unknown) and, if attitude is wanted, add a separate disposition field (friendly/neutral/hostile).
- **Files:** `src/features/world/DashboardPage.tsx`, `src/lib/schemas/world.ts`
- **T-185** 🟡 Handle imported homebrew from a foreign system `S` `correctness`
- importMany preserves an entry's original system, so a 5e pack imported into a PF2e campaign is stored but filtered out of every compendium category with no warning (I71). Coerce imported entries to the active system (or warn/skip mismatches) and surface a system-mismatch notice on HomebrewPage.
- **Files:** `src/lib/db/repositories.ts`, `src/features/compendium/CompendiumPage.tsx`
- **T-188** 🟡 Markdown/rich-text notes + session logs `M` `feature`
- Note bodies render as a styled <pre> with clickable wikilinks; no markdown/headings/bold/lists/tables/images and no journal/session-log concept (I74 + absent must). Render bodies through a markdown renderer (with a wikilink extension) or rich-text editor, and add a session-log/journal concept.
- **Files:** `src/features/world/NotesPage.tsx`
- **T-191** 🟡 Resync edit cards from external updates `S` `data-integrity`
- NpcCard/QuestCard/HomebrewCard seed local state from props once and never resync, so a liveQuery update from another tab/device/AI write is overwritten by the next debounced save — reintroducing silent data loss in the UI (finding). Resync edit cards from external updates (key by updatedAt or an effect).
- **Files:** `src/features/world/NpcsPage.tsx`, `src/features/world/QuestsPage.tsx`, `src/features/world/HomebrewPage.tsx`
---
## P2 — Run-the-table parity + AI flagship — combat automation, two-way play, grounded AI, search
_66 tasks._
### Data layer: backup/restore, cascade, referential integrity
- **T-007** 🟡 Mitigate whole-DB backup OOM `M` `performance`
- buildBackup loads all tables into memory and JSON.stringify(...,2) serializes base64 portraits/map images into one pretty-printed Blob, an OOM risk on mobile (I84/I126). Stream/chunk the export, drop 2-space indentation for large exports, add a size guard/warning, and/or offer per-campaign export to bound memory.
- **Files:** `src/lib/io/file.ts`, `src/lib/io/backup.ts`
- **T-008** ⚪ Enforce system match on character import `S` `correctness`
- parseCharacterImport trusts the file's system and never compares it to the target campaign, so a pf2e character can land in a 5e campaign and desync system math (I128). Pass the campaign system in and reject (or warn/convert) when character.system!==campaign.system.
- **Files:** `src/lib/io/character.ts`
- **T-009** ⚪ Per-system currency validation (PF2e has no electrum) `S` `correctness`
- currencySchema always includes ep but PF2e has no electrum; nothing enforces the 'ep stays 0' comment (finding). Add a per-system refine or UI guard so PF2e cannot persist ep>0.
- **Files:** `src/lib/schemas/common.ts`
- **T-011** ⚪ Backup integrity metadata + pre-restore snapshot `M` `data-integrity`
- There is no checksum/app-version gate and no explicit pre-restore safety snapshot (feature). Add a checksum + app/schema-version compatibility gate and take a pre-restore snapshot so a bad restore can roll back. The restore transaction already rolls back on throw, but invalid rows do not throw, so this complements T-001.
- **Files:** `src/lib/io/backup.ts`
- **Depends on:** T-001, T-003
### Server hardening, quotas, auth & scale
- **T-128** 🟡 O(1) token→userId index `M` `performance`
- userByToken does an O(users×tokens) linear scan on every authenticated request (I15). Maintain a Map<tokenHash,userId> updated in issueToken/logout/load and look up in O(1).
- **Files:** `server/src/accounts.ts`
- **T-130** 🟡 Optimistic concurrency for blob + characters `M` `feature`
- PUT /api/save and putCharacter are last-write-wins with no version/ETag/precondition, so two devices silently clobber each other (I21). Store a version/ETag, accept If-Match on PUT, and return 409 on mismatch (or adopt a version-vector merge).
- **Files:** `server/src/index.ts`, `server/src/campaigns.ts`
- **T-134** ⚪ Observability, audit log, security headers `M` `security`
- There is no helmet/HSTS, no @fastify/cors, and no structured audit log; the only hardening is the WS Origin allowlist (absent nice). Add security headers (helmet/HSTS), a CORS policy, and structured audit logging.
- **Files:** `server/src/index.ts`
- **T-135** ⚪ Validate/normalize stored blob + character JSON `S` `data-integrity`
- saveBlob/putCharacter store client strings opaquely and GET /api/save returns them as application/json without validating they are JSON (feature nice). Validate/normalize on write (or document the opaque contract) so garbage isn't served as application/json.
- **Files:** `server/src/accounts.ts`, `server/src/campaigns.ts`, `server/src/index.ts`
### Realtime sync & player-view robustness
- **T-145** 🟡 Delta/patch snapshot protocol `M` `performance`
- The full snapshot is rebuilt and fanned to every player on any change, reshipping the whole map/fog/party on a single HP tick (I49). Diff against the last snapshot and send only changed sub-trees (per-combatant HP, appended revealed cells, changed tokens) with a periodic full resync for late joiners.
- **Files:** `src/features/play/useSessionBroadcaster.ts`, `server/src/rooms.ts`
- **T-146** 🟡 Heartbeat + accurate presence `M` `feature`
- There is no application-level ping/pong or idle timeout, so a half-open connection lingers in the roster and the GM keeps fanning state into a dead socket (I51). Add server ping every ~20-30s with terminate on missed pong, a client heartbeat that forces reconnect, and latency/typing presence indicators.
- **Files:** `server/src/index.ts`, `src/lib/sync/wsSync.ts`
- **T-151** 🟡 Player rolls feed the GM tracker `M` `feature`
- Player rolls are display-only and nothing writes to the encounter/initiative or applies damage/saves GM-side (absent should). Let player-initiated rolls (initiative, saves, attacks) feed the GM's combat tracker.
- **Files:** `src/lib/sync/wsSync.ts`, `src/features/combat/EncounterTracker.tsx`
- **Depends on:** T-072
- **T-148** ⚪ Guard cross-campaign seat grant `S` `bug`
- grant() resolves the character via a global PK lookup, so if the id exists in a different campaign the player is seated to that foreign-campaign character and subsequent patches mutate that row (finding; the original 'insert collision' claim is refuted). Verify current.campaignId===active campaign and mint a fresh id/campaignId otherwise.
- **Files:** `src/features/play/SessionControl.tsx`
### Platform: PWA offline, durability, a11y, performance
- **T-164** 🟡 Controlled SW update prompt; defer during live session `M` `feature`
- registerType:'autoUpdate' with skipWaiting+clientsClaim and cleanupOutdatedCaches lets a mid-session deploy take control immediately and 404 old chunks, with no prompt or deferral (I30). Switch to registerType:'prompt' with a 'Reload to update' UI (useRegisterSW onNeedRefresh) and defer skipWaiting while a live session is active.
- **Files:** `vite.config.ts`
- **T-165** 🟡 Route-based code splitting + bundle budget `M` `performance`
- router.tsx statically imports all 17 feature pages with no React.lazy/lazyRouteComponent, so nothing splits the initial bundle (I31). Use lazyRouteComponent per feature page and add a CI bundle-size budget.
- **Files:** `src/router.tsx`
- **T-170** ⚪ Offline/online indicator + graceful degradation `S` `feature`
- There is no global online/offline listener or connectivity banner, and dynamic-import failures show a generic error rather than an offline-aware message, so networked features fail silently offline (I36 + finding). Add a connectivity indicator driven by navigator.onLine + online/offline events, degrade live-session/cloud UI gracefully, and show offline-aware messaging for failed data loads.
- **Files:** `src/app/RootLayout.tsx`, `src/lib/compendium/index.ts`
### Dice engine + UI correctness
- **T-016** 🟡 Thread/broadcast a per-session seed for auditable rolls `M` `feature`
- createRng is never seeded outside tests and no seed is sent in gmRoll/playerRoll, so 'reproducible shared-seed sessions' is dead infra (I85). Thread a per-session seed (sessionId + monotonic roll counter) into createRng for table rolls and broadcast it in roll messages so peers can verify, or downgrade the rng doc comment to stop advertising it.
- **Files:** `src/lib/rng.ts`, `src/lib/sync/wsSync.ts`, `src/lib/dice/notation.ts`
- **Depends on:** T-013
- **T-017** 🟡 Implement documented reroll breakdown ('5->18') `S` `feature`
- rollDice overwrites rerolled values in place and RolledDie has no field for the original, so the documented '5->18' breakdown is impossible (I89). Add an optional rerolledFrom to RolledDie, populate it at the reroll site, and render '{original}->{value}' in formatTerm (or correct the doc comment).
- **Files:** `src/lib/dice/notation.ts`
- **T-022** 🟡 Broader dice notation coverage `M` `feature`
- TERM_RE supports only NdM with !, r{n}, kh/kl/dh/dl and integer constants (absent feature). Add d%, dF/Fudge, success/target counting (>=/cs/cf), keep-by-value, min/max clamp, and inline [type] labels (e.g. 1d8[fire]).
- **Files:** `src/lib/dice/notation.ts`
- **T-024** 🟡 Richer roll macros `M` `feature`
- Macros store label===expression with no separate name, editing, prompts, modifiers, character/system binding, or folders (partial feature). Add named/editable macros with prompts, modifiers, character/system binding, and organization.
- **Files:** `src/features/dice/DicePage.tsx`, `src/stores/macroStore.ts`
- **T-019** ⚪ Index, prune, and stably order dice history `S` `performance`
- diceRepo.recent loads the whole table then sorts/slices in JS, ordering ties only by ISO timestamp with no tiebreak, and history is never pruned (I92 + finding). Add a compound [campaignId+createdAt] index (additive migration), query reverse().limit(), add a monotonic counter/id secondary sort, and prune old rolls.
- **Files:** `src/lib/db/repositories.ts`, `src/lib/db/db.ts`
### Guided, rules-validated level-up
- **T-045** 🟡 Build legality checker, respec, on-sheet compendium add, attunement cap `M` `feature`
- There is no prerequisite/completeness validation, no respec, no add-from-compendium control on the sheet, and no 5e attunement cap (I66). Add a legality/completeness checker, a respec/rebuild flow, an on-sheet add-from-search for inventory/attacks/spells, and enforce the 3-item attunement cap.
- **Correct behavior:** 5e (DMG attunement): a character can attune to at most 3 magic items at once — cap attuned items at 3. Build legality should validate ancestry/class/feat prerequisites and completeness.
- **Files:** `src/lib/schemas/character.ts`, `src/features/characters/sheet/InventorySection.tsx`, `src/features/compendium/CompendiumPage.tsx`
- **Depends on:** T-036
### Spellcasting, AC & equipment derivation
- **T-048** ⚪ Sum inventory weight/Bulk into encumbrance `S` `correctness`
- Inventory items carry weight but nothing sums owned Bulk/weight into carryingCapacity — it is all manual (partial feature). Aggregate inventory into the encumbrance calculation per system.
- **Files:** `src/lib/rules/dnd5e/index.ts`, `src/lib/rules/pf2e/index.ts`, `src/features/characters/sheet/InventorySection.tsx`
- **Depends on:** T-047
### Rules engine depth (5e + PF2e math)
- **T-053** ⚪ Warn on 5e proficiency rank above expert (cross-system contamination) `S` `correctness`
- profMultiplier maps any rank above trained to ×2, so a leaked master/legendary rank silently reads as expertise (I99). This is correct for legitimate 5e data (two tiers only); add a dev warning if a rank above 'expert' reaches a 5e character to flag contamination.
- **Correct behavior:** 5e has exactly two proficiency tiers: proficient = +PB and expertise = +2×PB (PHB; Rogue/Bard Expertise). There is no master/legendary in 5e, so mapping any rank>trained to ×2 PB is correct for valid 5e data; only out-of-system ranks are a concern.
- **Files:** `src/lib/rules/dnd5e/index.ts`
### Combat tracker automation
- **T-070** 🟠 Store full monster stat block on combatants `M` `feature`
- AddToCombat copies only name/AC/flat-HP/initBonus/cr; the combatant schema has no attacks/saves/abilities/multiattack/traits, so nothing can be run from the tracker (absent must). Add an optional statBlock/monsterRef to combatantSchema (additive migration) and copy the whole block; this unlocks attacks, concentration, legendary actions, and resist/vuln.
- **Files:** `src/lib/schemas/encounter.ts`, `src/features/compendium/CompendiumPage.tsx`
- **T-071** 🟠 Encounter builder UI over computeBudget `M` `feature`
- There is no roster-vs-party builder; you add combatants one at a time inside the tracker (partial must). Build a builder screen with monster search/picker and quantities showing live CR/XP/threat vs party via the already-tested computeBudget.
- **Files:** `src/features/combat/CombatPage.tsx`, `src/lib/combat/budget.ts`
- **Depends on:** T-062, T-070
- **T-072** 🟠 Auto-rolled attacks and saving throws from statblock `M` `feature`
- No attack/save resolution exists; the tracker applies only manually-typed damage (absent must). Add to-hit-vs-AC, save DC rolls, and damage-on-save resolved from the active combatant's stat block, routed through the unified dice path so crit damage works.
- **Files:** `src/lib/combat/engine.ts`, `src/features/combat/EncounterTracker.tsx`
- **Depends on:** T-070, T-013, T-014
- **T-073** 🟠 Concentration tracking `M` `feature`
- No concentration flag, save prompt, or auto-drop exists (absent must). Add a concentration flag on the combatant, prompt a concentration save when a concentrating creature takes damage, and auto-drop the effect on failure.
- **Correct behavior:** 5e (PHB Concentration): when a concentrating creature takes damage it makes a Constitution saving throw, DC = max(10, half the damage taken); failure ends concentration. PF2e has no direct equivalent (Sustain a Spell each turn); model 5e concentration and PF2e sustained-spell tracking separately.
- **Files:** `src/lib/combat/engine.ts`, `src/lib/schemas/encounter.ts`
- **Depends on:** T-070
- **T-074** 🟠 Legendary & lair actions + legendary resistance `M` `feature`
- The tracker has no legendary-action budget, no initiative-20 lair-action trigger, and no legendary-resistance counter (absent must). Add a per-round legendary-action budget, an initiative-20 lair-action prompt, and a legendary-resistance counter driven from the stat block.
- **Files:** `src/lib/combat/engine.ts`, `src/features/combat/EncounterTracker.tsx`
- **Depends on:** T-070
- **T-066** 🟡 Death saves / unconscious + HP clamp + instant death `M` `correctness`
- The tracker only dims rows at hp<=0 with a generic DOWN badge, applyDamage runs arbitrarily negative, and there is no death-save state (I41 + finding). Differentiate monster (dead at 0) vs PC (clamp 0, apply Unconscious, expose a death-save tracker), and apply the instant-death overkill rule.
- **Correct behavior:** 5e (PHB p.197): at 0 HP a creature falls Unconscious (HP clamps to 0, not negative). A PC at 0 makes a death save at the start of each turn: d20, >=10 success, <10 failure; 3 successes = stable, 3 failures = dead; nat 20 = regain 1 HP; nat 1 = two failures; any damage at 0 = one auto failure (a crit = two). Instant death: if a single hit drops a creature to 0 AND leftover damage >= its HP max, it dies outright. Monsters/NPCs typically die at 0 without death saves. PF2e uses a dying/wounded value system instead.
- **Files:** `src/features/combat/EncounterTracker.tsx`, `src/lib/combat/engine.ts`
- **T-075** 🟡 Damage types + resistance/vulnerability/immunity `M` `correctness`
- applyDamage takes a raw number with no type and no resist/vuln/immune handling, so the GM must halve/double by hand (partial should). Add damage typing and apply resistance/vulnerability/immunity in applyDamage from the stat block.
- **Correct behavior:** 5e (PHB Damage Resistance/Vulnerability): resistance halves damage of that type (round down), vulnerability doubles it, immunity reduces it to 0; apply after other modifiers, and a given type's resistance/vulnerability applies once. PF2e applies a flat resistance/weakness value (subtract resistance, add weakness) and immunity negates.
- **Files:** `src/lib/combat/engine.ts`
- **Depends on:** T-070
- **T-076** 🟡 Auto-applied conditions modify derived stats `L` `correctness`
- conditions.ts is pure data; no code applies a condition's effect to checks/AC/saves (absent should). Apply PF2e Frightened/Clumsy/Enfeebled/Drained and 5e Exhaustion (and similar) as mechanical modifiers in the math engine.
- **Correct behavior:** PF2e valued conditions impose status penalties equal to their value: Frightened −value to checks/DCs, Clumsy −value to Dex-based, Enfeebled −value to Str-based, Drained −value to Con/HP. 5e Exhaustion (2014) applies the per-level effects table (disadvantage on checks at 1, speed halved at 2, etc.). Apply these to the derived stats they affect.
- **Files:** `src/lib/rules/conditions.ts`, `src/lib/rules/dnd5e/index.ts`, `src/lib/rules/pf2e/index.ts`
- **T-067** ⚪ Editable condition chips `S` `feature`
- Condition chips are write-once: clicking removes them and there is no editor for value/duration, so adjusting Frightened 2→1 needs delete+re-add (I43). Make chips open a stepper for value/duration with a separate remove affordance, patching via updateCombatant.
- **Files:** `src/features/combat/EncounterTracker.tsx`
- **T-068** ⚪ Initiative tiebreaker + group/lair initiative `S` `correctness`
- sortByInitiative breaks ties only by insertion order, and rollAllInitiative rolls each combatant independently with no group roll (I44 + feature). Add a secondary tiebreak key and a tie-resolution control, plus an option to roll one initiative for a group of identical monsters.
- **Correct behavior:** 5e (PHB p.189): ties broken by the DM for monsters and by players among themselves; PC-vs-monster tie decided by the DM — or optionally each tied participant rolls a d20, highest first; common automation breaks ties by higher Dexterity. PF2e (CRB p.468): on a tie, same-side creatures decide order among themselves; if a PC ties an NPC, the NPC (adversary) goes first.
- **Files:** `src/lib/combat/engine.ts`, `src/features/combat/EncounterTracker.tsx`
- **T-069** ⚪ Correct condition tick timing + save-to-end `M` `correctness`
- tickConditions runs on the combatant whose turn is beginning, mis-timing 'end of your next turn' / end-of-turn-save effects, and there is no save-to-end mechanic (finding). Model start-of-turn vs end-of-turn condition expiry and add end-of-turn saves.
- **Correct behavior:** 5e durations are phrased relative to a creature's turn ('until the end of your next turn', 'start of its turn'). A 1-round condition applied on a creature's own turn should not expire the instant its next turn begins (before it acts); ongoing effects that allow a save 'at the end of your turn' must be resolved at end-of-turn, not start.
- **Files:** `src/lib/combat/engine.ts`
### AI flagship: chat, RAG Q&A, generation, recap
- **T-097** 🟠 Free-form chat assistant grounded in campaign data `L` `feature`
- The only LLM surfaces are encounter rebalance and level-up; complete() takes a single system+user pair with no conversation history and no chat UI (absent must). Add a multi-turn chat assistant grounded in campaign context.
- **Files:** `src/lib/llm/client.ts`, `src/lib/assistant/context.ts`
- **Depends on:** T-086, T-093
- **T-098** 🟠 SRD-grounded rules Q&A (RAG over src/data/srd) `L` `feature`
- There is no retrieval-over-rules path or Q&A entry point; the systemConstraint anchor is only an instruction (absent must). Retrieve relevant SRD entries (spells/rules/monsters) into the prompt for cited, offline-data-grounded answers.
- **Files:** `src/lib/llm/client.ts`, `src/lib/assistant/context.ts`, `src/lib/compendium/index.ts`
- **Depends on:** T-097
- **T-099** 🟠 AI content generation into real entities `L` `feature`
- The assistant only rebalances encounters and lists generic routes; nothing generates NPCs/quests/lore/names/read-aloud text into the campaign (absent must). Add generators whose output is safeParse'd through the Zod schemas before repo.insert (respecting campaignId/cascade ownership).
- **Files:** `src/lib/assistant/context.ts`, `src/lib/db/repositories.ts`
- **Depends on:** T-086, T-093
- **T-085** 🟡 Gate response_format per provider with 400 retry `S` `bug`
- buildOpenai unconditionally sends response_format:{json_object} to all OpenAI-compatible providers (OpenAI/OpenRouter/Ollama/LM Studio), and older shims 400 on it (I2). Make it best-effort: keep the prose JSON instruction as the floor and retry once without response_format on a 400, or gate by per-endpoint capability.
- **Files:** `src/lib/llm/client.ts`
- **T-086** 🟡 Retry/backoff on 429/5xx + surface full error message `S` `feature`
- complete() issues one fetch and returns err('http') on any !ok with no retry, and advisor hooks surface only the terse kind code instead of res.message (I3 + finding). Add a small retry loop for 429/500/502/503/504 with exponential backoff respecting Retry-After (bounded by the timeout) and show the full message in the advisor UIs.
- **Files:** `src/lib/llm/client.ts`, `src/features/assistant/useLevelUpAdvisor.ts`, `src/features/assistant/useEncounterAdvisor.ts`
- **T-088** 🟡 Cheaper default model + curated model picker `S` `feature`
- Default model is the top-tier claude-opus-4-8 for trivial advisory calls and the Model field is free-text with no list or cost guidance, so typos fail as HTTP errors (I4). Default Anthropic to a cheaper tier and add a curated per-provider dropdown with cost/speed labels plus a custom escape hatch.
- **Files:** `src/stores/assistantStore.ts`, `src/features/settings/AssistantSettings.tsx`
- **T-090** 🟡 Deeper level-up context in charLine `S` `feature`
- charLine emits only name/level/class/abilities, so the model recommends options the character already has (I6). Include subclass/ancestry/background, current feats, known/prepared spells, trained skills, and notable equipment, and instruct the model to recommend non-duplicate, specific options.
- **Files:** `src/lib/assistant/prompts.ts`
- **Depends on:** T-036
- **T-093** 🟡 Configurable/provider-aware timeout + SSE streaming `M` `feature`
- A fixed 30s timeout aborts slow local/reasoning models and all calls are non-streaming (I10). Raise/make the timeout provider-aware and add an SSE streaming path so long generations stay alive and render incrementally.
- **Files:** `src/lib/llm/client.ts`
- **T-100** 🟡 Session recap / note summarization `M` `feature`
- context builds note/quest summaries from titles only and never feeds note bodies to an LLM; no recap feature exists (absent should). Add a recap/summarization flow over note bodies the context builder aggregates.
- **Files:** `src/lib/assistant/context.ts`
- **Depends on:** T-097
- **T-087** ⚪ Detect CSP-blocked requests by URL scheme, not message regex `S` `correctness`
- The 'csp' error kind only fires on a message regex that real browsers never produce; an http:// non-localhost provider blocked by CSP surfaces as a generic network error (I1). When fetch throws a TypeError and the baseUrl is http:// non-localhost, surface a CSP-likely hint; optionally validate baseUrl against the allowed CSP origins up front.
- **Files:** `src/lib/llm/client.ts`
- **T-089** ⚪ Anthropic structured output via forced tool `M` `feature`
- The Anthropic path relies only on a prose JSON instruction, the weaker of the two structured paths by design (I5). Force structured output via a single tool whose input_schema is the Zod schema (converted to JSON Schema) with tool_choice, falling back to the prose+extractor path where unsupported.
- **Files:** `src/lib/llm/client.ts`
- **T-091** ⚪ Grounded creature candidate fields for synergy reasoning `S` `feature`
- CreatureCandidate carries only name/rating/ac/hp, so the LLM picks by the same axis the deterministic engine already uses (I7). Include creature type/traits, key damage types, and a one-line role tag pulled from the bestiary JSON.
- **Files:** `src/lib/assistant/context.ts`
- **T-094** ⚪ AI request hardening: trim gate, cancellation, validate removes `S` `bug`
- canUseLlm uses !!apiKey (whitespace passes, then fails no-key), no hook passes an AbortController so calls can't be cancelled, and LLM 'remove' suggestions aren't validated against candidates (findings). Trim the gate, wire a per-request AbortController cancelled on unmount/re-run, and validate remove names against current combatants.
- **Files:** `src/features/assistant/useLevelUpAdvisor.ts`, `src/features/assistant/useEncounterAdvisor.ts`, `src/lib/llm/client.ts`
- **T-095** ⚪ Tolerant OpenAI-compatible text extraction `S` `bug`
- extractText reads only choices[0].message.content, so reasoning endpoints returning reasoning_content or content-part arrays yield an empty string and a parse failure (finding). Make extraction tolerant of array content parts and reasoning_content.
- **Files:** `src/lib/llm/client.ts`
- **T-096** ⚪ AI observability: raw response + token/cost budgeting `M` `feature`
- Failed structured calls return a generic message with no access to the raw text, and usage/token fields are discarded with no budgeting/truncation (findings). Surface the raw response (debug log) on parse failure, capture Anthropic/OpenAI usage for cost visibility, and add token budgeting/context truncation.
- **Files:** `src/lib/llm/client.ts`, `src/lib/assistant/context.ts`, `src/lib/assistant/prompts.ts`
### Compendium search, fidelity & licensing
- **T-111** 🟠 Global cross-category / cross-system search `M` `feature`
- Search forces system+category selection and only searches that category; there is no single box across spells+monsters+feats (absent must). Add a global cross-category (and cross-system) search.
- **Files:** `src/features/compendium/CompendiumPage.tsx`
- **Depends on:** T-110
- **T-110** 🟡 Full-text search + Fuse index over unfiltered data `M` `performance`
- searchKeys exclude all body text so you cannot find entries by effect text, and Fuse is rebuilt on every filter change over 8,605-entry lists (I109). Add text/desc/summary keys and build the Fuse index over the unfiltered dataset (memoized on data/category), applying attribute filters to the result set.
- **Files:** `src/features/compendium/registry.tsx`, `src/features/compendium/CompendiumPage.tsx`
- **T-113** 🟡 5e spell filters + mundane equipment catalog `M` `feature`
- 5e spell filters are only Level+School though data carries class/components/ritual/concentration, and there is no adventuring-gear/tools catalog (partial should). Add class/components/ritual/concentration filters and a mundane equipment catalog.
- **Files:** `src/features/compendium/registry.tsx`
- **T-114** ⚪ Surface monster art + richer stat-block fields `S` `feature`
- monsters-srd carries img_main, skills, per-ability saves, spell_list, environments, and bonus_actions that the Monster type and MonsterDetail drop (I112). Extend the type and detail view to render image, skills, saves, spellcasting list, environments, and a Bonus Actions block.
- **Files:** `src/lib/compendium/types.ts`, `src/features/compendium/MonsterDetail.tsx`
- **T-116** ⚪ Dedup PF2e equipment/weapons/armor tabs `S` `bug`
- Separate PF2e equipment/weapons/armor categories are fed from distinct AoN scrapes that likely overlap (the AoN equipment index includes weapons/armor), so items can appear in multiple tabs (finding). Verify and dedup across tabs.
- **Files:** `src/features/compendium/registry.tsx`
- **T-117** ⚪ PF2e tradition + feat-type filters `S` `feature`
- All PF2e categories share a generic level/trait/rarity filter set with no tradition or feat-type filter despite the data carrying them (finding). Add tradition (spells) and type/category (feats) filters.
- **Files:** `src/features/compendium/registry.tsx`
### Worldbuilding depth: links, calendar, homebrew, secrets
- **T-182** 🟠 Cross-entity wikilink resolver `M` `feature`
- Wikilinks only connect notes; NPCs, quests, locations, and compendium entries cannot be linked or render links (I69). Introduce a cross-entity resolver (notes+npcs+quests+compendium+maps) and render links/backlinks on those entity pages.
- **Files:** `src/features/world/NotesPage.tsx`, `src/features/world/NpcsPage.tsx`, `src/features/world/QuestsPage.tsx`
- **Depends on:** T-181
- **T-183** 🟡 Entity references + relationship graph `L` `feature`
- npc.faction/location are free-text with no entity references, relation tables, or graph (absent must). Turn faction/location into entity references and add a relationship graph with views like 'NPCs in this location' and faction rosters.
- **Files:** `src/lib/schemas/world.ts`, `src/features/world/NpcsPage.tsx`
- **Depends on:** T-182
- **T-187** 🟡 Structured fantasy calendar + timeline `M` `feature`
- The calendar is a single currentDay integer with title-only events, no months/weekdays/years/seasons/moons, no event editing, and no links (I73 + finding). Add a structured/named calendar (months/weekdays/year), let events be placed/edited on arbitrary days with descriptions and links to quests/notes/sessions, support recurring events, and split past vs upcoming.
- **Files:** `src/features/world/CalendarPage.tsx`, `src/lib/schemas/world.ts`
- **T-190** 🟡 GM-only/visibility flags on world entities `M` `feature`
- Only map tokens/drawings carry gmOnly; notes/NPCs/quests/objectives/calendar events have no visibility flag, so worldbuilding can't be safely surfaced to players (I76). Add a gmOnly/visibility flag to those schemas and honor it in any player-facing projection.
- **Files:** `src/lib/schemas/world.ts`
- **T-193** 🟡 Handouts / image attachments to Player View `M` `feature`
- There is no image/handout field on notes/NPCs/quests; the /play route shows combat/map snapshots, not a worldbuilding handout feed (absent should). Add handout/image attachment fields shareable to the Player View.
- **Files:** `src/lib/schemas/world.ts`, `src/features/world/NotesPage.tsx`
- **Depends on:** T-190
- **T-194** 🟡 NPC portraits, statblock linkage, add-to-combat `M` `feature`
- npcSchema has no portrait, no compendium creatureId reference, and no add-to-combat, so a villain tracked as an NPC must be recreated as a combatant (absent should). Add NPC portraits, a compendium creature linkage, and an add-NPC-to-combat action.
- **Files:** `src/features/world/NpcsPage.tsx`, `src/lib/schemas/world.ts`
- **T-195** 🟡 Quest depth: links, sub-quests, rewards, hidden objectives `M` `feature`
- Quests are a flat objective checklist with free-text reward and no NPC link, sub-quests, itemized XP/loot, or per-objective secrecy (partial should). Add quest-giver/NPC links, sub-quest hierarchy, itemized XP/loot rewards (flowing to characters), and per-objective secrecy.
- **Files:** `src/lib/schemas/world.ts`, `src/features/world/QuestsPage.tsx`
- **Depends on:** T-182, T-190
- **T-186** ⚪ Make homebrew conditions/feats usable or reference-only `S` `feature`
- DetailActions returns null for homebrew feat/condition entries, which also have empty field arrays, so they are display-only (I72). Either allow custom (non-enum) conditions to be applied to combatants or clearly mark feat/condition homebrew as reference-only, and give them editable fields.
- **Files:** `src/features/compendium/CompendiumPage.tsx`, `src/features/world/homebrew.ts`
- **T-189** ⚪ Memoize backlink/link-resolution index `S` `performance`
- Backlinks and titlesLower are recomputed O(notes×links) on every keystroke with no memoization (I75). Memoize the backlink index and titlesLower at the container level keyed on notes.
- **Files:** `src/features/world/NotesPage.tsx`
- **T-192** ⚪ Fix homebrew numeric field coercion `S` `bug`
- setField uses Number(e.target.value)||0, so clearing AC/HP/CR/Dex snaps to 0 and CR 0 is indistinguishable from unset (finding). Allow empty/intermediate values and distinguish a real 0 from unset.
- **Files:** `src/features/world/HomebrewPage.tsx`
### Interop: import/export
- **T-222** 🟡 Per-campaign selective + merge import/export `M` `feature`
- Only whole-DB backup and single-character export exist; restore is a destructive full replace (absent should + finding). Add per-campaign export/import (campaign + children), non-destructive merge import, and regenerate nested ids (combatant/token/map) on bundle import.
- **Files:** `src/lib/io/backup.ts`, `src/lib/db/repositories.ts`
---
## P3 — Differentiation & polish — variants, companions, VTT, worldbuilding depth, interop
_48 tasks._
### Data layer: backup/restore, cascade, referential integrity
- **T-010** ⚪ DB integrity / orphan-sweep utility `M` `tech-debt`
- Given confirmed orphan paths (sessionLog after cascade, dangling characterId in tokens/combatants), there is no routine to detect/repair orphaned rows (finding). Add a startup or on-demand integrity sweep that backstops cascade/cleanup gaps.
- **Files:** `src/lib/db/repositories.ts`
- **Depends on:** T-005
### Server hardening, quotas, auth & scale
- **T-129** 🟠 Migrate JSON-file stores to a real DB `L` `performance`
- Both stores are JSON files rewritten in full on every mutation (every login rewrites users.json; every putCharacter rewrites cloud.json with all inline character data), and two instances against one DATA_DIR would corrupt the files (I16/I23). Move to SQLite-WAL/Postgres with indexing and per-record character storage; avoid rewriting users.json on every login.
- **Files:** `server/src/accounts.ts`, `server/src/campaigns.ts`
- **T-131** 🟡 Campaign membership lifecycle `M` `feature`
- campaigns.ts supports only create/join/list and character upsert/remove; there is no leave, remove-member, invite rotation, delete, or ownership transfer, and 6-char invites can't be revoked once leaked (I22). Add leaveCampaign, removeMember (owner), rotateInvite, deleteCampaign (cascade characters), and transferOwnership with routes.
- **Files:** `server/src/campaigns.ts`
- **T-132** 🟡 WS auth + link rooms to campaigns + persist room snapshots `M` `feature`
- The /ws handler does only an Origin check (no auth) and RoomHub shares nothing with CloudStore, so cloud characters aren't tied to seats and live state is never persisted (I18). Optionally authenticate the WS upgrade with a bearer token, link rooms to cloud campaign IDs/seats, and persist room snapshots for resume.
- **Files:** `server/src/index.ts`, `server/src/rooms.ts`
- **T-133** ⚪ Auth lifecycle: expiry/refresh/reset/verification/deletion `M` `security`
- Tokens never expire and there is no refresh, password reset, email verification, captcha, or account deletion; open registration is unimpeded (I25 + partial feature). Add token expiry/refresh, password reset, registration verification/captcha (or invite-gated signup), and account deletion.
- **Files:** `server/src/accounts.ts`, `server/src/index.ts`
- **T-136** ⚪ Horizontal scale via shared room state (deferred) `L` `tech-debt`
- RoomHub is purely in-memory with no Redis/pubsub backplane, so a process restart drops sessions and a second instance can't share rooms (absent nice). Move room state to a shared store (Redis pub/sub) for multi-node. Deferred per strategy (single-node is acceptable until traction).
- **Files:** `server/src/rooms.ts`
- **Depends on:** T-129
### Realtime sync & player-view robustness
- **T-150** 🟡 Player map agency: move own token, ping, measure `M` `feature`
- PlayerMapView is strictly read-only with no token-move/ping/measure path (absent should). Let players move their own token and use ping/measure tools (kept within the static/explored-fog model, not per-player real-time vision).
- **Files:** `src/features/play/PlayerMapView.tsx`, `src/features/world/map/MapCanvas.tsx`
- **T-149** ⚪ Fix GM chat recap author/target attribution `S` `bug`
- sendChat always persists author 'GM' and records no whisper target, so a GM whisper is stored as a plain GM line and any future non-GM use would be misattributed (finding). Record the whisper target and correct author attribution in the persisted recap.
- **Files:** `src/lib/sync/wsSync.ts`
- **T-152** ⚪ Per-seat vision scoping + spectator/GM-screen handoff `M` `feature`
- One shared snapshot is sent identically to all players; only fog and GM-only tokens differ, with no per-seat secret scoping or spectator/GM-screen mode (absent nice). Add per-seat vision/secret scoping and a spectator/GM-screen handoff.
- **Files:** `server/src/rooms.ts`, `src/lib/sync/snapshot.ts`
### Platform: PWA offline, durability, a11y, performance
- **T-166** ⚪ Shared VirtualList for large lists `M` `performance`
- Virtualization exists only in CompendiumPage; characters/combat/world lists render all rows (I32). Extract a shared VirtualList and apply useVirtualizer to characters/combat/world as row counts grow.
- **Files:** `src/features/compendium/CompendiumPage.tsx`
- **T-167** ⚪ Exclude unused -full JSON from Docker build context `S` `tech-debt`
- ~15MB of unused *-full.json are pulled into the Docker build context by COPY . . because .dockerignore doesn't exclude src/data/srd (I33 + finding). Add src/data/srd/*-full.json (and other unused generated JSON) to .dockerignore — do NOT delete the data files.
- **Files:** `.dockerignore`, `Dockerfile`
- **T-168** ⚪ Accessibility baseline + axe e2e `M` `accessibility`
- There is no skip-to-content link, <main> has no landmark name, nav active state has no aria-current, and there is no automated a11y test (I34). Add a visually-hidden skip link targeting <main id='main' aria-label>, set aria-current='page' on the active nav Link, and add an axe-core e2e check.
- **Files:** `src/app/RootLayout.tsx`
- **T-169** ⚪ Narrow connect-src CSP to a provider allowlist `S` `security`
- PROD_CSP's bare 'https:' token permits fetch to any HTTPS origin, so an XSS could exfiltrate browser-stored LLM keys (I35). Narrow connect-src to a user-allowlisted set of provider endpoints/domains instead of bare https:.
- **Files:** `vite.config.ts`
- **T-171** ⚪ Offline + a11y e2e tests in CI `M` `tech-debt`
- No e2e covers offline behavior (setOffline) or accessibility assertions (absent should). Add automated offline-parity and axe a11y tests to CI.
- **Files:** `e2e/`
- **Depends on:** T-160, T-168
- **T-172** ⚪ Install-prompt (beforeinstallprompt) affordance `S` `feature`
- The manifest makes the app installable but there is no beforeinstallprompt capture or 'Add to home screen' affordance (nice feature). Capture beforeinstallprompt and add an in-app install affordance.
- **Files:** `src/app/RootLayout.tsx`
### Dice engine + UI correctness
- **T-018** ⚪ Document/align reroll 'r'/'ro' and explode+keep semantics `S` `tech-debt`
- 'r' rerolls once (Roll20/Foundry treat 'r' as reroll-until and 'ro' as reroll-once), and explode+keep operates over the post-explosion pool ambiguously (I90 + finding). Document the divergence, optionally add 'ro', and either disallow or clearly document explode+keep semantics.
- **Files:** `src/lib/dice/notation.ts`
- **T-023** ⚪ 3D / animated physical dice `M` `feature`
- The only animation is a single tumbling number; there is no per-die 3D/physics visualization (absent feature). Add an animated/3D dice render path.
- **Files:** `src/features/dice/DicePage.tsx`
- **T-025** ⚪ Roll history detail + quick-entry UX `S` `feature`
- History is a read-only label/expression/total list with no drill-down, filter, or re-roll, and the die pool always rolls exactly 1dX with no stepper/quick modifier (absent/nice features). Add per-roll breakdown drill-down, filter/search, re-roll-from-history, a per-die count stepper, and a quick +/- modifier on the pool.
- **Files:** `src/features/dice/DicePage.tsx`
### Rules engine depth (5e + PF2e math)
- **T-055** 🟡 Multiclassing (5e combined caster; PF2e archetype/dedication) `L` `feature`
- A single className+level is stored; there is no class list, combined-caster slot aggregation, ability-13 prereqs, or PF2e dedication/archetype handling (I98). Model classes as a list of {class,levels}: for 5e compute combined caster level against the multiclass slot table and check 13-in-key-ability prereqs; for PF2e support archetype/dedication tracks.
- **Files:** `src/lib/rules/progression.ts`, `src/lib/schemas/character.ts`
- **Depends on:** T-036
- **T-054** ⚪ Surface PF2e named saves (Fortitude/Reflex/Will) `S` `tech-debt`
- PF2E_SAVES maps the three named saves to abilities but saveModifiers returns a Record keyed by raw ability, leaving the friendly labels unused in the math layer (finding). Surface Fortitude/Reflex/Will labels from the rules layer.
- **Files:** `src/lib/rules/pf2e/skills.ts`
- **T-056** ⚪ Encumbrance edge cases (size, push/drag/lift) `S` `correctness`
- carryingCapacity ignores size multipliers and 5e push/drag/lift (absent feature). Add size adjustments and the STR*30 push/drag/lift value for 5e; apply PF2e size Bulk adjustment.
- **Correct behavior:** 5e (PHB): carrying capacity = STR×15; encumbered (variant) at STR×5; push/drag/lift = STR×30; capacity doubles/halves for sizes larger/smaller than Medium (Tiny ×0.5, Large ×2). PF2e: encumbered at 5 + STR mod Bulk, max 10 + STR mod; adjust limits by creature size.
- **Files:** `src/lib/rules/dnd5e/index.ts`, `src/lib/rules/pf2e/index.ts`
### Combat tracker automation
- **T-077** ⚪ Drag-and-drop initiative reordering `S` `feature`
- Reordering is only via single-step up/down arrows (absent nice). Add drag-and-drop reordering of the initiative list.
- **Files:** `src/features/combat/EncounterTracker.tsx`
- **T-078** ⚪ Encounter templates / clone / save-as roster `S` `feature`
- CombatPage offers only create-empty and remove; no clone/duplicate/save-as-template (absent nice). Add reusable encounter templates and a clone/duplicate action.
- **Files:** `src/features/combat/CombatPage.tsx`
- **T-079** ⚪ Delay / ready / hold action `M` `feature`
- The engine has no first-class delay or readied-action affordance; manual arrows only approximate it (nice feature). Add delay (act later this round, reposition in order) and readied-action triggers.
- **Files:** `src/lib/combat/engine.ts`
- **T-080** ⚪ Round/turn timer + action-economy tracking `M` `feature`
- The tracker counts rounds but offers no per-turn action/bonus/reaction checklist (5e) or 3-action tracker (PF2e) (nice feature). Add a per-turn action-economy tracker and an optional turn timer.
- **Files:** `src/features/combat/EncounterTracker.tsx`
### AI flagship: chat, RAG Q&A, generation, recap
- **T-101** ⚪ Homebrew statblock generation + encounter narrative `M` `feature`
- The encounter advisor only adds/removes existing creatures with a one-line reasoning; no statblock generation or tactics/setup narrative (absent nice). Add homebrew statblock generation and encounter narrative output.
- **Files:** `src/lib/assistant/context.ts`, `src/lib/assistant/prompts.ts`
- **Depends on:** T-099
### Compendium search, fidelity & licensing
- **T-112** 🟡 Ingest SRD 5.2 (2024, CC-BY-4.0) and expand breadth `M` `content`
- 5e data is SRD 5.1 via Open5e with limited breadth and no 2024 content (absent should). Add an SRD 5.2 (2024, CC-BY-4.0) ingest with correct attribution and broader coverage.
- **Files:** `scripts/fetch_open5e.ts`
- **Depends on:** T-105
- **T-115** ⚪ Consolidate loaders to one authoritative variant; remove dead code `S` `tech-debt`
- Many committed -full/-sample/mpmb datasets are unused, loadWeapons5e uses weapons-full while weapons-srd sits unused, and monsterToCombatant is dead code (I111 + finding). Make loaders consistently use one authoritative variant per category, remove the dead monsterToCombatant export, and move generated/unused JSON out of the bundle/Docker context — KEEP the data files, delete nothing.
- **Files:** `src/lib/compendium/index.ts`, `.dockerignore`
- **T-118** ⚪ Favorites / bookmarks / recently viewed `S` `feature`
- There is no favorite/bookmark/recently-viewed mechanism; selection is transient state (absent nice). Add favorites/bookmarks and a recently-viewed list.
- **Files:** `src/features/compendium/CompendiumPage.tsx`
### Worldbuilding depth: links, calendar, homebrew, secrets
- **T-196** ⚪ World organization + global search `M` `feature`
- Tags exist only on notes with no tag-browser/folders/pinning, and there is no unified search across notes/NPCs/quests/calendar/homebrew (partial nice + finding). Add tags/folders/favorites and a cross-entity global search.
- **Files:** `src/features/world/NotesPage.tsx`, `src/features/world/NpcsPage.tsx`
### Maps / VTT: vision, lighting, hex, tokens
- **T-200** 🟠 Render lights or remove the dead schema `L` `feature`
- Lights are imported, persisted, and exported but never rendered or used for vision (I113). Either render lights (radial dim/bright radius + color, intersected with vision) or stop advertising lights and keep the schema inert with a clear note.
- **Files:** `src/features/world/map/MapCanvas.tsx`, `src/lib/schemas/world.ts`
- **T-201** 🟠 Functional hex grid (+ gridless) or drop hex `L` `feature`
- gridType 'hex' exists but only square is rendered/measured, and there is no gridless mode (I114). Add hex rendering plus hex distance/neighbor math (axial/offset) and a gridType selector with a gridless option, or drop 'hex' from the schema until implemented.
- **Files:** `src/features/world/map/MapCanvas.tsx`, `src/lib/map/distance.ts`, `src/lib/map/grid.ts`, `src/features/world/map/MapEditor.tsx`
- **T-202** 🟠 True dynamic LOS: un-reveal + explored vs visible `L` `feature`
- Vision is additive-only (cells stay revealed after PCs leave or a door closes), one shared fog set with a single global radius, no bright/dim/explored or per-token darkvision, and the revealed array grows unboundedly (I115 + finding). Recompute the currently-visible set each frame, separate explored memory from currently-visible, add per-token sight/darkvision and optional per-player fog, and prune the revealed array.
- **Files:** `src/features/world/map/MapEditor.tsx`
- **T-203** 🟡 Polygon/multi-point fog with soft edges `M` `feature`
- Occlusion samples only the cell center so a cell is fully shown or hidden, with hard rectangular fog (I116). Sample multiple points per cell or compute a true visibility polygon and rasterize/clip the fog with soft edges.
- **Files:** `src/lib/map/vision.ts`
- **Depends on:** T-202
- **T-204** 🟡 Spatial index + cache + worker for vision `M` `performance`
- Vision recomputes O(viewers×cells×segments) synchronously on every token drop with no index/cache/worker and persists the whole revealed array each move (I117 + finding). Add a segment spatial index, per-viewer cache, recompute only the moved viewer, offload to a Web Worker, and throttle to rAF.
- **Files:** `src/features/world/map/MapEditor.tsx`, `src/lib/map/vision.ts`
- **T-205** 🟡 Fix sight leak at shared wall vertices `M` `correctness`
- segmentsIntersect only returns true for proper crossings, treating collinear/touching cases as non-blocking, so a ray through a shared polyline vertex leaks sight; the fixed eye nudge is biased (I118). Handle the on-segment/touching/collinear case (epsilon or proper test) and jitter/sample multiple eye offsets.
- **Files:** `src/lib/map/vision.ts`
- **T-206** 🟡 Recompute fog on door toggle `S` `correctness`
- toggleDoorNear never recomputes visibility, so opening a door reveals nothing until a token moves and closing never re-hides (I119). Recompute visibility immediately on door toggle when dynamicVision is on.
- **Files:** `src/features/world/map/MapEditor.tsx`
- **Depends on:** T-202
- **T-211** 🟡 Token/asset library + auto monster token art `M` `feature`
- Linked PC portraits become token art but there is no asset browser and monster/encounter tokens get only a colored chip (partial should). Add a built-in token/asset library and auto-place monster token art from the compendium.
- **Files:** `src/features/world/map/TokenPalette.tsx`, `src/features/world/map/MapEditor.tsx`
- **T-207** ⚪ System-aware AoE templates + correct origin `S` `correctness`
- AoE cone angle (53°) and line width (5ft) are hardcoded system-agnostically, and templates measure from the cell center rather than a grid intersection/square edge (I121 + findings). Pass a system-appropriate cone angle, expose configurable line width, and place template origins correctly per system.
- **Correct behavior:** 5e cone: width at any point equals distance from origin → full apex angle 2*arctan(0.5)=53.13° (53 is correct for 5e); 5e bursts originate at a grid intersection. PF2e cone: a quarter circle = 90° spread (the code is WRONG for PF2e, must pass 90); PF2e measures areas from the originating square's edges. 5e default line is 5ft wide but width is per-spell and should be configurable.
- **Files:** `src/features/world/map/MapEditor.tsx`, `src/lib/map/shapes.ts`
- **T-208** ⚪ Richer token model + per-annotation edit + undo/redo `M` `feature`
- Tokens snap to integer cells with no rotation/elevation/free placement, annotations support only undo-last/clear-all, and there is no undo for fog/walls/doors/token edits (I122 + finding). Add token rotation/elevation/free placement, per-annotation select/edit/delete, and a general undo/redo stack covering fog/walls/tokens/drawings.
- **Files:** `src/lib/schemas/world.ts`, `src/features/world/map/MapEditor.tsx`, `src/features/world/map/MapCanvas.tsx`
- **T-209** ⚪ Guard UVTT image-size vs map_size mismatch `S` `correctness`
- The UVTT map_origin subtraction is correct (I120 refuted), but if the source image's natural pixel size differs from map_size*ppg there can be a scale mismatch (residual edge). Derive cols/rows from the decoded image instead of map_size when they differ.
- **Files:** `src/lib/vtt/uvtt.ts`
- **T-210** ⚪ Preserve UVTT colour alpha on round-trip `S` `correctness`
- toHexColor strips 8-digit UVTT colour alpha to 6 digits and re-exports 6 digits, so light/door colour alpha (common in Dungeondraft exports) is lost (finding). Preserve and restore the alpha channel on import/export.
- **Files:** `src/lib/vtt/uvtt.ts`
- **T-212** ⚪ Richer wall/door semantics + multi-floor/layers `M` `feature`
- Walls are just points and doors only open/closed; there is no terrain/sound/one-way wall, secret/locked door, window, multi-floor level, or true layer system (absent nice). Add wall/door typing and map levels/layers.
- **Files:** `src/lib/schemas/world.ts`
- **T-213** ⚪ Token automation + animated/FX/audio maps `M` `feature`
- Missing token automation (drag-from-encounter, auras, condition icons, measurement waypoints, difficult-terrain movement cost) and rich media (video/animated maps, weather/FX, ambient audio) (partial/absent nice). Add these enhancements.
- **Files:** `src/features/world/map/MapEditor.tsx`, `src/features/world/map/MapCanvas.tsx`
### Interop: import/export
- **T-220** 🟠 D&D Beyond character import `M` `feature`
- There is no DDB/5e ingestion; only Pathbuilder and the app's own format are supported (absent must). Add a D&D Beyond character importer mapping into the character schema.
- **Files:** `src/lib/io/character.ts`
- **T-221** 🟡 Deeper Pathbuilder import `M` `feature`
- pathbuilderToCharacterFields maps only basic stats and drops equipment/money/weapons/armor/AC/spellCasters/feats/focus; heritage only lands in notes (I82). Map the full build payload into the corresponding character fields.
- **Files:** `src/lib/io/pathbuilder.ts`
- **T-223** ⚪ Standard interchange export (Pathbuilder/Foundry/UVTT) `M` `feature`
- Out-bound standard interchange is absent: UVTT/Foundry is import-only and character export is the app's own JSON only (partial nice). Add Pathbuilder/Foundry-compatible character export and UVTT/Foundry map export.
- **Files:** `src/lib/io/file.ts`, `src/lib/vtt/uvtt.ts`
- **T-224** ⚪ PDF character sheet export `M` `feature`
- Character export exists as JSON and Pathbuilder import is implemented, but there is no full PDF sheet beyond window.print (missing piece of the present export feature). Add a proper PDF character-sheet export.
- **Files:** `src/features/characters/CharactersPage.tsx`
---
## Verified & Closed
_Claims the verifiers checked against real code and found refuted or already-implemented — recorded for completeness._
- **'Bring your own character' grant can insert with a colliding id** — Refuted (I53). charactersRepo.get is a global primary-key lookup, so when an id already exists anywhere the code takes the no-op/grant branch and never calls db.characters.add; the insert only runs when no row with that id exists, so no ConstraintError/collision is possible. The real residual edge (cross-campaign seating to a foreign-campaign character) is tracked separately as T-148. `src/features/play/SessionControl.tsx`
- **UVTT map_origin offset misaligns walls/doors against the image** — Refuted (I120). Subtracting map_origin and scaling by ppg is the correct transform: UVTT line_of_sight/portal/light coordinates are in scene-grid space and the exported image covers [map_origin, map_origin+map_size], so translating by -origin maps geometry into the cropped image's own pixel space (image drawn at 0,0). Removing the subtraction would misalign cropped maps. The only residual is an image-natural-size vs map_size*ppg mismatch, tracked as T-209. `src/lib/vtt/uvtt.ts`
- **Character export/import (JSON + Pathbuilder)** — Present (builder feature). JSON export (exportCharacter→downloadJson) and import (parseCharacterImport, wrapped or bare) exist and are wired, and Pathbuilder 2e import is implemented (isPathbuilder/pathbuilderToCharacterFields). Only the missing pieces are tracked as tasks: full PDF sheet (T-224) and D&D Beyond import (T-220); deeper Pathbuilder mapping is T-221. `src/lib/io/character.ts`
+257
View File
@@ -0,0 +1,257 @@
# TTRPG Campaign Manager — Product & Engineering Strategy Report
### Becoming a true competitor to D&D Beyond and Pathbuilder 2e
*Synthesis of 12 subsystem audits + 3 competitor profiles · June 2026*
---
## 1. Executive Summary
**Verdict: This is a genuinely well-architected mid-stage product with an exceptional engine core and a shallow, in places rules-incorrect, character layer.** The "boring" foundations a competitor is built on are unusually strong: pure, tested engines for dice, combat turn-order, and the rules-math seam; a disciplined data layer (Zod-on-write, transactional cascades, additive migrations); and — surprisingly — a real, working live-multiplayer/VTT-lite stack with fog, walls, line-of-sight, and player-safe snapshots. Several teams ship far less after far longer.
But the surface that *users actually judge a character tool on* is the weakest part. The character builder produces **rules-illegal characters** (PF2e abilities generated with 5e methods; 5e racial ASIs never applied; backgrounds/feats/equipment apply zero mechanics), and the things that make Pathbuilder and D&D Beyond indispensable — feats, subclasses, multiclassing, guided level-up, auto-derived AC/attacks — are absent or stubbed. There is also a critical data-loss path (backup restore bypasses validation) and a cluster of server/security issues that block any multi-tenant ambition.
**The single biggest strategic choice:** *depth-of-rules vs. VTT vs. all-in-one.* You cannot out-VTT Foundry, out-market D&D Beyond, or out-collaborate World Anvil simultaneously as an indie. The data says the highest-leverage, most-defensible bet is **rules-engine depth + PF2e build correctness + local-first/offline + a BYO-key AI layer competitors legally won't touch.** The VTT is a strong *supporting* differentiator (it already works), not the headline. The recommendation in §5 is: **own "the offline-first, open-content, AI-augmented PF2e+5e character & table tool"** — and deliberately not chase licensed marketplaces or Foundry-grade dynamic lighting.
The hard truth: **the core of competing with Pathbuilder — a correct, validating, content-complete PF2e build engine — is the one area rated lowest (maturity 2) and is mostly unbuilt.** That must become the first-class roadmap theme.
---
## 2. Current State Scorecard
| Subsystem | Maturity (1-5) | One-line verdict |
|---|:--:|---|
| Rules engine (5e + PF2e math) | **2** | Clean shared-interface foundation with correct check math, but only a foundation: no feats/subclasses/multiclass, and concrete bugs (no 20-cap ASI, fabricated PF2e slots, AC missing level scaling). |
| Character builder + sheet | **2** | Polished manual sheet, but as a *validated builder* it produces illegal characters (PF2e abilities, 5e ASIs, backgrounds/feats/gear all no-ops). |
| Combat tracker / encounter run | **3** | Excellent pure turn-order engine; manual tracker only — no stat-block automation, concentration, death saves, or encounter builder UI. |
| Dice engine + UI | **3** | Strong tested core; three drifted roll-flow copies, adv/dis-with-modifiers parse bugs, no crit damage, seeded RNG advertised but never wired. |
| Compendium + SRD data | **3** | Working browser; PF2e near-complete, 5e thin SRD 5.1; name-only search; real licensing exposure (bulk AoN, MPMB-as-Open5e). |
| Worldbuilding | **3** | Tidy CRUD MVP; plain-text only, title-based links that rot on rename, 5e-only skeletal homebrew. |
| Maps / VTT | **3** | Real VTT-lite (fog, walls, LoS, UVTT); lights and hex are dead schema; vision only reveals, never un-reveals. |
| Realtime sync + player view | **4** | Genuinely working GM-authoritative live sessions; seat-lost-on-reconnect, GM-reload kills room, unvalidated player patches. |
| Server (rooms/accounts/cloud) | **3** | Honest, tested single-instance server; broken proxy rate-limit, blob-corruption race, swallowed persist errors, unenforced quotas. |
| AI assistant (BYO-key) | **3** | Clean provider-agnostic client with offline fallbacks; only 3 narrow advisors, shallow context, no chat/Q&A/generation. |
| Data layer (schemas/repos/migrations/IO) | **4** | Best-in-codebase core; IO layer undercuts it — restore bypasses Zod (critical), sessionLog orphaned, Pathbuilder-only import. |
| Platform: PWA/offline/build/deploy | **3** | Competent plumbing; **PF2e compendium does not work offline** (breaks headline claim), no route code-splitting, no a11y baseline. |
---
## 3. Critical Issues & Risks (fix regardless of roadmap)
### CRITICAL — data loss / illegal output
| Issue | File | Why it must be fixed |
|---|---|---|
| **Backup restore bypasses Zod entirely** — `bulkAdd()` of raw rows; corrupt/hostile/version-mismatched data injected straight into IndexedDB | `src/lib/io/backup.ts:39` | Resurrects the exact "silent data loss / NaN inputs" failure mode the whole rewrite existed to kill. |
| **PF2e characters built with 5e ability methods** (standard array / point-buy / 4d6) — every PF2e character has illegal scores | `src/features/characters/builder/CreationWizard.tsx:92` | This is *the* job-to-be-done for a Pathbuilder competitor. As-is the builder cannot produce a legal PF2e character. |
| **5e racial ASIs never applied** — `asi` returned but only shown in a display string | `src/features/characters/builder/CreationWizard.tsx:68` | Every 5e character is missing its +2/+1; the most basic correctness expectation. |
### HIGH — correctness, security, offline, licensing
| Issue | File | Note |
|---|---|---|
| **PF2e compendium not available offline** — JSON fetched at runtime, excluded from precache, no runtimeCaching | `src/lib/compendium/index.ts:91`; `vite.config.ts:53` | Breaks the headline "works fully offline" promise for an entire system; 20MB+ re-fetched every cold load. |
| 5e ASI has no +20 cap → illegal inflated stats | `src/lib/rules/progression.ts:185` | |
| PF2e spell-slot counts fabricated (flat 3/rank) | `src/lib/rules/pf2e/progression.ts:51` | Caster has wrong slots at almost every level. |
| PF2e AC drops level+proficiency scaling (returns `10+dex+bonus` for both systems); 5e ignores armor Dex cap | `src/lib/rules/pf2e/index.ts:78` | AC is the most-referenced defense; effectively unmodeled for PF2e. |
| **Player patches written to GM DB with no validation/consent** — schema accepts full hp/spellcasting/resources | `src/lib/sync/messages.ts:90`; `src/lib/sync/wsSync.ts:87` | Contradicts "GM is sole authoritative writer." |
| **Player seat lost on every reconnect** (new `playerId` per socket) → edits silently stop persisting | `server/src/rooms.ts:111` | |
| **GM page reload orphans room + invalidates every join link** (gmSecret only in module var) | `src/lib/sync/wsSync.ts:23` | Mid-session unrecoverable. |
| **Per-IP rate limiter defeated by Traefik** (no `trustProxy`) — one bucket for all users | `server/src/index.ts:28-45` | One abuser 429s everyone. |
| **Concurrent blob save corrupts cloud backup** (fixed tmp path, not serialized) | `server/src/accounts.ts:135` | |
| **Server persist failures swallowed** (`.catch(()=>{})`) → API returns ok:true on failed write | `server/src/accounts.ts:79-87`; `campaigns.ts:62` | Re-introduces silent data loss server-side. |
| **Storage quota tracked but never enforced**; unauthenticated unlimited room/image creation | `server/src/index.ts:61-68,124`; `rooms.ts:70-99` | Disk/RAM DoS vector. |
| **sessionLog excluded from backup, cascade delete, and wipe** — orphaned forever | `src/lib/db/repositories.ts:65`; `backup.ts:4-7` | Contradicts the "real cascade" claim. |
| Backup format/version never verified or migrated on restore | `src/lib/io/backup.ts:29` | Old/foreign backups loaded raw; cross-version data loss. |
| Bulk AoN redistribution + MPMB feats credited as Open5e | `scripts/fetch_pf2e.ts:72`; `src/lib/compendium/index.ts:66` | Real legal exposure for a product positioned against commercial competitors. |
| Compendium "Add to combat" uses non-transactional save (the C19 race) | `src/features/compendium/CompendiumPage.tsx:302` | Route through `encountersRepo.mutate`. |
| No crit damage handling on attacks | `src/features/characters/sheet/AttacksSection.tsx:80` | Core combat math incomplete. |
| Three drifted roll-flow copies disagree on persist/tray/broadcast | `src/features/dice/DicePage.tsx:55` + `useRoll.ts` + `MyCharacterPanel.tsx` | |
| adv/dis + explode/reroll throws `DiceParseError` instead of rolling | `src/lib/dice/notation.ts:72` | |
### MEDIUM (representative — see audits for full list)
- Repo `update()` methods bypass Zod (`repositories.ts:256`) — partial writes unvalidated.
- Downed combatants still take turns; no death saves; no concentration tracking; no temp-HP UI (`engine.ts:68,228`).
- Sync rebroadcasts full snapshot on any change; no heartbeat/liveness (`useSessionBroadcaster.ts:33`; `index.ts:147`).
- Synchronous scrypt blocks event loop on login/register (`accounts.ts:28`).
- Wikilinks resolve by title → rename orphans every link (`NotesPage.tsx:113`).
- Lights/hex are dead schema in maps (`world.ts:145,181`); vision only reveals, never un-reveals (`MapEditor.tsx:215`).
- No route code-splitting; entire app + 7 fonts ship up-front (`router.tsx:2`).
---
## 4. Competitive Gap Analysis
Legend: **★ Existential** (adoption-blocking given our positioning) · ○ Optional / defer · ✗ Deliberate non-goal.
### vs. D&D Beyond (official 5e; content moat + sheet automation)
| Capability they have | Our state | Verdict |
|---|---|---|
| Character builder + sheet automation (guided level-up, auto-applied modifiers, roll-from-sheet) | Manual sheet good; builder shallow/illegal | **★** |
| Combat tracker pulling monster stat blocks + HP/conditions | Tracker exists; only name/AC/HP copied | **★** |
| Well-linked compendium, fast search, inline tooltips | Name-only search, no tooltips/cross-links | **★** |
| Campaign content sharing across players | Shared cloud campaigns exist; homebrew not surfaced | **★** (and our SRD/open angle means we share *everything free*) |
| Homebrew toolset (monsters→subclasses) | 5e-only, skeletal | ○ |
| Encounter builder w/ CR-XP budgeting | Budget math exists, read-only, no builder UI | ○→★ (cheap; math is done) |
| First-party VTT (Maps, still maturing) | We already have VTT-lite | ○ (place to *compete*, they're weak) |
| 2024 ruleset | SRD 5.1 only | ○ (SRD 5.2 is CC-BY — ingest it) |
| Mobile + offline owned content | PWA offline-by-default (stronger) — but PF2e broken offline | **★** (fix offline) |
| 3D dice / Discord | Number-tumbler only | ○ |
| Licensed marketplace | n/a | **✗ non-goal** |
### vs. Pathbuilder 2e (the PF2e build gold standard — *our hardest, most important comparison*)
| Capability they have | Our state | Verdict |
|---|---|---|
| Full PF2e rules engine w/ prerequisite/legality validation + live recompute L1-20 | Math primitives only; no validation | **★ (THE bet)** |
| Exhaustive, Remaster-aware content (ancestries/heritages, classes+subclasses, all feats, backgrounds) | Data partially present; not wired into builds | **★** |
| Full spellcasting (prepared/spontaneous/focus/innate/rituals, heightening, signature) | Fabricated flat slots, no focus/cantrips | **★** |
| Variant toggles (Free Archetype, Dual Class, Ancestry Paragon, Gradual Boosts, PWL) | None | ○→★ (signature PF2e-table feature) |
| Companion builders (animal/familiar/eidolon) | None | ○ (high-value differentiator they nail) |
| Guided level-up walking every choice | HP+slots+1 ability/skill only; breaks for non-curated classes | **★** |
| Pathbuilder/Foundry/Roll20 JSON interop | Shallow Pathbuilder *import* only | ○ (import = switching wedge; export = pipeline) |
| Printable PDF sheet | `window.print` only | ○ |
| Rune/bulk inventory w/ auto-applied bonuses | Manual inventory, AC ignores gear | ○ |
| Free-tier cloud sync | We have it; ensure low-friction | ○ |
### vs. VTT field (Foundry / Roll20 / Owlbear / World Anvil / LegendKeeper)
| Capability | Our state | Verdict |
|---|---|---|
| Two-way player participation (own/edit sheet, move own token live) | GM-authoritative read-only | **★** (we read as GM-only tool otherwise) |
| Deep rules automation (auto-apply conditions/effects, MAP, degrees of success, concentration) | Conditions reference-only | **★** (our pure engines make this winnable) |
| Guided validated builder w/ inline "Click-to-Know" | Builder shallow | **★** (overlaps Pathbuilder/DDB) |
| Spell-area templates wired to rules, snapping, measurement | Partial (hardcoded cone 53°/5ft) | ○ |
| Dynamic line-of-sight lighting | Static/explored fog only | **✗ do not chase early** |
| Encounter builder (deterministic budgeting) | LLM advisor only | ○→★ |
| Interlinked worldbuilding (backlinks, relationship graph, map pins, per-player reveal) | Shallow note-to-note links | ○ |
| Real-time collaborative editing | Roles only, no live co-edit | ○ |
| UVTT/statblock/DDB import | UVTT in; no DDB; shallow statblock | ○ (import lowers switching cost) |
| Audio / Discord relay / streaming | None | ○ (cheap webhook later) |
| Plugin/module API, licensed marketplace | None | **✗ non-goal** |
**Existential cluster (must-do to be taken seriously):** PF2e build correctness + validation, character-builder/sheet automation parity, monster-stat-block-driven combat, offline parity for PF2e, two-way player participation, well-linked/searchable compendium.
---
## 5. Recommended Strategy & Positioning
### The niche to own
> **"The offline-first, open-content, AI-augmented build & table tool for both PF2e *and* 5e."**
Three things no single competitor offers together, each defensible:
1. **Dual-system depth via one rules seam.** Pathbuilder is PF2e-only; D&D Beyond is 5e-only. A *correct* shared `RulesSystem` engine that does both — with the hard PF2e math (proficiency-by-rank, MAP, three-action economy, variant toggles) done right — is a unique position. This is the credibility test and the moat.
2. **Local-first / offline / own-your-data.** IndexedDB + PWA is structurally stronger than DDB's cloud-gated app and matches Pathbuilder's offline story while adding a real (if lite) VTT and live sessions. Foundry's "you own it" appeal without Foundry's setup tax.
3. **BYO-key AI layer.** Neither incumbent has meaningful AI. Grounded rules Q&A over SRD, content generation into real entities (NPCs/quests/statblocks), and session recaps are flagship differentiators the architecture (provider-agnostic client + `systemConstraint` anti-hallucination + Zod-validated writes) is *already shaped for*.
### Where we can realistically win
- **PF2e build correctness + variant toggles** (Pathbuilder's exact turf, but we also do 5e).
- **Free, fully-shareable open content** (nothing to gate → "one person builds it, the whole table uses it, for free").
- **Offline at the table** on the phone people actually bring.
- **AI generation/Q&A** competitors won't touch.
### Deliberately DO NOT build
- ✗ **Licensed/official non-SRD marketplace** — legally and commercially out of reach. Counter-position with SRD + homebrew + LLM generation.
- ✗ **Foundry-grade dynamic lighting / per-player real-time vision** — heavy, not where most tables live. Keep static/explored fog; invest in automation + player sync instead.
- ✗ **Full plugin/module API** — large scope; defer indefinitely. A narrow homebrew-sharing hook over the existing cloud server gets 80%.
- ✗ **Horizontal multi-node server scale, native app-store wrappers** — premature for a hobby/indie instance; PWA + single-node is fine until traction demands otherwise.
- ⚠️ **Prune the licensing exposure** rather than expand content scraping: don't ship bulk AoN flavor text or MPMB-as-Open5e; lean on ORC-rules + CC-BY SRD 5.2 + user homebrew.
---
## 6. Phased Roadmap
Effort: **S** ≈ days · **M** ≈ 1-3 wks · **L** ≈ 1-2 mo (one dev). Rules-engine/PF2e depth is woven through P1-P2 as the spine.
### P0 — Stabilize (must-fix; do before any feature work)
*Goal: stop data loss, illegal output, and security/offline footguns. Nothing here is optional.*
| Item | Subsystem · Files | Effort |
|---|---|:--:|
| Schema-validate backup restore (`safeParse` each row, skip+report invalid); add format/version gate + per-version migration | Data IO · `io/backup.ts:39,29` | M |
| Add `sessionLog` to backup TABLES, cascade delete, and wipe; scope it into the campaign delete transaction | Data · `backup.ts:4`; `repositories.ts:65` | S |
| Fix PF2e ability generation → boost flow; apply 5e racial ASIs; apply background mechanics | Builder/Rules · `CreationWizard.tsx:92,68,205` | M |
| 5e ASI +20 cap; PF2e AC level+prof scaling + 5e Dex cap | Rules · `progression.ts:185`; `pf2e/index.ts:78` | S |
| PWA runtimeCaching for `/data/pf2e/*.json` (StaleWhileRevalidate, explicit budget) → PF2e offline works | Platform · `vite.config.ts`; `compendium/index.ts:91` | S |
| Server: `trustProxy` + rate-limit keyed on user/token; serialize blob writes (per-user lock); remove `.catch(()=>{})`; enforce quota; cap rooms/images | Server · `index.ts:28-45,61-68`; `accounts.ts:79-87,135`; `rooms.ts:70-99` | M |
| Sync: durable rejoin token (seat survives reconnect); persist gmSecret+roomId (GM reload resumes); tighten `partialCharacterDiffSchema` + clamp player patches | Sync · `rooms.ts:111`; `wsSync.ts:23`; `messages.ts:90` | M |
| Route "Add to combat" through `encountersRepo.mutate`; repo `update()` methods validate via Zod | Combat/Data · `CompendiumPage.tsx:302`; `repositories.ts:256` | S |
| Licensing cleanup: stop shipping bulk AoN flavor + fix MPMB/Open5e attribution; per-entry source/license tags | Compendium · `scripts/fetch_pf2e.ts:72`; `compendium/index.ts:66` | M |
| Unify the three dice roll-flows into one path; fix adv/dis+modifier parse; wire async scrypt | Dice/Server · `notation.ts:72`; `accounts.ts:28` | S |
### P1 — Build-engine credibility (the PF2e/5e depth bet)
*Goal: produce **legal**, fully-derived characters with guided level-up — the daily-driver surface.*
| Item | Subsystem · Files | Effort |
|---|---|:--:|
| **Feat system**: data model + Zod schema + prerequisite engine on `RulesSystem`; class/ancestry/skill/general (PF2e) + feats-vs-ASI (5e); authoring + selection UI | Rules+Builder · `rules/types.ts`, `schemas/character.ts` | L |
| **Subclasses** (5e archetype/domain; PF2e doctrine/order/bloodline/research field) feeding proficiencies/slots/features | Rules · `dnd5e/`, `pf2e/` | L |
| **Correct PF2e spellcasting** (real per-level slot tables, cantrips, prepared/spontaneous, focus points + Refocus = 1/activity, class DC) | Rules · `pf2e/progression.ts:51`; `rest.ts:19` | M |
| **AC/attacks auto-derived from equipped gear** + weapon/armor proficiency; starting gold/gear by class+background | Rules+Sheet · `pf2e/index.ts:78`; `CharacterSheet.tsx:106`; `InventorySection.tsx` | M |
| **Guided, validated level-up stepper** keyed to class *data* not name; walks every choice; stops overwriting spent slots | Builder · `LevelUpModal.tsx:58`; `progression.ts:139` | M |
| **Build legality / completeness checker** + respec | Builder | M |
| **Compendium-linked spell selection** constrained by class/tradition with enforced counts | Builder · `CreationWizard.tsx:137` | S |
| **Crit damage automation** (5e double dice, PF2e double-on-crit) wired from `degreeOfSuccess` | Dice/Combat · `AttacksSection.tsx:80` | S |
### P2 — Run-the-table parity + AI flagship
*Goal: combat automation, two-way play, and the AI layer competitors lack.*
| Item | Subsystem · Files | Effort |
|---|---|:--:|
| **Full monster stat block on combatants** (copy whole block, not name/AC/HP) → enables everything below | Combat/Compendium · `CompendiumPage.tsx:306`; `combat/engine.ts` | M |
| **Encounter builder UI** over existing `computeBudget` (pick monsters, quantities, live CR/XP/threat vs party) | Combat · `budget.ts` (done), new UI | M |
| **Auto-rolled attacks/saves, concentration, death saves, temp-HP UI, resist/vuln/immune** | Combat · `engine.ts:68,211,228` | M |
| **Auto-applied conditions/effects** (PF2e Frightened/Clumsy/Enfeebled/Drained, 5e Exhaustion) modifying derived stats | Rules+Combat · `conditions.ts` | L |
| **Two-way player participation**: validated write/claim path so players edit own sheet + (later) move own token; player rolls feed GM tracker | Sync · `wsSync.ts`, `MyCharacterPanel.tsx`, `PlayerMapView.tsx` | L |
| **AI: free-form chat + SRD-grounded rules Q&A (RAG over `src/data/srd`)** | AI · `lib/llm/`, `lib/assistant/context.ts` | M |
| **AI: content generation into real entities** (NPCs/quests/lore/statblocks) + session recap from notes | AI · `lib/assistant/`, repos | M |
| AI polish: streaming, retry/backoff on 429/5xx, cheaper default model + picker, deeper level-up context | AI · `client.ts:113,148`; `assistantStore.ts:13`; `prompts.ts:70` | S |
| Compendium: global cross-category + full-text search; inline tooltips/cross-links; SRD 5.2 (2024, CC-BY) ingest | Compendium · `registry.tsx:138`; `scripts/` | M |
| Delta/patch snapshot sync + heartbeat/presence | Sync · `useSessionBroadcaster.ts:33`; `index.ts:147` | M |
### P3+ — Differentiation & polish
*Goal: variant rules, companions, interop, worldbuilding depth, VTT polish.*
| Item | Subsystem | Effort |
|---|---|:--:|
| **PF2e variant toggles** (Free Archetype, Dual Class, Ancestry Paragon, Gradual Boosts, PWL) behind `RulesSystem` | Rules | L |
| **Multiclassing** (5e combined-caster slots + ability-13 prereqs; PF2e dedication archetypes) | Rules | L |
| **Companion builders** (animal/familiar/eidolon) as cascade-deleted child entities | Rules+Data | L |
| **Interop**: D&D Beyond import; deeper Pathbuilder import (spells/feats/equipment); Pathbuilder/Foundry-compatible export; PDF sheet | Data IO · `io/` | M |
| **Worldbuilding depth**: rich text, id-based links w/ rename propagation, cross-entity relationship graph, per-player reveals, system-aware homebrew | World · `NotesPage.tsx:113`; `homebrew.ts:12` | L |
| **VTT polish**: rules-wired spell templates, real hex grid, lighting *or* explicitly drop the dead schema; player token control | Maps · `MapEditor.tsx:162`; `world.ts:145,181` | M |
| Route code-splitting, a11y baseline (skip link, focus mgmt, axe CI), SW update prompt, mobile/offline e2e | Platform · `router.tsx:2`; `vite.config.ts:37` | M |
| Server: real DB (SQLite+WAL/Postgres), token expiry/refresh, member lifecycle, conflict resolution | Server | L |
---
## 7. Build Guidance — Top 4 Highest-Leverage Initiatives
### A) The PF2e/5e Feat + Prerequisite Engine *(P1 — the moat)*
**Where it slots:** Add capability to the `RulesSystem` interface (`src/lib/rules/types.ts`) — e.g. `listFeats(ctx)`, `checkPrerequisites(feat, character)`, `applyFeat(feat, character)`. Implement per-system in `dnd5e/` and `pf2e/`. Feats become real entities: add a `featSchema` (`src/lib/schemas/`) and a `feats` field on `characterSchema` (new **additive** Dexie `version(n)` with an `.upgrade()` backfilling `feats: []` — never edit a past version). Selection/validation UI lives in `features/characters/builder/` and the level-up stepper; data flows from the compendium feat datasets (after the P0 MPMB/license cleanup).
**Key risks:** PF2e prerequisites are a graph (feat → feat → proficiency → level), not a flat list — model gating as predicates evaluated against the *derived* character, not raw inputs. Don't branch on `if (system==='5e')` anywhere; both implement the interface. The cross-system footgun where 5e collapses expert/master/legendary to expertise (`dnd5e/index.ts:34`) means the shared `CharacterRulesInput` must validate rank legality per system.
**Sequencing:** schema + interface method → prerequisite predicate engine → deterministic level-up stepper consumes it → UI last. Land *after* P0 fixes the ability-generation/ASI bugs (illegal base inputs would poison prereq checks).
### B) Guided, Rules-Validated Level-Up Stepper *(P1 — daily driver)*
**Where it slots:** Replace name-keyed `getClassDef`/`planLevelUp` lookups (`progression.ts:139`) with **class-data-keyed** resolution so homebrew/non-curated classes work. The stepper is deterministic feature code that *calls* `RulesSystem` for every choice (ASI/boosts, skill increases, feats from §A, subclass features, spells known/prepared, proficiency milestones). Make spell-slot updates **merge** (preserve spent/custom slots) instead of wholesale replace (`LevelUpModal.tsx:58`). Keep the existing LLM level-up advisor (`lib/assistant/`) as an *optional* suggestion layer on top of the deterministic walk — never the source of truth.
**Key risks:** Pull the level cap from `RulesSystem` (currently hardcoded 20 in `useLevelUpAdvisor.ts:18`). Every write goes through `charactersRepo` (Zod-validated) — including a transactional read-modify-write so a multi-step level-up can't be clobbered (mirror the `mutate()` pattern used for encounters/maps, and fix the `update()`-bypasses-Zod issue first).
### C) Monster-Stat-Block-Driven Combat *(P2 — unlocks the whole tracker)*
**Where it slots:** Today "Add to combat" copies only name/AC/HP/init (`CompendiumPage.tsx:306`). Store the **full stat block** on the combatant (`combatantSchema` gains an optional `statBlock`/`monsterRef` — additive migration, optional field needs no backfill). The pure engine (`src/lib/combat/engine.ts`) stays the authority for turn order/HP — add concentration state, death saves, and resist/vuln/immune as new engine functions with unit tests preserving the C16-C19 turn-pointer invariants. Then the encounter-builder UI consumes the already-tested `computeBudget` (`budget.ts`), and auto-rolled attacks/saves route through the *unified* dice path (§ P0 roll-flow merge) so crit damage works everywhere.
**Key risks:** Don't let downed combatants keep taking turns silently (`engine.ts:68`) — add auto-skip/death-save handling as engine state, not UI flags. All combatant writes via `encountersRepo.mutate` (the C19 race). Player-safe projection (`playerProjection.ts`) must keep masking enemy stat blocks — don't leak the new full block over the snapshot.
### D) The AI Flagship: Grounded Q&A + Generation *(P2 — the competitor blind spot)*
**Where it slots:** The provider-agnostic client (`src/lib/llm/client.ts`) and prompt/context builders (`src/lib/assistant/`) already exist with offline fallbacks and the `systemConstraint` anti-hallucination anchor. Add: (1) **RAG rules Q&A** — retrieve relevant entries from the already-lazy-loaded SRD JSON (`src/lib/compendium`) into the prompt for cited, offline-data-grounded answers; (2) **generation that writes into real entities** — NPC/quest/statblock generators whose output is `safeParse`'d through the existing Zod schemas before `repo.insert` (the Zod boundary *is* the safety net against bad LLM output); (3) **session recap** over notes the context builder already aggregates.
**Key risks:** Add streaming (currently fetch+json, 30s timeout will bite on chat/generation) and retry/backoff on 429/5xx before exposing chat. Fix `response_format:{json_object}` being sent to *all* OpenAI-compatible providers including Ollama/LM Studio (`client.ts:113`) — gate it by provider, since BYO-local is a marketed path. Default off the $$$ opus model (`assistantStore.ts:13`). Generated entities must respect cascade-delete ownership (campaignId) so AI content participates in the data-layer guarantees.
**Cross-cutting sequencing rule:** P0 first (it removes data-loss and illegal-input footguns that would otherwise corrupt everything built on top), then A→B (build correctness) before C→D (run-the-table + AI), because a tool that builds illegal characters can't be trusted to run or reason about them.
---
*All file references are drawn from the provided subsystem audits. Maturity scores are the auditors'; verdicts and prioritization are this report's synthesis.*
+134
View File
@@ -0,0 +1,134 @@
# TTRPG Manager — Roadmap (July 2026)
Successor to [`BACKLOG.md`](./BACKLOG.md) (June 2026 audit — substantially implemented; kept as the
historical record). Strategy context: [`COMPETITIVE_STRATEGY_REPORT.md`](./COMPETITIVE_STRATEGY_REPORT.md).
Positioning is unchanged: **the offline-first, open-content, AI-augmented build & table tool for
both PF2e and 5e.**
Legend: effort **S**≈days · **M**≈1–2 wk · **L**≈multi-week. Items marked ✅ shipped 2026-07-02.
---
## Milestone 1 — Trust + quick wins ✅ (shipped 2026-07-02)
Three genuine bugs found by a fresh code audit, plus S-sized wins:
- ✅ **Bug: cloud character publish silently last-write-wins.** The client sent no `version`, so the
server's optimistic concurrency (T-130) never engaged — a stale device silently overwrote another
player's edits. Now versioned per device with an Overwrite / Pull conflict prompt.
(`src/lib/cloud/campaigns.ts`, `src/features/settings/CloudCampaigns.tsx`)
- ✅ **Bug: password guessing via authenticated endpoints.** `changePassword`/`verifyPassword`/account
delete now feed the same exponential-backoff lockout as login (keyed by user id).
(`server/src/accounts.ts`)
- ✅ **Bug: PF2e basic-save damage used 5e save-for-half.** Crit success now deals 0×, crit failure
2× via the new `saveDamageMultiplier` seam method. (`EncounterTracker.tsx`)
- ✅ **Bug-class: session log could miss cloud backup** (autosave fingerprint excludes the high-churn
`sessionLog` table). A backup now pushes on GM session end. (`src/lib/sync/wsSync.ts`)
- ✅ Degrees of success moved behind the RulesSystem seam (`checkOutcome`) — no system branches in
the dice layer. (`src/lib/rules/types.ts`, both system impls, `src/lib/dice/check.ts`)
- ✅ RulesSystem **contract test suite** (`src/lib/rules/contract.test.ts`) — interface-conformance
invariants run against both systems; every new seam member adds its table here.
- ✅ Homebrew rows re-validated on read; malformed stat blocks degrade instead of crashing click
paths. (`repositories.ts`, `features/world/homebrew.ts`)
- ✅ PF2e offline pre-warm: Settings → Data → "Offline Pathfinder 2e data" downloads the full ~60 MB
dataset into the service-worker cache. (`src/lib/compendium/offline.ts`)
- ✅ Session log export (.md / .json) from the session panel. (`SessionSidebar.tsx`)
- ✅ LLM model registry: added `claude-sonnet-5`; Sonnet 4.6 marked legacy. (`src/lib/llm/models.ts`)
## Milestone 2 — Combat depth (rules-engine track) ✅ (shipped 2026-07-02)
All schema changes were additive optional fields (no Dexie migration); all mechanics go through the
RulesSystem seam.
1. ✅ **R4 — Legendary action costs.** `legendaryActions: [{name, cost, desc?}]` on the stat block;
`(Costs N Actions)` parsed from Open5e; named tracker buttons spend their cost.
2. ✅ **R5 — Automated condition saves.** Conditions carry optional `saveDc`/`saveAbility`;
`nextTurn` queues `pendingSaves` on the encounter (survives reload, rides undo); tracker banner
offers Roll (via the linked PC's real save or the stat block) / Succeeded / Failed.
3. ✅ **R6 — Turn action budget / PF2e 3-action economy.** Seam `turnActionBudget`; combatant
`actionsUsed` persisted and reset by the engine in `nextTurn` **and** `actNow`; TurnPanel
de-branched and persistent; read-only pips in the player view (optional wire fields + snapshot
`system`). Follow-up flagged: slowed/stunned/quickened budget adjustment.
4. ✅ **R7 — Multiple Attack Penalty.** Seam `attackPenalty(n, traits?)`; `attacksThisTurn` counter;
MAP folded into `resolveAttack` + "next attack −5" badge; `traits` on stat-block/character/
homebrew attacks. Follow-up also shipped: `parsePf2eStrikes` extracts strikes (name, to-hit,
traits incl. agile, damage) from the PF2e dataset's text — 4,423 of 4,702 creatures now carry
runnable attacks, so MAP + attack automation work for PF2e monsters, not just homebrew.
5. ✅ **R8 — Inspiration / Hero Points.** Seam `heroResource` descriptor; `DefensesSection` +
`MyCharacterPanel` de-branched; tracker Spend (arms one-shot advantage / logs reroll) and
PF2e "Avoid death" (spend all → clears dying).
Verified: 937 unit tests (incl. new contract suite tables), full e2e (18) + new PF2e combat-depth
e2e (pips persist/reset, save prompt resolves), realtime e2e (2) after repairing stale locators.
## Milestone 3 — The AI-augmented table ✅ (shipped 2026-07-02)
- ✅ **M1 — Session recap grounded in the session log + replay timeline.** `buildRecapCorpus` now
feeds real table events (rolls + chat, whispers excluded) to the recap generator; new
`SessionReplay` timeline on the Dashboard (filter chips, search, .md/.json export).
- ✅ **M4 — Random encounter generation.** New pure, seeded `src/lib/combat/randomEncounter.ts`
(playable-window filter + greedy band-fill over `computeBudget`; never overshoots the target
difficulty; reproducible by seed) + "Surprise me" (difficulty picker) in the EncounterBuilder,
which respects the search filter for themed encounters. Roll-table entities remain open.
- ✅ **M5 — Reconnect-chaos realtime e2e** (`e2e-realtime/reconnect.spec.ts`): player reload,
GM reload resume, player network drop. **Found and fixed a real bug:** on durable rejoin the
server rebound the seat but never restored the player's sheet — new `seatRejoined` server→GM
message; the GM re-grants with the fresh character from its DB. Cloud-conflict + stress
scenarios remain open.
## Big differentiators (ranked)
1. **D1 — AI GM copilot at the table (L).** ✅ *First slice shipped 2026-07-02:* "Ask the rules"
in the encounter tracker — SRD-grounded (retrieval + citations), combat-state-aware
(`summarizeCombatState`: turn, PC HP, enemy bands only, conditions, pending saves), terse
table-ready rulings, system-locked, BYO-key gated. (`src/lib/assistant/copilot.ts`,
`src/features/combat/CopilotPanel.tsx`.) Remaining: proactive condition/rules cards on state
changes, NPC persona memory fed by session-log recaps, streaming answers.
2. **D2 — Player agency expansion (M→L).** Clamped `playerDrawing` message mirroring `tokenMove`
validation; player AoE template placement; player initiative self-entry; durable roll history
beyond the 100-entry replay.
3. **D3 — Mobile/tablet table-mode polish (M→L).** Touch targets, one-hand dice tray, wake-lock
during sessions, landscape tablet GM layout.
4. **D4 — Homebrew classes/subclasses (M→L) → homebrew sharing over cloud campaigns.** Extend
`homebrewKindSchema`; data-driven class defs resolved before curated data in the builder and
level-up; then publish homebrew bundles to a shared campaign (the plugin-API substitute).
## Also queued (M-tier)
- ✅ **M2 — Conflict-merge UX** (shipped 2026-07-02): field-level local-vs-cloud character diff
(`src/features/cloud/characterDiff.ts` + `ConflictDialog.tsx`) — level/HP/conditions/slots/
inventory/feats/notes/last-edited side by side, differing rows highlighted, with
Overwrite / Pull / Cancel; the inline banner remains the fallback when the cloud copy
doesn't parse.
- **M6 — TOTP 2FA** (deferred behind everything above).
## Known debt discovered 2026-07-02 — repaired same day
- The `e2e/` suite had drifted badly: 20 of 38 tests failed against the current UI (stale
locators/flows — wizard gained Feats/Gear steps, curated model `<select>`, condition-tag
redesign, dashboard "Jump to" nav duplication, a stale `.dd2vtt` fixture). All 38 now pass;
no app bugs were behind any failure. Lesson: run playwright with `--reporter=list` and read
the failed count — the default `line` reporter's tail reads as green.
## Code audit 2026-07-02 (prune/tighten pass)
- Dead code removed: `StatCoin`, `useCharacter` hook, `cloudUsageBytes`, `disposeVisionWorker`,
`getClassDefs`, `multiclassHp` (its tested inner `dnd5eMulticlassHp` remains), redundant
import-barrel re-exports, and ~15 needlessly-exported internal helpers/constants un-exported.
- `src/lib/rules/index.ts` barrel trimmed to what is actually consumed.
- Duplication consolidated: the hand-rolled ability-key lists in `combatActions.ts` and
`EncounterTracker.tsx` now use the canonical `ABILITY_KEYS`; the session-log export logic
(previously copied in `SessionSidebar` and `SessionReplay`) lives in
`src/lib/io/sessionLogExport.ts`.
- Dependencies: removed unused `dompurify`, `@types/dompurify`, `@testing-library/user-event`;
added `ws` as an explicit devDependency (server tests imported it transitively).
- `knip` config added to package.json (entries: server, realtime playwright config, scrapers;
`ignoreExportsUsedInFile`) — `bunx knip` now reports only 10 deliberate schema-companion types.
Re-run it after feature work to keep the surface tight.
- Verified clean: no `@ts-ignore`/`@ts-expect-error`, 3 justified `eslint-disable`s, no stray
`Math.random` in game logic (visual dice physics + retry jitter only), no commented-out code.
## Non-goals (reaffirmed)
Foundry-grade dynamic lighting · licensed/official content marketplace · general plugin API ·
multi-node server scale · native app wrappers.
+1 -1
View File
@@ -19,7 +19,7 @@ test('GM hosts a session; a player on another device sees live combat', async ({
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();
await gm.getByRole('button', { name: 'Host', exact: true }).click();
await gm.getByRole('button', { name: 'Start hosting' }).click();
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
expect(code).toHaveLength(6);
+114
View File
@@ -0,0 +1,114 @@
import { test, expect, type BrowserContext, type Page } from '@playwright/test';
/**
* Reconnect chaos (M3-M5): live sessions must survive the two things that
* ALWAYS happen at a real table — someone's browser reloads, and the GM's
* laptop drops off the network for a moment. Covers the durable rejoin token
* (player seat recovery, T-140) and GM session resume (T-141).
*/
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;
}
/** GM: sample campaign + host; returns the join code. */
async function hostSession(gm: Page): Promise<string> {
await gm.evaluate(() => localStorage.setItem('ttrpg-cloud-user', 'GM'));
gm.on('dialog', (d) => d.dismiss()); // 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', exact: true }).click();
await gm.getByRole('button', { name: 'Start hosting' }).click();
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
expect(code).toHaveLength(6);
return code;
}
/** Player joins and claims Lia; GM grants the seat. */
async function seatPlayer(gm: Page, player: Page, code: string) {
await player.goto(`/play?room=${code}`);
await expect(player.getByRole('heading', { name: 'Which character is yours?' })).toBeVisible();
await player.getByTestId('seat-option').filter({ hasText: 'Lia the Brave' }).getByRole('button', { name: 'This is me' }).click();
await gm.getByTestId('seat-requests').click();
await gm.getByRole('button', { name: 'Grant', exact: true }).click();
await expect(player.getByTestId('my-character')).toBeVisible();
}
test('player reload: the rejoin token recovers the seat without re-approval', async ({ browser }) => {
const gmCtx = await browser.newContext();
const playerCtx = await browser.newContext();
const gm = await fresh(gmCtx);
const code = await hostSession(gm);
const player = await playerCtx.newPage();
await seatPlayer(gm, player, code);
// Chaos: hard reload mid-session. The persisted join intent + rejoin token
// must land the player straight back on their seat — no new GM approval.
await player.reload();
await expect(player.getByTestId('my-character')).toBeVisible({ timeout: 15_000 });
// The recovered seat is still WRITABLE (server recognizes the seat, not just the view).
const hp = await player.getByTestId('my-hp').textContent();
const cur = Number((hp ?? '0/0').split('/')[0]);
await player.getByRole('button', { name: '−1' }).click();
await expect(player.getByTestId('my-hp')).toHaveText(new RegExp(`^${cur - 1}/`));
await gmCtx.close();
await playerCtx.close();
});
test('GM reload: the session resumes and the seated player keeps receiving state', async ({ browser }) => {
const gmCtx = await browser.newContext();
const playerCtx = await browser.newContext();
const gm = await fresh(gmCtx);
const code = await hostSession(gm);
const player = await playerCtx.newPage();
await seatPlayer(gm, player, code);
// Chaos: the GM's tab reloads. The host intent + gmSecret must resume the SAME
// room (same join code) rather than killing it.
await gm.reload();
gm.on('dialog', (d) => d.dismiss());
await expect(gm.getByTestId('join-code')).toHaveText(new RegExp(code), { timeout: 15_000 });
// The player was never told to leave — and a post-resume edit still round-trips
// through the resumed GM to the party board.
await expect(player.getByTestId('my-character')).toBeVisible();
const hp = await player.getByTestId('my-hp').textContent();
const cur = Number((hp ?? '0/0').split('/')[0]);
await player.getByRole('button', { name: '−5' }).click();
const party = player.locator('section').filter({ has: player.getByRole('heading', { name: 'Party' }) });
await expect(party.getByText(`${cur - 5}/`)).toBeVisible({ timeout: 10_000 });
await gmCtx.close();
await playerCtx.close();
});
test('player network drop: offline then online reconnects with backoff and state intact', async ({ browser }) => {
const gmCtx = await browser.newContext();
const playerCtx = await browser.newContext();
const gm = await fresh(gmCtx);
const code = await hostSession(gm);
const player = await playerCtx.newPage();
await seatPlayer(gm, player, code);
// Chaos: sever the player's network (kills the socket), then restore it.
await playerCtx.setOffline(true);
await player.waitForTimeout(1500);
await playerCtx.setOffline(false);
// The reconnect/backoff loop re-attaches the same seat; edits work again.
await expect(player.getByTestId('my-character')).toBeVisible({ timeout: 20_000 });
const hp = await player.getByTestId('my-hp').textContent();
const cur = Number((hp ?? '0/0').split('/')[0]);
await player.getByRole('button', { name: '−1' }).click();
await expect(player.getByTestId('my-hp')).toHaveText(new RegExp(`^${cur - 1}/`), { timeout: 10_000 });
await gmCtx.close();
await playerCtx.close();
});
+1 -1
View File
@@ -19,7 +19,7 @@ test('a player claims a seat and manages their character; edits round-trip to th
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();
await gm.getByRole('button', { name: 'Host', exact: true }).click();
await gm.getByRole('button', { name: 'Start hosting' }).click();
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
expect(code).toHaveLength(6);
+71
View File
@@ -0,0 +1,71 @@
import { test, expect, type Page } from '@playwright/test';
/**
* Accessibility e2e (T-171, builds on the T-168 baseline).
*
* axe-core / @axe-core/playwright is intentionally NOT a dependency of this repo
* (the task forbids adding heavy deps), so instead of a full axe audit this runs
* a lightweight, dependency-free a11y scan that catches the highest-value
* structural issues: required landmarks, a skip link, a page title, a top-level
* heading, and — the most common WCAG 4.1.2 failure — interactive controls
* (buttons/links) with no accessible name. If axe-core is later vendored, this
* spec can be upgraded to `new AxeBuilder({ page }).analyze()`.
*/
/** Collect visible buttons/links that expose no accessible name (a 4.1.2 check). */
async function unnamedControls(page: Page): Promise<string[]> {
return page.evaluate(() => {
const bad: string[] = [];
const els = document.querySelectorAll('button, a[href], [role="button"]');
for (const el of Array.from(els)) {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
const visible = rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
if (!visible) continue;
const name =
(el.textContent || '').trim() ||
el.getAttribute('aria-label') ||
el.getAttribute('title') ||
el.getAttribute('aria-labelledby');
if (!name) bad.push(el.outerHTML.slice(0, 140));
}
return bad;
});
}
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(async () => {
indexedDB.deleteDatabase('ttrpg-manager');
localStorage.clear();
});
await page.reload();
});
test('app shell has the core a11y landmarks and named controls', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Campaigns' })).toBeVisible();
// Document title.
await expect(page).toHaveTitle(/TTRPG/i);
// Skip link is present (first focusable, lets keyboard users jump to content).
await expect(page.getByRole('link', { name: 'Skip to content' })).toHaveCount(1);
// Exactly one banner + one main; a named primary navigation.
await expect(page.getByRole('banner')).toHaveCount(1);
await expect(page.getByRole('main')).toHaveCount(1);
await expect(page.getByRole('navigation', { name: 'Primary' })).toHaveCount(1);
// A top-level heading exists.
await expect(page.getByRole('heading', { level: 1 }).first()).toBeVisible();
// No interactive control is missing an accessible name.
expect(await unnamedControls(page)).toEqual([]);
});
test('the dice page (icon-heavy) keeps all controls accessibly named', async ({ page }) => {
await page.getByRole('link', { name: 'Dice' }).click();
await expect(page.getByRole('button', { name: 'Roll', exact: true })).toBeVisible();
// Die-pool buttons, steppers, mode toggles, seed + macro controls all rendered.
expect(await unnamedControls(page)).toEqual([]);
});
+2
View File
@@ -33,6 +33,8 @@ test('level-up advisor offers routes and expands a chosen one (deterministic)',
test('assistant page renders the campaign insights section', async ({ page }) => {
await page.getByLabel('Settings').click();
await page.getByRole('button', { name: 'Load sample campaign' }).click();
// Loading the sample navigates to the dashboard — wait for it so its own
// navigation can't yank us off the Assistant page after we click through.
await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
await page.getByLabel('Primary').getByRole('link', { name: 'Assistant' }).click();
await expect(page.getByRole('heading', { name: 'Campaign insights' })).toBeVisible();
+1
View File
@@ -23,6 +23,7 @@ test('assistant flags a bloodied PC and builds an encounter', async ({ page }) =
// Assistant shows a resource suggestion
await page.getByLabel('Primary').getByRole('link', { name: 'Dashboard' }).click();
// The dashboard's "Jump to" card also links to Assistant — use the nav link.
await page.getByLabel('Primary').getByRole('link', { name: 'Assistant' }).click();
await expect(page.getByRole('heading', { name: 'Assistant' })).toBeVisible();
await expect(page.getByText(/Lia the Brave is bloodied/)).toBeVisible();
+4 -3
View File
@@ -17,11 +17,12 @@ test('generate ability scores and level up', async ({ page }) => {
await page.getByRole('link', { name: 'Characters' }).click();
// Guided wizard builds a complete level-1 character (standard array → STR 15).
await createCharacter(page, 'Builder');
// Ability scores render as roll buttons ("STR +2 15") — standard array puts 15 in STR.
await expect(page.getByRole('button', { name: 'STR +2 15' })).toBeVisible();
// Abilities render as a modifier roll-button + a score button; standard array
// puts 15 in STR for a Barbarian (mod +2).
await expect(page.getByTitle('Roll STR check')).toHaveText('+2');
// Guided level-up from 1 -> 2 (HP + any choices applied automatically)
await page.getByRole('button', { name: 'Level up' }).click();
await page.getByRole('button', { name: 'Apply', exact: true }).click();
await page.getByRole('button', { name: /Apply level 2/ }).click();
await expect(page.getByRole('spinbutton', { name: 'Barbarian level' })).toHaveValue('2');
});
+12 -5
View File
@@ -25,19 +25,26 @@ test('character depth: inventory, spellcasting, attacks, resources + rest', asyn
await page.getByLabel('Manual override for Intelligence').fill('6');
await page.getByRole('button', { name: 'Close dialog' }).click();
// Inventory — add an item via Enter
// Inventory — add an item via Enter (the wizard grants a starting kit,
// so the new item is appended as the last row)
await page.getByPlaceholder('Longsword', { exact: true }).fill('Staff of Power');
await page.getByPlaceholder('Longsword', { exact: true }).press('Enter');
await expect(page.locator('input[aria-label="Item name"]')).toHaveValue('Staff of Power');
await expect(page.locator('input[aria-label="Item name"]').last()).toHaveValue('Staff of Power');
// Spellcasting — choose ability, expect a derived DC to appear
await page.getByLabel('Casting ability').selectOption('int');
await expect(page.getByText('Spell DC')).toBeVisible();
// Attacks — add one, expect a computed to-hit
// Attacks — add one, expect a computed to-hit (the starting-kit weapons also
// render "to hit", so scope the assertion to the new attack's row)
await page.getByPlaceholder('Longsword, Shortbow…').fill('Quarterstaff');
await page.getByPlaceholder('Longsword, Shortbow…').press('Enter');
await expect(page.getByText('to hit')).toBeVisible();
await expect(
page
.getByRole('listitem')
.filter({ has: page.getByRole('button', { name: 'Remove Quarterstaff' }) })
.getByText('to hit'),
).toBeVisible();
// Resources — add one, spend it, long rest restores it. The wizard grants class
// resources (Barbarian → Rage), so scope to the Sorcery Points row.
@@ -52,5 +59,5 @@ test('character depth: inventory, spellcasting, attacks, resources + rest', asyn
// Reload — persistence survived (autosave). Wait for the debounce + beforeunload flush.
await page.waitForTimeout(500);
await page.reload();
await expect(page.locator('input[aria-label="Item name"]')).toHaveValue('Staff of Power');
await expect(page.locator('input[aria-label="Item name"]').last()).toHaveValue('Staff of Power');
});
+7 -3
View File
@@ -30,12 +30,16 @@ test('creation wizard builds a complete character (HP, skills, spells)', async (
await page.getByRole('button', { name: 'Next' }).click(); // → Spells (Wizard is a caster)
await expect(page.getByPlaceholder('Search spells…')).toBeVisible();
await page.getByRole('button', { name: 'Next' }).click(); // → Details (optional flavour)
await page.getByRole('button', { name: 'Next' }).click(); // → Review
// The remaining step count varies (Feats, Gear, Details…) — advance until the
// Review step's Create button renders, then assert the review summary.
const create = page.getByRole('button', { name: 'Create character' });
for (let i = 0; i < 8 && (await create.count()) === 0; i++) {
await page.getByRole('button', { name: 'Next' }).click();
}
await expect(page.getByText('Max HP')).toBeVisible();
await expect(page.getByText('Spell slots')).toBeVisible();
await page.getByRole('button', { name: 'Create character' }).click();
await create.click();
// Lands on the sheet with real derived HP, not the old 1/1.
await expect(page.getByText('Hit Points')).toBeVisible();
+47
View File
@@ -42,3 +42,50 @@ test('combat depth: timed condition auto-expires, log records events, roll-all w
await expect(page.getByText('Combat Log')).toBeVisible();
await expect(page.getByText(/Prone wore off/)).toBeVisible();
});
test('PF2e combat depth: 3-action pips persist and end-of-turn save prompt resolves (M2)', async ({ page }) => {
// PF2e campaign — the action economy and save ladder come off the rules seam.
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('PF Depth');
await page.getByLabel('System').selectOption('pf2e');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Combat' }).click();
await page.getByRole('button', { name: '+ New encounter' }).first().click();
await page.locator('input[data-autofocus]').fill('Ambush');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByLabel('Name').fill('Bandit');
await page.getByRole('button', { name: 'Add', exact: true }).click();
const row = page.locator('li', { hasText: 'Bandit' });
await page.getByRole('button', { name: /Roll all/ }).click();
await page.getByRole('button', { name: 'Start combat' }).click();
// PF2e turn panel: three action pips + a reaction, driven by turnActionBudget.
const pip1 = page.getByRole('button', { name: 'Actions 1 available' });
await expect(pip1).toBeVisible();
await expect(page.getByRole('button', { name: 'Actions 3 available' })).toBeVisible();
await pip1.click(); // spend one — persists on the combatant (M2-R6)
await expect(page.getByRole('button', { name: 'Actions 1 used' })).toBeVisible();
// Add Prone, open its editor, mark it save-ends with a DC (M2-R5).
await row.getByLabel('Add condition').selectOption('Prone');
await row.getByRole('button', { name: 'Prone', exact: true }).click(); // opens the condition editor
await row.getByText('save ends').click();
await row.getByLabel('Condition save DC').fill('12');
// End the turn: the outgoing combatant's save prompt is queued on the encounter.
await page.getByRole('button', { name: /Next turn/ }).click();
await expect(page.getByText('Saves to end conditions')).toBeVisible();
await expect(page.getByText(/save to end Prone \(DC 12/)).toBeVisible();
// A fresh turn began — the spent action pip was reset by the engine.
await expect(page.getByRole('button', { name: 'Actions 1 available' })).toBeVisible();
// Resolve as a success: the condition and the prompt both clear, and it logs.
await page.getByRole('button', { name: 'Succeeded' }).click();
await expect(page.getByText('Saves to end conditions')).toHaveCount(0);
await expect(row.getByRole('button', { name: 'Prone', exact: true })).toHaveCount(0);
await expect(page.getByText(/saves — Prone ends/)).toBeVisible();
});
+4 -5
View File
@@ -51,18 +51,17 @@ test('combat condition picker adds preset and valued conditions as tags', async
// Non-valued condition adds immediately on selection
await row.getByLabel('Add condition').selectOption('Prone');
await expect(row.getByRole('button', { name: /Prone/ })).toBeVisible();
await expect(row.getByRole('button', { name: 'Prone', exact: true })).toBeVisible();
// Valued condition (5e Exhaustion) shows a value field, then adds a tag
await row.getByLabel('Add condition').selectOption('Exhaustion');
await row.getByLabel('Condition value').fill('3');
await row.getByRole('button', { name: 'Add', exact: true }).click();
await expect(row.getByRole('button', { name: 'Remove Exhaustion' })).toBeVisible();
await expect(row.getByText('Exhaustion 3', { exact: true })).toBeVisible();
await expect(row.getByRole('button', { name: /Exhaustion 3/ })).toBeVisible();
// The tag's remove button removes it
// Each tag has a dedicated remove button
await row.getByRole('button', { name: 'Remove Prone' }).click();
await expect(row.getByRole('button', { name: 'Remove Prone' })).toHaveCount(0);
await expect(row.getByRole('button', { name: /Prone/ })).toHaveCount(0);
});
test('character can be deleted', async ({ page }) => {
+58 -5
View File
@@ -1,8 +1,61 @@
{
"format": 0.3,
"resolution": { "map_origin": { "x": 0, "y": 0 }, "map_size": { "x": 4, "y": 3 }, "pixels_per_grid": 100 },
"line_of_sight": [[{ "x": 1, "y": 1 }, { "x": 3, "y": 1 }, { "x": 3, "y": 2 }]],
"portals": [{ "position": { "x": 3, "y": 1.5 }, "bounds": [{ "x": 3, "y": 1 }, { "x": 3, "y": 2 }], "closed": true }],
"lights": [{ "position": { "x": 2, "y": 2 }, "range": 4, "color": "ffd9a0", "intensity": 0.6 }],
"image": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
"resolution": {
"map_origin": {
"x": 0,
"y": 0
},
"map_size": {
"x": 4,
"y": 3
},
"pixels_per_grid": 100
},
"line_of_sight": [
[
{
"x": 1,
"y": 1
},
{
"x": 3,
"y": 1
},
{
"x": 3,
"y": 2
}
]
],
"portals": [
{
"position": {
"x": 3,
"y": 1.5
},
"bounds": [
{
"x": 3,
"y": 1
},
{
"x": 3,
"y": 2
}
],
"closed": true
}
],
"lights": [
{
"position": {
"x": 2,
"y": 2
},
"range": 4,
"color": "ffd9a0",
"intensity": 0.6
}
],
"image": "iVBORw0KGgoAAAANSUhEUgAAAZAAAAEsCAIAAABi1XKVAAAC90lEQVR42u3UQQ0AAAjEsJOIFCQgHR2QJlWwxzJdACdEAsCwAAwLMCwAwwIwLMCwAAwLwLAAwwIwLADDAgwLwLAADAswLADDAjAswLAADAvAsADDAjAsAMMCDAvAsAAMCzAsAMMCDEsFwLAADAswLADDAjAswLAADAvAsADDAjAsAMMCDAvAsAAMCzAsAMMCMCzAsAAMC8CwAMMCMCwAwwIMC8CwAAwLMCwAwwIMSwXAsAAMCzAsAMMCMCzAsAAMC8CwAMMCMCwAwwIMC8CwAAwLMCwAwwIwLMCwAAwLwLAAwwIwLADDAgwLwLAADAswLADDAgwLwLAADAswLADDAjAswLAADAvAsADDAjAsAMMCDAvAsAAMCzAsAMMCMCzAsAAMC8CwAMMCMCwAwwIMC8CwAAwLMCwAwwIMC8CwAAwLMCwAwwIwLMCwAAwLwLAAwwIwLADDAgwLwLAADAswLADDAjAswLAADAvAsADDAjAsAMMCDAvAsAAMCzAsAMMCDAvAsAAMCzAsAMMCMCzAsAAMC8CwAMMCMCwAwwIMC8CwAAwLMCwAwwIwLMCwAAwLwLAAwwIwLADDAgwLwLAADAswLADDAgwLwLAADAswLADDAjAswLAADAvAsADDAjAsAMMCDAvAsAAMCzAsAMMCMCzAsAAMC8CwAMMCMCwAwwIMC8CwAAwLMCwAwwIMC8CwAAwLMCwAwwIwLMCwAAwLwLAAwwIwLADDAgwLwLAADAswLADDAjAswLAADAvAsADDAjAsAMMCDAvAsAAMCzAsAMMCDAvAsAAMCzAsAMMCMCzAsAAMC8CwAMMCMCwAwwIMC8CwAAwLMCwAwwIwLMCwAAwLwLAAwwIwLADDAgwLwLAAw5IAMCwAwwIMC8CwAAwLMCwAwwIwLMCwAAwLwLAAwwIwLADDAgwLwLAADAswLADDAjAswLAADAvAsADDAjAsAMMCDAvAsADDUgEwLADDAgwLwLAADAswLADDAjAswLAADAvAsIDPFpA5ElcPlo2gAAAAAElFTkSuQmCC"
}
+7 -3
View File
@@ -21,7 +21,11 @@ export async function createCharacter(page: Page, name: string): Promise<void> {
if (!(await next.isDisabled())) break;
await boxes.nth(i).check();
}
await next.click(); // → Details (optional flavour)
await next.click(); // → Review
await page.getByRole('button', { name: 'Create character' }).click();
// The remaining step count varies (Feats, Gear, Details… depend on class and
// system) — advance until the Review step's Create button renders.
const create = page.getByRole('button', { name: 'Create character' });
for (let i = 0; i < 8 && (await create.count()) === 0; i++) {
await next.click();
}
await create.click();
}
+56
View File
@@ -0,0 +1,56 @@
import { test, expect } from '@playwright/test';
/**
* Offline-parity smoke test (T-171).
*
* The app is local-first: once the shell + a route's chunk are loaded, the UI,
* client-side routing, and IndexedDB reads/writes must keep working with the
* network cut. We warm the routes we need while online, drop the connection,
* and assert the offline indicator appears and core local-first actions (rolling
* dice, creating + persisting a campaign) still succeed with no network.
*
* Note: this runs against the Vite dev server (no service worker), so it does
* NOT exercise SW precache / offline cold-reload — that requires the built app
* (playwright.realtime.config.ts). This covers offline behaviour of the running
* client, which is where the local-first guarantee lives.
*/
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(async () => {
indexedDB.deleteDatabase('ttrpg-manager');
localStorage.clear();
});
await page.reload();
});
test('app stays usable offline: indicator, dice roll, and a persisted campaign', async ({ page }) => {
const offlineBadge = page.getByTitle(/You're offline/);
// Online to start: no offline indicator.
await expect(page.getByRole('heading', { name: 'Campaigns' })).toBeVisible();
await expect(offlineBadge).toHaveCount(0);
// Warm the Dice route's lazy chunk while still online.
await page.getByRole('link', { name: 'Dice' }).click();
await expect(page.getByRole('button', { name: 'Roll', exact: true })).toBeVisible();
// ---- Go offline ----
await page.context().setOffline(true);
await expect(offlineBadge).toBeVisible();
// Rolling dice is pure client work — it must still produce a result offline.
await page.getByRole('button', { name: 'Roll', exact: true }).click();
await expect(page.locator('text=/= \\d+/').first()).toBeVisible();
// Navigate back to the (already-loaded) Campaigns route and create a campaign
// while offline — proving IndexedDB writes + liveQuery work with no network.
await page.getByRole('link', { name: 'Campaigns' }).click();
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('Offline Keep');
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByRole('heading', { name: 'Offline Keep' })).toBeVisible();
// ---- Back online: indicator clears ----
await page.context().setOffline(false);
await expect(offlineBadge).toHaveCount(0);
});
+18 -4
View File
@@ -24,16 +24,30 @@ test('PF2e sheet: Class DC card, armor picker + defense proficiency, agile/strik
const next = page.getByRole('button', { name: 'Next' });
await next.click(); // → Origin
await next.click(); // → Abilities
// PF2e Abilities: assign every free boost slot before Next enables.
const boostBtns = page.getByRole('button', { name: /Assign boost to / });
for (let i = 0; i < 12 && (await next.isDisabled()); i++) {
const b = boostBtns.first();
if ((await b.count()) === 0) break;
await b.click();
}
await next.click(); // → Skills
const boxes = page.locator('input[type="checkbox"]');
// Skills: tick free skills until the step validates.
const boxes = page.locator('input[type="checkbox"]:enabled');
const count = await boxes.count();
for (let i = 0; i < count; i++) {
if (!(await next.isDisabled())) break;
await boxes.nth(i).check();
}
await next.click(); // → Details
await next.click(); // → Review
await page.getByRole('button', { name: 'Create character' }).click();
// Advance the remaining always-valid steps (Feats, Gear, Details) to Review.
const create = page.getByRole('button', { name: 'Create character' });
for (let i = 0; i < 8 && (await create.count()) === 0; i++) {
await next.click();
}
await create.click();
// Class DC card with an editable proficiency rank
await expect(page.getByText('Class DC')).toBeVisible();
+7 -2
View File
@@ -11,6 +11,8 @@ test.beforeEach(async ({ page }) => {
});
test('command palette navigates', async ({ page }) => {
// Wait for the app shell so the global Ctrl+K listener is attached before pressing it.
await expect(page.getByRole('button', { name: 'Open command palette' })).toBeVisible();
await page.keyboard.press('Control+k');
const input = page.getByLabel('Command search');
await expect(input).toBeVisible();
@@ -34,13 +36,16 @@ test('assistant config persists and the key is never exported', async ({ page })
await page.getByLabel('Settings').click();
await page.getByLabel('Enable AI assistant').check();
await page.getByLabel('API key', { exact: true }).fill(SECRET);
await page.getByLabel('Model').fill('claude-opus-4-8');
await page.getByLabel('Model').selectOption('claude-opus-4-8');
// Remembering the key is opt-in now — turn it on so the key survives reload.
await page.getByLabel('Remember API key').check();
// Persists across reload (rememberKey defaults on)
// Persists across reload (rememberKey opted in above)
await page.reload();
await page.getByLabel('Settings').click();
await expect(page.getByLabel('Enable AI assistant')).toBeChecked();
await expect(page.getByLabel('API key', { exact: true })).toHaveValue(SECRET);
await expect(page.getByLabel('Model')).toHaveValue('claude-opus-4-8');
// The backup export (Dexie only) must not contain the localStorage-held key
const downloadPromise = page.waitForEvent('download');
+3 -2
View File
@@ -33,11 +33,12 @@ test('full core flow: campaign → character → dice → combat → compendium'
await page.locator('tr', { hasText: 'Strength' }).getByRole('button', { name: '±' }).click();
await page.getByLabel('Manual override for Strength').fill('1');
await page.getByRole('button', { name: 'Close dialog' }).click();
await expect(page.getByRole('button', { name: 'STR +3 16' })).toBeVisible();
await expect(page.getByTitle('Roll STR check')).toHaveText('+3');
// --- Dice ---
await page.getByRole('link', { name: 'Dice' }).click();
await page.getByRole('button', { name: 'Roll' }).click();
await expect(page.getByRole('heading', { name: 'Dice' })).toBeVisible();
await page.getByRole('button', { name: 'Roll', exact: true }).click();
// a result total renders (1..20 for default 1d20)
await expect(page.locator('text=/= \\d+/')).toBeVisible();
+2 -2
View File
@@ -21,7 +21,7 @@ test('worldbuilding: dashboard, note with wiki link, npc, quest, calendar', asyn
await page.getByRole('link', { name: 'Notes & Wiki' }).click();
await page.getByRole('button', { name: '+ New note' }).first().click();
await page.getByLabel('Note title').fill('Strahd');
await page.getByPlaceholder(/Write here/).fill('Lord of [[Castle Ravenloft]].');
await page.getByPlaceholder(/Write Markdown/).fill('Lord of [[Castle Ravenloft]].');
// The link is "missing" until created; click it to create the target note
await page.getByRole('button', { name: 'Castle Ravenloft' }).click();
await expect(page.getByLabel('Note title')).toHaveValue('Castle Ravenloft');
@@ -46,5 +46,5 @@ test('worldbuilding: dashboard, note with wiki link, npc, quest, calendar', asyn
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByLabel('Primary').getByRole('link', { name: 'Calendar' }).click();
await page.getByRole('button', { name: '+1 week' }).click();
await expect(page.getByText('7', { exact: true })).toBeVisible();
await expect(page.getByText(/Year 1 · day 7/)).toBeVisible();
});
+13 -4
View File
@@ -22,16 +22,17 @@
"@fontsource/spectral": "^5.2.8",
"@tanstack/react-router": "^1.95.0",
"@tanstack/react-virtual": "^3.14.2",
"cannon-es": "^0.20.0",
"clsx": "^2.1.1",
"dexie": "^4.0.10",
"dexie-react-hooks": "^1.1.7",
"dompurify": "^3.2.3",
"fuse.js": "^7.0.0",
"lucide-react": "^1.17.0",
"nanoid": "^5.0.9",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.6.0",
"three": "^0.185.0",
"zod": "^3.24.1",
"zustand": "^5.0.2"
},
@@ -43,11 +44,10 @@
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/dompurify": "^3.0.5",
"@types/node": "^22.10.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/three": "^0.185.0",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.3.4",
"esbuild": "^0.28.0",
@@ -64,6 +64,15 @@
"typescript-eslint": "^8.19.0",
"vite": "^6.0.7",
"vite-plugin-pwa": "^0.21.1",
"vitest": "^2.1.8"
"vitest": "^2.1.8",
"ws": "^8.21.0"
},
"knip": {
"entry": [
"server/src/index.ts",
"playwright.realtime.config.ts",
"scripts/*.ts"
],
"ignoreExportsUsedInFile": true
}
}
+137 -20
View File
@@ -1,12 +1,25 @@
/* Scrape Open5e (SRD 5.1, CC-BY-4.0 / OGL) → normalized JSON in src/data/srd/.
* Run: bunx tsx scripts/fetch_open5e.ts */
/* Scrape Open5e → normalized JSON in src/data/srd/.
*
* Two sources are pulled and MERGED (existing entries are never dropped):
* - Open5e v1 (broad community SRD/OGL corpus across all documents) — classes,
* races, backgrounds, feats. Descriptions are kept in FULL (T-109).
* - Open5e v2, document `srd-2024` = "System Reference Document 5.2" (the 2024
* rules, CC-BY-4.0) — species/backgrounds/feats, normalized into the same
* shapes and tagged with per-entry source/license attribution (T-112, T-105).
*
* Every entry carries a `source` (+ `license` when known) so provenance is
* inline and consistent with NOTICE.md.
*
* Run: bun run scripts/fetch_open5e.ts (or: bunx tsx scripts/fetch_open5e.ts) */
import { writeFileSync } from 'node:fs';
import { normalizeOpen5eClass, type Open5eClass } from '../src/lib/ruleset/normalize';
const BASE = 'https://api.open5e.com/v1';
const V1 = 'https://api.open5e.com/v1';
const V2 = 'https://api.open5e.com/v2';
async function all<T = Record<string, unknown>>(path: string): Promise<T[]> {
let url: string | null = `${BASE}/${path}/?limit=500`;
async function paginate<T>(base: string, path: string, query = ''): Promise<T[]> {
const sep = query ? `&${query}` : '';
let url: string | null = `${base}/${path}/?limit=500${sep}`;
const out: T[] = [];
while (url) {
const r = await fetch(url);
@@ -17,32 +30,136 @@ async function all<T = Record<string, unknown>>(path: string): Promise<T[]> {
}
return out;
}
const allV1 = <T = Record<string, unknown>>(path: string) => paginate<T>(V1, path);
const allV2 = <T = Record<string, unknown>>(path: string, query: string) => paginate<T>(V2, path, query);
const first = (s: string | undefined): string => {
const p = (s ?? '').replace(/[#*_`>]/g, '').split('\n').map((x) => x.trim()).filter(Boolean)[0] ?? '';
return p.length > 500 ? `${p.slice(0, 497)}…` : p;
};
/**
* Keep the FULL description text (T-109): strip Markdown emphasis/heading/quote
* markers and normalize whitespace, but DO NOT take only the first paragraph and
* DO NOT cap the length — the previous `first()` helper truncated to ~500 chars,
* degrading the builder pickers (I106). Paragraph breaks are preserved.
*/
const full = (s: string | undefined): string =>
(s ?? '')
.replace(/\r\n?/g, '\n')
.replace(/[#*_`>]/g, '')
.split('\n')
.map((x) => x.trim())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
// ---- SRD 5.2 (2024) ingest via Open5e v2 (document key `srd-2024`) ----
const SRD24_DOC = 'srd-2024';
const SRD24_SOURCE = 'System Reference Document 5.2 (2024)';
const SRD24_LICENSE = 'https://creativecommons.org/licenses/by/4.0/';
/** Suffix 2024 entries so they coexist with the SRD-5.1/community editions of the
* same name without colliding in name-keyed pickers (the builder selects races/
* backgrounds by name). Keeps BOTH editions visible; never overwrites. */
const ed2024 = (name: string): string => `${name} (2024)`;
interface V2Trait { name: string; desc: string; type: string | null }
interface V2Benefit { name?: string; desc: string; type?: string }
interface V2Species { key: string; name: string; desc?: string; traits?: V2Trait[]; is_subspecies?: boolean }
interface V2Background { key: string; name: string; desc?: string; benefits?: V2Benefit[] }
interface V2Feat { key: string; name: string; desc?: string; prerequisite?: string; type?: string; benefits?: V2Benefit[] }
function traitsText(traits: V2Trait[]): string {
return full(traits.map((t) => `${t.name}. ${t.desc}`).join('\n\n'));
}
function speedFromTraits(traits: V2Trait[]): string {
const t = traits.find((x) => x.type === 'SPEED' || /^speed$/i.test(x.name));
return t ? full(t.desc) : '';
}
function visionFromTraits(traits: V2Trait[]): string {
const t = traits.find((x) => /darkvision|blindsight|truesight|tremorsense/i.test(x.name));
return t ? full(`${t.name}. ${t.desc}`) : '';
}
/** Append `extra` to `base` (existing first), dropping only exact slug duplicates —
* never an existing entry. Sorted by name for stable, picker-friendly order. */
function mergeBySlug<T extends { slug: string; name: string }>(base: T[], extra: T[]): T[] {
const seen = new Set(base.map((e) => e.slug));
const merged = [...base];
for (const e of extra) if (!seen.has(e.slug)) { seen.add(e.slug); merged.push(e); }
return merged.sort((a, b) => a.name.localeCompare(b.name));
}
async function main() {
const classes = (await all<Open5eClass>('classes')).map(normalizeOpen5eClass).sort((a, b) => a.name.localeCompare(b.name));
// ---- Classes (Open5e v1, broad corpus; normalized to RulesetClass) ----
const classes = (await allV1<Open5eClass>('classes')).map(normalizeOpen5eClass).sort((a, b) => a.name.localeCompare(b.name));
writeFileSync('src/data/srd/classes.json', JSON.stringify(classes));
const races = (await all<Record<string, string>>('races')).map((r) => ({
slug: r.slug, name: r.name, desc: first(r.desc), asi: r.asi_desc ?? '', speed: r.speed_desc ?? '', vision: r.vision ?? '', traits: first(r.traits),
})).sort((a, b) => a.name.localeCompare(b.name));
// ---- Races (v1 broad) + SRD 5.2 species (v2 srd-2024) ----
const racesV1 = (await allV1<Record<string, string>>('races')).map((r) => ({
slug: r.slug, name: r.name, desc: full(r.desc), asi: r.asi_desc ?? '', speed: r.speed_desc ?? '',
vision: r.vision ?? '', traits: full(r.traits),
source: r.document__title ?? 'Open5e',
...(r.document__license_url ? { license: r.document__license_url } : {}),
}));
const speciesV2 = (await allV2<V2Species>('species', `document__key=${SRD24_DOC}`))
.filter((s) => !s.is_subspecies)
.map((s) => {
const traits = s.traits ?? [];
return {
slug: s.key, name: ed2024(s.name),
desc: full(s.desc) || `${s.name} — SRD 5.2 (2024) species.`,
asi: '', // 2024 rules grant ability score increases via Background, not species
speed: speedFromTraits(traits), vision: visionFromTraits(traits), traits: traitsText(traits),
source: SRD24_SOURCE, license: SRD24_LICENSE,
};
});
const races = mergeBySlug(racesV1, speciesV2);
writeFileSync('src/data/srd/races.json', JSON.stringify(races));
const backgrounds = (await all<Record<string, string>>('backgrounds')).map((b) => ({
slug: b.slug, name: b.name, desc: first(b.desc), skills: b.skill_proficiencies ?? '', tools: b.tool_proficiencies ?? '', languages: b.languages ?? '', feature: b.feature ?? '',
})).sort((a, b) => a.name.localeCompare(b.name));
// ---- Backgrounds (v1 broad) + SRD 5.2 backgrounds (v2 srd-2024) ----
const backgroundsV1 = (await allV1<Record<string, string>>('backgrounds')).map((b) => ({
slug: b.slug, name: b.name, desc: full(b.desc), skills: b.skill_proficiencies ?? '',
tools: b.tool_proficiencies ?? '', languages: b.languages ?? '', feature: b.feature ?? '',
source: b.document__title ?? 'Open5e',
...(b.document__license_url ? { license: b.document__license_url } : {}),
}));
const backgroundsV2 = (await allV2<V2Background>('backgrounds', `document__key=${SRD24_DOC}`)).map((b) => {
const benefit = (type: string): string => full(b.benefits?.find((x) => x.type === type)?.desc ?? '');
return {
slug: b.key, name: ed2024(b.name),
desc: full(b.desc) || full((b.benefits ?? []).map((x) => `${x.name ?? ''}: ${x.desc}`).join('\n')),
// mapSkillNames() splits on commas only — turn "Insight and Religion" into a list.
skills: benefit('skill_proficiency').replace(/\s+and\s+/gi, ', '),
tools: benefit('tool_proficiency'), languages: '', feature: benefit('feat'),
source: SRD24_SOURCE, license: SRD24_LICENSE,
};
});
const backgrounds = mergeBySlug(backgroundsV1, backgroundsV2);
writeFileSync('src/data/srd/backgrounds.json', JSON.stringify(backgrounds));
const feats = (await all<Record<string, string>>('feats')).map((f) => ({
slug: f.slug, name: f.name, desc: first(f.desc), prerequisite: f.prerequisite ?? '',
})).sort((a, b) => a.name.localeCompare(b.name));
// ---- Feats (v1 broad) + SRD 5.2 feats (v2 srd-2024) ----
const featsV1 = (await allV1<Record<string, string>>('feats')).map((f) => ({
slug: f.slug, name: f.name, desc: full(f.desc), prerequisite: f.prerequisite ?? '',
source: f.document__title ?? 'Open5e',
...(f.document__license_url ? { license: f.document__license_url } : {}),
}));
const featsV2 = (await allV2<V2Feat>('feats', `document__key=${SRD24_DOC}`)).map((f) => {
// The mechanical text lives in benefits[]; the top-level desc is only a lead-in
// ("You gain the following benefits.") — keep BOTH so the feat is complete (T-109).
const benefits = full((f.benefits ?? []).map((x) => x.desc).join('\n\n'));
const lead = full(f.desc);
return {
slug: f.key, name: ed2024(f.name),
desc: [lead, benefits].filter(Boolean).join('\n\n') || lead,
prerequisite: f.prerequisite ?? '',
source: SRD24_SOURCE, license: SRD24_LICENSE,
};
});
const feats = mergeBySlug(featsV1, featsV2);
writeFileSync('src/data/srd/feats.json', JSON.stringify(feats));
console.log(`5e: classes ${classes.length}, races ${races.length}, backgrounds ${backgrounds.length}, feats ${feats.length}`);
console.log(
`5e merged: classes ${classes.length}, ` +
`races ${races.length} (+${speciesV2.length} SRD 5.2), ` +
`backgrounds ${backgrounds.length} (+${backgroundsV2.length} SRD 5.2), ` +
`feats ${feats.length} (+${featsV2.length} SRD 5.2)`,
);
}
main().catch((e) => { console.error(e); process.exit(1); });
+239 -9
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { AccountStore } from './accounts';
import { AccountStore, backupBlobProblem, PayloadError } from './accounts';
describe('AccountStore', () => {
let dir: string;
@@ -23,12 +23,12 @@ describe('AccountStore', () => {
if (!r.ok) throw new Error('register failed');
const u = await store.userByToken(r.token);
expect(u).toBeTruthy();
const ver = await store.saveBlob(u!.id, '{"x":1}');
expect(typeof ver).toBe('number');
expect(await store.loadBlob(u!.id)).toBe('{"x":1}');
// the version token round-trips and is null for a user with no blob
expect(await store.blobSavedAt(u!.id)).toBe(ver);
expect(await store.blobSavedAt('no-such-user')).toBeNull();
const res = await store.saveBlob(u!.id, '{"data":{"campaigns":[]}}');
expect(res.ok).toBe(true);
expect(await store.loadBlob(u!.id)).toBe('{"data":{"campaigns":[]}}');
// the version token round-trips and is 0 for a user with no blob (T-130)
expect(store.blobVersionFor(u!.id)).toBe(1);
expect(store.blobVersionFor('no-such-user')).toBe(0);
expect(await store.userByToken('garbage-token')).toBeNull();
});
@@ -43,6 +43,138 @@ describe('AccountStore', () => {
expect((await store2.login('dave', 'password123')).ok).toBe(true);
});
it('throttles repeated login failures with a backoff lock, then unlocks (T-126)', async () => {
let clock = 1_000_000;
const s = new AccountStore(dir, () => clock);
expect((await s.register('eve', 'password123')).ok).toBe(true);
// Failures up to the threshold all read as plain bad-credentials.
for (let i = 0; i < 5; i++) {
const r = await s.login('eve', 'wrong');
expect(r.ok).toBe(false);
if (!r.ok) expect(r.code).toBe('bad-credentials');
}
// Now locked: even the *correct* password is refused as 'locked'.
const locked = await s.login('eve', 'password123');
expect(locked.ok).toBe(false);
if (!locked.ok) expect(locked.code).toBe('locked');
// Past the (1 min) lock window the correct password works again.
clock += 61_000;
expect((await s.login('eve', 'password123')).ok).toBe(true);
});
it('returns identical failure codes for a missing user vs a wrong password (anti-enumeration, T-126)', async () => {
await store.register('frank', 'password123');
const missing = await store.login('ghost', 'whatever');
const wrong = await store.login('frank', 'whatever');
expect(missing.ok).toBe(false);
expect(wrong.ok).toBe(false);
if (!missing.ok && !wrong.ok) {
expect(missing.code).toBe('bad-credentials');
expect(missing.code).toBe(wrong.code);
}
});
it('rejects an over-long password and accepts one at the cap (T-126)', async () => {
expect((await store.register('grace', 'a'.repeat(257))).ok).toBe(false);
expect((await store.register('grace', 'a'.repeat(256))).ok).toBe(true);
});
it('keeps an O(1) token index: evicts oldest past the cap; logout + reload stay consistent (T-128)', async () => {
const r = await store.register('heidi', 'password123');
if (!r.ok) throw new Error('register failed');
const tokens: string[] = [r.token];
expect(await store.userByToken(r.token)).toBeTruthy();
// Issue 10 more (11 total > MAX_TOKENS=10) so the first token is evicted.
for (let i = 0; i < 10; i++) {
const li = await store.login('heidi', 'password123');
if (!li.ok) throw new Error('login failed');
tokens.push(li.token);
}
expect(await store.userByToken(tokens[0]!)).toBeNull(); // oldest evicted from the index
const live = tokens[tokens.length - 1]!;
expect(await store.userByToken(live)).toBeTruthy();
await store.logout(live); // logout drops it from the index
expect(await store.userByToken(live)).toBeNull();
// A still-valid token survives a reload (index rebuilt from disk).
const survivor = tokens[tokens.length - 2]!;
const store2 = new AccountStore(dir);
await store2.load();
expect(await store2.userByToken(survivor)).toBeTruthy();
expect(await store2.userByToken(tokens[0]!)).toBeNull();
});
it('versions the blob and refuses a stale If-Match write (T-130)', async () => {
const r = await store.register('ivan', 'password123');
if (!r.ok) throw new Error('register failed');
const u = await store.userByToken(r.token);
expect(store.blobVersionFor(u!.id)).toBe(0);
const v1 = await store.saveBlob(u!.id, '{"data":{"v":[1]}}');
expect(v1.ok && v1.version).toBe(1);
// A matching precondition succeeds and bumps the version.
const v2 = await store.saveBlob(u!.id, '{"data":{"v":[2]}}', 1);
expect(v2.ok && v2.version).toBe(2);
// A stale precondition is refused without overwriting.
const conflict = await store.saveBlob(u!.id, '{"data":{"v":[99]}}', 1);
expect(conflict.ok).toBe(false);
if (!conflict.ok) expect(conflict.version).toBe(2);
expect(await store.loadBlob(u!.id)).toBe('{"data":{"v":[2]}}');
// Omitting the precondition keeps last-write-wins for older clients.
const v3 = await store.saveBlob(u!.id, '{"data":{"v":[3]}}');
expect(v3.ok && v3.version).toBe(3);
});
it('expires tokens after the TTL and refreshes a valid one (T-133)', async () => {
let clock = 1_000_000;
const s = new AccountStore(dir, () => clock, [], 1000); // 1s TTL
const r = await s.register('judy', 'password123');
if (!r.ok) throw new Error('register failed');
expect(await s.userByToken(r.token)).toBeTruthy();
// Refresh before expiry → a new token works, the old one is revoked.
const refreshed = await s.refresh(r.token);
expect(refreshed.ok).toBe(true);
if (!refreshed.ok) throw new Error('refresh failed');
expect(await s.userByToken(r.token)).toBeNull(); // old token rotated out
expect(await s.userByToken(refreshed.token)).toBeTruthy();
// Past the TTL the (new) token no longer authenticates and can't be refreshed.
clock += 1001;
expect(await s.userByToken(refreshed.token)).toBeNull();
expect((await s.refresh(refreshed.token)).ok).toBe(false);
});
it('changes a password: verifies the old one and revokes existing sessions (T-133)', async () => {
const r = await store.register('kyle', 'password123');
if (!r.ok) throw new Error('register failed');
expect((await store.changePassword((await store.userByToken(r.token))!.id, 'wrong', 'newpassword1')).ok).toBe(false);
const changed = await store.changePassword((await store.userByToken(r.token))!.id, 'password123', 'newpassword1');
expect(changed.ok).toBe(true);
if (!changed.ok) throw new Error('change failed');
expect(await store.userByToken(r.token)).toBeNull(); // old session revoked
expect(await store.userByToken(changed.token)).toBeTruthy(); // fresh token issued
expect((await store.login('kyle', 'password123')).ok).toBe(false);
expect((await store.login('kyle', 'newpassword1')).ok).toBe(true);
});
it('deletes an account: verifies password, drops tokens + blob (T-133)', async () => {
const r = await store.register('lana', 'password123');
if (!r.ok) throw new Error('register failed');
const u = await store.userByToken(r.token);
await store.saveBlob(u!.id, '{"data":{}}');
expect(await store.verifyPassword(u!.id, 'wrong')).toBe(false);
expect(await store.verifyPassword(u!.id, 'password123')).toBe(true);
expect(await store.deleteAccount(u!.id)).toBe(true);
expect(await store.userByToken(r.token)).toBeNull();
expect(await store.loadBlob(u!.id)).toBeNull();
expect((await store.login('lana', 'password123')).ok).toBe(false);
// Gone after reload too.
const store2 = new AccountStore(dir);
await store2.load();
expect((await store2.login('lana', 'password123')).ok).toBe(false);
});
it('gates admin + stores a quota override', async () => {
const adminStore = new AccountStore(dir, undefined, ['boss']);
await adminStore.register('boss', 'password123');
@@ -66,7 +198,7 @@ describe('AccountStore', () => {
});
it('refuses registration past the max-users cap', async () => {
const capped = new AccountStore(dir, undefined, [], 1);
const capped = new AccountStore(dir, undefined, [], undefined, 1);
expect((await capped.register('first', 'password123')).ok).toBe(true);
const second = await capped.register('second', 'password123');
expect(second.ok).toBe(false);
@@ -89,7 +221,7 @@ describe('AccountStore', () => {
const r = await s.register('victim', 'password123');
if (!r.ok) throw new Error('register failed');
const u = (await s.userByToken(r.token))!;
await s.saveBlob(u.id, '{"x":1}');
await s.saveBlob(u.id, '{"data":{}}');
expect(await s.deleteUser('boss2')).toBeNull(); // admins are protected
const removedId = await s.deleteUser('victim');
expect(removedId).toBe(u.id);
@@ -106,4 +238,102 @@ describe('AccountStore', () => {
expect(row.admin).toBe(false);
expect(row.createdAt).toBeGreaterThan(0);
});
it('saveBlob throws a 400-tagged PayloadError on a malformed blob (T-135)', async () => {
const r = await store.register('mara', 'password123');
if (!r.ok) throw new Error('register failed');
const u = await store.userByToken(r.token);
await expect(store.saveBlob(u!.id, 'not json')).rejects.toBeInstanceOf(PayloadError);
await expect(store.saveBlob(u!.id, '{"format":"x"}')).rejects.toMatchObject({ statusCode: 400 });
// A rejected write leaves no stored blob and doesn't bump the version.
expect(await store.loadBlob(u!.id)).toBeNull();
expect(store.blobVersionFor(u!.id)).toBe(0);
// A well-formed envelope still saves.
expect((await store.saveBlob(u!.id, '{"data":{}}')).ok).toBe(true);
});
});
describe('backupBlobProblem (T-135)', () => {
it('accepts a real backup envelope and tolerates absent optional metadata', () => {
expect(backupBlobProblem('{"format":"ttrpg-manager:backup","version":7,"exportedAt":"2026-01-01T00:00:00.000Z","checksum":"deadbeef","data":{"campaigns":[],"characters":[{"id":"c1"}]}}')).toBeNull();
expect(backupBlobProblem('{"data":{}}')).toBeNull(); // empty backup
expect(backupBlobProblem('{"data":{"campaigns":[]}}')).toBeNull();
});
it('rejects non-JSON, non-objects, and a missing or malformed data map', () => {
expect(backupBlobProblem('not json')).toBe('not valid JSON');
expect(backupBlobProblem('"a string"')).toBe('expected a JSON object');
expect(backupBlobProblem('[1,2,3]')).toBe('expected a JSON object');
expect(backupBlobProblem('42')).toBe('expected a JSON object');
expect(backupBlobProblem('{"format":"x"}')).toBe('missing a "data" object');
expect(backupBlobProblem('{"data":[]}')).toBe('missing a "data" object');
expect(backupBlobProblem('{"data":{"campaigns":{}}}')).toBe('every table must be an array');
});
it('type-checks envelope metadata when present', () => {
expect(backupBlobProblem('{"format":5,"data":{}}')).toBe('format must be a string');
expect(backupBlobProblem('{"version":"x","data":{}}')).toBe('version must be a finite number');
expect(backupBlobProblem('{"checksum":1,"data":{}}')).toBe('checksum must be a string');
});
});
describe('re-auth backoff lockout on password change / verify (M1-QW3)', () => {
let dir: string;
beforeEach(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'acct-reauth-')); });
async function registeredUser(s: AccountStore): Promise<string> {
const r = await s.register('mallory', 'password123');
if (!r.ok) throw new Error('register failed');
const u = await s.userByToken(r.token);
if (!u) throw new Error('no user');
return u.id;
}
it('changePassword locks after repeated wrong old-passwords, then unlocks after the window', async () => {
let clock = 1_000_000;
const s = new AccountStore(dir, () => clock);
const id = await registeredUser(s);
for (let i = 0; i < 5; i++) {
const r = await s.changePassword(id, 'wrong-old', 'newpassword123');
expect(r.ok).toBe(false);
if (!r.ok) expect(r.code).toBe('bad-credentials');
}
// Locked: even the CORRECT old password is refused now.
const locked = await s.changePassword(id, 'password123', 'newpassword123');
expect(locked.ok).toBe(false);
if (!locked.ok) expect(locked.code).toBe('locked');
// Past the lock window the correct old password works again.
clock += 61_000;
expect((await s.changePassword(id, 'password123', 'newpassword123')).ok).toBe(true);
});
it('verifyPassword feeds the same lock and always fails while locked', async () => {
let clock = 2_000_000;
const s = new AccountStore(dir, () => clock);
const id = await registeredUser(s);
for (let i = 0; i < 5; i++) expect(await s.verifyPassword(id, 'wrong')).toBe(false);
// Locked: correct password refused (account-delete stays blocked).
expect(await s.verifyPassword(id, 'password123')).toBe(false);
clock += 61_000;
expect(await s.verifyPassword(id, 'password123')).toBe(true);
});
it('a successful re-auth clears the failure streak', async () => {
const s = new AccountStore(dir);
const id = await registeredUser(s);
for (let i = 0; i < 4; i++) expect(await s.verifyPassword(id, 'wrong')).toBe(false);
expect(await s.verifyPassword(id, 'password123')).toBe(true); // clears streak
// Four more failures don't lock (streak restarted), the fifth+1 would.
for (let i = 0; i < 4; i++) expect(await s.verifyPassword(id, 'wrong')).toBe(false);
expect(await s.verifyPassword(id, 'password123')).toBe(true);
});
it('re-auth failures do not lock the login path (separate key namespaces)', async () => {
const clock = 3_000_000;
const s = new AccountStore(dir, () => clock);
const id = await registeredUser(s);
for (let i = 0; i < 6; i++) expect(await s.verifyPassword(id, 'wrong')).toBe(false);
// login by username still works despite the re-auth lock on the user id
expect((await s.login('mallory', 'password123')).ok).toBe(true);
});
});
+411 -88
View File
@@ -1,38 +1,81 @@
import crypto from 'node:crypto';
import { promisify } from 'node:util';
import { promises as fs } from 'node:fs';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { promisify } from 'node:util';
import { openDatabase, type Db } from './db';
// Async scrypt so password hashing runs on the threadpool, NOT the single event
// loop — a burst of logins can't freeze the whole server (scryptSync did).
const scryptAsync = promisify(crypto.scrypt) as (pw: crypto.BinaryLike, salt: crypto.BinaryLike, keylen: number) => Promise<Buffer>;
const scryptAsync = promisify(crypto.scrypt) as (password: crypto.BinaryLike, salt: crypto.BinaryLike, keylen: number) => Promise<Buffer>;
/** Fixed salt for the missing-user dummy hash (constant-time login, anti-enumeration). */
const DUMMY_SALT = 'dummy-salt-constant-time-login';
/** Reject absurdly long passwords before they reach the (expensive) hash (T-126). */
const MAX_PASSWORD = 256;
/**
* Lean file-backed accounts + cloud-backup store. No external DB — a single
* users.json plus one blob file per user under DATA_DIR. Passwords are scrypt-
* hashed with a per-user salt; bearer tokens are stored hashed. Single-process
* only (writes are serialised through a promise queue). Good enough for a small
* self-hosted instance; the protocol leaves room to swap in a real DB later.
* Account + cloud-backup store backed by bun:sqlite (T-129). Replaces the previous
* users.json-rewritten-on-every-login design: each mutation now touches only the
* affected user's row (+ its bounded token set) and the per-user blob lives in its
* own row, so a login no longer rewrites every account and two processes against one
* DATA_DIR no longer corrupt a shared file. Durable state lives in sqlite; the maps
* below are an in-memory read cache (rebuilt on load) used for O(1) lookups and the
* injectable-clock login throttle. Passwords are scrypt-hashed with a per-user salt;
* bearer tokens are stored hashed.
*/
/** A live bearer token: sha256(token) plus its absolute expiry (T-133). */
interface TokenRecord { hash: string; expiresAt: number }
interface User {
id: string;
username: string; // display
salt: string;
hash: string;
tokens: string[]; // sha256(token)
tokens: TokenRecord[]; // hashed tokens + expiry
quotaBytes?: number; // admin override; 0/undefined = default
blobVersion?: number; // optimistic-concurrency version for the backup blob (T-130)
createdAt: number;
updatedAt: number;
}
const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/;
const MAX_TOKENS = 10;
/** Default bearer-token lifetime: 30 days, then refresh or re-login (T-133). */
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
/** Default per-user cloud storage cap when no admin override is set. */
export const DEFAULT_QUOTA_BYTES = Number(process.env.DEFAULT_QUOTA_BYTES) || 30 * 1024 * 1024;
export const DEFAULT_QUOTA_BYTES = Number(process.env.DEFAULT_QUOTA_BYTES) || 50 * 1024 * 1024;
/**
* Accept either the legacy `string[]` token shape or the `{hash,expiresAt}[]` shape
* when importing an existing users.json during the one-time sqlite migration. Legacy
* tokens are given a fresh expiry window from load time rather than expiring instantly.
*/
function normalizeTokens(raw: unknown, fallbackExpiry: number): TokenRecord[] {
if (!Array.isArray(raw)) return [];
const out: TokenRecord[] = [];
for (const t of raw) {
if (typeof t === 'string') out.push({ hash: t, expiresAt: fallbackExpiry });
else if (t && typeof t === 'object' && typeof (t as { hash?: unknown }).hash === 'string') {
const exp = Number((t as { expiresAt?: unknown }).expiresAt);
out.push({ hash: (t as { hash: string }).hash, expiresAt: Number.isFinite(exp) ? exp : fallbackExpiry });
}
}
return out;
}
// Login brute-force throttle (T-126). Failures are counted per *submitted*
// username — whether or not it exists — so a lockout can't be used to probe for
// real accounts (account-enumeration resistance). Past the threshold the key is
// locked with exponential backoff.
const LOGIN_MAX_FAILS = 5; // consecutive failures before the first lock
const LOGIN_BASE_LOCK_MS = 60_000; // 1 min lock at the threshold…
const LOGIN_MAX_LOCK_MS = 15 * 60_000; // …doubling per extra failure, capped at 15 min
const LOGIN_FAIL_RESET_MS = 15 * 60_000; // forget a streak after a quiet period
const LOGIN_THROTTLE_MAX = 5_000; // hard cap on tracked keys (memory guard)
function sha256(s: string): string { return crypto.createHash('sha256').update(s).digest('hex'); }
async function hashPassword(password: string, salt: string): Promise<string> { return (await scryptAsync(password, salt, 64)).toString('hex'); }
/** Async scrypt so password hashing never blocks the single event loop (T-121). */
async function hashPassword(password: string, salt: string): Promise<string> {
return (await scryptAsync(password, salt, 64)).toString('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);
@@ -41,13 +84,76 @@ function timingEqual(a: string, b: string): boolean {
export interface AuthResult { ok: true; token: string; username: string }
export interface AuthError { ok: false; code: string; message: string }
/**
* Coarse cap on the number of top-level tables in a backup envelope (T-135). The
* real schema has ~11; this only trips on a pathological object, never a genuine
* backup. Complements the byte-size cap enforced in the HTTP layer.
*/
const MAX_BACKUP_TABLES = 512;
/**
* Thrown when a stored payload is structurally invalid (T-135). Fastify's default
* error handler honours `statusCode`, so a malformed blob surfaces as a clean 400
* without the route handler needing to special-case a new result code.
*/
export class PayloadError extends Error {
readonly statusCode = 400;
constructor(message: string) { super(message); this.name = 'PayloadError'; }
}
/**
* Structural guard for a cloud-backup blob (T-135). The client uploads
* `JSON.stringify(buildBackup())`: a top-level object carrying a `data` map whose
* every value is an array of rows (plus format/version/checksum metadata). We reject
* anything that isn't well-formed JSON of that envelope, so garbage can never be
* stored and later handed back to a restoring client (whose restore path hard-
* requires a `data` object).
*
* Dependency-free and intentionally shallow: row *contents* stay the client's
* concern (it re-validates every row through Zod on restore); the server only
* guarantees the envelope. Returns the offending reason, or null when acceptable.
*/
export function backupBlobProblem(blob: string): string | null {
let parsed: unknown;
try { parsed = JSON.parse(blob); } catch { return 'not valid JSON'; }
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return 'expected a JSON object';
const obj = parsed as Record<string, unknown>;
const data = obj.data;
if (typeof data !== 'object' || data === null || Array.isArray(data)) return 'missing a "data" object';
const tables = Object.values(data as Record<string, unknown>);
if (tables.length > MAX_BACKUP_TABLES) return 'too many tables';
for (const rows of tables) if (!Array.isArray(rows)) return 'every table must be an array';
// Envelope metadata, when present, must carry the right primitive types.
if (obj.format !== undefined && typeof obj.format !== 'string') return 'format must be a string';
if (obj.version !== undefined && (typeof obj.version !== 'number' || !Number.isFinite(obj.version))) return 'version must be a finite number';
if (obj.checksum !== undefined && typeof obj.checksum !== 'string') return 'checksum must be a string';
if (obj.exportedAt !== undefined && typeof obj.exportedAt !== 'string') return 'exportedAt must be a string';
return null;
}
interface UserRow { id: string; username: string; username_key: string; salt: string; hash: string; quota_bytes: number | null; blob_version: number; created_at: number; updated_at: number }
interface TokenRow { hash: string; user_id: string; expires_at: number }
interface BlobRow { blob: string; bytes: number; version: number }
interface CountRow { c: number }
export class AccountStore {
private users = new Map<string, User>(); // key: lowercased username
private byId = new Map<string, User>();
private byToken = new Map<string, User>(); // sha256(token) → user, for O(1) auth lookups
private queue: Promise<unknown> = Promise.resolve();
// O(1) auth lookup: sha256(token) -> userId, kept in sync on load/issue/logout/
// eviction so userByToken never linear-scans every user × token (T-128).
private byToken = new Map<string, string>();
// Login throttle state, keyed by lowercased submitted username (T-126).
private loginThrottle = new Map<string, { fails: number; lockedUntil: number; lastFail: number }>();
private db!: Db;
private loaded = false;
private loadPromise: Promise<void> | null = null;
constructor(private dir: string, private now: () => number = () => Date.now(), private admins: string[] = [], private maxUsers = Infinity) {}
constructor(
private dir: string,
private now: () => number = () => Date.now(),
private admins: string[] = [],
private tokenTtlMs: number = TOKEN_TTL_MS,
private maxUsers: number = Number(process.env.MAX_USERS) || Infinity,
) {}
private usersFile() { return path.join(this.dir, 'users.json'); }
private blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); }
@@ -55,7 +161,7 @@ export class AccountStore {
isAdmin(username: string): boolean { return this.admins.includes(username.toLowerCase()); }
/** This user's storage limit: their admin override, else the instance default. */
quotaFor(u: User): number { return u.quotaBytes && u.quotaBytes > 0 ? u.quotaBytes : DEFAULT_QUOTA_BYTES; }
quotaFor(u: { quotaBytes?: number }): number { return u.quotaBytes && u.quotaBytes > 0 ? u.quotaBytes : DEFAULT_QUOTA_BYTES; }
/** Whether a new account can be created (registration is open under the cap). */
atCapacity(): boolean { return this.users.size >= this.maxUsers; }
@@ -79,28 +185,25 @@ export class AccountStore {
await this.load();
const u = this.users.get(username.toLowerCase());
if (!u) return false;
for (const h of u.tokens) this.byToken.delete(h);
for (const t of u.tokens) this.byToken.delete(t.hash);
u.tokens = [];
u.updatedAt = this.now();
await this.persist();
this.persistUser(u);
return true;
}
/**
* Delete an account and its backup blob. Admin accounts can't be deleted from the
* panel (protects against lockout + a compromised admin nuking another admin).
* Returns the removed user's id so the caller can purge their cloud data too.
* Admin-panel delete: drop an account and its backup blob by USERNAME. Admin
* accounts can't be deleted from the panel (protects against lockout + a
* compromised admin nuking another admin). Returns the removed user's id so
* the caller can purge their cloud data too.
*/
async deleteUser(username: string): Promise<string | null> {
await this.load();
const u = this.users.get(username.toLowerCase());
if (!u || this.isAdmin(u.username)) return null;
for (const h of u.tokens) this.byToken.delete(h);
this.users.delete(u.username.toLowerCase());
this.byId.delete(u.id);
try { await fs.unlink(this.blobFile(u.id)); } catch { /* no blob */ }
await this.persist();
return u.id;
const ok = await this.deleteAccount(u.id);
return ok ? u.id : null;
}
async setQuota(username: string, bytes: number): Promise<boolean> {
@@ -109,52 +212,123 @@ export class AccountStore {
if (!u) return false;
u.quotaBytes = Math.max(0, Math.floor(bytes));
u.updatedAt = this.now();
await this.persist();
this.persistUser(u);
return true;
}
/** Size in bytes of a user's stored backup blob (0 if none). */
async blobBytes(id: string): Promise<number> {
try { return (await fs.stat(this.blobFile(id))).size; } catch { return 0; }
await this.load();
const row = this.db.query<{ bytes: number }>('SELECT bytes FROM blobs WHERE user_id = ?').get(id);
return row ? row.bytes : 0;
}
/** Memoized so a request that lands mid-boot AWAITS the read instead of seeing an
* empty store (which could let register() persist a users.json missing everyone). */
load(): Promise<void> {
this.loadPromise ??= (async () => {
try {
const raw = await fs.readFile(this.usersFile(), 'utf8');
const arr = JSON.parse(raw) as User[];
for (const u of arr) {
this.users.set(u.username.toLowerCase(), u);
this.byId.set(u.id, u);
for (const h of u.tokens) this.byToken.set(h, u);
}
} catch (e) {
// ENOENT on first boot is expected; anything else is a real problem worth seeing.
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[accounts] load failed', e);
}
})();
async load(): Promise<void> {
if (this.loaded) return;
// Latch a single in-flight load so concurrent callers await the SAME work instead
// of seeing loaded=true mid-load and operating on an empty cache — which let a
// register() during startup persist a store with only the new user in the old
// JSON design (T-120). sqlite is the source of truth now, but the latch still
// guards the one-time legacy import + cache hydration.
if (!this.loadPromise) {
this.loadPromise = (async () => {
this.db = openDatabase(this.dir);
this.migrateLegacy();
this.hydrate();
this.loaded = true;
})();
}
return this.loadPromise;
}
private persist(): Promise<void> {
this.queue = this.queue.then(async () => {
await fs.mkdir(this.dir, { recursive: true });
const tmp = `${this.usersFile()}.tmp`;
await fs.writeFile(tmp, JSON.stringify([...this.users.values()]));
await fs.rename(tmp, this.usersFile());
}).catch((e) => { console.error('[accounts] persist failed — data may be lost on restart', e); });
return this.queue as Promise<void>;
/** One-time import of a legacy users.json (+ per-user blob files) into sqlite (T-129).
* Guarded by a meta flag so it's idempotent and never re-runs — even if every user is
* later deleted and a stale users.json still lingers on disk. */
private migrateLegacy(): void {
const done = this.db.query<{ value: string }>("SELECT value FROM meta WHERE key = 'migrated_accounts'").get();
if (done) return;
const existing = this.db.query<CountRow>('SELECT count(*) AS c FROM users').get();
if (!existing || existing.c === 0) {
let arr: User[] = [];
try { arr = JSON.parse(readFileSync(this.usersFile(), 'utf8')) as User[]; } catch { arr = []; }
const insUser = this.db.query(
`INSERT OR IGNORE INTO users (id, username, username_key, salt, hash, quota_bytes, blob_version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
const insTok = this.db.query('INSERT OR IGNORE INTO tokens (hash, user_id, expires_at) VALUES (?, ?, ?)');
const insBlob = this.db.query('INSERT OR IGNORE INTO blobs (user_id, blob, bytes, version) VALUES (?, ?, ?, ?)');
const tx = this.db.transaction(() => {
for (const u of arr) {
if (!u || typeof u.id !== 'string' || typeof u.username !== 'string') continue;
const blobVersion = typeof u.blobVersion === 'number' ? u.blobVersion : 0;
insUser.run(u.id, u.username, u.username.toLowerCase(), u.salt, u.hash, u.quotaBytes ?? null, blobVersion, u.createdAt ?? this.now(), u.updatedAt ?? this.now());
for (const t of normalizeTokens(u.tokens as unknown, this.now() + this.tokenTtlMs)) insTok.run(t.hash, u.id, t.expiresAt);
let blob: string | null = null;
try { blob = readFileSync(this.blobFile(u.id), 'utf8'); } catch { blob = null; }
if (blob !== null) insBlob.run(u.id, blob, Buffer.byteLength(blob), blobVersion);
}
});
tx();
}
this.db.query("INSERT OR REPLACE INTO meta (key, value) VALUES ('migrated_accounts', '1')").run();
}
/** Rebuild the in-memory read cache (users, byId, byToken) from sqlite. */
private hydrate(): void {
for (const r of this.db.query<UserRow>('SELECT * FROM users').all()) {
const u: User = { id: r.id, username: r.username, salt: r.salt, hash: r.hash, tokens: [], blobVersion: r.blob_version, createdAt: r.created_at, updatedAt: r.updated_at };
if (r.quota_bytes != null) u.quotaBytes = r.quota_bytes;
this.users.set(r.username_key, u);
this.byId.set(u.id, u);
}
for (const t of this.db.query<TokenRow>('SELECT hash, user_id, expires_at FROM tokens').all()) {
const u = this.byId.get(t.user_id);
if (!u) continue;
u.tokens.push({ hash: t.hash, expiresAt: t.expires_at });
this.byToken.set(t.hash, u.id);
}
}
/** Durably write one user's row + its (bounded) token set in a single transaction.
* Only this user is touched — no full-store rewrite (the core T-129 fix). */
private persistUser(u: User): void {
const writeRow = this.db.query(
`INSERT INTO users (id, username, username_key, salt, hash, quota_bytes, blob_version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
username = excluded.username, username_key = excluded.username_key,
salt = excluded.salt, hash = excluded.hash, quota_bytes = excluded.quota_bytes,
blob_version = excluded.blob_version, updated_at = excluded.updated_at`,
);
const delToks = this.db.query('DELETE FROM tokens WHERE user_id = ?');
const insTok = this.db.query('INSERT INTO tokens (hash, user_id, expires_at) VALUES (?, ?, ?)');
const tx = this.db.transaction(() => {
writeRow.run(u.id, u.username, u.username.toLowerCase(), u.salt, u.hash, u.quotaBytes ?? null, u.blobVersion ?? 0, u.createdAt, u.updatedAt);
delToks.run(u.id);
for (const t of u.tokens) insTok.run(t.hash, u.id, t.expiresAt);
});
tx();
}
/** Drop a user, its tokens and its blob (account deletion). */
private deleteUserRows(id: string): void {
const tx = this.db.transaction(() => {
this.db.query('DELETE FROM tokens WHERE user_id = ?').run(id);
this.db.query('DELETE FROM blobs WHERE user_id = ?').run(id);
this.db.query('DELETE FROM users WHERE id = ?').run(id);
});
tx();
}
private issueToken(u: User): string {
const token = crypto.randomBytes(32).toString('base64url');
const h = sha256(token);
u.tokens.push(h);
this.byToken.set(h, u);
u.tokens.push({ hash: h, expiresAt: this.now() + this.tokenTtlMs });
this.byToken.set(h, u.id);
if (u.tokens.length > MAX_TOKENS) {
for (const stale of u.tokens.slice(0, u.tokens.length - MAX_TOKENS)) this.byToken.delete(stale);
// Evict the oldest token(s) and drop them from the index so they stop authenticating.
const dropped = u.tokens.slice(0, u.tokens.length - MAX_TOKENS);
for (const d of dropped) this.byToken.delete(d.hash);
u.tokens = u.tokens.slice(-MAX_TOKENS);
}
u.updatedAt = this.now();
@@ -165,64 +339,213 @@ export class AccountStore {
await this.load();
if (!USERNAME_RE.test(username)) return { ok: false, code: 'bad-username', message: 'Username must be 3–32 letters, digits, . _ or -.' };
if (typeof password !== 'string' || password.length < 8) return { ok: false, code: 'weak-password', message: 'Password must be at least 8 characters.' };
if (password.length > MAX_PASSWORD) return { ok: false, code: 'weak-password', message: 'Password is too long.' };
if (this.users.has(username.toLowerCase())) return { ok: false, code: 'taken', message: 'That username is taken.' };
if (this.atCapacity()) return { ok: false, code: 'closed', message: 'Registration is temporarily closed (instance at capacity).' };
const salt = crypto.randomBytes(16).toString('hex');
const u: User = { id: crypto.randomUUID(), username, salt, hash: await hashPassword(password, salt), tokens: [], createdAt: this.now(), updatedAt: this.now() };
const u: User = { id: crypto.randomUUID(), username, salt, hash: await hashPassword(password, salt), tokens: [], blobVersion: 0, createdAt: this.now(), updatedAt: this.now() };
const token = this.issueToken(u);
this.users.set(username.toLowerCase(), u);
this.byId.set(u.id, u);
await this.persist();
this.persistUser(u);
return { ok: true, token, username: u.username };
}
async login(username: string, password: string): Promise<AuthResult | AuthError> {
await this.load();
const u = this.users.get((username ?? '').toLowerCase());
// Hash even when the user is unknown (against a throwaway salt) so the response
// time doesn't reveal whether a username exists.
const salt = u?.salt ?? 'absent';
const candidate = await hashPassword(password ?? '', salt);
if (!u || !timingEqual(u.hash, candidate)) return { ok: false, code: 'bad-credentials', message: 'Wrong username or password.' };
const key = (username ?? '').toLowerCase();
const now = this.now();
const t = this.loginThrottle.get(key);
if (t && t.lockedUntil > now) {
// Burn a constant-time hash so a locked key isn't measurably faster/slower
// than a live login (keeps the lockout itself enumeration-neutral).
await hashPassword('', DUMMY_SALT);
return { ok: false, code: 'locked', message: 'Too many attempts. Try again later.' };
}
const pw = (password ?? '').slice(0, MAX_PASSWORD);
const u = this.users.get(key);
// Always run a hash (against a dummy salt when the user is missing) so login
// timing doesn't reveal whether an account exists (anti-enumeration).
const candidate = await hashPassword(pw, u?.salt ?? DUMMY_SALT);
if (!u || !timingEqual(u.hash, candidate)) {
this.recordLoginFailure(key, now);
return { ok: false, code: 'bad-credentials', message: 'Wrong username or password.' };
}
this.loginThrottle.delete(key); // success clears the streak
const token = this.issueToken(u);
await this.persist();
this.persistUser(u);
return { ok: true, token, username: u.username };
}
/** Count a failed login for `key` and (re)arm the backoff lock once over threshold. */
private recordLoginFailure(key: string, now: number): void {
const e = this.loginThrottle.get(key);
// Decay: a streak that's been quiet past the reset window starts over.
const fails = e && now - e.lastFail <= LOGIN_FAIL_RESET_MS ? e.fails + 1 : 1;
let lockedUntil = 0;
if (fails >= LOGIN_MAX_FAILS) {
const over = fails - LOGIN_MAX_FAILS; // 0,1,2,… → 1,2,4,… min, capped
lockedUntil = now + Math.min(LOGIN_BASE_LOCK_MS * 2 ** over, LOGIN_MAX_LOCK_MS);
}
this.loginThrottle.set(key, { fails, lockedUntil, lastFail: now });
if (this.loginThrottle.size > LOGIN_THROTTLE_MAX) this.pruneLoginThrottle(now);
}
private pruneLoginThrottle(now: number): void {
for (const [k, v] of this.loginThrottle) {
if (v.lockedUntil <= now && now - v.lastFail > LOGIN_FAIL_RESET_MS) this.loginThrottle.delete(k);
}
}
/**
* Throttle key for authenticated re-auth checks (password change / account
* delete), keyed by user id. The ':' can't appear in a username (USERNAME_RE),
* so these keys can never collide with login throttle entries. Without this a
* stolen bearer token allowed ~60 old-password guesses/min against the generic
* HTTP bucket — versus login's exponential backoff.
*/
private reauthKey(id: string): string { return `reauth:${id}`; }
private reauthLocked(id: string): boolean {
const t = this.loginThrottle.get(this.reauthKey(id));
return !!t && t.lockedUntil > this.now();
}
async userByToken(token: string | undefined): Promise<User | null> {
if (!token) return null;
await this.load();
return this.byToken.get(sha256(token)) ?? null;
const h = sha256(token);
const id = this.byToken.get(h); // O(1) — see byToken (T-128)
if (!id) return null;
const u = this.byId.get(id);
if (!u) return null;
const rec = u.tokens.find((t) => t.hash === h);
if (!rec) return null;
// Expired tokens stop authenticating; prune lazily in memory (no write on the
// read path — the next persistUser() snapshots the cleaned set; reload re-checks).
if (rec.expiresAt <= this.now()) {
u.tokens = u.tokens.filter((t) => t.hash !== h);
this.byToken.delete(h);
return null;
}
return u;
}
async logout(token: string): Promise<void> {
await this.load();
const h = sha256(token);
const u = this.byToken.get(h);
const u = await this.userByToken(token);
if (!u) return;
u.tokens = u.tokens.filter((t) => t !== h);
const h = sha256(token);
u.tokens = u.tokens.filter((t) => t.hash !== h);
this.byToken.delete(h);
await this.persist();
this.persistUser(u);
}
/** Write the backup blob and return its version token (the file mtime, ms). */
async saveBlob(id: string, blob: string): Promise<number> {
await fs.mkdir(path.join(this.dir, 'blobs'), { recursive: true });
const tmp = `${this.blobFile(id)}.tmp`;
await fs.writeFile(tmp, blob);
await fs.rename(tmp, this.blobFile(id));
return Math.floor((await fs.stat(this.blobFile(id))).mtimeMs);
/** Rotate a still-valid token for a fresh one (sliding session) — T-133. */
async refresh(token: string): Promise<AuthResult | AuthError> {
const u = await this.userByToken(token); // validates presence + expiry
if (!u) return { ok: false, code: 'bad-token', message: 'Session expired — sign in again.' };
const h = sha256(token);
u.tokens = u.tokens.filter((t) => t.hash !== h); // revoke the presented token
this.byToken.delete(h);
const next = this.issueToken(u);
this.persistUser(u);
return { ok: true, token: next, username: u.username };
}
/**
* Constant-time password check for a known user id (account-delete
* confirmation). Backoff-locked like login: while locked it always fails
* (burning a constant-time hash), and each mismatch feeds the same
* exponential lock so a stolen token can't brute-force the password.
*/
async verifyPassword(id: string, password: string): Promise<boolean> {
await this.load();
if (this.reauthLocked(id)) {
await hashPassword('', DUMMY_SALT);
return false;
}
const u = this.byId.get(id);
const candidate = await hashPassword((password ?? '').slice(0, MAX_PASSWORD), u?.salt ?? DUMMY_SALT);
const ok = !!u && timingEqual(u.hash, candidate);
if (ok) this.loginThrottle.delete(this.reauthKey(id));
else this.recordLoginFailure(this.reauthKey(id), this.now());
return ok;
}
/** Change a password: re-salt+hash, revoke every existing session, issue a fresh token (T-133). */
async changePassword(id: string, oldPassword: string, newPassword: string): Promise<AuthResult | AuthError> {
await this.load();
const u = this.byId.get(id);
if (!u) return { ok: false, code: 'no-user', message: 'No such account.' };
if (typeof newPassword !== 'string' || newPassword.length < 8) return { ok: false, code: 'weak-password', message: 'Password must be at least 8 characters.' };
if (newPassword.length > MAX_PASSWORD) return { ok: false, code: 'weak-password', message: 'Password is too long.' };
if (this.reauthLocked(id)) {
await hashPassword('', DUMMY_SALT);
return { ok: false, code: 'locked', message: 'Too many attempts. Try again later.' };
}
const candidate = await hashPassword((oldPassword ?? '').slice(0, MAX_PASSWORD), u.salt);
if (!timingEqual(u.hash, candidate)) {
this.recordLoginFailure(this.reauthKey(id), this.now());
return { ok: false, code: 'bad-credentials', message: 'Wrong password.' };
}
this.loginThrottle.delete(this.reauthKey(id));
u.salt = crypto.randomBytes(16).toString('hex');
u.hash = await hashPassword(newPassword, u.salt);
for (const t of u.tokens) this.byToken.delete(t.hash); // log out all other devices
u.tokens = [];
const token = this.issueToken(u);
this.persistUser(u);
return { ok: true, token, username: u.username };
}
/** Delete an account: drop the user, its tokens, and its backup blob (T-133). */
async deleteAccount(id: string): Promise<boolean> {
await this.load();
const u = this.byId.get(id);
if (!u) return false;
this.users.delete(u.username.toLowerCase());
this.byId.delete(u.id);
for (const t of u.tokens) this.byToken.delete(t.hash);
this.deleteUserRows(id);
return true;
}
/** Current optimistic-concurrency version of a user's blob (0 if none yet) — T-130. */
blobVersionFor(id: string): number { return this.byId.get(id)?.blobVersion ?? 0; }
/**
* Persist a user's backup blob with optimistic concurrency (T-130). When
* `ifMatch` is supplied it must equal the server's current version or the write
* is rejected as a conflict (no overwrite) — a stale client can't clobber a
* newer copy. Returns the new version on success. Omitting `ifMatch` keeps the
* legacy last-write-wins behaviour for older clients (the HTTP layer gates
* that path behind an explicit `force` when a copy already exists).
*/
async saveBlob(id: string, blob: string, ifMatch?: number): Promise<{ ok: true; version: number } | { ok: false; code: 'conflict'; version: number }> {
// Reject structurally-malformed blobs before they're persisted (T-135). Throwing
// a 400-tagged error keeps the existing conflict result shape intact while still
// surfacing a clean Bad Request for garbage.
const problem = backupBlobProblem(blob);
if (problem) throw new PayloadError(`Malformed backup: ${problem}.`);
await this.load();
const u = this.byId.get(id);
const current = u?.blobVersion ?? 0;
if (ifMatch !== undefined && ifMatch !== current) return { ok: false, code: 'conflict', version: current };
const version = current + 1;
this.db
.query(
`INSERT INTO blobs (user_id, blob, bytes, version) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET blob = excluded.blob, bytes = excluded.bytes, version = excluded.version`,
)
.run(id, blob, Buffer.byteLength(blob), version);
if (u) { u.blobVersion = version; u.updatedAt = this.now(); this.persistUser(u); }
return { ok: true, version };
}
async loadBlob(id: string): Promise<string | null> {
try { return await fs.readFile(this.blobFile(id), 'utf8'); } catch { return null; }
}
/** Version token of the stored blob (file mtime, ms), or null if none. Used for
* optimistic-concurrency conflict detection so a second device can't silently
* overwrite a newer cloud copy. */
async blobSavedAt(id: string): Promise<number | null> {
try { return Math.floor((await fs.stat(this.blobFile(id))).mtimeMs); } catch { return null; }
await this.load();
const row = this.db.query<BlobRow>('SELECT blob FROM blobs WHERE user_id = ?').get(id);
return row ? row.blob : null;
}
userCount(): number { return this.users.size; }
+121 -24
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
import { CloudStore, MAX_CHARACTER_BYTES, characterDataProblem, PayloadError } from './campaigns';
describe('CloudStore', () => {
let dir: string;
@@ -31,10 +31,10 @@ describe('CloudStore', () => {
await store.joinByInvite('p1', c.inviteCode);
await store.joinByInvite('p2', c.inviteCode);
expect(await store.putCharacter('stranger', c.id, { id: 'ch1', name: 'X', data: '{}' })).toBeNull(); // not a member
expect((await store.putCharacter('stranger', c.id, { id: 'ch1', name: 'X', data: '{}' })).ok).toBe(false); // not a member
const r = await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{"hp":1}' });
expect(r?.ownerUserId).toBe('p1');
expect(await store.putCharacter('p2', c.id, { id: 'ch1', name: 'hijack', data: '{}' })).toBeNull(); // p2 can't update p1's char
expect(r.ok && r.character.ownerUserId).toBe('p1');
expect((await store.putCharacter('p2', c.id, { id: 'ch1', name: 'hijack', data: '{}' })).ok).toBe(false); // p2 can't update p1's char
const all = await store.listCharacters(c.id);
expect(all).toHaveLength(1);
@@ -61,16 +61,18 @@ describe('CloudStore', () => {
expect(await store.joinByInvite('p9', mine.inviteCode)).toBeNull(); // invite dead
expect((await store.totals()).campaigns).toBe(1);
expect(await store.deleteCampaign(theirs.id)).toBe(true);
expect(await store.deleteCampaign(theirs.id)).toBe(false); // already gone
expect(await store.adminDeleteCampaign(theirs.id)).toBe(true);
expect(await store.adminDeleteCampaign(theirs.id)).toBe(false); // already gone
expect((await store.totals()).campaigns).toBe(0);
});
it('rejects an oversized character and reports usage deltas', async () => {
const c = await store.createCampaign('gm1', 'C', '5e');
await store.joinByInvite('p1', c.inviteCode);
const huge = 'x'.repeat(MAX_CHARACTER_BYTES + 1);
expect(await store.putCharacter('p1', c.id, { id: 'big', name: 'Big', data: huge })).toBeNull();
const huge = `{"pad":"${'x'.repeat(MAX_CHARACTER_BYTES + 1)}"}`;
const rejected = await store.putCharacter('p1', c.id, { id: 'big', name: 'Big', data: huge });
expect(rejected.ok).toBe(false);
if (!rejected.ok) expect(rejected.code).toBe('too-large');
expect(await store.characterBytes('big')).toBe(0); // nothing stored
await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{"hp":1}' });
@@ -93,24 +95,119 @@ describe('CloudStore', () => {
expect(await store2.listCharacters(c.id)).toHaveLength(0);
});
it('rotates the invite code (owner only) so the old code stops working', async () => {
const c = await store.createCampaign('gm1', 'C', '5e');
const old = c.inviteCode;
expect(await store.rotateInvite('p1', c.id)).toBeNull(); // non-owner can't
const fresh = await store.rotateInvite('gm1', c.id);
expect(fresh).toBeTruthy();
expect(fresh).not.toBe(old);
expect(await store.joinByInvite('p1', old)).toBeNull(); // old code is dead
expect((await store.joinByInvite('p1', fresh!))?.id).toBe(c.id); // new code works
});
it('removes a member and their characters (owner only)', async () => {
it('rejects a stale character write with a conflict but allows a matching version (T-130)', async () => {
const c = await store.createCampaign('gm1', 'C', '5e');
await store.joinByInvite('p1', c.inviteCode);
const first = await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{"hp":1}' });
if (!first.ok) throw new Error('first put failed');
expect(first.character.version).toBe(1);
// A device holding version 1 updates cleanly → version 2.
const second = await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{"hp":2}', version: 1 });
expect(second.ok && second.character.version).toBe(2);
// A stale device still on version 1 is refused (no clobber).
const stale = await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{"hp":99}', version: 1 });
expect(stale.ok).toBe(false);
if (!stale.ok) { expect(stale.code).toBe('conflict'); if (stale.code === 'conflict') expect(stale.version).toBe(2); }
// The server copy is untouched.
expect((await store.listCharacters(c.id))[0]?.data).toBe('{"hp":2}');
// Omitting the version keeps last-write-wins for older clients.
const lww = await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{"hp":3}' });
expect(lww.ok && lww.character.version).toBe(3);
});
it('membership lifecycle: leave, removeMember, rotateInvite, transfer, delete (T-131)', async () => {
const c = await store.createCampaign('gm1', 'C', '5e');
await store.joinByInvite('p1', c.inviteCode);
await store.joinByInvite('p2', c.inviteCode);
await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{}' });
expect(await store.removeMember('p1', c.id, 'p1')).toBe(false); // non-owner can't
expect(await store.removeMember('gm1', c.id, 'p1')).toBe(true);
expect(store.isMember(c.id, 'p1')).toBe(false);
expect(await store.listCharacters(c.id)).toHaveLength(0); // their char is gone
// Only the owner can manage; a non-owner removeMember is forbidden.
const denied = await store.removeMember('p2', c.id, 'p1');
expect(denied.ok).toBe(false);
if (!denied.ok) expect(denied.code).toBe('forbidden');
// Owner evicts p2 (a member with no characters).
expect((await store.removeMember('gm1', c.id, 'p2')).ok).toBe(true);
expect(store.isMember(c.id, 'p2')).toBe(false);
// The owner can't leave their own campaign.
const ownerLeave = await store.leaveCampaign('gm1', c.id);
expect(ownerLeave.ok).toBe(false);
if (!ownerLeave.ok) expect(ownerLeave.code).toBe('owner-cannot-leave');
// Rotate the invite — the old code stops working, the new one works.
const oldCode = c.inviteCode;
const rot = await store.rotateInvite('gm1', c.id);
if (!rot.ok) throw new Error('rotate failed');
expect(rot.inviteCode).not.toBe(oldCode);
expect(await store.joinByInvite('p3', oldCode)).toBeNull();
expect((await store.joinByInvite('p3', rot.inviteCode))?.id).toBe(c.id);
// Transfer ownership to a member; the old owner becomes a member.
const badTransfer = await store.transferOwnership('gm1', c.id, 'stranger');
expect(badTransfer.ok).toBe(false);
if (!badTransfer.ok) expect(badTransfer.code).toBe('not-member');
expect((await store.transferOwnership('gm1', c.id, 'p1')).ok).toBe(true);
expect(store.isOwner(c.id, 'p1')).toBe(true);
expect(store.isMember(c.id, 'gm1')).toBe(true);
expect(store.isOwner(c.id, 'gm1')).toBe(false);
// p1 (now owner) leaves is blocked; a member can leave with their characters.
expect((await store.leaveCampaign('gm1', c.id)).ok).toBe(true); // gm1 is now a member
// New owner deletes the campaign → cascade characters + invite.
const notOwnerDelete = await store.deleteCampaign('p3', c.id);
expect(notOwnerDelete.ok).toBe(false);
if (!notOwnerDelete.ok) expect(notOwnerDelete.code).toBe('forbidden');
expect((await store.deleteCampaign('p1', c.id)).ok).toBe(true);
expect(await store.listCharacters(c.id)).toHaveLength(0);
expect(await store.joinByInvite('p4', rot.inviteCode)).toBeNull(); // invite gone
});
it('purgeUser cascades owned campaigns, memberships, and characters (T-133)', async () => {
const owned = await store.createCampaign('gm1', 'Owned', '5e');
const other = await store.createCampaign('gm2', 'Other', '5e');
await store.joinByInvite('gm1', other.inviteCode); // gm1 is a member of gm2's campaign
await store.putCharacter('gm1', owned.id, { id: 'a', name: 'A', data: '{}' });
await store.putCharacter('gm1', other.id, { id: 'b', name: 'B', data: '{}' });
await store.purgeUser('gm1');
expect((await store.listForUser('gm1'))).toHaveLength(0);
// gm1's own campaign + its characters are gone…
expect(await store.joinByInvite('x', owned.inviteCode)).toBeNull();
// …and gm1 is removed from gm2's campaign, including the character it owned there.
expect(store.isMember(other.id, 'gm1')).toBe(false);
expect(await store.listCharacters(other.id)).toHaveLength(0);
});
it('putCharacter throws a 400-tagged PayloadError on malformed character data (T-135)', async () => {
const c = await store.createCampaign('p1', 'Camp', '5e');
await expect(store.putCharacter('p1', c.id, { id: 'ch1', name: 'A', data: 'not json' })).rejects.toBeInstanceOf(PayloadError);
await expect(store.putCharacter('p1', c.id, { id: 'ch1', name: 'A', data: '[1,2,3]' })).rejects.toMatchObject({ statusCode: 400 });
// Nothing was stored for the rejected writes.
expect(await store.listCharacters(c.id)).toHaveLength(0);
// A well-formed object is accepted.
expect((await store.putCharacter('p1', c.id, { id: 'ch1', name: 'A', data: '{"id":"ch1","name":"A"}' })).ok).toBe(true);
});
});
describe('characterDataProblem (T-135)', () => {
it('accepts JSON objects, including the legacy minimal/empty shape', () => {
expect(characterDataProblem('{}')).toBeNull();
expect(characterDataProblem('{"hp":1}')).toBeNull();
expect(characterDataProblem('{"id":"c1","name":"Lia","system":"5e"}')).toBeNull();
});
it('rejects non-JSON, arrays, primitives, and wrong-typed core fields', () => {
expect(characterDataProblem('not json')).toBe('not valid JSON');
expect(characterDataProblem('[1,2,3]')).toBe('expected a JSON object');
expect(characterDataProblem('"x"')).toBe('expected a JSON object');
expect(characterDataProblem('5')).toBe('expected a JSON object');
expect(characterDataProblem('null')).toBe('expected a JSON object');
expect(characterDataProblem('{"id":7}')).toBe('id must be a string');
expect(characterDataProblem('{"name":7}')).toBe('name must be a string');
expect(characterDataProblem('{"system":7}')).toBe('system must be a string');
expect(characterDataProblem(`{"name":"${'x'.repeat(201)}"}`)).toBe('name too long');
});
});
+317 -98
View File
@@ -1,14 +1,17 @@
import crypto from 'node:crypto';
import { promises as fs } from 'node:fs';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { openDatabase, type Db } from './db';
/**
* File-backed cloud store for shared campaigns + member-owned characters. Sits
* alongside AccountStore (users). A campaign has an owner (the GM) and members
* (players who joined by invite code). Characters are owned by the player who
* published them — only the owner updates them (last-write-wins); the GM gets
* read access to all of a campaign's characters. Single-process; writes are
* serialised. Swap for a real DB later without changing the wire shape.
* sqlite-backed cloud store for shared campaigns + member-owned characters (T-129).
* Sits alongside AccountStore (users) on the same database. A campaign has an owner
* (the GM) and members (players who joined by invite code). Characters are owned by
* the player who published them — only the owner updates them (last-write-wins); the
* GM gets read access to all of a campaign's characters. Each mutation now touches
* only the affected rows (a character upsert no longer rewrites every other inline
* character, the prior cloud.json failure mode). The maps below are an in-memory read
* cache rebuilt on load; sqlite is the source of truth.
*/
export interface CloudCampaign {
@@ -28,46 +31,175 @@ export interface CloudCharacter {
ownerUserId: string;
name: string;
data: string; // serialized character JSON
version: number; // optimistic-concurrency version, bumped on every write (T-130)
updatedAt: number;
}
/** Result of an optimistic-concurrency character upsert (T-130). */
export type PutCharacterResult =
| { ok: true; character: CloudCharacter }
| { ok: false; code: 'forbidden' }
| { ok: false; code: 'too-large' }
| { ok: false; code: 'conflict'; version: number };
/** Result of a membership-lifecycle mutation (T-131). */
export type MembershipResult =
| { ok: true }
| { ok: false; code: 'not-found' | 'forbidden' | 'owner-cannot-leave' | 'not-member' };
const INVITE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
/** Generous upper bound on a character's display name inside its serialized data. */
const MAX_CHAR_NAME = 200;
/** A single published character can't exceed this (a sheet with an embedded portrait
* is well under 2 MB); a hard stop before per-user quota even comes into play. */
export const MAX_CHARACTER_BYTES = 2 * 1024 * 1024;
/**
* Thrown when a stored payload is structurally invalid (T-135). Fastify's default
* error handler honours `statusCode`, so a malformed character surfaces as a clean
* 400 without the route handler special-casing a new result code.
*/
export class PayloadError extends Error {
readonly statusCode = 400;
constructor(message: string) { super(message); this.name = 'PayloadError'; }
}
/**
* Structural guard for a published character's serialized `data` (T-135). The client
* sends `JSON.stringify(character)` — a JSON object describing one character. We
* require it parse to a plain object and that any id/name/system it carries have the
* right primitive type, so a stale or hostile client can't store a non-object (an
* array, a bare number, truncated JSON) that another member would later fail to
* import.
*
* Dependency-free and shallow by design: the authoritative character schema lives on
* the client and re-validates on import; the server only guards the envelope.
* Returns the offending reason, or null when acceptable.
*/
export function characterDataProblem(data: string): string | null {
let parsed: unknown;
try { parsed = JSON.parse(data); } catch { return 'not valid JSON'; }
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return 'expected a JSON object';
const obj = parsed as Record<string, unknown>;
if (obj.id !== undefined && typeof obj.id !== 'string') return 'id must be a string';
if (obj.name !== undefined && typeof obj.name !== 'string') return 'name must be a string';
if (typeof obj.name === 'string' && obj.name.length > MAX_CHAR_NAME) return 'name too long';
if (obj.system !== undefined && typeof obj.system !== 'string') return 'system must be a string';
return null;
}
interface CampaignRow { id: string; owner_user_id: string; name: string; system: string; invite_code: string; created_at: number; updated_at: number }
interface MemberRow { campaign_id: string; user_id: string }
interface CharacterRow { id: string; campaign_id: string; owner_user_id: string; name: string; data: string; version: number; updated_at: number }
interface CountRow { c: number }
export class CloudStore {
private campaigns = new Map<string, CloudCampaign>();
private characters = new Map<string, CloudCharacter>();
private byInvite = new Map<string, string>();
private queue: Promise<unknown> = Promise.resolve();
private db!: Db;
private loaded = false;
private loadPromise: Promise<void> | null = null;
constructor(private dir: string, private now: () => number = () => Date.now()) {}
private file() { return path.join(this.dir, 'cloud.json'); }
/** Memoized so a request that lands mid-boot awaits the read instead of seeing an empty store. */
load(): Promise<void> {
this.loadPromise ??= (async () => {
try {
const raw = JSON.parse(await fs.readFile(this.file(), 'utf8')) as { campaigns: CloudCampaign[]; characters: CloudCharacter[] };
for (const c of raw.campaigns ?? []) { this.campaigns.set(c.id, c); this.byInvite.set(c.inviteCode, c.id); }
for (const ch of raw.characters ?? []) this.characters.set(ch.id, ch);
} catch (e) {
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[cloud] load failed', e);
}
})();
async load(): Promise<void> {
if (this.loaded) return;
// Latch a single in-flight load so a concurrent mutation during startup can't
// operate on an empty cache (T-120). sqlite is the source of truth; the latch
// guards the one-time legacy import + cache hydration.
if (!this.loadPromise) {
this.loadPromise = (async () => {
this.db = openDatabase(this.dir);
this.migrateLegacy();
this.hydrate();
this.loaded = true;
})();
}
return this.loadPromise;
}
private persist(): Promise<void> {
this.queue = this.queue.then(async () => {
await fs.mkdir(this.dir, { recursive: true });
const tmp = `${this.file()}.tmp`;
await fs.writeFile(tmp, JSON.stringify({ campaigns: [...this.campaigns.values()], characters: [...this.characters.values()] }));
await fs.rename(tmp, this.file());
}).catch((e) => { console.error('[cloud] persist failed — data may be lost on restart', e); });
return this.queue as Promise<void>;
/** One-time import of a legacy cloud.json into sqlite (T-129). Idempotent via a meta
* flag so it never re-runs even if every campaign is later deleted. */
private migrateLegacy(): void {
const done = this.db.query<{ value: string }>("SELECT value FROM meta WHERE key = 'migrated_cloud'").get();
if (done) return;
const existing = this.db.query<CountRow>('SELECT count(*) AS c FROM campaigns').get();
if (!existing || existing.c === 0) {
let raw: { campaigns?: CloudCampaign[]; characters?: CloudCharacter[] } = {};
try { raw = JSON.parse(readFileSync(this.file(), 'utf8')) as typeof raw; } catch { raw = {}; }
const insCampaign = this.db.query('INSERT OR IGNORE INTO campaigns (id, owner_user_id, name, system, invite_code, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)');
const insMember = this.db.query('INSERT OR IGNORE INTO campaign_members (campaign_id, user_id) VALUES (?, ?)');
const insChar = this.db.query('INSERT OR IGNORE INTO characters (id, campaign_id, owner_user_id, name, data, version, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)');
const tx = this.db.transaction(() => {
for (const c of raw.campaigns ?? []) {
if (!c || typeof c.id !== 'string') continue;
insCampaign.run(c.id, c.ownerUserId, c.name, c.system, c.inviteCode, c.createdAt ?? this.now(), c.updatedAt ?? this.now());
for (const m of c.members ?? []) insMember.run(c.id, m);
}
for (const ch of raw.characters ?? []) {
if (!ch || typeof ch.id !== 'string') continue;
insChar.run(ch.id, ch.campaignId, ch.ownerUserId, ch.name, ch.data, ch.version ?? 1, ch.updatedAt ?? this.now());
}
});
tx();
}
this.db.query("INSERT OR REPLACE INTO meta (key, value) VALUES ('migrated_cloud', '1')").run();
}
/** Rebuild the in-memory read cache (campaigns, members, characters, byInvite). */
private hydrate(): void {
for (const r of this.db.query<CampaignRow>('SELECT * FROM campaigns').all()) {
this.campaigns.set(r.id, { id: r.id, ownerUserId: r.owner_user_id, name: r.name, system: r.system, inviteCode: r.invite_code, members: [], createdAt: r.created_at, updatedAt: r.updated_at });
this.byInvite.set(r.invite_code, r.id);
}
for (const m of this.db.query<MemberRow>('SELECT campaign_id, user_id FROM campaign_members').all()) {
this.campaigns.get(m.campaign_id)?.members.push(m.user_id);
}
for (const r of this.db.query<CharacterRow>('SELECT * FROM characters').all()) {
this.characters.set(r.id, { id: r.id, campaignId: r.campaign_id, ownerUserId: r.owner_user_id, name: r.name, data: r.data, version: r.version, updatedAt: r.updated_at });
}
}
/** Run `fn` inside a sqlite transaction (atomic multi-row writes). */
private tx(fn: () => void): void { this.db.transaction(fn)(); }
private writeCampaignRow(c: CloudCampaign): void {
this.db
.query(
`INSERT INTO campaigns (id, owner_user_id, name, system, invite_code, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
owner_user_id = excluded.owner_user_id, name = excluded.name, system = excluded.system,
invite_code = excluded.invite_code, updated_at = excluded.updated_at`,
)
.run(c.id, c.ownerUserId, c.name, c.system, c.inviteCode, c.createdAt, c.updatedAt);
}
private writeMembers(c: CloudCampaign): void {
this.db.query('DELETE FROM campaign_members WHERE campaign_id = ?').run(c.id);
const ins = this.db.query('INSERT INTO campaign_members (campaign_id, user_id) VALUES (?, ?)');
for (const m of c.members) ins.run(c.id, m);
}
/** Persist a campaign row + its membership set atomically. */
private persistCampaign(c: CloudCampaign): void {
this.tx(() => { this.writeCampaignRow(c); this.writeMembers(c); });
}
private writeCharacter(ch: CloudCharacter): void {
this.db
.query(
`INSERT INTO characters (id, campaign_id, owner_user_id, name, data, version, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
campaign_id = excluded.campaign_id, owner_user_id = excluded.owner_user_id,
name = excluded.name, data = excluded.data, version = excluded.version, updated_at = excluded.updated_at`,
)
.run(ch.id, ch.campaignId, ch.ownerUserId, ch.name, ch.data, ch.version, ch.updatedAt);
}
private mintInvite(): string {
@@ -86,36 +218,10 @@ export class CloudStore {
};
this.campaigns.set(c.id, c);
this.byInvite.set(c.inviteCode, c.id);
await this.persist();
this.persistCampaign(c);
return c;
}
/** Owner-only: invalidate the current invite code and mint a new one, so a
* leaked/old code can no longer be used to join. Returns the new code. */
async rotateInvite(ownerUserId: string, campaignId: string): Promise<string | null> {
await this.load();
const c = this.campaigns.get(campaignId);
if (!c || c.ownerUserId !== ownerUserId) return null;
this.byInvite.delete(c.inviteCode);
c.inviteCode = this.mintInvite();
c.updatedAt = this.now();
this.byInvite.set(c.inviteCode, c.id);
await this.persist();
return c.inviteCode;
}
/** Owner-only: remove a member and all of their published characters. */
async removeMember(ownerUserId: string, campaignId: string, memberUserId: string): Promise<boolean> {
await this.load();
const c = this.campaigns.get(campaignId);
if (!c || c.ownerUserId !== ownerUserId || memberUserId === ownerUserId) return false;
c.members = c.members.filter((m) => m !== memberUserId);
c.updatedAt = this.now();
for (const [id, ch] of this.characters) if (ch.campaignId === campaignId && ch.ownerUserId === memberUserId) this.characters.delete(id);
await this.persist();
return true;
}
isMember(campaignId: string, userId: string): boolean {
const c = this.campaigns.get(campaignId);
return !!c && (c.ownerUserId === userId || c.members.includes(userId));
@@ -139,21 +245,37 @@ export class CloudStore {
const id = this.byInvite.get(inviteCode.trim().toUpperCase());
const c = id ? this.campaigns.get(id) : undefined;
if (!c) return null;
if (c.ownerUserId !== userId && !c.members.includes(userId)) { c.members.push(userId); c.updatedAt = this.now(); await this.persist(); }
if (c.ownerUserId !== userId && !c.members.includes(userId)) { c.members.push(userId); c.updatedAt = this.now(); this.persistCampaign(c); }
return c;
}
/** Upsert a character the user owns. Returns null if they're not a member or don't own an existing record. */
async putCharacter(userId: string, campaignId: string, char: { id: string; name: string; data: string }): Promise<CloudCharacter | null> {
/**
* Upsert a character the user owns, with optimistic concurrency (T-130). When
* `char.version` is supplied it must match the server's current version or the
* write is rejected as a conflict — so a stale device can't silently overwrite a
* newer copy. Omitting it keeps last-write-wins for older clients. `forbidden`
* means not a member, doesn't own the record, or is targeting another campaign.
*/
async putCharacter(userId: string, campaignId: string, char: { id: string; name: string; data: string; version?: number }): Promise<PutCharacterResult> {
// Reject structurally-malformed character JSON before it's persisted (T-135).
// Throwing a 400-tagged error keeps the forbidden/conflict result shape intact
// while surfacing a clean Bad Request for garbage.
const problem = characterDataProblem(char.data);
if (problem) throw new PayloadError(`Malformed character: ${problem}.`);
await this.load();
if (!this.isMember(campaignId, userId)) return null;
if (Buffer.byteLength(char.data) > MAX_CHARACTER_BYTES) return null; // oversized blob — reject
if (!this.isMember(campaignId, userId)) return { ok: false, code: 'forbidden' };
// Hard per-character size stop, before per-user quota even comes into play.
if (Buffer.byteLength(char.data) > MAX_CHARACTER_BYTES) return { ok: false, code: 'too-large' };
const existing = this.characters.get(char.id);
if (existing && existing.ownerUserId !== userId) return null; // only the owner may update
const record: CloudCharacter = { id: char.id, campaignId, ownerUserId: existing?.ownerUserId ?? userId, name: char.name.slice(0, 120), data: char.data, updatedAt: this.now() };
if (existing) {
if (existing.ownerUserId !== userId) return { ok: false, code: 'forbidden' }; // only the owner may update
if (existing.campaignId !== campaignId) return { ok: false, code: 'forbidden' }; // can't move a record across campaigns
if (char.version !== undefined && char.version !== existing.version) return { ok: false, code: 'conflict', version: existing.version };
}
const record: CloudCharacter = { id: char.id, campaignId, ownerUserId: existing?.ownerUserId ?? userId, name: char.name.slice(0, 120), data: char.data, version: (existing?.version ?? 0) + 1, updatedAt: this.now() };
this.characters.set(record.id, record);
await this.persist();
return record;
this.writeCharacter(record);
return { ok: true, character: record };
}
async listCharacters(campaignId: string): Promise<CloudCharacter[]> {
@@ -167,7 +289,7 @@ export class CloudStore {
if (!ch) return false;
if (ch.ownerUserId !== userId && !this.isOwner(ch.campaignId, userId)) return false; // owner of char or campaign owner
this.characters.delete(characterId);
await this.persist();
this.db.query('DELETE FROM characters WHERE id = ?').run(characterId);
return true;
}
@@ -183,6 +305,137 @@ export class CloudStore {
return this.characters.get(characterId)?.ownerUserId === userId;
}
// ---- membership lifecycle (T-131) ----
/** Remove every character belonging to `userId` within one campaign (cache + db). */
private dropCharactersIn(campaignId: string, userId: string): void {
for (const ch of [...this.characters.values()]) {
if (ch.campaignId === campaignId && ch.ownerUserId === userId) this.characters.delete(ch.id);
}
this.db.query('DELETE FROM characters WHERE campaign_id = ? AND owner_user_id = ?').run(campaignId, userId);
}
/** A member removes themselves (with their characters). The owner can't leave — transfer or delete instead. */
async leaveCampaign(userId: string, campaignId: string): Promise<MembershipResult> {
await this.load();
const c = this.campaigns.get(campaignId);
if (!c) return { ok: false, code: 'not-found' };
if (c.ownerUserId === userId) return { ok: false, code: 'owner-cannot-leave' };
if (!c.members.includes(userId)) return { ok: false, code: 'not-member' };
c.members = c.members.filter((m) => m !== userId);
c.updatedAt = this.now();
this.tx(() => { this.persistCampaignInTx(c); this.dropCharactersIn(campaignId, userId); });
return { ok: true };
}
/** Owner-only: evict a member and their characters from the campaign. */
async removeMember(ownerUserId: string, campaignId: string, targetUserId: string): Promise<MembershipResult> {
await this.load();
const c = this.campaigns.get(campaignId);
if (!c) return { ok: false, code: 'not-found' };
if (c.ownerUserId !== ownerUserId) return { ok: false, code: 'forbidden' };
if (targetUserId === ownerUserId || !c.members.includes(targetUserId)) return { ok: false, code: 'not-member' };
c.members = c.members.filter((m) => m !== targetUserId);
c.updatedAt = this.now();
this.tx(() => { this.persistCampaignInTx(c); this.dropCharactersIn(campaignId, targetUserId); });
return { ok: true };
}
/** Campaign row + members write without opening its own transaction (for use inside a tx). */
private persistCampaignInTx(c: CloudCampaign): void { this.writeCampaignRow(c); this.writeMembers(c); }
/** Owner-only: mint a fresh invite code, revoking the old one (leak containment). */
async rotateInvite(ownerUserId: string, campaignId: string): Promise<{ ok: true; inviteCode: string } | { ok: false; code: 'not-found' | 'forbidden' }> {
await this.load();
const c = this.campaigns.get(campaignId);
if (!c) return { ok: false, code: 'not-found' };
if (c.ownerUserId !== ownerUserId) return { ok: false, code: 'forbidden' };
this.byInvite.delete(c.inviteCode);
c.inviteCode = this.mintInvite();
this.byInvite.set(c.inviteCode, c.id);
c.updatedAt = this.now();
this.persistCampaign(c);
return { ok: true, inviteCode: c.inviteCode };
}
/** Cascade-delete a campaign: its characters, membership rows, and invite. */
private cascadeDelete(c: CloudCampaign): void {
for (const ch of [...this.characters.values()]) if (ch.campaignId === c.id) this.characters.delete(ch.id);
this.byInvite.delete(c.inviteCode);
this.campaigns.delete(c.id);
this.tx(() => {
this.db.query('DELETE FROM characters WHERE campaign_id = ?').run(c.id);
this.db.query('DELETE FROM campaign_members WHERE campaign_id = ?').run(c.id);
this.db.query('DELETE FROM campaigns WHERE id = ?').run(c.id);
});
}
/** Owner-only: delete a campaign and cascade its characters + invite. */
async deleteCampaign(ownerUserId: string, campaignId: string): Promise<MembershipResult> {
await this.load();
const c = this.campaigns.get(campaignId);
if (!c) return { ok: false, code: 'not-found' };
if (c.ownerUserId !== ownerUserId) return { ok: false, code: 'forbidden' };
this.cascadeDelete(c);
return { ok: true };
}
/** Admin: delete any campaign (no ownership check) and everything published into it. */
async adminDeleteCampaign(campaignId: string): Promise<boolean> {
await this.load();
const c = this.campaigns.get(campaignId);
if (!c) return false;
this.cascadeDelete(c);
return true;
}
/** Owner-only: hand ownership to an existing member; the old owner becomes a member. */
async transferOwnership(ownerUserId: string, campaignId: string, newOwnerUserId: string): Promise<MembershipResult> {
await this.load();
const c = this.campaigns.get(campaignId);
if (!c) return { ok: false, code: 'not-found' };
if (c.ownerUserId !== ownerUserId) return { ok: false, code: 'forbidden' };
if (newOwnerUserId === ownerUserId) return { ok: true }; // no-op
if (!c.members.includes(newOwnerUserId)) return { ok: false, code: 'not-member' };
c.members = c.members.filter((m) => m !== newOwnerUserId);
c.members.push(ownerUserId);
c.ownerUserId = newOwnerUserId;
c.updatedAt = this.now();
this.persistCampaign(c);
return { ok: true };
}
/**
* Remove every trace of a user (account deletion / admin purge): campaigns they
* own (with those campaigns' characters), their memberships, and their published
* characters — T-133. Returns counts for the admin panel's report.
*/
async purgeUser(userId: string): Promise<{ campaigns: number; characters: number }> {
await this.load();
let campaigns = 0, characters = 0;
this.tx(() => {
for (const c of [...this.campaigns.values()]) {
if (c.ownerUserId === userId) {
campaigns++;
for (const ch of [...this.characters.values()]) if (ch.campaignId === c.id) { this.characters.delete(ch.id); characters++; }
this.byInvite.delete(c.inviteCode);
this.campaigns.delete(c.id);
this.db.query('DELETE FROM characters WHERE campaign_id = ?').run(c.id);
this.db.query('DELETE FROM campaign_members WHERE campaign_id = ?').run(c.id);
this.db.query('DELETE FROM campaigns WHERE id = ?').run(c.id);
} else if (c.members.includes(userId)) {
c.members = c.members.filter((m) => m !== userId);
c.updatedAt = this.now();
this.persistCampaignInTx(c);
}
}
// Any characters this user still owns in campaigns owned by others.
for (const ch of [...this.characters.values()]) if (ch.ownerUserId === userId) { this.characters.delete(ch.id); characters++; }
this.db.query('DELETE FROM characters WHERE owner_user_id = ?').run(userId);
});
return { campaigns, characters };
}
/** Admin overview rows: every campaign with member/character counts + stored bytes. */
async adminList(): Promise<Array<{ id: string; name: string; system: string; ownerUserId: string; members: number; characters: number; bytes: number; createdAt: number; updatedAt: number }>> {
await this.load();
@@ -193,40 +446,6 @@ export class CloudStore {
});
}
/** Admin: delete a campaign and every character published into it. */
async deleteCampaign(campaignId: string): Promise<boolean> {
await this.load();
const c = this.campaigns.get(campaignId);
if (!c) return false;
this.byInvite.delete(c.inviteCode);
this.campaigns.delete(campaignId);
for (const [id, ch] of this.characters) if (ch.campaignId === campaignId) this.characters.delete(id);
await this.persist();
return true;
}
/**
* Admin: purge everything a deleted user left behind — campaigns they own (with
* those campaigns' characters), their memberships, and their published characters.
*/
async purgeUser(userId: string): Promise<{ campaigns: number; characters: number }> {
await this.load();
let campaigns = 0, characters = 0;
for (const [id, c] of [...this.campaigns]) {
if (c.ownerUserId === userId) {
campaigns++;
this.byInvite.delete(c.inviteCode);
this.campaigns.delete(id);
for (const [chId, ch] of [...this.characters]) if (ch.campaignId === id) { this.characters.delete(chId); characters++; }
} else if (c.members.includes(userId)) {
c.members = c.members.filter((m) => m !== userId);
}
}
for (const [chId, ch] of [...this.characters]) if (ch.ownerUserId === userId) { this.characters.delete(chId); characters++; }
await this.persist();
return { campaigns, characters };
}
/** Instance-wide totals for the admin overview. */
async totals(): Promise<{ campaigns: number; characters: number; bytes: number }> {
await this.load();
+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment node
import { describe, it, expect, beforeEach } from 'vitest';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { AccountStore } from './accounts';
import { CloudStore } from './campaigns';
import { SqliteRoomStore } from './db';
import type { Snapshot } from '@/lib/sync/messages';
const sha256 = (s: string) => crypto.createHash('sha256').update(s).digest('hex');
const snap: Snapshot = { campaignName: 'Restored', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null, quests: [] };
describe('legacy JSON → sqlite migration (T-129)', () => {
let dir: string;
beforeEach(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mig-')); });
it('imports a legacy users.json (+ blob file) once, idempotently', async () => {
const id = 'u-legacy';
const token = 'legacy-token-value';
const legacy = [{ id, username: 'Legacy', salt: 'abcd', hash: 'deadbeef', tokens: [sha256(token)], blobVersion: 2, createdAt: 1, updatedAt: 1 }];
await fs.writeFile(path.join(dir, 'users.json'), JSON.stringify(legacy));
await fs.mkdir(path.join(dir, 'blobs'), { recursive: true });
await fs.writeFile(path.join(dir, 'blobs', `${id}.json`), '{"data":{}}');
const store = new AccountStore(dir);
await store.load();
expect((await store.listUsers()).find((u) => u.username === 'Legacy')?.id).toBe(id);
// The legacy (bare-string) token still authenticates after normalisation.
expect((await store.userByToken(token))?.id).toBe(id);
expect(await store.loadBlob(id)).toBe('{"data":{}}');
expect(store.blobVersionFor(id)).toBe(2);
// Idempotent: deleting the user then loading a fresh instance does NOT re-import
// from the lingering users.json (the migration flag is sticky).
expect(await store.deleteAccount(id)).toBe(true);
const store2 = new AccountStore(dir);
await store2.load();
expect(await store2.userByToken(token)).toBeNull();
expect((await store2.listUsers())).toHaveLength(0);
});
it('imports a legacy cloud.json (campaigns, members, characters)', async () => {
const legacy = {
campaigns: [{ id: 'c1', ownerUserId: 'gm', name: 'Old', system: '5e', inviteCode: 'ABC234', members: ['p1'], createdAt: 1, updatedAt: 1 }],
characters: [{ id: 'ch1', campaignId: 'c1', ownerUserId: 'p1', name: 'Lia', data: '{"hp":1}', version: 3, updatedAt: 1 }],
};
await fs.writeFile(path.join(dir, 'cloud.json'), JSON.stringify(legacy));
const store = new CloudStore(dir);
await store.load();
expect(store.isOwner('c1', 'gm')).toBe(true);
expect(store.isMember('c1', 'p1')).toBe(true);
const chars = await store.listCharacters('c1');
expect(chars).toHaveLength(1);
expect(chars[0]).toMatchObject({ name: 'Lia', version: 3 });
// The imported invite code still works.
expect((await store.joinByInvite('p2', 'ABC234'))?.id).toBe('c1');
});
});
describe('SqliteRoomStore (T-132)', () => {
it('round-trips a persisted room snapshot and removes it', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'rooms-'));
const store = new SqliteRoomStore(dir, () => 42);
store.upsert({ roomId: 'r1', joinCode: 'CODE12', gmSecretHash: 'h', passwordHash: null, campaignId: 'c1', snapshot: snap });
const all = store.all();
expect(all).toHaveLength(1);
expect(all[0]).toMatchObject({ roomId: 'r1', joinCode: 'CODE12', campaignId: 'c1' });
expect(all[0]!.snapshot).toMatchObject({ campaignName: 'Restored' });
// Upsert again updates in place (no duplicate row).
store.upsert({ roomId: 'r1', joinCode: 'CODE12', gmSecretHash: 'h', passwordHash: null, campaignId: 'c1', snapshot: null });
expect(store.all()).toHaveLength(1);
expect(store.all()[0]!.snapshot).toBeNull();
store.remove('r1');
expect(store.all()).toHaveLength(0);
});
});
+249
View File
@@ -0,0 +1,249 @@
import path from 'node:path';
import { mkdirSync } from 'node:fs';
// Aliased so it never clashes with the `createRequire` the esbuild server bundle
// injects in its banner (both land in one module scope) — a plain import collides.
import { createRequire as createNodeRequire } from 'node:module';
import type { PersistedRoom, RoomStore } from './rooms';
/**
* Single embedded-SQLite database backing the account, cloud-campaign and live-room
* stores (T-129 / T-132). Replaces the previous JSON files that were rewritten in
* full on every mutation (and corrupted by two processes sharing one DATA_DIR). The
* file lives at `${DATA_DIR}/ttrpg.sqlite` in WAL mode.
*
* Driver selection (zero external dependencies either way):
* - under the **bun** runtime → `bun:sqlite` (the driver T-129 names);
* - otherwise (e.g. the Node-based vitest runner, or a Node ≥22.5 host) → the
* built-in `node:sqlite`.
* Both are loaded with a runtime `require` (never a static `import`) so the bundler /
* vitest transformer never tries to resolve a builtin the host lacks. The two are
* adapted to the one small {@link Db} interface the stores use, so the rest of the
* server is driver-agnostic.
*
* NOTE: a Node host needs ≥22.5 for `node:sqlite`; the deploy image must therefore run
* either bun (preferred) or Node ≥22.5 — see the deployment notes.
*/
const nodeRequire = createNodeRequire(import.meta.url);
/** Minimal prepared-statement surface common to both drivers. */
export interface SqlStatement<R = unknown> {
get(...params: unknown[]): R | undefined;
all(...params: unknown[]): R[];
run(...params: unknown[]): unknown;
}
/** Driver-agnostic database handle the stores depend on. */
export interface Db {
exec(sql: string): void;
/** Prepare (and cache) a statement. Repeated calls with the same SQL are cheap. */
query<R = unknown>(sql: string): SqlStatement<R>;
/** Wrap `fn` so it runs atomically (BEGIN/COMMIT, ROLLBACK on throw). */
transaction(fn: () => void): () => void;
}
interface BunStatement { get(...p: unknown[]): unknown; all(...p: unknown[]): unknown[]; run(...p: unknown[]): unknown }
interface BunDatabase { exec(sql: string): void; query(sql: string): BunStatement; transaction(fn: () => void): () => void }
interface NodeStatement { get(...p: unknown[]): unknown; all(...p: unknown[]): unknown[]; run(...p: unknown[]): unknown }
interface NodeDatabase { exec(sql: string): void; prepare(sql: string): NodeStatement }
function isBun(): boolean {
return typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined';
}
function createDb(file: string): Db {
if (isBun()) {
const { Database } = nodeRequire('bun:sqlite') as { Database: new (f: string) => BunDatabase };
const db = new Database(file);
db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA busy_timeout = 5000;');
return {
exec: (sql) => db.exec(sql),
query: <R>(sql: string): SqlStatement<R> => db.query(sql) as unknown as SqlStatement<R>,
transaction: (fn) => db.transaction(fn),
};
}
// Node fallback: the built-in node:sqlite (Node >= 22.5). No external dependency.
const { DatabaseSync } = nodeRequire('node:sqlite') as { DatabaseSync: new (f: string) => NodeDatabase };
const db = new DatabaseSync(file);
db.exec('PRAGMA journal_mode = WAL;');
db.exec('PRAGMA busy_timeout = 5000;');
const cache = new Map<string, NodeStatement>();
const prep = (sql: string): NodeStatement => {
let s = cache.get(sql);
if (!s) { s = db.prepare(sql); cache.set(sql, s); }
return s;
};
return {
exec: (sql) => db.exec(sql),
query: <R>(sql: string): SqlStatement<R> => {
const s = prep(sql);
return {
get: (...p: unknown[]) => s.get(...p) as R | undefined,
all: (...p: unknown[]) => s.all(...p) as R[],
run: (...p: unknown[]) => s.run(...p),
};
},
transaction: (fn) => () => {
db.exec('BEGIN');
try { fn(); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; }
},
};
}
/**
* One connection is shared per resolved directory: AccountStore, CloudStore and the
* room store all call {@link openDatabase} with the same DATA_DIR and get the same
* handle, so they observe each other's writes immediately and never contend on the
* OS file lock.
*/
const cache = new Map<string, Db>();
export function openDatabase(dir: string): Db {
const key = path.resolve(dir);
const existing = cache.get(key);
if (existing) return existing;
mkdirSync(key, { recursive: true });
const db = createDb(path.join(key, 'ttrpg.sqlite'));
migrateSchema(db);
cache.set(key, db);
return db;
}
/** Strictly-additive schema (mirrors the Dexie convention): only ever add tables/
* columns, never reshape an existing one in place. `IF NOT EXISTS` keeps it idempotent. */
function migrateSchema(db: Db): void {
db.exec(`
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
username_key TEXT NOT NULL UNIQUE,
salt TEXT NOT NULL,
hash TEXT NOT NULL,
quota_bytes INTEGER,
blob_version INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS tokens (
hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tokens_user ON tokens(user_id);
CREATE TABLE IF NOT EXISTS blobs (
user_id TEXT PRIMARY KEY,
blob TEXT NOT NULL,
bytes INTEGER NOT NULL,
version INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS campaigns (
id TEXT PRIMARY KEY,
owner_user_id TEXT NOT NULL,
name TEXT NOT NULL,
system TEXT NOT NULL,
invite_code TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS campaign_members (
campaign_id TEXT NOT NULL,
user_id TEXT NOT NULL,
PRIMARY KEY (campaign_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_members_user ON campaign_members(user_id);
CREATE TABLE IF NOT EXISTS characters (
id TEXT PRIMARY KEY,
campaign_id TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
name TEXT NOT NULL,
data TEXT NOT NULL,
version INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_characters_campaign ON characters(campaign_id);
CREATE TABLE IF NOT EXISTS room_snapshots (
room_id TEXT PRIMARY KEY,
join_code TEXT NOT NULL,
gm_secret_hash TEXT NOT NULL,
password_hash TEXT,
campaign_id TEXT,
snapshot TEXT,
updated_at INTEGER NOT NULL
);
`);
}
interface RoomRow {
room_id: string;
join_code: string;
gm_secret_hash: string;
password_hash: string | null;
campaign_id: string | null;
snapshot: string | null;
}
/**
* SQLite-backed {@link RoomStore} for live-session resume across restarts (T-132).
* Only the player-safe snapshot + room identity are persisted (bounded by the hub's
* MAX_ROOMS); images, seats and chat history stay in memory and are re-requested on
* reconnect. Implements the interface RoomHub declares, so the hub stays free of any
* sqlite import and remains unit-testable with a fake store.
*/
export class SqliteRoomStore implements RoomStore {
private db: Db;
constructor(dir: string, private now: () => number = () => Date.now()) {
this.db = openDatabase(dir);
}
upsert(room: PersistedRoom): void {
this.db
.query(
`INSERT INTO room_snapshots (room_id, join_code, gm_secret_hash, password_hash, campaign_id, snapshot, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(room_id) DO UPDATE SET
join_code = excluded.join_code,
gm_secret_hash = excluded.gm_secret_hash,
password_hash = excluded.password_hash,
campaign_id = excluded.campaign_id,
snapshot = excluded.snapshot,
updated_at = excluded.updated_at`,
)
.run(
room.roomId,
room.joinCode,
room.gmSecretHash,
room.passwordHash,
room.campaignId,
room.snapshot ? JSON.stringify(room.snapshot) : null,
this.now(),
);
}
remove(roomId: string): void {
this.db.query('DELETE FROM room_snapshots WHERE room_id = ?').run(roomId);
}
all(): PersistedRoom[] {
const rows = this.db.query<RoomRow>('SELECT * FROM room_snapshots').all();
return rows.map((r) => ({
roomId: r.room_id,
joinCode: r.join_code,
gmSecretHash: r.gm_secret_hash,
passwordHash: r.password_hash,
campaignId: r.campaign_id,
snapshot: r.snapshot ? JSON.parse(r.snapshot) : null,
}));
}
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest';
import { TokenBucket, originAllowed, SECURITY_HEADERS, corsHeaders } from './hardening';
describe('TokenBucket', () => {
it('allows a burst up to capacity, then drops', () => {
const b = new TokenBucket(3, 1, 0);
expect(b.take(0)).toBe(true);
expect(b.take(0)).toBe(true);
expect(b.take(0)).toBe(true);
expect(b.take(0)).toBe(false); // drained — over budget
});
it('refills continuously over time (not a fixed window)', () => {
const b = new TokenBucket(3, 1, 0); // 1 token/sec
expect(b.take(0)).toBe(true);
expect(b.take(0)).toBe(true);
expect(b.take(0)).toBe(true);
expect(b.take(0)).toBe(false);
// After 1s exactly one token is back.
expect(b.take(1000)).toBe(true);
expect(b.take(1000)).toBe(false);
// After 2 more seconds, 2 tokens accrue.
expect(b.take(3000)).toBe(true);
expect(b.take(3000)).toBe(true);
expect(b.take(3000)).toBe(false);
});
it('never refills beyond capacity', () => {
const b = new TokenBucket(2, 100, 0);
b.take(0); b.take(0); // drained
// A long idle would over-fill a naive counter; the bucket caps at capacity (2).
expect(b.take(60_000)).toBe(true);
expect(b.take(60_000)).toBe(true);
expect(b.take(60_000)).toBe(false);
});
});
describe('originAllowed', () => {
it('allows everything when no allowlist is configured', () => {
expect(originAllowed(undefined, [])).toBe(true);
expect(originAllowed('https://evil.example', [])).toBe(true);
});
it('allows only listed origins and rejects a missing Origin when configured', () => {
const allow = ['https://app.example'];
expect(originAllowed('https://app.example', allow)).toBe(true);
expect(originAllowed('https://evil.example', allow)).toBe(false);
expect(originAllowed(undefined, allow)).toBe(false); // missing Origin rejected (T-127)
});
});
describe('corsHeaders', () => {
it('reflects any origin when no allowlist is configured (local default)', () => {
const h = corsHeaders('https://anything.example', []);
expect(h?.['Access-Control-Allow-Origin']).toBe('https://anything.example');
expect(h?.['Access-Control-Allow-Headers']).toContain('authorization');
expect(h?.Vary).toBe('Origin');
});
it('adds nothing for a same-origin / non-browser request (no Origin)', () => {
expect(corsHeaders(undefined, [])).toBeNull();
expect(corsHeaders(undefined, ['https://app.example'])).toBeNull();
});
it('honours ALLOWED_ORIGINS: reflects a listed origin, omits ACAO for others (T-134)', () => {
const allow = ['https://app.example'];
expect(corsHeaders('https://app.example', allow)?.['Access-Control-Allow-Origin']).toBe('https://app.example');
expect(corsHeaders('https://evil.example', allow)).toBeNull();
});
});
describe('SECURITY_HEADERS', () => {
it('includes the core hardening headers and no CSP (build-time in the PWA)', () => {
expect(SECURITY_HEADERS['X-Content-Type-Options']).toBe('nosniff');
expect(SECURITY_HEADERS['X-Frame-Options']).toBe('SAMEORIGIN');
expect(SECURITY_HEADERS['Strict-Transport-Security']).toContain('max-age=');
expect(SECURITY_HEADERS['Content-Security-Policy']).toBeUndefined();
});
});
+86
View File
@@ -0,0 +1,86 @@
/**
* Small, dependency-free transport-hardening primitives shared by the HTTP and
* WebSocket layers (index.ts). Kept pure so they're unit-testable in isolation.
*/
/**
* Time-based refilling token bucket. Unlike a fixed window (which snaps back to
* full on a tick boundary and lets a client burst 2× at the seam), this refills
* continuously at `refillPerSec` up to `capacity`. Callers drop/ignore a message
* when {@link take} returns false instead of tearing down the connection.
*/
export class TokenBucket {
private tokens: number;
private last: number;
constructor(
private readonly capacity: number,
private readonly refillPerSec: number,
now: number = Date.now(),
) {
this.tokens = capacity;
this.last = now;
}
/** Try to consume `cost` tokens. Returns true if allowed, false if drained. */
take(now: number = Date.now(), cost = 1): boolean {
const elapsed = (now - this.last) / 1000;
if (elapsed > 0) {
this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerSec);
this.last = now;
}
if (this.tokens >= cost) {
this.tokens -= cost;
return true;
}
return false;
}
}
/**
* Origin allowlist check (anti-CSWSH for the WS upgrade). When an allowlist is
* configured a *missing* Origin is rejected too — a same-site browser always
* sends one, so an absent header means a non-browser/forged client. With no
* allowlist configured every origin is allowed (local/offline-first default).
*/
export function originAllowed(origin: string | undefined, allowed: readonly string[]): boolean {
if (!allowed.length) return true;
return origin !== undefined && allowed.includes(origin);
}
/**
* Conservative security response headers. No CSP here on purpose — the PWA ships
* its CSP at build time (vite.config.ts); duplicating it server-side risks drift.
* HSTS is inert over plain HTTP, so it's safe to send unconditionally behind the
* TLS-terminating proxy.
*/
export const SECURITY_HEADERS: Readonly<Record<string, string>> = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'SAMEORIGIN',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Strict-Transport-Security': 'max-age=15552000; includeSubDomains',
};
/** Methods/headers the cloud API accepts cross-origin (kept in sync with the routes). */
const CORS_ALLOW_METHODS = 'GET, POST, PUT, DELETE, OPTIONS';
const CORS_ALLOW_HEADERS = 'authorization, content-type, if-match';
/**
* CORS response headers for a request Origin, honouring the same allowlist as the
* WS upgrade (T-134). Returns the headers to set, or null when there's nothing to
* add — either a same-origin/non-browser request (no Origin) or a present Origin
* that isn't allowed (the browser then blocks the response for lacking ACAO). With
* no allowlist configured every origin is reflected (local/offline-first default).
* `ETag` is exposed so a client can read it back for optimistic concurrency (T-130).
*/
export function corsHeaders(origin: string | undefined, allowed: readonly string[]): Record<string, string> | null {
if (origin === undefined) return null;
if (allowed.length && !allowed.includes(origin)) return null;
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': CORS_ALLOW_METHODS,
'Access-Control-Allow-Headers': CORS_ALLOW_HEADERS,
'Access-Control-Expose-Headers': 'ETag',
'Access-Control-Max-Age': '600',
Vary: 'Origin',
};
}
+160
View File
@@ -0,0 +1,160 @@
// @vitest-environment node
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import type { FastifyInstance } from 'fastify';
// Set the env the module reads at import time BEFORE importing index.ts: an
// isolated DATA_DIR (so the test never touches ./data) and a CORS allowlist.
let app: FastifyInstance;
const ORIGIN = 'https://app.example';
beforeAll(async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'httpapi-'));
process.env.DATA_DIR = dir;
process.env.ALLOWED_ORIGINS = ORIGIN;
const { buildServer } = await import('./index');
app = buildServer();
await app.ready();
});
afterAll(async () => { await app.close(); });
async function register(username: string, password = 'password123'): Promise<string> {
const res = await app.inject({ method: 'POST', url: '/api/register', payload: { username, password } });
return (res.json() as { token: string }).token;
}
const auth = (token: string) => ({ authorization: `Bearer ${token}` });
describe('CORS (T-134)', () => {
it('answers a preflight from an allowed origin with ACAO and 204', async () => {
const res = await app.inject({ method: 'OPTIONS', url: '/api/login', headers: { origin: ORIGIN } });
expect(res.statusCode).toBe(204);
expect(res.headers['access-control-allow-origin']).toBe(ORIGIN);
expect(String(res.headers['access-control-allow-headers'])).toContain('authorization');
});
it('omits ACAO for a disallowed origin (browser blocks it)', async () => {
const res = await app.inject({ method: 'OPTIONS', url: '/api/login', headers: { origin: 'https://evil.example' } });
expect(res.statusCode).toBe(204);
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});
it('reflects ACAO on an actual API response from an allowed origin', async () => {
const res = await app.inject({ method: 'POST', url: '/api/register', headers: { origin: ORIGIN }, payload: { username: 'cors_user', password: 'password123' } });
expect(res.statusCode).toBe(200);
expect(res.headers['access-control-allow-origin']).toBe(ORIGIN);
});
});
describe('blob optimistic concurrency (T-130)', () => {
it('returns an ETag and 409s on a stale If-Match', async () => {
const token = await register('blobby');
const h = auth(token);
const put1 = await app.inject({ method: 'PUT', url: '/api/save', headers: h, payload: { blob: '{"data":{"a":[1]}}' } });
expect(put1.statusCode).toBe(200);
expect(put1.headers.etag).toBe('"1"');
const get = await app.inject({ method: 'GET', url: '/api/save', headers: h });
expect(get.headers.etag).toBe('"1"');
const stale = await app.inject({ method: 'PUT', url: '/api/save', headers: { ...h, 'if-match': '"0"' }, payload: { blob: '{"data":{"a":[2]}}' } });
expect(stale.statusCode).toBe(409);
expect((stale.json() as { version: number }).version).toBe(1);
const matched = await app.inject({ method: 'PUT', url: '/api/save', headers: { ...h, 'if-match': '"1"' }, payload: { blob: '{"data":{"a":[2]}}' } });
expect(matched.statusCode).toBe(200);
expect(matched.headers.etag).toBe('"2"');
});
});
describe('character optimistic concurrency (T-130)', () => {
it('409s a stale character PUT and accepts a matching version', async () => {
const token = await register('charcc');
const h = auth(token);
const camp = (await app.inject({ method: 'POST', url: '/api/campaigns', headers: h, payload: { name: 'X', system: '5e' } })).json() as { id: string };
const put1 = await app.inject({ method: 'PUT', url: '/api/characters', headers: h, payload: { campaignId: camp.id, character: { id: 'c1', name: 'A', data: '{}' } } });
expect((put1.json() as { version: number }).version).toBe(1);
const stale = await app.inject({ method: 'PUT', url: '/api/characters', headers: h, payload: { campaignId: camp.id, character: { id: 'c1', name: 'A', data: '{"hp":1}', version: 0 } } });
expect(stale.statusCode).toBe(409);
const ok = await app.inject({ method: 'PUT', url: '/api/characters', headers: h, payload: { campaignId: camp.id, character: { id: 'c1', name: 'A', data: '{"hp":1}', version: 1 } } });
expect((ok.json() as { version: number }).version).toBe(2);
});
});
describe('payload validation (T-135)', () => {
it('rejects a structurally-malformed backup blob with 400, accepts a well-formed one', async () => {
const token = await register('blobval');
const h = auth(token);
// Not JSON at all.
const garbage = await app.inject({ method: 'PUT', url: '/api/save', headers: h, payload: { blob: 'not json {' } });
expect(garbage.statusCode).toBe(400);
// Valid JSON but missing the `data` envelope a restore needs.
const noData = await app.inject({ method: 'PUT', url: '/api/save', headers: h, payload: { blob: '{"format":"x"}' } });
expect(noData.statusCode).toBe(400);
// A table that isn't an array is rejected too.
const badTable = await app.inject({ method: 'PUT', url: '/api/save', headers: h, payload: { blob: '{"data":{"campaigns":{}}}' } });
expect(badTable.statusCode).toBe(400);
// A well-formed envelope is stored.
const ok = await app.inject({ method: 'PUT', url: '/api/save', headers: h, payload: { blob: '{"data":{"campaigns":[]}}' } });
expect(ok.statusCode).toBe(200);
});
it('rejects malformed character JSON with 400, accepts a JSON object', async () => {
const token = await register('charval');
const h = auth(token);
const camp = (await app.inject({ method: 'POST', url: '/api/campaigns', headers: h, payload: { name: 'V', system: '5e' } })).json() as { id: string };
// A JSON array is not a character object.
const arr = await app.inject({ method: 'PUT', url: '/api/characters', headers: h, payload: { campaignId: camp.id, character: { id: 'cv', name: 'A', data: '[1,2,3]' } } });
expect(arr.statusCode).toBe(400);
// Truncated / invalid JSON.
const broken = await app.inject({ method: 'PUT', url: '/api/characters', headers: h, payload: { campaignId: camp.id, character: { id: 'cv', name: 'A', data: '{"id":' } } });
expect(broken.statusCode).toBe(400);
// A plain object is accepted.
const ok = await app.inject({ method: 'PUT', url: '/api/characters', headers: h, payload: { campaignId: camp.id, character: { id: 'cv', name: 'A', data: '{"id":"cv","name":"A"}' } } });
expect((ok.json() as { version: number }).version).toBe(1);
});
});
describe('membership authorization (T-131)', () => {
it('only the owner can rotate the invite / remove members; a member can leave', async () => {
const owner = await register('owner1');
const player = await register('player1');
const camp = (await app.inject({ method: 'POST', url: '/api/campaigns', headers: auth(owner), payload: { name: 'M', system: '5e' } })).json() as { id: string; inviteCode: string };
const joined = (await app.inject({ method: 'POST', url: '/api/campaigns/join', headers: auth(player), payload: { inviteCode: camp.inviteCode } })).json() as { id: string };
expect(joined.id).toBe(camp.id);
// A member cannot rotate the invite.
const denied = await app.inject({ method: 'POST', url: `/api/campaigns/${camp.id}/rotate-invite`, headers: auth(player) });
expect(denied.statusCode).toBe(403);
// The owner can.
const rotated = await app.inject({ method: 'POST', url: `/api/campaigns/${camp.id}/rotate-invite`, headers: auth(owner) });
expect(rotated.statusCode).toBe(200);
expect((rotated.json() as { inviteCode: string }).inviteCode).not.toBe(camp.inviteCode);
// The member leaves; afterwards they no longer see the campaign.
const left = await app.inject({ method: 'POST', url: `/api/campaigns/${camp.id}/leave`, headers: auth(player) });
expect(left.statusCode).toBe(200);
expect((await app.inject({ method: 'GET', url: '/api/campaigns', headers: auth(player) })).json()).toHaveLength(0);
});
});
describe('auth lifecycle (T-133)', () => {
it('refreshes a token, then deletes the account (password-gated)', async () => {
const token = await register('lifecycle');
const refreshed = await app.inject({ method: 'POST', url: '/api/refresh', headers: auth(token) });
expect(refreshed.statusCode).toBe(200);
const fresh = (refreshed.json() as { token: string }).token;
expect(fresh).toBeTruthy();
// The old token is rotated out.
expect((await app.inject({ method: 'POST', url: '/api/refresh', headers: auth(token) })).statusCode).toBe(401);
// Account deletion requires the correct password.
expect((await app.inject({ method: 'DELETE', url: '/api/account', headers: auth(fresh), payload: { password: 'wrong' } })).statusCode).toBe(403);
expect((await app.inject({ method: 'DELETE', url: '/api/account', headers: auth(fresh), payload: { password: 'password123' } })).statusCode).toBe(200);
// The session is gone.
expect((await app.inject({ method: 'GET', url: '/api/campaigns', headers: auth(fresh) })).statusCode).toBe(401);
});
});
+254 -87
View File
@@ -7,16 +7,19 @@ import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
import { promises as fs } from 'node:fs';
import { RoomHub, type Sender } from './rooms';
import { AccountStore, DEFAULT_QUOTA_BYTES } from './accounts';
import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
import { CloudStore } from './campaigns';
import { SqliteRoomStore } from './db';
import { TokenBucket, originAllowed, SECURITY_HEADERS, corsHeaders } from './hardening';
const PORT = Number(process.env.PORT ?? 8787);
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
const DATA_DIR = path.resolve(process.env.DATA_DIR ?? path.join(process.cwd(), 'data'));
const MAX_PAYLOAD = 12 * 1024 * 1024; // 12 MB (map images over WS)
const BODY_LIMIT = 32 * 1024 * 1024; // 32 MB (cloud backup blobs; > the per-user quota)
const BODY_LIMIT = 48 * 1024 * 1024; // 48 MB (cloud backup blobs)
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const ADMIN_USERS = (process.env.ADMIN_USERS ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
const MAX_USERS = Number(process.env.MAX_USERS) || Infinity;
const MAX_OBJECT_BYTES = 16 * 1024 * 1024; // hard cap on a single blob/character, below BODY_LIMIT
// Concurrent WebSocket connections allowed from one client IP — generous for a
// household sharing a NAT, but a hard stop on socket-spam room/image floods.
const MAX_WS_PER_IP = Number(process.env.MAX_WS_PER_IP) || 20;
@@ -26,13 +29,38 @@ const bearer = (req: FastifyRequest): string | undefined => {
return h?.startsWith('Bearer ') ? h.slice(7) : undefined;
};
// per-socket token bucket: 80 messages / 10s
const RATE = { capacity: 80, refillMs: 10_000 };
/** Bearer token for a WS upgrade (T-132): `?token=` first (browsers can't set the
* Authorization header on a WebSocket), then the Authorization header for non-browser
* clients/tests. Returns undefined for an anonymous (guest) connection. */
const wsBearer = (req: FastifyRequest): string | undefined => {
const q = (req.query as { token?: unknown } | undefined)?.token;
if (typeof q === 'string' && q.length > 0) return q;
return bearer(req);
};
/** Parse an If-Match precondition (quoted or bare) to a numeric version (T-130). */
const parseIfMatch = (h: string | string[] | undefined): number | undefined => {
const v = Array.isArray(h) ? h[0] : h;
const m = v ? /\d+/.exec(v) : null;
return m ? Number(m[0]) : undefined;
};
/** Map a membership-lifecycle result code to an HTTP status (T-131). */
const membershipStatus = (code: 'not-found' | 'forbidden' | 'owner-cannot-leave' | 'not-member'): number =>
code === 'not-found' ? 404 : code === 'forbidden' ? 403 : code === 'owner-cannot-leave' ? 409 : 400;
// Per-socket WS rate limit: a burst of 80 messages, refilling at 8/s (T-127).
const RATE = { capacity: 80, refillPerSec: 8 };
// Protocol-level keepalive: ping each socket on an interval and terminate one
// that misses a pong, so a vanished player (closed laptop, dead network) is
// detected and drops out of the GM's roster within ~one interval.
// detected server-side and drops out of the GM's roster within ~one interval.
const HEARTBEAT_MS = 30_000;
/** Minimal structured audit log for auth events — stdout JSON; no passwords/tokens (T-134). */
const audit = (event: string, fields: Record<string, unknown>): void => {
try { console.log(JSON.stringify({ log: 'audit', ts: new Date().toISOString(), event, ...fields })); } catch { /* never throw from logging */ }
};
export function buildServer() {
// trustProxy: behind Traefik the socket peer is the proxy, so without this every
// visitor shares one rate-limit bucket. Trust exactly ONE hop (the rightmost
@@ -49,36 +77,66 @@ export function buildServer() {
try { done(null, JSON.parse(s)); }
catch { const err = new Error('Invalid JSON') as Error & { statusCode?: number }; err.statusCode = 400; done(err, undefined); }
});
const hub = new RoomHub();
const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS, MAX_USERS);
void accounts.load();
const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS);
const cloud = new CloudStore(DATA_DIR);
void cloud.load();
const sweeper = setInterval(() => { hub.sweep(); pruneRateBuckets(); }, 5 * 60 * 1000);
app.addHook('onClose', async () => clearInterval(sweeper));
// Rooms persist their player-safe snapshot to the same sqlite DB so an active
// session survives a restart (T-132).
const hub = new RoomHub(undefined, new SqliteRoomStore(DATA_DIR));
// Await both stores before the server accepts connections, so an early request
// can't race a half-loaded store (T-120; the stores also latch internally), then
// rebuild any live rooms persisted before a restart (T-132).
app.addHook('onReady', async () => { await accounts.load(); await cloud.load(); hub.restore(); });
// Per-IP throttle for the account/cloud API. Auth endpoints get a tighter bucket
// since each call does password hashing and is the target of credential-stuffing.
const ipHits = new Map<string, { n: number; reset: number }>();
const authHits = new Map<string, { n: number; reset: number }>();
const AUTH_PATHS = new Set(['/api/register', '/api/login']);
const bump = (map: Map<string, { n: number; reset: number }>, ip: string, limit: number, windowMs: number): boolean => {
const now = Date.now();
const e = map.get(ip);
if (!e || now > e.reset) { map.set(ip, { n: 1, reset: now + windowMs }); return true; }
return ++e.n <= limit;
};
const pruneRateBuckets = () => {
const now = Date.now();
for (const [ip, e] of ipHits) if (now > e.reset) ipHits.delete(ip);
for (const [ip, e] of authHits) if (now > e.reset) authHits.delete(ip);
};
// Conservative security headers on every HTTP response (T-134). Skips /ws: the
// upgrade reply is hijacked, and CSP stays build-time in the PWA (see hardening.ts).
app.addHook('onRequest', async (req, reply) => {
if (req.url.startsWith('/ws')) return;
for (const [k, v] of Object.entries(SECURITY_HEADERS)) reply.header(k, v);
});
// CORS for the account/cloud HTTP API, honouring ALLOWED_ORIGINS (T-134). Mirrors
// the WS Origin allowlist: with none configured every origin is reflected (local
// default); otherwise only listed origins receive ACAO. Preflight (OPTIONS)
// requests short-circuit here, before auth/rate-limiting run.
app.addHook('onRequest', async (req, reply) => {
if (!req.url.startsWith('/api/')) return;
if (!bump(ipHits, req.ip, 60, 60_000)) return reply.code(429).send({ error: 'rate' });
const ch = corsHeaders(req.headers.origin, ALLOWED_ORIGINS);
if (ch) for (const [k, v] of Object.entries(ch)) reply.header(k, v);
if (req.method === 'OPTIONS') return reply.code(204).send();
});
const ipHits = new Map<string, { n: number; reset: number }>();
const sweeper = setInterval(() => {
hub.sweep();
// Prune expired throttle buckets so the map can't grow unbounded.
const now = Date.now();
for (const [k, v] of ipHits) if (now > v.reset) ipHits.delete(k);
}, 5 * 60 * 1000);
app.addHook('onClose', async () => clearInterval(sweeper));
// Per-client throttle for the account/cloud API: keyed by bearer token when
// authenticated (so users behind one NAT don't share a bucket), else by IP.
// Auth endpoints get an extra, tighter per-IP bucket since each call does
// password hashing and is the target of credential-stuffing.
const AUTH_PATHS = new Set(['/api/register', '/api/login']);
app.addHook('onRequest', async (req, reply) => {
if (!req.url.startsWith('/api/')) return;
const now = Date.now();
const token = bearer(req);
// Always enforce a per-IP backstop so a client can't bypass the throttle by
// rotating arbitrary (unvalidated) bearer tokens; a token additionally gets its
// own bucket so distinct users behind one NAT aren't starved.
const over = (k: string, limit: number): boolean => {
const e = ipHits.get(k);
if (!e || now > e.reset) { ipHits.set(k, { n: 1, reset: now + 60_000 }); return false; }
return ++e.n > limit;
};
const ipOver = over(`ip:${req.ip}`, 120);
const tokenOver = token ? over(`t:${token}`, 60) : false;
if (ipOver || tokenOver) return reply.code(429).send({ error: 'rate' });
// Strip the querystring before matching the auth path.
const path0 = req.url.split('?')[0]!;
if (AUTH_PATHS.has(path0) && !bump(authHits, req.ip, 10, 60_000)) {
if (AUTH_PATHS.has(path0) && over(`auth:${req.ip}`, 10)) {
return reply.code(429).send({ error: 'rate', message: 'Too many attempts — wait a minute and try again.' });
}
});
@@ -87,43 +145,88 @@ export function buildServer() {
app.post('/api/register', async (req, reply) => {
const { username, password } = (req.body ?? {}) as { username?: string; password?: string };
const r = await accounts.register(String(username ?? ''), String(password ?? ''));
audit('register', { ip: req.ip, username: String(username ?? ''), ok: r.ok, ...(r.ok ? {} : { code: r.code }) });
if (!r.ok) return reply.code(r.code === 'closed' ? 503 : 400).send({ error: r.code, message: r.message });
return { token: r.token, username: r.username };
});
app.post('/api/login', async (req, reply) => {
const { username, password } = (req.body ?? {}) as { username?: string; password?: string };
const r = await accounts.login(String(username ?? ''), String(password ?? ''));
audit('login', { ip: req.ip, username: String(username ?? ''), ok: r.ok, ...(r.ok ? {} : { code: r.code }) });
// A throttled key gets 429 (Too Many Requests); other failures stay 401.
if (!r.ok) return reply.code(r.code === 'locked' ? 429 : 401).send({ error: r.code, message: r.message });
return { token: r.token, username: r.username };
});
app.post('/api/logout', async (req) => { const t = bearer(req); if (t) await accounts.logout(t); audit('logout', { ip: req.ip }); return { ok: true }; });
// Rotate a still-valid token for a fresh one (sliding session) — T-133.
app.post('/api/refresh', async (req, reply) => {
const t = bearer(req);
const r = t ? await accounts.refresh(t) : { ok: false, code: 'bad-token', message: 'Not signed in.' } as const;
audit('refresh', { ip: req.ip, ok: r.ok, ...(r.ok ? { username: r.username } : { code: r.code }) });
if (!r.ok) return reply.code(401).send({ error: r.code, message: r.message });
return { token: r.token, username: r.username };
});
app.post('/api/logout', async (req) => { const t = bearer(req); if (t) await accounts.logout(t); return { ok: true }; });
// Change password: verifies the old one, rotates sessions, returns a fresh token — T-133.
app.post('/api/account/password', async (req, reply) => {
const u = await accounts.userByToken(bearer(req));
if (!u) return reply.code(401).send({ error: 'unauthorized' });
const { oldPassword, newPassword } = (req.body ?? {}) as { oldPassword?: string; newPassword?: string };
const r = await accounts.changePassword(u.id, String(oldPassword ?? ''), String(newPassword ?? ''));
audit('password-change', { ip: req.ip, username: u.username, ok: r.ok, ...(r.ok ? {} : { code: r.code }) });
if (!r.ok) return reply.code(r.code === 'locked' ? 429 : r.code === 'bad-credentials' ? 403 : 400).send({ error: r.code, message: r.message });
return { token: r.token, username: r.username };
});
// Delete the account (cascade cloud data + backup blob), confirmed by password — T-133.
app.delete('/api/account', async (req, reply) => {
const u = await accounts.userByToken(bearer(req));
if (!u) return reply.code(401).send({ error: 'unauthorized' });
const { password } = (req.body ?? {}) as { password?: string };
if (!(await accounts.verifyPassword(u.id, String(password ?? '')))) {
audit('account-delete', { ip: req.ip, username: u.username, ok: false, code: 'bad-credentials' });
return reply.code(403).send({ error: 'bad-credentials', message: 'Wrong password.' });
}
await cloud.purgeUser(u.id);
await accounts.deleteAccount(u.id);
audit('account-delete', { ip: req.ip, username: u.username, ok: true });
return { ok: true };
});
app.put('/api/save', async (req, reply) => {
const u = await accounts.userByToken(bearer(req));
if (!u) return reply.code(401).send({ error: 'unauthorized' });
const body = (req.body as { blob?: string; baseSavedAt?: number | null; force?: boolean } | undefined) ?? {};
if (typeof body.blob !== 'string') return reply.code(400).send({ error: 'bad-blob' });
// Quota: the backup blob plus the user's published characters must fit their limit.
const projected = Buffer.byteLength(body.blob) + (await cloud.usageBytes(u.id));
const size = Buffer.byteLength(body.blob);
if (size > MAX_OBJECT_BYTES) return reply.code(413).send({ error: 'too-large', message: 'That backup is too large.' });
// The blob replaces any existing one, so prospective usage = new blob + cloud chars.
const projected = size + (await cloud.usageBytes(u.id));
if (projected > accounts.quotaFor(u)) {
return reply.code(413).send({ error: 'quota', message: 'Cloud storage limit reached. Trim old data or ask the admin to raise your quota.', limit: accounts.quotaFor(u) });
}
// Optimistic concurrency: when a cloud copy exists, only an explicit force or a
// matching baseline may replace it. A missing/null baseline (fresh device that
// never synced) is a conflict, NOT consent — no silent overwrite.
const current = await accounts.blobSavedAt(u.id);
if (current !== null && body.force !== true && body.baseSavedAt !== current) {
return reply.code(409).send({ error: 'conflict', savedAt: current });
// Optimistic concurrency (T-130): the expected base version comes from either an
// If-Match header (ETag protocol) or the body's baseSavedAt (the PWA client, which
// round-trips the version as an opaque token). When a cloud copy exists, only an
// explicit force or a matching baseline may replace it. A missing/null baseline
// (fresh device that never synced) is a conflict, NOT consent — no silent overwrite.
const current = accounts.blobVersionFor(u.id);
const expected = parseIfMatch(req.headers['if-match']) ?? (typeof body.baseSavedAt === 'number' ? body.baseSavedAt : undefined);
if (current !== 0 && body.force !== true && expected !== current) {
return reply.code(409).header('etag', `"${current}"`).send({ error: 'conflict', message: 'A newer copy exists on the server.', version: current, savedAt: current });
}
const savedAt = await accounts.saveBlob(u.id, body.blob);
return { ok: true, size: body.blob.length, savedAt };
const r = await accounts.saveBlob(u.id, body.blob, body.force === true ? undefined : expected);
if (!r.ok) return reply.code(409).header('etag', `"${r.version}"`).send({ error: 'conflict', message: 'A newer copy exists on the server.', version: r.version, savedAt: r.version });
return reply.header('etag', `"${r.version}"`).send({ ok: true, size: body.blob.length, version: r.version, savedAt: r.version });
});
app.get('/api/save', async (req, reply) => {
const u = await accounts.userByToken(bearer(req));
if (!u) return reply.code(401).send({ error: 'unauthorized' });
const blob = await accounts.loadBlob(u.id);
if (blob === null) return reply.code(404).send({ error: 'no-save' });
const savedAt = await accounts.blobSavedAt(u.id);
return reply.header('content-type', 'application/json').header('x-saved-at', String(savedAt ?? '')).send(blob);
const version = accounts.blobVersionFor(u.id);
return reply
.header('content-type', 'application/json')
.header('etag', `"${version}"`)
.header('x-saved-at', String(version))
.send(blob);
});
// ---- shared cloud campaigns + member-owned characters ----
@@ -146,41 +249,26 @@ export function buildServer() {
if (!c) return reply.code(404).send({ error: 'no-campaign', message: 'No campaign with that invite code.' });
return { id: c.id, name: c.name, system: c.system, role: c.ownerUserId === u.id ? 'owner' : 'member' };
});
app.post('/api/campaigns/:id/invite', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const code = await cloud.rotateInvite(u.id, (req.params as { id: string }).id);
return code ? { inviteCode: code } : reply.code(403).send({ error: 'forbidden', message: 'Only the campaign owner can rotate the invite.' });
});
app.delete('/api/campaigns/:id/members/:userId', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const p = req.params as { id: string; userId: string };
const ok = await cloud.removeMember(u.id, p.id, p.userId);
return ok ? { ok: true } : reply.code(403).send({ error: 'forbidden' });
});
// Owner-facing unpublish: delete a shared campaign (and every character published
// into it). Admins keep their separate /api/admin route.
app.delete('/api/campaigns/:id', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const id = (req.params as { id: string }).id;
await cloud.load();
if (!cloud.isOwner(id, u.id)) return reply.code(403).send({ error: 'forbidden', message: 'Only the campaign owner can delete it.' });
const ok = await cloud.deleteCampaign(id);
return ok ? { ok: true } : reply.code(404).send({ error: 'not-found' });
});
app.put('/api/characters', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const { campaignId, character } = (req.body ?? {}) as { campaignId?: string; character?: { id?: string; name?: string; data?: string } };
const { campaignId, character } = (req.body ?? {}) as { campaignId?: string; character?: { id?: string; name?: string; data?: string; version?: number } };
if (!campaignId || !character?.id || typeof character.data !== 'string') return reply.code(400).send({ error: 'bad-request' });
if (Buffer.byteLength(character.data) > MAX_CHARACTER_BYTES) return reply.code(413).send({ error: 'too-large', message: 'That character is too large to publish.' });
const size = Buffer.byteLength(character.data);
if (size > MAX_OBJECT_BYTES) return reply.code(413).send({ error: 'too-large', message: 'That character is too large.' });
// Quota: total usage after this upsert (replace the old copy's bytes with the new).
const delta = Buffer.byteLength(character.data) - (cloud.ownsCharacter(u.id, character.id) ? await cloud.characterBytes(character.id) : 0);
const delta = size - (cloud.ownsCharacter(u.id, character.id) ? await cloud.characterBytes(character.id) : 0);
const projected = (await accounts.blobBytes(u.id)) + (await cloud.usageBytes(u.id)) + delta;
if (projected > accounts.quotaFor(u)) {
return reply.code(413).send({ error: 'quota', message: 'Cloud storage limit reached.', limit: accounts.quotaFor(u) });
return reply.code(413).send({ error: 'quota', message: 'Storage quota exceeded.', limit: accounts.quotaFor(u) });
}
const r = await cloud.putCharacter(u.id, campaignId, { id: character.id, name: String(character.name ?? ''), data: character.data });
if (!r) return reply.code(403).send({ error: 'forbidden', message: 'Not a member, you do not own that character, or it is too large.' });
return { ok: true, updatedAt: r.updatedAt };
const r = await cloud.putCharacter(u.id, campaignId, {
id: character.id, name: String(character.name ?? ''), data: character.data,
...(typeof character.version === 'number' ? { version: character.version } : {}),
});
if (!r.ok && r.code === 'too-large') return reply.code(413).send({ error: 'too-large', message: 'That character is too large to publish.' });
if (!r.ok && r.code === 'conflict') return reply.code(409).send({ error: 'conflict', message: 'A newer copy of that character exists.', version: r.version });
if (!r.ok) return reply.code(403).send({ error: 'forbidden', message: 'Not a member, or you do not own that character.' });
return { ok: true, updatedAt: r.character.updatedAt, version: r.character.version };
});
app.get('/api/campaigns/:id/characters', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
@@ -194,7 +282,7 @@ export function buildServer() {
return chars.map((c) => ({
id: c.id, name: c.name, ownerUserId: c.ownerUserId, mine: c.ownerUserId === u.id,
...(isGm || c.ownerUserId === u.id ? { data: c.data } : {}),
updatedAt: c.updatedAt,
version: c.version, updatedAt: c.updatedAt,
}));
});
app.delete('/api/characters/:id', async (req, reply) => {
@@ -202,6 +290,43 @@ export function buildServer() {
const ok = await cloud.removeCharacter(u.id, (req.params as { id: string }).id);
return ok ? { ok: true } : reply.code(403).send({ error: 'forbidden' });
});
// ---- campaign membership lifecycle (T-131) ----
app.post('/api/campaigns/:id/leave', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const r = await cloud.leaveCampaign(u.id, (req.params as { id: string }).id);
if (!r.ok) return reply.code(membershipStatus(r.code)).send({ error: r.code });
return { ok: true };
});
app.delete('/api/campaigns/:id/members/:userId', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const p = req.params as { id: string; userId: string };
const r = await cloud.removeMember(u.id, p.id, p.userId);
if (!r.ok) return reply.code(membershipStatus(r.code)).send({ error: r.code });
return { ok: true };
});
app.post('/api/campaigns/:id/rotate-invite', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const r = await cloud.rotateInvite(u.id, (req.params as { id: string }).id);
if (!r.ok) return reply.code(membershipStatus(r.code)).send({ error: r.code });
return { ok: true, inviteCode: r.inviteCode };
});
// Owner-facing unpublish: delete a shared campaign (and every character published
// into it). Admins keep their separate /api/admin route.
app.delete('/api/campaigns/:id', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const r = await cloud.deleteCampaign(u.id, (req.params as { id: string }).id);
if (!r.ok) return reply.code(membershipStatus(r.code)).send({ error: r.code });
return { ok: true };
});
app.post('/api/campaigns/:id/transfer', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const { userId } = (req.body ?? {}) as { userId?: string };
if (!userId) return reply.code(400).send({ error: 'bad-request' });
const r = await cloud.transferOwnership(u.id, (req.params as { id: string }).id, String(userId));
if (!r.ok) return reply.code(membershipStatus(r.code)).send({ error: r.code });
return { ok: true };
});
app.get('/api/usage', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const bytes = (await accounts.blobBytes(u.id)) + (await cloud.usageBytes(u.id));
@@ -214,7 +339,7 @@ export function buildServer() {
const u = await userOf(req);
return u && accounts.isAdmin(u.username) ? u : null;
};
/** Recursive byte total of the data dir (users.json + cloud.json + blobs). */
/** Recursive byte total of the data dir (sqlite db + any legacy files). */
const dirBytes = async (dir: string): Promise<number> => {
let total = 0;
try {
@@ -295,7 +420,7 @@ export function buildServer() {
app.delete('/api/admin/campaigns/:id', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const ok = await cloud.deleteCampaign((req.params as { id: string }).id);
const ok = await cloud.adminDeleteCampaign((req.params as { id: string }).id);
return ok ? { ok: true } : reply.code(404).send({ error: 'no-campaign' });
});
@@ -311,9 +436,9 @@ export function buildServer() {
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)) {
// Anti-CSWSH: when an allowlist is configured, require a present, listed
// Origin (a missing Origin from a non-browser client is rejected too) — T-127.
if (!originAllowed(req.headers.origin, ALLOWED_ORIGINS)) {
socket.close(1008, 'origin');
return;
}
@@ -323,10 +448,12 @@ export function buildServer() {
wsPerIp.set(ip, live + 1);
const releaseIp = () => { const n = (wsPerIp.get(ip) ?? 1) - 1; if (n <= 0) wsPerIp.delete(ip); else wsPerIp.set(ip, n); };
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);
// Real refilling token bucket: over-rate messages are dropped (ignored), not
// grounds to close the socket, so a brief burst can't kill a live session (T-127).
const bucket = new TokenBucket(RATE.capacity, RATE.refillPerSec);
// Heartbeat: a socket that doesn't answer the previous ping is dead → drop it.
// Heartbeat: a socket that doesn't answer the previous ping is dead → drop it,
// so it stops occupying the roster and the GM's snapshot fan-out.
let alive = true;
socket.on('pong', () => { alive = true; });
const heartbeat = setInterval(() => {
@@ -335,18 +462,44 @@ export function buildServer() {
try { socket.ping(); } catch { /* closed */ }
}, HEARTBEAT_MS);
socket.on('message', (raw: Buffer) => {
if (tokens-- <= 0) { socket.close(1008, 'rate'); return; }
// Optional WS auth (T-132): a bearer token in `?token=` (browsers can't set the
// Authorization header on a WS upgrade) or the Authorization header. Anonymous
// connections (no token) still host/join by code as guests; a token that is
// *present but invalid* is rejected. An authenticated GM gets their account id
// linked to the room, gating campaign linkage against membership.
const wsToken = wsBearer(req);
let authedUserId: string | null = null;
let ready = !wsToken; // guests are ready immediately
const pending: Buffer[] = []; // messages buffered until a token is validated
const handle = (raw: Buffer): void => {
if (!bucket.take()) return; // over budget → drop this message, keep the connection
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, m.playerId); break;
case 'state': hub.state(sender, m.gmSecret, m.snapshot); break;
case 'host': {
// An authenticated GM may only link a room to a campaign they belong to.
// Guests (no token) keep hosting anonymously — campaignId is then just a
// client-side label with no authoritative link.
if (authedUserId && !cloud.isMember(m.campaignId, authedUserId)) {
sender.send({ t: 'error', code: 'forbidden', message: 'You are not a member of that campaign.' });
break;
}
hub.host(sender, m.password, m.resume, m.campaignId, authedUserId ?? undefined);
break;
}
case 'join': hub.join(sender, m.joinCode, m.password, m.rejoinToken); break;
case 'state': hub.state(sender, m.gmSecret, m.snapshot, m.seq); break;
case 'statePatch': hub.statePatch(sender, m.gmSecret, m.baseSeq, m.seq, m.patch); break;
case 'requestSnapshot': hub.requestSnapshot(sender); break;
case 'image': hub.image(sender, m.gmSecret, m.id, m.dataUrl); break;
case 'requestImage': hub.requestImage(sender, m.id); break;
case 'seatFog': hub.seatFog(sender, m.gmSecret, m.characterId, m.cells); break;
case 'tokenMove': hub.tokenMove(sender, m.characterId, m.tokenId, m.col, m.row); break;
case 'ping': hub.ping(sender, m.mapId, m.point); break;
case 'spectate': hub.spectate(sender, m.on); break;
case 'claimSeat': hub.claimSeat(sender, m.characterId, m.offlineSnapshot); break;
case 'seatGrant': hub.seatGrant(sender, m.gmSecret, m.targetPlayerId, m.character); break;
case 'seatDeny': hub.seatDeny(sender, m.gmSecret, m.targetPlayerId); break;
@@ -358,12 +511,26 @@ export function buildServer() {
case 'setName': hub.setName(sender, m.name); break;
case 'end': hub.end(sender, m.gmSecret); break;
}
});
socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); releaseIp(); hub.disconnect(sender); });
};
socket.on('message', (raw: Buffer) => { if (ready) handle(raw); else pending.push(raw); });
socket.on('close', () => { clearInterval(heartbeat); releaseIp(); hub.disconnect(sender); });
if (wsToken) {
accounts.userByToken(wsToken)
.then((u) => {
if (!u) { socket.close(1008, 'auth'); return; }
authedUserId = u.id;
ready = true;
for (const raw of pending) handle(raw);
pending.length = 0;
})
.catch(() => socket.close(1011, 'auth-error'));
}
});
});
app.get('/healthz', async () => ({ ok: true }));
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 });
+13 -6
View File
@@ -1,18 +1,26 @@
// @vitest-environment node
import { describe, it, expect, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { WebSocket } from 'ws';
import type { AddressInfo } from 'node:net';
import { buildServer } from './index';
import type { FastifyInstance } from 'fastify';
import type { ClientMessage, ServerMessage } from '@/lib/sync/messages';
const app = buildServer();
// Use an isolated DATA_DIR (set before importing index.ts, which reads it at module
// load) so the sqlite store the server now opens never lands in the repo's ./data.
let app: FastifyInstance;
let base = '';
async function start(): Promise<void> {
beforeAll(async () => {
process.env.DATA_DIR = await fs.mkdtemp(path.join(os.tmpdir(), 'rt-'));
const { buildServer } = await import('./index');
app = buildServer();
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> {
@@ -33,7 +41,6 @@ const snap = { campaignName: 'Live', calendarDay: null, party: [], encounter: nu
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' }>;
+255 -4
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { RoomHub, type Sender } from './rooms';
import { RoomHub, type Sender, type RoomStore, type PersistedRoom } from './rooms';
import type { ServerMessage, Snapshot } from '@/lib/sync/messages';
import { characterSchema, type Character } from '@/lib/schemas';
@@ -93,7 +93,7 @@ describe('RoomHub', () => {
});
it('caps the number of concurrent rooms', () => {
const hub = new RoomHub(() => 0, { maxRooms: 2 });
const hub = new RoomHub(() => 0, undefined, { maxRooms: 2 });
const a = fake(); hub.host(a);
const b = fake(); hub.host(b);
expect(hub.roomCount()).toBe(2);
@@ -104,7 +104,7 @@ describe('RoomHub', () => {
});
it('caps per-room image count and total bytes', () => {
const hub = new RoomHub(() => 0, { maxImagesPerRoom: 2, maxRoomImageBytes: 100 });
const hub = new RoomHub(() => 0, undefined, { maxImagesPerRoom: 2, maxRoomImageBytes: 100 });
const gm = fake(); hub.host(gm);
const { gmSecret } = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
hub.image(gm, gmSecret, 'm1', 'x'.repeat(40));
@@ -117,6 +117,21 @@ describe('RoomHub', () => {
expect(lastOf(gm, 'error')).toMatchObject({ code: 'image-limit' });
});
it('re-broadcasts the roster on sweep as a presence heartbeat (T-146)', () => {
let t = 0;
const hub = new RoomHub(() => t);
const { gm, player } = hostAndJoin(hub);
const rosters = (s: ReturnType<typeof fake>) => s.msgs.filter((m) => m.t === 'roster').length;
const gmBefore = rosters(gm), pBefore = rosters(player);
t += 60 * 1000; // still well within TTL
hub.sweep();
expect(rosters(gm)).toBeGreaterThan(gmBefore); // live peers get a keepalive beat
expect(rosters(player)).toBeGreaterThan(pBefore);
expect(hub.roomCount()).toBe(1); // the room survives
});
it('routes a seat claim → GM grant → player gets their sheet', () => {
const hub = new RoomHub();
const { gm, player, secret } = hostAndJoin(hub);
@@ -145,7 +160,9 @@ describe('RoomHub', () => {
expect(lastOf(gm, 'playerPatched')).toMatchObject({ characterId: 'ch1', diff: { hp: { current: 7 } } });
hub.playerRoll(player, 'ch1', 'Sword', '1d20+5', 18, '[13]+5');
expect(lastOf(gm, 'rollBroadcast')).toMatchObject({ playerName: 'Lia', total: 18 });
// T-151: the broadcast carries the seat's authoritative characterId so the GM
// can feed it into the combat tracker.
expect(lastOf(gm, 'rollBroadcast')).toMatchObject({ playerName: 'Lia', total: 18, characterId: 'ch1' });
// A patch for a character the player doesn't hold is rejected.
const before = gm.msgs.filter((m) => m.t === 'playerPatched').length;
@@ -237,6 +254,71 @@ describe('RoomHub', () => {
expect(lastOf(p2, 'privateHandout')).toBeUndefined(); // a non-recipient never receives it
});
it('replays roll + table chat history and the target-only private handout on (re)join (T-147)', () => {
const hub = new RoomHub();
const gm = fake(); hub.host(gm);
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
const p1 = fake(); hub.join(p1, hosted.joinCode, undefined, 'tokA');
// Build some shared + targeted history while p1 is connected.
hub.gmRoll(gm, hosted.gmSecret, 'Fire', '1d6', 4, '[4]');
hub.chat(gm, 'hello table');
hub.chat(gm, 'for your eyes', 'tokA');
hub.privateState(gm, hosted.gmSecret, 'tokA', { title: 'Secret map', body: 'shh' });
// A DIFFERENT player joins fresh: gets the table roll + table chat, but never
// the whisper to tokA nor tokA's private handout.
const p2 = fake(); hub.join(p2, hosted.joinCode, undefined, 'tokB');
expect(p2.msgs.filter((m) => m.t === 'rollBroadcast')).toHaveLength(1);
const p2chat = p2.msgs.filter((m): m is Extract<ServerMessage, { t: 'chat' }> => m.t === 'chat');
expect(p2chat.map((c) => c.body)).toEqual(['hello table']);
expect(lastOf(p2, 'privateHandout')).toBeUndefined();
// tokA reconnects (new socket, same durable token): gets the roll, BOTH chat
// lines (table + their whisper), and their cached private handout.
const p1b = fake(); hub.join(p1b, hosted.joinCode, undefined, 'tokA');
expect(p1b.msgs.filter((m) => m.t === 'rollBroadcast')).toHaveLength(1);
const p1bchat = p1b.msgs.filter((m): m is Extract<ServerMessage, { t: 'chat' }> => m.t === 'chat');
expect(p1bchat.map((c) => c.body)).toEqual(['hello table', 'for your eyes']);
expect(lastOf(p1b, 'privateHandout')).toMatchObject({ handout: { title: 'Secret map' } });
});
it('does not replay a private handout the GM has cleared (T-147)', () => {
const hub = new RoomHub();
const gm = fake(); hub.host(gm);
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
const p1 = fake(); hub.join(p1, hosted.joinCode, undefined, 'tokA');
hub.privateState(gm, hosted.gmSecret, 'tokA', { title: 'Note', body: 'x' });
hub.privateState(gm, hosted.gmSecret, 'tokA', null); // GM hides it
const p1b = fake(); hub.join(p1b, hosted.joinCode, undefined, 'tokA');
expect(lastOf(p1b, 'privateHandout')).toBeUndefined();
});
it('caps the replayed roll history at the bound (T-147)', () => {
const hub = new RoomHub();
const gm = fake(); hub.host(gm);
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
for (let i = 0; i < 150; i++) hub.gmRoll(gm, hosted.gmSecret, `r${i}`, '1d6', i, `[${i}]`);
const p = fake(); hub.join(p, hosted.joinCode, undefined, 'late');
const replayed = p.msgs.filter((m): m is Extract<ServerMessage, { t: 'rollBroadcast' }> => m.t === 'rollBroadcast');
expect(replayed).toHaveLength(100); // last MAX_HISTORY only
expect(replayed[replayed.length - 1]?.label).toBe('r149'); // newest kept
});
it('rejects an oversized inline private-handout image (T-193)', () => {
const hub = new RoomHub();
const gm = fake(); hub.host(gm);
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
const p1 = fake(); hub.join(p1, hosted.joinCode, undefined, 'tokA');
const huge = 'a'.repeat(8 * 1024 * 1024 + 1); // > MAX_PRIVATE_HANDOUT_BYTES
hub.privateState(gm, hosted.gmSecret, 'tokA', { title: 'Big', body: '', image: huge });
expect(lastOf(gm, 'error')?.code).toBe('too-large');
expect(lastOf(p1, 'privateHandout')).toBeUndefined(); // never forwarded
const p1b = fake(); hub.join(p1b, hosted.joinCode, undefined, 'tokA');
expect(lastOf(p1b, 'privateHandout')).toBeUndefined(); // never cached
});
it('rejects privateState from a non-GM', () => {
const hub = new RoomHub();
const { player } = hostAndJoin(hub);
@@ -251,6 +333,51 @@ describe('RoomHub', () => {
expect(lastOf(gm, 'roster')!.players.find((p) => p.name === 'Alice')).toBeTruthy();
});
it('persists a campaign-linked room + its latest snapshot and restores it across a restart (T-132)', () => {
const persisted = new Map<string, PersistedRoom>();
const store: RoomStore = { upsert: (r) => persisted.set(r.roomId, r), remove: (id) => persisted.delete(id), all: () => [...persisted.values()] };
const hub = new RoomHub(() => 1000, store);
const gm = fake();
hub.host(gm, undefined, undefined, 'camp-1'); // GM hosts, linked to a campaign
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
expect(persisted.size).toBe(1);
expect([...persisted.values()][0]).toMatchObject({ roomId: hosted.roomId, campaignId: 'camp-1' });
hub.state(gm, hosted.gmSecret, snap); // a snapshot is persisted too
expect([...persisted.values()][0]!.snapshot).toMatchObject({ campaignName: 'C' });
// Restart: a fresh hub backed by the SAME store rebuilds the room from disk.
const hub2 = new RoomHub(() => 2000, store);
hub2.restore();
expect(hub2.roomCount()).toBe(1);
// A player joins by code and receives the restored snapshot.
const player = fake();
hub2.join(player, hosted.joinCode);
expect(lastOf(player, 'joined')).toBeTruthy();
expect(lastOf(player, 'snapshot')).toMatchObject({ snapshot: { campaignName: 'C' } });
// The GM resumes the same room by presenting its secret (rehashed against the
// persisted hash) → same room id, no duplicate room created.
const gm2 = fake();
hub2.host(gm2, undefined, hosted.gmSecret);
expect((lastOf(gm2, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>).roomId).toBe(hosted.roomId);
expect(hub2.roomCount()).toBe(1);
});
it('drops a persisted room from the store when it is swept for inactivity (T-132)', () => {
const persisted = new Map<string, PersistedRoom>();
const store: RoomStore = { upsert: (r) => persisted.set(r.roomId, r), remove: (id) => persisted.delete(id), all: () => [...persisted.values()] };
let t = 0;
const hub = new RoomHub(() => t, store);
hub.host(fake(), undefined, undefined, 'camp-x');
expect(persisted.size).toBe(1);
t += 7 * 60 * 60 * 1000; // past the idle TTL
hub.sweep();
expect(persisted.size).toBe(0);
});
it('routes chat: table to all, whisper to the target only', () => {
const hub = new RoomHub();
const gm = fake(); hub.host(gm);
@@ -282,4 +409,128 @@ describe('RoomHub', () => {
expect(lastOf(gm, 'chat')).toMatchObject({ body: 'private q', scope: 'whisper' });
expect(chatCount(p2)).toBe(p2c2);
});
// ---- delta/patch snapshot protocol (T-145) ----
it('versions the full snapshot, then applies + fans a delta patch advancing the seq', () => {
const hub = new RoomHub();
const { gm, player, secret } = hostAndJoin(hub);
hub.state(gm, secret, snap, 1);
expect(lastOf(player, 'snapshot')).toMatchObject({ seq: 1, snapshot: { campaignName: 'C' } });
hub.statePatch(gm, secret, 1, 2, { campaignName: 'C2' });
expect(lastOf(player, 'snapshotPatch')).toMatchObject({ baseSeq: 1, seq: 2, patch: { campaignName: 'C2' } });
// The server advanced its canonical snapshot, so a fresh joiner gets the new full state.
const late = fake();
hub.join(late, (lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>).joinCode);
expect(lastOf(late, 'snapshot')).toMatchObject({ seq: 2, snapshot: { campaignName: 'C2' } });
});
it('asks the GM for a full snapshot when a patch base no longer matches (seq drift)', () => {
const hub = new RoomHub();
const { gm, player, secret } = hostAndJoin(hub);
hub.state(gm, secret, snap, 5);
const patches = () => player.msgs.filter((m) => m.t === 'snapshotPatch').length;
const before = patches();
hub.statePatch(gm, secret, 99, 100, { campaignName: 'X' }); // wrong base
expect(lastOf(gm, 'needSnapshot')).toBeTruthy();
expect(patches()).toBe(before); // never fanned to players
});
it('serves the current full snapshot on requestSnapshot', () => {
const hub = new RoomHub();
const { gm, player, secret } = hostAndJoin(hub);
hub.state(gm, secret, snap, 3);
hub.requestSnapshot(player);
expect(lastOf(player, 'snapshot')).toMatchObject({ seq: 3, snapshot: { campaignName: 'C' } });
});
it('rejects a statePatch from a non-GM', () => {
const hub = new RoomHub();
const { player } = hostAndJoin(hub);
hub.statePatch(player, 'nope', 0, 1, { campaignName: 'X' });
expect(lastOf(player, 'error')?.code).toBe('forbidden');
});
// ---- player map agency (T-150) ----
it('forwards a seated player token move to the GM; drops it otherwise', () => {
const hub = new RoomHub();
const { gm, player, secret } = hostAndJoin(hub);
// Not seated → dropped.
hub.tokenMove(player, 'ch1', 'tok1', 2, 3);
expect(lastOf(gm, 'tokenMoved')).toBeUndefined();
hub.claimSeat(player, 'ch1');
hub.seatGrant(gm, secret, lastOf(gm, 'seatRequest')!.playerId, char);
hub.tokenMove(player, 'ch1', 'tok1', 4, 2);
expect(lastOf(gm, 'tokenMoved')).toMatchObject({ characterId: 'ch1', tokenId: 'tok1', col: 4, row: 2 });
// A move claiming a character the player doesn't hold is rejected.
const before = gm.msgs.filter((m) => m.t === 'tokenMoved').length;
hub.tokenMove(player, 'someone-else', 'tok1', 0, 0);
expect(gm.msgs.filter((m) => m.t === 'tokenMoved').length).toBe(before);
});
it('broadcasts a ping to the table except the sender', () => {
const hub = new RoomHub();
const gm = fake(); hub.host(gm);
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
const p1 = fake(); hub.join(p1, hosted.joinCode);
const p2 = fake(); hub.join(p2, hosted.joinCode);
hub.ping(p1, 'm1', { x: 12, y: 34 });
expect(lastOf(gm, 'ping')).toMatchObject({ mapId: 'm1', point: { x: 12, y: 34 } });
expect(lastOf(p2, 'ping')).toMatchObject({ point: { x: 12, y: 34 } });
expect(lastOf(p1, 'ping')).toBeUndefined(); // the sender shows it optimistically, not via the server
});
// ---- per-seat vision + spectator (T-152) ----
it('routes per-seat fog to the matching seat only, and replays it on rejoin', () => {
const hub = new RoomHub();
const gm = fake(); hub.host(gm);
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
const p1 = fake(); hub.join(p1, hosted.joinCode, undefined, 'tokA');
const p2 = fake(); hub.join(p2, hosted.joinCode, undefined, 'tokB');
hub.claimSeat(p1, 'ch1');
hub.seatGrant(gm, hosted.gmSecret, 'tokA', char); // p1 (tokA) now seats character ch1
hub.seatFog(gm, hosted.gmSecret, 'ch1', ['0,0', '1,0']);
expect(lastOf(p1, 'seatFog')).toMatchObject({ cells: ['0,0', '1,0'] });
expect(lastOf(p2, 'seatFog')).toBeUndefined(); // a different/unseated player never gets it
// tokA reconnects (same durable token) → the cached seat fog is replayed.
const p1b = fake(); hub.join(p1b, hosted.joinCode, undefined, 'tokA');
expect(lastOf(p1b, 'seatFog')).toMatchObject({ cells: ['0,0', '1,0'] });
});
it('skips per-seat fog for a spectator seat and re-sends the full view', () => {
const hub = new RoomHub();
const gm = fake(); hub.host(gm);
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
const p1 = fake(); hub.join(p1, hosted.joinCode, undefined, 'tokA');
hub.claimSeat(p1, 'ch1');
hub.seatGrant(gm, hosted.gmSecret, 'tokA', char);
hub.state(gm, hosted.gmSecret, snap, 1);
hub.spectate(p1, true);
expect(lastOf(p1, 'snapshot')).toMatchObject({ seq: 1 }); // switched to the full view
const before = p1.msgs.filter((m) => m.t === 'seatFog').length;
hub.seatFog(gm, hosted.gmSecret, 'ch1', ['0,0']);
expect(p1.msgs.filter((m) => m.t === 'seatFog').length).toBe(before); // spectator is not scoped
});
it('rejects seatFog from a non-GM', () => {
const hub = new RoomHub();
const { player } = hostAndJoin(hub);
hub.seatFog(player, 'nope', 'ch1', ['0,0']);
expect(lastOf(player, 'error')?.code).toBe('forbidden');
});
});
+327 -42
View File
@@ -1,16 +1,45 @@
import crypto from 'node:crypto';
import type { ServerMessage, Snapshot, PartialCharacterDiff, PrivateHandout } from '@/lib/sync/messages';
import type { ServerMessage, Snapshot, SnapshotPatch, PartialCharacterDiff, PrivateHandout } from '@/lib/sync/messages';
import { applySnapshotPatch } from '@/lib/sync/patch';
import type { Character } from '@/lib/schemas/character';
export interface Sender {
send: (msg: ServerMessage) => void;
}
/**
* The bounded, restart-survivable slice of a room (T-132): its identity, secrets and
* the latest player-safe snapshot. Images, seats, chat history and live sockets stay
* in memory and are re-established on reconnect. `campaignId` links the room to a
* cloud campaign so it can be authorised against membership.
*/
export interface PersistedRoom {
roomId: string;
joinCode: string;
gmSecretHash: string;
passwordHash: string | null;
campaignId: string | null;
snapshot: Snapshot | null;
}
/**
* Persistence seam for live rooms (T-132). Injected into {@link RoomHub} so the hub
* itself never imports bun:sqlite and stays unit-testable with a fake. The sqlite
* implementation lives in db.ts; when no store is supplied the hub is purely
* in-memory (the prior behaviour).
*/
export interface RoomStore {
upsert: (room: PersistedRoom) => void;
remove: (roomId: string) => void;
all: () => PersistedRoom[];
}
interface Seat {
sender: Sender;
characterId: string;
name: string;
/** the granted sheet, re-sent on reconnect so the player regains control seamlessly */
/** the granted sheet, re-sent on reconnect so the player regains control even
* while the GM is briefly offline; the GM's seatRejoined re-grant refreshes it */
character: Character;
/** set when the holder's socket drops; the seat survives a short grace window */
disconnectedAt?: number;
@@ -25,26 +54,67 @@ interface SeatRequest {
offlineSnapshot?: Character;
}
/** A buffered table roll, replayed to a (re)joining player so their feed isn't blank (T-147). */
interface RollLogEntry {
playerName: string;
label: string;
expression: string;
total: number;
breakdown: string;
characterId?: string;
}
/** A buffered chat line. `playerId` is the non-GM endpoint of a whisper, so a replay
* can be scoped to only the players who were actually party to it (T-147). */
interface ChatLogEntry {
from: string;
body: string;
scope: 'table' | 'whisper';
playerId?: string;
}
interface Room {
roomId: string;
joinCode: string;
gmSecretHash: string;
passwordHash: string | null;
/** the cloud campaign this room is linked to, for membership authz + resume (T-132) */
campaignId?: string;
/** the authenticated account hosting the room, when the GM connected with a token (T-132) */
ownerUserId?: string;
gm: Sender | null;
players: Set<Sender>;
snapshot: Snapshot | null;
/** version of `snapshot`; GM delta patches are gated against it so a patch only
* advances the canonical snapshot when it diffs from the exact base (T-145). */
snapshotSeq: number;
images: Map<string, string>;
/** running total of bytes in `images`, kept so the cap is O(1) to check */
/** running total of bytes in `images`, kept so the caps are O(1) to check */
imageBytes: number;
/** granted seats keyed by playerId */
seats: Map<string, Seat>;
/** seat requests awaiting GM approval (re-sent when the GM reconnects) */
pendingSeatRequests: SeatRequest[];
/** bounded table-roll history, replayed on (re)join (T-147) */
rollLog: RollLogEntry[];
/** bounded chat history (table + whispers), replayed scoped on (re)join (T-147) */
chatLog: ChatLogEntry[];
/** the current private handout per durable playerId, replayed on rejoin (T-147) */
privateHandouts: Map<string, PrivateHandout>;
/** latest per-seat fog keyed by characterId, replayed to a (re)joining seat (T-152) */
seatFog: Map<string, string[]>;
lastActivity: number;
}
const JOIN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no ambiguous chars
const ROOM_TTL_MS = 6 * 60 * 60 * 1000; // 6h idle
const MAX_PLAYERS_PER_ROOM = 50;
// Rejoin replay buffers (T-147): bound so a long-running room can't grow unboundedly.
const MAX_HISTORY = 100; // last N rolls + last N chat lines kept per room
const MAX_PRIVATE_HANDOUTS = 100; // distinct durable players whose handout we cache
const MAX_PRIVATE_HANDOUT_BYTES = 8 * 1024 * 1024; // cap an inline handout image (T-193)
const MAX_SEAT_FOG_CELLS = 200_000; // defensive cap on a per-seat fog cell list (T-152)
// Memory guardrails so an anonymous GM can't balloon the (in-RAM) hub on a small box.
export interface RoomLimits { maxRooms: number; maxImagesPerRoom: number; maxRoomImageBytes: number; maxTotalImageBytes: number }
const DEFAULT_LIMITS: RoomLimits = {
@@ -65,22 +135,57 @@ function timingEqual(a: string, b: string): boolean {
}
/**
* In-memory, GM-authoritative room registry. No persistence. The GM is the only
* writer of shared state; players are read-only EXCEPT for their own seat (HP,
* slots, conditions, rolls), which the hub forwards to the GM to persist.
* `now` is injectable for tests.
* In-memory, GM-authoritative room registry. The GM is the only writer of shared
* state; players are read-only EXCEPT for their own seat (HP, slots, conditions,
* rolls, their own token), which the hub forwards to the GM to persist. An optional
* {@link RoomStore} persists the restart-survivable slice (T-132). `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'; playerId: string; name?: string }>();
private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player'; playerId: string; name?: string; durable?: boolean; spectator?: boolean }>();
private limits: RoomLimits;
/** bytes of images held across ALL rooms (global memory guardrail) */
private totalImageBytes = 0;
constructor(private now: () => number = () => Date.now(), limits: Partial<RoomLimits> = {}) {
constructor(private now: () => number = () => Date.now(), private store?: RoomStore, limits: Partial<RoomLimits> = {}) {
this.limits = { ...DEFAULT_LIMITS, ...limits };
}
/** Snapshot the durable slice of a room to the injected store (no-op without one). */
private persistRoom(room: Room): void {
this.store?.upsert({
roomId: room.roomId,
joinCode: room.joinCode,
gmSecretHash: room.gmSecretHash,
passwordHash: room.passwordHash,
campaignId: room.campaignId ?? null,
snapshot: room.snapshot,
});
}
/**
* Rebuild in-memory rooms from the store after a process restart (T-132). Restored
* rooms have no live GM/players and an empty image/seat/history set; a GM resumes by
* presenting its gmSecret (rehashed against the persisted hash) and players rejoin by
* code, receiving the persisted snapshot. Call once on startup.
*/
restore(): void {
if (!this.store) return;
for (const p of this.store.all()) {
if (this.rooms.has(p.roomId) || this.byCode.has(p.joinCode)) continue;
const room: Room = {
roomId: p.roomId, joinCode: p.joinCode, gmSecretHash: p.gmSecretHash, passwordHash: p.passwordHash,
...(p.campaignId ? { campaignId: p.campaignId } : {}),
gm: null, players: new Set(), snapshot: p.snapshot, snapshotSeq: 0, images: new Map(), imageBytes: 0,
seats: new Map(), pendingSeatRequests: [], rollLog: [], chatLog: [], privateHandouts: new Map(), seatFog: new Map(),
lastActivity: this.now(),
};
this.rooms.set(room.roomId, room);
this.byCode.set(room.joinCode, room.roomId);
}
}
private mintJoinCode(): string {
for (let attempt = 0; attempt < 50; attempt++) {
let code = '';
@@ -91,14 +196,16 @@ export class RoomHub {
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).
host(socket: Sender, password?: string, resume?: string, campaignId?: string, ownerUserId?: string): void {
// Resume an existing room if the GM presents its secret (seamless reconnect, and
// the path that re-attaches a room restored from the store after a restart).
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();
if (ownerUserId) room.ownerUserId = ownerUserId;
this.conns.set(socket, { roomId: room.roomId, role: 'gm', playerId: 'gm' });
socket.send({ t: 'hosted', roomId: room.roomId, joinCode: room.joinCode, gmSecret: resume });
for (const req of room.pendingSeatRequests) socket.send({ t: 'seatRequest', ...req });
@@ -118,7 +225,10 @@ export class RoomHub {
// a sweep runs first to reclaim any that just expired before we refuse.
if (this.rooms.size >= this.limits.maxRooms) {
this.sweep();
if (this.rooms.size >= this.limits.maxRooms) { socket.send({ t: 'error', code: 'busy', message: 'The server is at capacity — please try again shortly.' }); return; }
if (this.rooms.size >= this.limits.maxRooms) {
socket.send({ t: 'error', code: 'busy', message: 'The server is at capacity — please try again shortly.' });
return;
}
}
const roomId = crypto.randomUUID();
const joinCode = this.mintJoinCode();
@@ -126,16 +236,21 @@ export class RoomHub {
const room: Room = {
roomId, joinCode, gmSecretHash: sha256(gmSecret),
passwordHash: password ? sha256(password) : null,
gm: socket, players: new Set(), snapshot: null, images: new Map(), imageBytes: 0,
seats: new Map(), pendingSeatRequests: [], lastActivity: this.now(),
...(campaignId ? { campaignId } : {}),
...(ownerUserId ? { ownerUserId } : {}),
gm: socket, players: new Set(), snapshot: null, snapshotSeq: 0, images: new Map(), imageBytes: 0,
seats: new Map(), pendingSeatRequests: [],
rollLog: [], chatLog: [], privateHandouts: new Map(), seatFog: new Map(),
lastActivity: this.now(),
};
this.rooms.set(roomId, room);
this.byCode.set(joinCode, roomId);
this.conns.set(socket, { roomId, role: 'gm', playerId: 'gm' });
this.persistRoom(room); // durable from creation so a restart can restore it (T-132)
socket.send({ t: 'hosted', roomId, joinCode, gmSecret });
}
join(socket: Sender, joinCode: string, password?: string, clientPlayerId?: string): void {
join(socket: Sender, joinCode: string, password?: string, rejoinToken?: 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; }
@@ -143,43 +258,170 @@ export class RoomHub {
socket.send({ t: 'error', code: 'bad-password', message: 'Wrong session password.' });
return;
}
room.players.add(socket);
room.lastActivity = this.now();
// Reuse the client's stable id so a transient drop keeps the seat. The id is a
// client-generated UUID kept in localStorage, so a connection presenting it IS
// that browser: if a stale socket still holds it (fast reconnect inside the
// heartbeat window), the new connection takes over rather than being demoted
// to a fresh id (which silently stripped the seat).
let playerId: string = crypto.randomUUID();
if (clientPlayerId && clientPlayerId !== 'gm' && clientPlayerId.length <= 64) {
playerId = clientPlayerId;
// A durable rejoinToken becomes the stable playerId, so a reconnecting player
// keeps the same identity (and seat) instead of being issued a fresh id whose
// edits then silently stop persisting (T-140). 'gm' is reserved for the host so
// a hostile token can never impersonate/receive GM-targeted traffic.
const durable = !!rejoinToken && rejoinToken.length > 0 && rejoinToken.length <= 64 && rejoinToken !== 'gm';
const playerId = durable ? rejoinToken! : crypto.randomUUID();
if (durable) {
// The token is a private per-browser id: whoever presents it IS that browser.
// A fast reconnect (old socket not yet reaped) must take the identity over on
// the NEW socket instead of being demoted to a fresh, seatless id. Evicting
// the stale socket first also keeps a full room rejoinable at the cap.
for (const [s, c] of this.conns) {
if (s !== socket && c.roomId === room.roomId && c.playerId === clientPlayerId) {
if (s !== socket && c.roomId === room.roomId && c.playerId === playerId) {
room.players.delete(s);
this.conns.delete(s);
}
}
}
this.conns.set(socket, { roomId: room.roomId, role: 'player', playerId });
socket.send({ t: 'joined', roomId: room.roomId });
if (room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot });
// Reconnect: if this player still owns a seat, re-bind it to the new socket and
// re-grant the sheet so their HP/condition/roll messages are accepted again.
if (room.players.size >= MAX_PLAYERS_PER_ROOM) {
socket.send({ t: 'error', code: 'room-full', message: 'This session is full.' });
return;
}
room.players.add(socket);
room.lastActivity = this.now();
this.conns.set(socket, { roomId: room.roomId, role: 'player', playerId, ...(durable ? { durable: true } : {}) });
// Rebind a surviving seat to the new socket so HP/slot/roll forwarding works again.
const seat = room.seats.get(playerId);
if (seat) {
seat.sender = socket;
delete seat.disconnectedAt;
// Re-grant the stored sheet immediately (works even while the GM is offline)…
socket.send({ t: 'seatGranted', character: seat.character });
// …and tell the GM so it re-grants with the FRESH character from its DB
// (M3-M5): the GM is the authority holding the current copy.
room.gm?.send({ t: 'seatRejoined', playerId, characterId: seat.characterId });
}
socket.send({ t: 'joined', roomId: room.roomId });
// The snapshot carries its seq so the GM's later delta patches can be gated against
// it (a (re)joiner always starts from a full snapshot, then receives deltas) — T-145.
if (room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot, seq: room.snapshotSeq });
// Replay recent table history + this player's current private handout so a
// (re)joining player's panel isn't blank (T-147). The client clears its own
// ephemeral feed before (re)joining, so this rebuild is idempotent.
this.replayHistory(socket, room, playerId);
// Replay the seat's per-seat fog (T-152) so a reconnecting seat re-scopes its view
// without waiting for the GM's next push.
const fog = seat ? room.seatFog.get(seat.characterId) : undefined;
if (fog) socket.send({ t: 'seatFog', cells: fog });
this.sendRoster(room);
}
state(socket: Sender, gmSecret: string, snapshot: Snapshot): void {
/**
* Replay the bounded room history to one (re)joining player: every table roll,
* the chat they were party to (table lines + their own whispers), then their
* current private handout. Order is chronological so the client rebuilds its
* feed in the same order it would have received it live (T-147).
*/
private replayHistory(socket: Sender, room: Room, playerId: string): void {
for (const r of room.rollLog) {
socket.send({ t: 'rollBroadcast', playerName: r.playerName, label: r.label, expression: r.expression, total: r.total, breakdown: r.breakdown, ...(r.characterId ? { characterId: r.characterId } : {}) });
}
for (const c of room.chatLog) {
if (c.scope === 'table' || c.playerId === playerId) socket.send({ t: 'chat', from: c.from, body: c.body, scope: c.scope });
}
const handout = room.privateHandouts.get(playerId);
if (handout) socket.send({ t: 'privateHandout', handout });
}
/** Append to a bounded replay buffer, dropping the oldest entries past the cap. */
private pushHistory<T>(log: T[], entry: T): void {
log.push(entry);
if (log.length > MAX_HISTORY) log.splice(0, log.length - MAX_HISTORY);
}
state(socket: Sender, gmSecret: string, snapshot: Snapshot, seq?: number): 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;
// A full snapshot re-baselines the version (the GM picks the seq; older clients
// that send none just advance it by one and never patch) — T-145.
room.snapshotSeq = seq ?? room.snapshotSeq + 1;
room.lastActivity = this.now();
for (const p of room.players) p.send({ t: 'snapshot', snapshot });
this.persistRoom(room); // latest player-safe snapshot survives a restart (T-132)
for (const p of room.players) p.send({ t: 'snapshot', snapshot, seq: room.snapshotSeq });
}
/**
* Apply a GM delta patch (T-145). It only advances the canonical snapshot when it
* diffs from the EXACT base the server holds and applies cleanly; otherwise the
* server can't keep `room.snapshot` (the late-joiner baseline) consistent, so it
* asks the GM for a fresh full snapshot rather than fan a patch nobody can apply.
*/
statePatch(socket: Sender, gmSecret: string, baseSeq: number, seq: number, patch: SnapshotPatch): void {
const room = this.gmRoom(socket, gmSecret);
if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; }
room.lastActivity = this.now();
const applied = room.snapshot && room.snapshotSeq === baseSeq ? applySnapshotPatch(room.snapshot, patch) : null;
if (!applied) { socket.send({ t: 'needSnapshot' }); return; }
room.snapshot = applied;
room.snapshotSeq = seq;
this.persistRoom(room);
for (const p of room.players) p.send({ t: 'snapshotPatch', baseSeq, seq, patch });
}
/** A client that fell behind asks for the current full snapshot to re-baseline (T-145). */
requestSnapshot(socket: Sender): void {
const conn = this.conns.get(socket);
const room = conn ? this.rooms.get(conn.roomId) : undefined;
if (room?.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot, seq: room.snapshotSeq });
}
/**
* GM → seat per-seat fog (T-152): cache it (replayed on rejoin) and forward to the
* seat(s) holding that character. Spectators are skipped — they keep the full view.
*/
seatFog(socket: Sender, gmSecret: string, characterId: string, cells: 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.lastActivity = this.now();
const bounded = cells.length > MAX_SEAT_FOG_CELLS ? cells.slice(0, MAX_SEAT_FOG_CELLS) : cells;
room.seatFog.set(characterId, bounded);
for (const [s, c] of this.conns) {
if (c.roomId !== room.roomId || c.spectator) continue;
const seat = room.seats.get(c.playerId);
if (seat?.characterId === characterId) s.send({ t: 'seatFog', cells: bounded });
}
}
/**
* A seated player moves the token they control (T-150). The hub only checks the
* sender holds the seat for `characterId`; the GM does the authoritative check that
* the token actually belongs to that character before writing it (the clamped path).
*/
tokenMove(socket: Sender, characterId: string, tokenId: string, col: number, row: number): void {
const conn = this.conns.get(socket);
const room = conn ? this.rooms.get(conn.roomId) : undefined;
if (!conn || conn.role !== 'player' || !room) return;
const seat = room.seats.get(conn.playerId);
if (!seat || seat.characterId !== characterId) return; // must hold the seat for this character
room.lastActivity = this.now();
room.gm?.send({ t: 'tokenMoved', characterId, tokenId, col, row });
}
/** A transient ping dropped on the map, broadcast to the whole table (T-150). */
ping(socket: Sender, mapId: string, point: { x: number; y: number }): void {
const conn = this.conns.get(socket);
const room = conn ? this.rooms.get(conn.roomId) : undefined;
if (!conn || !room) return;
room.lastActivity = this.now();
const from = conn.role === 'gm' ? 'GM' : (conn.name ?? room.seats.get(conn.playerId)?.name ?? `Player ${conn.playerId.slice(0, 4)}`);
const msg = { t: 'ping', mapId, point, from } as const;
if (conn.role !== 'gm') room.gm?.send(msg);
for (const p of room.players) if (p !== socket) p.send(msg);
}
/** Toggle a connection into spectator / GM-screen mode (T-152): full view, no
* per-seat fog. Switching on re-sends the full snapshot to clear any seat scoping. */
spectate(socket: Sender, on: boolean): void {
const conn = this.conns.get(socket);
const room = conn ? this.rooms.get(conn.roomId) : undefined;
if (!conn || !room) return;
conn.spectator = on;
room.lastActivity = this.now();
if (on && room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot, seq: room.snapshotSeq });
}
image(socket: Sender, gmSecret: string, id: string, dataUrl: string): void {
@@ -255,15 +497,34 @@ export class RoomHub {
privateState(socket: Sender, gmSecret: string, targetPlayerId: string, handout: PrivateHandout | null): void {
const room = this.gmRoom(socket, gmSecret);
if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; }
// Bound an inline handout image like the shared image channel does (T-193).
if (handout?.image && Buffer.byteLength(handout.image) > MAX_PRIVATE_HANDOUT_BYTES) {
socket.send({ t: 'error', code: 'too-large', message: 'That handout image is too large.' });
return;
}
room.lastActivity = this.now();
// Cache the player's CURRENT handout (or clear it) so it replays on rejoin (T-147).
if (handout) this.cachePrivateHandout(room, targetPlayerId, handout);
else room.privateHandouts.delete(targetPlayerId);
for (const [s, c] of this.conns) {
if (c.roomId === room.roomId && c.playerId === targetPlayerId) s.send({ t: 'privateHandout', handout });
}
}
/** Store the latest handout for a player, evicting the oldest if over the cap. */
private cachePrivateHandout(room: Room, playerId: string, handout: PrivateHandout): void {
room.privateHandouts.delete(playerId); // re-insert at the end to refresh recency
room.privateHandouts.set(playerId, handout);
while (room.privateHandouts.size > MAX_PRIVATE_HANDOUTS) {
const oldest = room.privateHandouts.keys().next().value;
if (oldest === undefined) break;
room.privateHandouts.delete(oldest);
}
}
/** Tell everyone (GM + players) the current roster: the GM (if present) + players. */
private sendRoster(room: Room): void {
const players: { playerId: string; name: string; character?: string }[] = [];
const players: { playerId: string; name: string; character?: string; characterId?: string }[] = [];
if (room.gm) players.push({ playerId: 'gm', name: 'GM' });
const seen = new Set<string>();
for (const s of room.players) {
@@ -277,6 +538,7 @@ export class RoomHub {
playerId: conn.playerId,
name,
...(conn.name && seatName ? { character: seatName } : {}),
// The seat's characterId lets the GM push sheet updates to the right player.
...(seat ? { characterId: seat.characterId } : {}),
});
}
@@ -326,7 +588,10 @@ export class RoomHub {
const seat = room.seats.get(conn.playerId);
if (!seat) return; // only seated players broadcast rolls
room.lastActivity = this.now();
const msg = { t: 'rollBroadcast', playerName: seat.name, label, expression, total, breakdown } as const;
// Carry the seat's authoritative characterId so the GM can attribute the roll
// to a combatant and feed it into the tracker (e.g. initiative) — T-151.
const msg = { t: 'rollBroadcast', playerName: seat.name, label, expression, total, breakdown, characterId: seat.characterId } as const;
this.pushHistory(room.rollLog, { playerName: seat.name, label, expression, total, breakdown, characterId: seat.characterId });
room.gm?.send(msg);
for (const p of room.players) if (p !== socket) p.send(msg);
}
@@ -337,6 +602,7 @@ export class RoomHub {
if (!room) return;
room.lastActivity = this.now();
const msg = { t: 'rollBroadcast', playerName: 'GM', label, expression, total, breakdown } as const;
this.pushHistory(room.rollLog, { playerName: 'GM', label, expression, total, breakdown });
for (const p of room.players) p.send(msg); // players only; the GM already sees it locally
}
@@ -349,17 +615,23 @@ export class RoomHub {
if (conn.role === 'gm') {
if (to) {
const msg = { t: 'chat', from: 'GM', body, scope: 'whisper' } as const;
this.pushHistory(room.chatLog, { from: 'GM', body, scope: 'whisper', playerId: to });
for (const [s, c] of this.conns) if (c.roomId === room.roomId && c.playerId === to) s.send(msg);
} else {
const msg = { t: 'chat', from: 'GM', body, scope: 'table' } as const;
this.pushHistory(room.chatLog, { from: 'GM', body, scope: 'table' });
for (const p of room.players) p.send(msg);
}
} else {
const from = conn.name ?? room.seats.get(conn.playerId)?.name ?? `Player ${conn.playerId.slice(0, 4)}`;
if (to) {
// A player whisper has the player as the non-GM endpoint, so a replay reaches
// only them (and the GM, who isn't replayed history here) — T-147.
this.pushHistory(room.chatLog, { from, body, scope: 'whisper', playerId: conn.playerId });
room.gm?.send({ t: 'chat', from, body, scope: 'whisper' });
} else {
const msg = { t: 'chat', from, body, scope: 'table' } as const;
this.pushHistory(room.chatLog, { from, body, scope: 'table' });
room.gm?.send(msg);
for (const p of room.players) if (p !== socket) p.send(msg);
}
@@ -373,9 +645,15 @@ export class RoomHub {
if (room) {
if (conn.role === 'gm' && room.gm === socket) room.gm = null;
room.players.delete(socket);
// Don't drop the seat on disconnect — start its grace timer so a reconnect (same
// stable playerId) can reclaim it. sweep() evicts seats that stay gone too long.
for (const [, seat] of room.seats) if (seat.sender === socket) seat.disconnectedAt = this.now();
// A DURABLE player's seat isn't dropped — it starts a grace timer so a
// reconnect (same rejoinToken) can reclaim it; sweep() evicts seats that stay
// gone too long (T-140). Non-durable seats can never be reclaimed, so they
// are dropped immediately.
for (const [pid, seat] of room.seats) {
if (seat.sender !== socket) continue;
if (conn.durable) seat.disconnectedAt = this.now();
else room.seats.delete(pid);
}
// Refresh the roster for whoever remains (GM left → players see it; player left → GM sees it).
this.sendRoster(room);
}
@@ -399,9 +677,17 @@ export class RoomHub {
this.totalImageBytes -= room.imageBytes;
this.byCode.delete(room.joinCode);
this.rooms.delete(room.roomId);
this.store?.remove(room.roomId);
}
/** Evict rooms idle past the TTL, and seats whose holder has been gone past the grace window. */
/**
* Evict rooms idle past the TTL and seats whose holder has been gone past the
* grace window, then send a presence heartbeat to the rooms that survive.
* Called periodically. The re-broadcast roster doubles as an application-level
* keepalive: a client whose socket has gone half-open stops receiving these
* beats and its watchdog forces a reconnect, so the presence list (and the GM's
* snapshot fan-out) stops targeting dead sockets (T-146).
*/
sweep(): void {
const now = this.now();
const cutoff = now - ROOM_TTL_MS;
@@ -410,16 +696,15 @@ export class RoomHub {
this.totalImageBytes -= room.imageBytes;
this.byCode.delete(room.joinCode);
this.rooms.delete(id);
this.store?.remove(id);
continue;
}
let dropped = false;
for (const [pid, seat] of room.seats) {
if (seat.disconnectedAt !== undefined && now - seat.disconnectedAt > SEAT_GRACE_MS) {
room.seats.delete(pid);
dropped = true;
}
}
if (dropped) this.sendRoster(room);
this.sendRoster(room);
}
}
+76
View File
@@ -0,0 +1,76 @@
// @vitest-environment node
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { WebSocket } from 'ws';
import type { AddressInfo } from 'node:net';
import type { FastifyInstance } from 'fastify';
import type { ClientMessage, ServerMessage } from '@/lib/sync/messages';
let app: FastifyInstance;
let port = 0;
beforeAll(async () => {
process.env.DATA_DIR = await fs.mkdtemp(path.join(os.tmpdir(), 'wsauth-'));
process.env.ALLOWED_ORIGINS = ''; // no allowlist → the test ws client (no Origin) is accepted
const { buildServer } = await import('./index');
app = buildServer();
await app.listen({ port: 0, host: '127.0.0.1' });
port = (app.server.address() as AddressInfo).port;
});
afterAll(async () => { await app.close(); });
const auth = (token: string) => ({ authorization: `Bearer ${token}` });
async function register(username: string): Promise<string> {
const res = await app.inject({ method: 'POST', url: '/api/register', payload: { username, password: 'password123' } });
return (res.json() as { token: string }).token;
}
function openWs(query = ''): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws${query}`);
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);
});
}
describe('WS auth + campaign linkage (T-132)', () => {
it('an authenticated GM hosts a room linked to a campaign they own; a guest joins by code', async () => {
const token = await register('ws_owner');
const camp = (await app.inject({ method: 'POST', url: '/api/campaigns', headers: auth(token), payload: { name: 'WS', system: '5e' } })).json() as { id: string };
const gm = await openWs(`?token=${encodeURIComponent(token)}`);
send(gm, { t: 'host', campaignId: camp.id });
const hosted = await next(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
expect(hosted.joinCode).toBeTruthy();
// A guest (no token) can still join by code — the anonymous flow is preserved.
const guest = await openWs();
send(guest, { t: 'join', joinCode: hosted.joinCode });
expect((await next(guest, 'joined')).t).toBe('joined');
gm.close(); guest.close();
});
it('rejects linking a room to a campaign the authenticated user is not a member of', async () => {
const token = await register('ws_outsider');
const gm = await openWs(`?token=${encodeURIComponent(token)}`);
send(gm, { t: 'host', campaignId: 'not-my-campaign' });
const err = await next(gm, 'error') as Extract<ServerMessage, { t: 'error' }>;
expect(err.code).toBe('forbidden');
gm.close();
});
it('closes a connection whose bearer token is present but invalid (1008)', async () => {
const ws = await openWs('?token=totally-invalid');
const code = await new Promise<number>((resolve) => ws.on('close', (c) => resolve(c)));
expect(code).toBe(1008);
});
});
+162 -42
View File
@@ -3,11 +3,17 @@ import { Link, Outlet, useRouterState } from '@tanstack/react-router';
import {
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight,
ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles, Wand2, ShieldCheck,
WifiOff, Download, Menu, LogIn,
NotebookPen, UsersRound, ScrollText, CalendarDays, FlaskConical, Sparkles, Wand2, ShieldCheck,
type LucideIcon,
} from 'lucide-react';
import { getSystem } from '@/lib/rules';
import { useUiStore } from '@/stores/uiStore';
import { useOnlineStatus } from '@/lib/useOnlineStatus';
import { useInstallPrompt } from '@/lib/useInstallPrompt';
import { CommandPalette } from '@/components/CommandPalette';
import { Icon } from '@/components/ui/Icon';
import { UpdatePrompt } from '@/components/UpdatePrompt';
import { useActiveCampaign, useCampaigns } from '@/features/campaigns/hooks';
import { cn } from '@/lib/cn';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
@@ -18,6 +24,7 @@ import { SessionControl } from '@/features/play/SessionControl';
import { HandoutControl } from '@/features/play/HandoutControl';
import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster';
import { usePlayerConnection } from '@/features/play/usePlayerConnection';
import { useGmConnection } from '@/features/play/useGmConnection';
import { useCloudAutosave } from '@/features/cloud/useCloudAutosave';
import { useCharacterPublishSync } from '@/features/cloud/useCharacterPublishSync';
import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
@@ -36,14 +43,16 @@ const NAV: NavItem[] = [
{ to: '/director', label: 'AI Director', icon: Wand2, exact: false, group: 'play' },
{ to: '/maps', label: 'Battle Map', icon: MapIcon, exact: false, group: 'play' },
{ to: '/dice', label: 'Dice', icon: Dices, exact: false, group: 'play' },
{ to: '/notes', label: 'Notes', icon: ScrollText, exact: false, group: 'world' },
{ to: '/npcs', label: 'NPCs', icon: Drama, exact: false, group: 'world' },
{ to: '/quests', label: 'Quests', icon: Target, exact: false, group: 'world' },
{ to: '/npcs', label: 'NPCs', icon: UsersRound, exact: false, group: 'world' },
{ to: '/quests', label: 'Quests', icon: ScrollText, exact: false, group: 'world' },
{ to: '/notes', label: 'Notes', icon: NotebookPen, exact: false, group: 'world' },
{ to: '/calendar', label: 'Calendar', icon: CalendarDays, exact: false, group: 'world' },
{ to: '/homebrew', label: 'Homebrew', icon: FlaskConical, exact: false, group: 'world' },
{ to: '/assistant', label: 'Assistant', icon: Sparkles, exact: false, group: 'world' },
{ to: '/compendium', label: 'Compendium', icon: BookOpenText, exact: false, group: 'reference' },
{ to: '/homebrew', label: 'Homebrew', icon: FlaskConical, exact: false, group: 'reference' },
{ to: '/assistant', label: 'Assistant', icon: Sparkles, exact: false, group: 'reference' },
{ to: '/play', label: 'Live Session', icon: RadioTower, exact: false, group: 'reference' },
// exact: plain startsWith lit "Live Session" (/play) on the Player setup page (/player).
{ to: '/play', label: 'Live Session', icon: RadioTower, exact: true, group: 'reference' },
{ to: '/player', label: 'Join a Game', icon: LogIn, exact: false, group: 'reference' },
];
const GROUPS: { id: NavItem['group']; label: string | null }[] = [
{ id: 'top', label: null },
@@ -52,6 +61,61 @@ const GROUPS: { id: NavItem['group']; label: string | null }[] = [
{ id: 'reference', label: 'Reference' },
];
/**
* The grouped nav links, shared by the desktop rail and the mobile drawer.
* `mode='rail'` keeps the responsive icon-strip behavior (labels gated by the
* collapse state + the `sm:` breakpoint); `mode='drawer'` always shows labels.
*/
function NavItems({
pathname,
mode,
items = NAV,
showLabel = true,
onNavigate,
}: {
pathname: string;
mode: 'rail' | 'drawer';
items?: NavItem[];
showLabel?: boolean;
onNavigate?: () => void;
}) {
const drawer = mode === 'drawer';
return (
<>
{GROUPS.map((group) => (
<div key={group.id}>
{group.label && (drawer || showLabel) && (
<div className={cn('smallcaps pt-4 pb-1.5', drawer ? 'block px-4' : 'hidden px-6 sm:block')} style={{ fontSize: 9.5 }}>{group.label}</div>
)}
{items.filter((n) => n.group === group.id).map((item) => {
const active = item.exact ? pathname === item.to : pathname.startsWith(item.to);
const Ico = item.icon;
return (
<Link
key={item.to}
to={item.to}
onClick={onNavigate}
title={item.label}
aria-current={active ? 'page' : undefined}
className={cn(
'relative mx-2.5 my-px flex h-[42px] items-center gap-3 rounded-[10px] text-[14.5px] font-medium transition-colors',
drawer ? 'justify-start px-3.5' : cn('justify-center px-0', showLabel && 'sm:justify-start sm:px-3.5'),
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60',
active ? 'bg-accent-glow font-semibold text-accent-deep' : 'text-muted hover:bg-elevated hover:text-ink',
)}
>
{active && <span className="absolute -left-2.5 top-2 bottom-2 w-[3px] rounded-r bg-accent" aria-hidden />}
<Ico size={19} strokeWidth={active ? 2 : 1.7} aria-hidden className="flex-none" />
{(drawer || showLabel) && <span className={drawer ? 'inline' : 'hidden sm:inline'}>{item.label}</span>}
</Link>
);
})}
</div>
))}
</>
);
}
function CampaignSwitcher() {
const campaigns = useCampaigns();
const active = useActiveCampaign();
@@ -70,7 +134,7 @@ function CampaignSwitcher() {
<option value="">— Select campaign —</option>
{campaigns.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.system === '5e' ? 'D&D 5e' : 'PF2e'})
{c.name} ({getSystem(c.system).label})
</option>
))}
</Select>
@@ -90,10 +154,14 @@ function ThemeToggle() {
export function RootLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const [paletteOpen, setPaletteOpen] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const drawerRef = useRef<HTMLElement | null>(null);
const sessionDockOpen = useUiStore((s) => s.sessionDockOpen);
const setSessionDock = useUiStore((s) => s.setSessionDock);
const railCollapsed = useUiStore((s) => s.railCollapsed);
const toggleRail = useUiStore((s) => s.toggleRail);
const online = useOnlineStatus();
const { canInstall, promptInstall } = useInstallPrompt();
const sessionConnected = useSessionStore((s) => s.status === 'connected');
const sessionRole = useSessionStore((s) => s.role);
const joinIntent = useSessionStore((s) => s.joinIntent);
@@ -101,6 +169,7 @@ export function RootLayout() {
// While the GM is hosting, mirror state to players (inert unless hosting).
useSessionBroadcaster(activeCampaign ?? null);
usePlayerConnection();
useGmConnection();
useCloudAutosave();
useCharacterPublishSync();
// Instance admins get an extra nav entry (server re-checks every /api/admin call).
@@ -121,6 +190,28 @@ export function RootLayout() {
return () => window.removeEventListener('keydown', onKey);
}, []);
// Close the mobile nav drawer whenever the route changes.
useEffect(() => { setMobileNavOpen(false); }, [pathname]);
// Mobile drawer = a real modal: focus into it on open, Escape closes, Tab is trapped.
useEffect(() => {
if (!mobileNavOpen) return;
const panel = drawerRef.current;
panel?.querySelector<HTMLElement>('a[href], button:not([disabled])')?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); setMobileNavOpen(false); return; }
if (e.key === 'Tab' && panel) {
const f = panel.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])');
if (f.length === 0) return;
const first = f[0]!, last = f[f.length - 1]!;
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [mobileNavOpen]);
// Players get the session panel docked open by default (once) — they live in it.
const autoOpenedRef = useRef(false);
useEffect(() => {
@@ -136,9 +227,16 @@ export function RootLayout() {
return (
<>
{/* Skip link (T-168): first focusable element; visually hidden until focused. */}
<a
href="#main"
className="sr-only rounded-md border border-line bg-panel px-4 py-2 text-sm font-medium text-ink shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[100]"
>
Skip to content
</a>
<div className="grid h-full grid-cols-[auto_1fr]">
{/* ---- Left nav rail (single, responsive: icon strip on phones / collapsed) ---- */}
<aside className={cn('paper-grain relative z-20 flex flex-col border-r border-line bg-panel transition-[width] duration-200 print:hidden', railCollapsed ? 'w-[68px]' : 'w-[68px] sm:w-60')}>
{/* ---- Left nav rail: desktop icon/label strip (≥sm). Phones use the drawer below. ---- */}
<aside className={cn('paper-grain relative z-20 hidden flex-col border-r border-line bg-panel transition-[width] duration-200 print:hidden sm:flex', railCollapsed ? 'sm:w-[68px]' : 'sm:w-60')}>
<div className="relative z-[1] flex h-full flex-col">
<Link to="/" className={cn('flex h-[60px] flex-none items-center gap-3 border-b border-line px-4', railCollapsed ? 'justify-center px-0' : 'justify-center px-0 sm:justify-start sm:px-4')} title="TTRPG Manager">
<span className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-gradient-to-b from-accent-soft to-accent text-accent-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.3)]">
@@ -153,34 +251,7 @@ export function RootLayout() {
</Link>
<nav aria-label="Primary" className="flex-1 overflow-y-auto py-2">
{GROUPS.map((group) => (
<div key={group.id}>
{group.label && showLabel && <div className="smallcaps hidden px-6 pt-4 pb-1.5 sm:block" style={{ fontSize: 9.5 }}>{group.label}</div>}
{nav.filter((n) => n.group === group.id).map((item) => {
// Exact-or-segment match: plain startsWith lit "Live Session" (/play)
// on the Player setup page (/player).
const active = item.exact ? pathname === item.to : pathname === item.to || pathname.startsWith(`${item.to}/`);
const Ico = item.icon;
return (
<Link
key={item.to}
to={item.to}
title={item.label}
className={cn(
'relative mx-2.5 my-px flex h-[42px] items-center gap-3 rounded-[10px] text-[14.5px] font-medium transition-colors',
'justify-center px-0',
showLabel && 'sm:justify-start sm:px-3.5',
active ? 'bg-accent-glow font-semibold text-accent-deep' : 'text-muted hover:bg-elevated hover:text-ink',
)}
>
{active && <span className="absolute -left-2.5 top-2 bottom-2 w-[3px] rounded-r bg-accent" aria-hidden />}
<Ico size={19} strokeWidth={active ? 2 : 1.7} aria-hidden className="flex-none" />
{showLabel && <span className="hidden sm:inline">{item.label}</span>}
</Link>
);
})}
</div>
))}
<NavItems pathname={pathname} mode="rail" items={nav} showLabel={showLabel} />
</nav>
<div className="hidden flex-none border-t border-line p-2.5 sm:block">
@@ -199,8 +270,11 @@ export function RootLayout() {
{/* ---- Workarea: top context bar + canvas ---- */}
<div className="flex min-w-0 flex-col">
<header className="paper-grain relative z-10 flex h-[60px] flex-none items-center gap-2 border-b border-line bg-panel px-3 sm:px-5">
<header aria-label="Global" className="paper-grain relative z-10 flex h-[60px] flex-none items-center gap-2 border-b border-line bg-panel px-3 sm:px-5">
<div className="relative z-[1] flex w-full items-center gap-2">
<Button size="icon" variant="ghost" className="sm:hidden" onClick={() => setMobileNavOpen(true)} aria-label="Open navigation" title="Menu">
<Menu size={18} aria-hidden />
</Button>
<CampaignSwitcher />
<div className="flex-1" />
@@ -212,12 +286,20 @@ export function RootLayout() {
>
<Search size={16} aria-hidden />
<span className="flex-1 text-left text-sm text-faint">Search the codex…</span>
<kbd className="rounded border border-line px-1.5 font-mono text-[10.5px] text-faint">⌘K</kbd>
<kbd className="inline-flex items-center gap-0.5 rounded border border-line px-1.5 font-mono text-[10.5px] text-faint"><Icon name="Command" size={11} className="inline" />K</kbd>
</button>
<Button size="icon" variant="ghost" className="md:hidden" onClick={() => setPaletteOpen(true)} aria-label="Open command palette" title="Command palette">
<Search size={18} aria-hidden />
</Button>
{!online && (
<span
className="inline-flex items-center gap-1 rounded-full border border-warning/50 bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning"
title="You're offline. Your campaign works locally; live session and cloud sync resume when you reconnect."
>
<WifiOff size={13} aria-hidden /> <span className="hidden sm:inline">Offline</span>
</span>
)}
<PlayerSessionBadge />
<Button size="sm" variant="subtle" onClick={() => setSessionDock(true)} title="Session panel — who's here, rolls, chat" aria-label="Open session panel">
<PanelRight size={15} aria-hidden />
@@ -228,6 +310,12 @@ export function RootLayout() {
<SessionControl />
<SyncStatusIndicator />
<SignalsBell />
{canInstall && (
<Button size="sm" variant="subtle" onClick={() => void promptInstall()} title="Install this app on your device" aria-label="Install app">
<Download size={15} aria-hidden />
<span className="hidden sm:inline">Install</span>
</Button>
)}
<ThemeToggle />
<Link to="/settings" className="grid h-9 w-9 flex-none place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink" title="Settings" aria-label="Settings">
<SettingsIcon size={18} aria-hidden />
@@ -236,7 +324,12 @@ export function RootLayout() {
</header>
<div className="flex min-h-0 flex-1">
<main className="flex-1 overflow-auto">
<main
id="main"
tabIndex={-1}
aria-label="Main content"
className="flex-1 overflow-auto focus:outline-none"
>
<ErrorBoundary resetKey={pathname}>
<Outlet />
</ErrorBoundary>
@@ -246,7 +339,34 @@ export function RootLayout() {
</div>
</div>
<RollTray />
{/* The global floating tray is the everywhere-affordance; on /dice the page's
own result panel is canonical, so suppress the tray there to avoid showing
a single roll in two places at once. */}
{/* Mobile nav drawer (< sm): the desktop rail is hidden on phones, so this
off-canvas panel shows the full labeled nav. */}
{mobileNavOpen && (
<div className="fixed inset-0 z-50 sm:hidden" role="dialog" aria-modal aria-label="Navigation">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setMobileNavOpen(false)} aria-hidden />
<aside ref={drawerRef} className="paper-grain absolute inset-y-0 left-0 flex w-64 max-w-[80vw] flex-col border-r border-line bg-panel shadow-2xl">
<div className="flex h-[60px] flex-none items-center justify-between border-b border-line px-4">
<span className="flex items-center gap-2">
<span className="grid h-9 w-9 place-items-center rounded-lg bg-gradient-to-b from-accent-soft to-accent text-accent-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.3)]">
<Crown size={19} strokeWidth={2} aria-hidden />
</span>
<span className="font-display text-base font-semibold text-ink">TTRPG Manager</span>
</span>
<Button size="icon" variant="ghost" onClick={() => setMobileNavOpen(false)} aria-label="Close navigation">
<Icon name="X" />
</Button>
</div>
<nav aria-label="Primary" className="flex-1 overflow-y-auto py-2">
<NavItems pathname={pathname} mode="drawer" items={nav} onNavigate={() => setMobileNavOpen(false)} />
</nav>
</aside>
</div>
)}
{!pathname.startsWith('/dice') && <RollTray />}
<UpdatePrompt />
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
</>
);
+12 -10
View File
@@ -5,6 +5,7 @@ import { useUiStore } from '@/stores/uiStore';
import { useActiveCampaign } from '@/features/campaigns/hooks';
import { useCharacters } from '@/features/characters/hooks';
import { useNotes, useNpcs, useQuests } from '@/features/world/hooks';
import { requestWorldFocus, PATH_FOR } from '@/features/world/worldNav';
import { useIsAdmin } from '@/features/admin/useIsAdmin';
import { cn } from '@/lib/cn';
@@ -19,6 +20,7 @@ const NAV: { label: string; to: string }[] = [
{ label: 'Campaigns', to: '/' },
{ label: 'Dashboard', to: '/dashboard' },
{ label: 'Assistant', to: '/assistant' },
{ label: 'Assistant Chat', to: '/assistant/chat' },
{ label: 'AI Director', to: '/director' },
{ label: 'Characters', to: '/characters' },
{ label: 'Combat', to: '/combat' },
@@ -27,11 +29,11 @@ const NAV: { label: string; to: string }[] = [
{ label: 'Notes', to: '/notes' },
{ label: 'NPCs', to: '/npcs' },
{ label: 'Quests', to: '/quests' },
{ label: 'Battle Map', to: '/maps' },
{ label: 'Calendar', to: '/calendar' },
{ label: 'Maps', to: '/maps' },
{ label: 'Homebrew', to: '/homebrew' },
{ label: 'Player View', to: '/play' },
{ label: 'Player Setup', to: '/player' },
{ label: 'Live Session', to: '/play' },
{ label: 'Player View', to: '/player' },
{ label: 'Settings', to: '/settings' },
];
@@ -57,10 +59,10 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
void navigate(params ? { to, params } : { to });
onClose();
};
/** Navigate to a list page AND open the picked item (one-shot reveal). */
const goReveal = (to: string, kind: 'note' | 'npc' | 'quest', id: string) => () => {
useUiStore.getState().setPendingReveal({ kind, id });
void navigate({ to });
/** Navigate to a list page AND focus the picked item (one-shot, via worldNav). */
const goFocus = (kind: 'note' | 'npc' | 'quest', title: string) => () => {
requestWorldFocus(kind, title);
void navigate({ to: PATH_FOR[kind] });
onClose();
};
@@ -69,9 +71,9 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
if (isAdmin) list.push({ id: 'nav:/admin', label: 'Admin', hint: 'Go', run: go('/admin') });
list.push({ id: 'act:theme', label: 'Toggle theme', hint: 'Action', run: () => { toggleTheme(); onClose(); } });
for (const c of characters) list.push({ id: `char:${c.id}`, label: c.name, hint: 'Character', run: go('/characters/$characterId', { characterId: c.id }) });
for (const n of notes) list.push({ id: `note:${n.id}`, label: n.title, hint: 'Note', run: goReveal('/notes', 'note', n.id) });
for (const n of npcs) list.push({ id: `npc:${n.id}`, label: n.name, hint: 'NPC', run: goReveal('/npcs', 'npc', n.id) });
for (const qu of quests) list.push({ id: `quest:${qu.id}`, label: qu.title, hint: 'Quest', run: goReveal('/quests', 'quest', qu.id) });
for (const n of notes) list.push({ id: `note:${n.id}`, label: n.title, hint: 'Note', run: goFocus('note', n.title) });
for (const n of npcs) list.push({ id: `npc:${n.id}`, label: n.name, hint: 'NPC', run: goFocus('npc', n.name) });
for (const qu of quests) list.push({ id: `quest:${qu.id}`, label: qu.title, hint: 'Quest', run: goFocus('quest', qu.title) });
return list;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [characters, notes, npcs, quests, toggleTheme, isAdmin]);
+64
View File
@@ -0,0 +1,64 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
/**
* Top-level error boundary around the app shell (T-163). A render/runtime error
* anywhere in the tree shows a recoverable fallback instead of a blank white
* screen — the user can retry (re-render) or reload, and their offline IndexedDB
* data is untouched. Error boundaries must be class components.
*/
export class ErrorBoundary extends Component<Props, State> {
override state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
// Best-effort log; never throw from the handler.
console.error('Unhandled UI error:', error, info.componentStack);
}
private reset = () => this.setState({ error: null });
override render(): ReactNode {
const { error } = this.state;
if (!error) return this.props.children;
return (
<div role="alert" className="grid min-h-screen place-items-center bg-surface p-6 text-ink">
<div className="paper-grain w-full max-w-md rounded-xl border border-line bg-panel p-6 text-center">
<h1 className="font-display text-2xl font-semibold text-accent-deep">Something went wrong</h1>
<p className="mt-2 text-sm text-muted">
The app hit an unexpected error. Your campaign data is stored locally and is safe — try again, or reload.
</p>
{error.message && (
<pre className="mt-3 max-h-40 overflow-auto rounded-md border border-line bg-surface-2 p-2 text-left font-mono text-[11px] text-faint">
{error.message}
</pre>
)}
<div className="mt-4 flex justify-center gap-2">
<button
className="rounded-lg border border-line bg-surface-2 px-4 py-2 text-sm font-medium hover:border-line-strong"
onClick={this.reset}
>
Try again
</button>
<button
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-ink hover:opacity-90"
onClick={() => window.location.reload()}
>
Reload app
</button>
</div>
</div>
</div>
);
}
}
+65
View File
@@ -0,0 +1,65 @@
/// <reference types="vite-plugin-pwa/react" />
import { useRegisterSW } from 'virtual:pwa-register/react';
import { RefreshCw, X } from 'lucide-react';
import { useSessionStore } from '@/stores/sessionStore';
import { Button } from '@/components/ui/Button';
import { shouldOfferUpdate } from './swUpdate';
/**
* Non-intrusive "Update available" prompt (T-164).
*
* With `registerType: 'prompt'` the freshly-deployed service worker installs but
* waits; it only activates (and drops the stale precache) when the user clicks
* Reload here. While a live session is active we suppress the prompt entirely so
* a mid-session deploy can't yank the GM/players out of the room — `needRefresh`
* stays latched and the prompt re-appears once the session ends.
*
* Session state is read READ-ONLY; this component never mutates it.
*/
export function UpdatePrompt() {
const {
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW();
// "Active" = connected/connecting, or a persisted intent to be in a room
// (host or join), which also survives a reload.
const liveSessionActive = useSessionStore(
(s) => s.status === 'connected' || s.status === 'connecting' || s.hostIntent !== null || s.joinIntent !== null,
);
if (!shouldOfferUpdate(needRefresh, liveSessionActive)) return null;
return (
<div
role="status"
aria-live="polite"
className="pointer-events-none fixed bottom-4 left-4 z-50 w-72 max-w-[calc(100vw-2rem)] print:hidden"
>
<div className="paper-grain pointer-events-auto rounded-lg border border-accent/50 bg-panel p-4 shadow-2xl">
<div className="flex items-start gap-3">
<RefreshCw size={18} aria-hidden className="mt-0.5 flex-none text-accent" />
<div className="min-w-0">
<div className="text-sm font-semibold text-ink">Update available</div>
<p className="mt-0.5 text-xs text-muted">A new version is ready. Reload to get the latest fixes.</p>
<div className="mt-3 flex gap-2">
<Button size="sm" variant="primary" onClick={() => void updateServiceWorker(true)}>
Reload to update
</Button>
<Button size="sm" variant="ghost" onClick={() => setNeedRefresh(false)}>
Later
</Button>
</div>
</div>
<button
onClick={() => setNeedRefresh(false)}
aria-label="Dismiss update notice"
className="flex-none rounded p-1 text-muted hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
>
<X size={16} aria-hidden />
</button>
</div>
</div>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { shouldOfferUpdate } from './swUpdate';
describe('shouldOfferUpdate (T-164)', () => {
it('offers the update when one is waiting and no session is active', () => {
expect(shouldOfferUpdate(true, false)).toBe(true);
});
it('does not offer when no update is waiting', () => {
expect(shouldOfferUpdate(false, false)).toBe(false);
expect(shouldOfferUpdate(false, true)).toBe(false);
});
it('DEFERS a waiting update while a live session is active', () => {
expect(shouldOfferUpdate(true, true)).toBe(false);
});
});
+13
View File
@@ -0,0 +1,13 @@
/**
* Decide whether to surface the "Update available" service-worker prompt (T-164).
*
* A new build only takes control when the user accepts (we ship the SW with
* `registerType: 'prompt'`, so the waiting worker does NOT `skipWaiting`). We
* additionally DEFER the prompt entirely while a live multiplayer session is
* active — a mid-session reload would drop the GM/players out of the room and
* 404 the chunks the old page is still using. The prompt re-appears once the
* session ends, because `needRefresh` stays latched.
*/
export function shouldOfferUpdate(needRefresh: boolean, liveSessionActive: boolean): boolean {
return needRefresh && !liveSessionActive;
}
+26
View File
@@ -0,0 +1,26 @@
import { forwardRef, type InputHTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
/**
* Design-system checkbox: a native `<input type="checkbox">` themed with the
* accent/line tokens and the same focus-visible ring as the other Input
* primitives, so checkboxes stop drifting to the browser default across the app.
* Use inside a `<label className="flex items-center gap-2">` for a clickable label.
*/
export const Checkbox = forwardRef<HTMLInputElement, Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>>(
function Checkbox({ className, ...props }, ref) {
return (
<input
ref={ref}
type="checkbox"
className={cn(
'size-4 shrink-0 cursor-pointer rounded border border-line bg-surface accent-accent',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-glow',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
);
},
);
+1 -10
View File
@@ -3,7 +3,7 @@ import { cn } from '@/lib/cn';
/**
* Living-Codex display primitives: a thin HP/resource Meter, a tinted Badge, an
* initials Avatar, and an embossed ability-score StatCoin. Token-driven so they
* and an initials Avatar. Token-driven so they
* follow the theme + the --app-accent-hue reskin.
*/
@@ -47,12 +47,3 @@ export function Avatar({ name, size = 40, tone }: { name: string; size?: number;
);
}
export function StatCoin({ label, value, mod, active }: { label: string; value: number | string; mod: string; active?: boolean }) {
return (
<div className={cn('flex flex-col items-center gap-0.5 rounded-xl border bg-panel px-2 py-2 text-center', active ? 'border-accent shadow-[0_0_0_3px_var(--app-accent-glow)]' : 'border-line')}>
<div className="smallcaps" style={{ fontSize: 10 }}>{label}</div>
<div className="font-display font-semibold leading-none text-ink" style={{ fontSize: 26 }}>{value}</div>
<div className="font-mono text-[13px] font-semibold text-accent-deep">{mod}</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import {
X, Check, CheckCheck, Swords, Sword, Shield, ShieldCheck, Skull, Flame, Sparkles, Sparkle,
Wand2, ScrollText, BookOpen, Map, Calendar, Library, Brain, MessageCircle, Megaphone, Radio,
Mail, Send, Drama, Zap, SquareCheck, Square, Upload, Download, TriangleAlert, Lock, Settings,
EyeOff, Dices, Users, User, Flag, Castle, Command, Star, Heart, Pin, Bell, Target, Wrench,
MonitorPlay, Maximize, Coins, Backpack, Plus, Info, Footprints, Hand, Eye,
type LucideIcon,
} from 'lucide-react';
/**
* The app's curated icon vocabulary. Replaces ad-hoc emoji glyphs — which render
* inconsistently across platforms and announce as junk to screen readers (✕ is
* "multiplication sign", ⚡ is "high voltage") — with a single Lucide-backed set.
*
* Add a name here, then render <Icon name="Swords" />. Keys are the Lucide
* component names so the mapping from the audit's emoji inventory is 1:1.
*/
const ICONS = {
X, Check, CheckCheck, Swords, Sword, Shield, ShieldCheck, Skull, Flame, Sparkles, Sparkle,
Wand2, ScrollText, BookOpen, Map, Calendar, Library, Brain, MessageCircle, Megaphone, Radio,
Mail, Send, Drama, Zap, SquareCheck, Square, Upload, Download, TriangleAlert, Lock, Settings,
EyeOff, Dices, Users, User, Flag, Castle, Command, Star, Heart, Pin, Bell, Target, Wrench,
MonitorPlay, Maximize, Coins, Backpack, Plus, Info, Footprints, Hand, Eye,
} satisfies Record<string, LucideIcon>;
export type IconName = keyof typeof ICONS;
export interface IconProps {
name: IconName;
/** px size (width = height). Default 16. */
size?: number;
className?: string;
/**
* Accessible name. Provide ONLY when the icon is the *sole* content of an
* interactive control (button/link) with no visible text — then it is announced.
* Omit for icons sitting beside text; those are decorative and hidden from AT.
*/
label?: string;
}
/** A Lucide icon from the curated {@link ICONS} set; decorative (aria-hidden) unless `label` is given. */
export function Icon({ name, size = 16, className, label }: IconProps) {
const Glyph = ICONS[name];
return label ? (
<Glyph size={size} className={className} role="img" aria-label={label} />
) : (
<Glyph size={size} className={className} aria-hidden />
);
}
+6 -6
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { Button } from './Button';
import { Icon } from '@/components/ui/Icon';
interface ModalProps {
open: boolean;
@@ -76,18 +76,18 @@ export function Modal({ open, onClose, title, children, footer, className }: Mod
aria-modal="true"
aria-label={title}
className={cn(
'paper-grain relative w-full max-w-lg max-h-[85vh] overflow-auto rounded-xl border border-line bg-panel shadow-2xl',
'paper-grain relative flex max-h-[85vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-line bg-panel shadow-2xl',
className,
)}
>
<div className="flex items-center justify-between border-b border-line px-5 py-3">
<div className="flex shrink-0 items-center justify-between border-b border-line px-5 py-3">
<h2 className="text-lg font-display font-semibold text-ink">{title}</h2>
<Button size="icon" variant="ghost" onClick={onClose} aria-label="Close dialog">
<X size={16} aria-hidden />
<Icon name="X" label="Close" />
</Button>
</div>
<div className="px-5 py-4">{children}</div>
{footer && <div className="flex justify-end gap-2 border-t border-line px-5 py-3">{footer}</div>}
<div className="flex-1 overflow-auto px-5 py-4">{children}</div>
{footer && <div className="flex shrink-0 justify-end gap-2 border-t border-line px-5 py-3">{footer}</div>}
</div>
</div>
);
+23 -4
View File
@@ -1,8 +1,14 @@
import { Sparkles, Skull, X } from 'lucide-react';
import { lazy, Suspense } from 'react';
import { useRollStore, type TrayRoll } from '@/stores/rollStore';
import { useDice3DStore } from '@/features/dice/dice3dStore';
import { DEGREE_COLOR, DEGREE_LABEL } from '@/lib/dice/check';
import { naturalD20 } from '@/lib/dice/notation';
import { cn } from '@/lib/cn';
import { Icon } from '@/components/ui/Icon';
// Heavy three.js renderer (T-023) — code-split so it only downloads when a user
// turns 3D dice on; surfaced in the global tray so it shows wherever you roll.
const Dice3DStage = lazy(() => import('@/features/dice/Dice3DStage'));
/** A critical-success is a crit; a critical-failure is a fumble. When a degree
* was computed against a DC it is authoritative — a natural 20 that only
@@ -24,14 +30,27 @@ function critKind(roll: TrayRoll): 'crit' | 'fumble' | null {
export function RollTray() {
const last = useRollStore((s) => s.last);
const dismiss = useRollStore((s) => s.dismiss);
const dice3dEnabled = useDice3DStore((s) => s.enabled);
if (!last) return null;
const crit = critKind(last);
return (
<div data-roll-tray className="pointer-events-none fixed bottom-4 right-4 z-50 w-72 print:hidden">
{dice3dEnabled && (
// Kept OUTSIDE the keyed card below so the persistent WebGL scene re-animates
// via the nonce (last.seq) instead of rebuilding its context every roll.
<div className="pointer-events-none mb-2 h-28 w-full overflow-hidden" aria-hidden>
<Suspense fallback={null}>
<Dice3DStage result={last.result} nonce={last.seq} />
</Suspense>
</div>
)}
<div
key={last.seq}
role="status"
aria-live="polite"
aria-atomic
className={cn(
'pointer-events-auto rounded-lg border bg-panel p-4 shadow-2xl',
crit === 'crit' && 'animate-crit animate-crit-glow border-warning',
@@ -42,8 +61,8 @@ export function RollTray() {
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
{last.label && <div className="truncate text-xs font-medium text-muted">{last.label}</div>}
{crit === 'crit' && <div className="flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-warning"><Sparkles size={12} aria-hidden /> Critical <Sparkles size={12} aria-hidden /></div>}
{crit === 'fumble' && <div className="flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-danger"><Skull size={12} aria-hidden /> Fumble</div>}
{crit === 'crit' && <div className="flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-warning"><Icon name="Sparkle" size={14} className="inline" /> Critical <Icon name="Sparkle" size={14} className="inline" /></div>}
{crit === 'fumble' && <div className="flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-danger"><Icon name="Sparkle" size={14} className="inline" /> Fumble <Icon name="Sparkle" size={14} className="inline" /></div>}
{last.degree && (
<div className={cn('text-xs font-semibold', DEGREE_COLOR[last.degree])}>
{DEGREE_LABEL[last.degree]}
@@ -51,7 +70,7 @@ export function RollTray() {
</div>
)}
</div>
<button onClick={dismiss} aria-label="Dismiss roll" className="text-muted hover:text-ink"><X size={14} aria-hidden /></button>
<button onClick={dismiss} aria-label="Dismiss roll" className="text-muted hover:text-ink"><Icon name="X" /></button>
</div>
<div className={cn('mt-1 font-display text-4xl font-bold', crit === 'crit' ? 'text-warning' : crit === 'fumble' ? 'text-danger' : 'text-accent')}>{last.result.total}</div>
<div className="mt-1 font-mono text-xs text-muted">{last.result.breakdown}</div>
+55
View File
@@ -0,0 +1,55 @@
import { type ComponentType, type ReactNode } from 'react';
import type { LucideProps } from 'lucide-react';
import { cn } from '@/lib/cn';
/**
* A Living-Codex setting card: section eyebrow + a gilt hairline over its rows.
* Shared so every settings surface (preferences, assistant, cloud, danger zone)
* uses one shell instead of three divergent hand-rolled card styles.
*/
export function SettingsCard({
label,
children,
className,
tone = 'default',
}: {
label: string;
children: ReactNode;
className?: string;
tone?: 'default' | 'danger';
}) {
const danger = tone === 'danger';
return (
<section className={cn('rounded-xl border p-4 sm:p-5', danger ? 'border-danger/40 bg-danger/5' : 'border-line bg-panel', className)}>
<h2 className={cn('smallcaps text-[11px]', danger ? 'text-danger' : 'text-muted')}>{label}</h2>
<hr className={cn('mt-2 mb-1', danger ? 'border-t border-danger/30' : 'gilt-rule')} />
<div>{children}</div>
</section>
);
}
/** An icon-tile row: leading rounded tile, title + description, trailing controls. */
export function SettingsRow({
icon: Icon,
title,
desc,
children,
}: {
icon: ComponentType<LucideProps>;
title: ReactNode;
desc?: ReactNode;
children?: ReactNode;
}) {
return (
<div className="flex flex-wrap items-center gap-3 border-b border-line py-4 last:border-b-0">
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-deep" aria-hidden>
<Icon size={18} />
</span>
<div className="min-w-0 flex-1">
<div className="font-display text-[15px] font-semibold text-ink">{title}</div>
{desc && <div className="mt-0.5 text-xs text-faint">{desc}</div>}
</div>
{children && <div className="flex shrink-0 flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { render, screen } from '@testing-library/react';
import { VirtualList } from './VirtualList';
// jsdom has no layout engine and no ResizeObserver, so the virtualizer would see
// a 0px viewport and never window any rows. `@tanstack/react-virtual` measures
// elements via `offsetHeight`, so we stub a stable ResizeObserver and force a
// non-zero height: the scroll container (role="list") gets a 400px viewport, and
// each row (role="listitem") measures to the estimate (20px) so the total size
// stays predictable. Scoped to this file and restored afterwards.
const ROW_PX = 20;
const VIEWPORT_PX = 400;
let originalRO: typeof globalThis.ResizeObserver | undefined;
let originalOffsetHeight: PropertyDescriptor | undefined;
beforeAll(() => {
originalRO = globalThis.ResizeObserver;
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof globalThis.ResizeObserver;
originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight');
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
get(this: HTMLElement): number {
const role = this.getAttribute('role');
if (role === 'list') return VIEWPORT_PX;
if (role === 'listitem') return ROW_PX;
return 0;
},
});
});
afterAll(() => {
if (originalRO) globalThis.ResizeObserver = originalRO;
if (originalOffsetHeight) Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeight);
});
describe('VirtualList (T-166)', () => {
const items = Array.from({ length: 1000 }, (_, i) => `item-${i}`);
it('exposes the list as an accessible landmark and sizes the spacer to the full list', () => {
render(
<VirtualList
items={items}
estimateSize={ROW_PX}
ariaLabel="Test rows"
renderItem={(item) => <span>{item}</span>}
/>,
);
const list = screen.getByRole('list', { name: 'Test rows' });
expect(list).toBeInTheDocument();
// Spacer height reflects the full content, not just the rendered window.
const spacer = list.firstElementChild as HTMLElement;
expect(spacer.style.height).toBe(`${items.length * ROW_PX}px`);
});
it('windows the list: renders only a subset of the 1000 rows', () => {
render(
<VirtualList
items={items}
estimateSize={ROW_PX}
ariaLabel="Windowed"
renderItem={(item) => <span>{item}</span>}
/>,
);
const rendered = screen.getAllByRole('listitem');
expect(rendered.length).toBeGreaterThan(0);
expect(rendered.length).toBeLessThan(items.length);
// The first row is in view.
expect(screen.getByText('item-0')).toBeInTheDocument();
});
it('renders no rows but an empty spacer for an empty list', () => {
render(<VirtualList items={[]} estimateSize={ROW_PX} renderItem={(item) => <span>{item}</span>} />);
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
});
it('uses getKey for stable row keys without throwing', () => {
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
render(
<VirtualList
items={rows}
estimateSize={ROW_PX}
getKey={(r) => r.id}
renderItem={(r) => <span>{r.id}</span>}
/>,
);
expect(screen.getByText('a')).toBeInTheDocument();
});
});
+75
View File
@@ -0,0 +1,75 @@
import { useRef, type ReactNode } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { cn } from '@/lib/cn';
export interface VirtualListProps<T> {
/** The full, already-filtered/sorted list to render. */
items: readonly T[];
/** Estimated row height in px (used before a row is measured). */
estimateSize: number;
/** Render one row. The returned node is positioned/measured by the list. */
renderItem: (item: T, index: number) => ReactNode;
/** Stable React key for a row. Defaults to the row index. */
getKey?: (item: T, index: number) => string | number;
/** Rows to render beyond the viewport on each side (smoother fast scroll). */
overscan?: number;
/** Classes for the scroll container (must constrain its height, e.g. h-full). */
className?: string;
/** Accessible name for the list region. */
ariaLabel?: string;
}
/**
* Shared windowed list (T-166). Renders only the rows near the viewport via
* `@tanstack/react-virtual`, so a list of thousands stays cheap. Rows are
* absolutely positioned inside a spacer sized to the full (estimated) height and
* are auto-measured, so variable-height rows work without a fixed row height.
*
* Extracted from the bespoke virtualizer in CompendiumPage so characters/combat/
* world lists can adopt the same windowing as their row counts grow.
*/
export function VirtualList<T>({
items,
estimateSize,
renderItem,
getKey,
overscan = 8,
className,
ariaLabel,
}: VirtualListProps<T>) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => estimateSize,
overscan,
});
return (
<div ref={parentRef} className={cn('overflow-auto', className)} role="list" aria-label={ariaLabel}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const item = items[virtualRow.index];
if (item === undefined) return null;
return (
<div
key={getKey ? getKey(item, virtualRow.index) : virtualRow.index}
role="listitem"
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderItem(item, virtualRow.index)}
</div>
);
})}
</div>
</div>
);
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12 -3
View File
@@ -5,7 +5,7 @@ import { encountersRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
import { createRng } from '@/lib/rng';
import { addCombatant } from '@/lib/combat/engine';
import { loadMonsters, loadPf2e } from '@/lib/compendium';
import { loadCreatures } from '@/lib/compendium';
import { type Suggestion, type SuggestAction } from '@/lib/assistant/advisors';
import { buildSignals } from '@/lib/assistant/signals';
import { useSignalAction } from './useSignalAction';
@@ -16,10 +16,12 @@ import { useNotes, useQuests } from '@/features/world/hooks';
import { useEncounters } from '@/features/combat/hooks';
import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Icon } from '@/components/ui/Icon';
import { cn } from '@/lib/cn';
import { CampaignInsights } from './CampaignInsights';
import { SessionPrepCard } from './SessionPrepCard';
import { NpcGenCard } from './NpcGenCard';
import { GeneratorPanel } from './GeneratorPanel';
export function AssistantPage() {
return <RequireCampaign>{(c) => <Assistant campaign={c} />}</RequireCampaign>;
@@ -55,7 +57,7 @@ function Assistant({ campaign }: { campaign: Campaign }) {
try {
const partyLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level);
if (partyLevels.length === 0) { setMsg('Add player characters first.'); return; }
const raw = campaign.system === '5e' ? await loadMonsters() : await loadPf2e('creatures');
const raw = await loadCreatures(campaign.system);
const pool = raw as unknown as (Record<string, unknown> & { cr?: number; level?: number })[];
const chosen = buildSuggestedEncounter(campaign.system, partyLevels, pool, difficulty, createRng());
if (chosen.length === 0) { setMsg('No suitable monsters found for that difficulty.'); return; }
@@ -94,7 +96,11 @@ function Assistant({ campaign }: { campaign: Campaign }) {
return (
<Page>
<PageHeader title="Assistant" subtitle={`${campaign.name} · suggestions from your campaign data`} />
<PageHeader
title="Assistant"
subtitle={`${campaign.name} · suggestions from your campaign data`}
actions={<Button variant="primary" onClick={() => void navigate({ to: '/assistant/chat' })}><Icon name="MessageCircle" size={16} className="mr-1 inline" /> Chat assistant</Button>}
/>
{msg && <p className="mb-3 text-sm text-success" aria-live="polite">{msg}</p>}
<div className="grid gap-6 lg:grid-cols-2">
@@ -111,6 +117,9 @@ function Assistant({ campaign }: { campaign: Campaign }) {
<h2 className="mb-2 mt-6 smallcaps">Combat</h2>
<SuggestionList items={byCat('combat')} onAction={runAction} empty="No active combat." />
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">AI content generator</h2>
<GeneratorPanel campaign={campaign} />
</section>
<section>
+76
View File
@@ -0,0 +1,76 @@
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import type { Campaign } from '@/lib/schemas';
import { getSystem } from '@/lib/rules';
import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
import { useChat } from './useChat';
export function ChatPage() {
return <RequireCampaign>{(c) => <Chat campaign={c} />}</RequireCampaign>;
}
function Chat({ campaign }: { campaign: Campaign }) {
const navigate = useNavigate();
const { messages, busy, error, canUseLlm, send, clear } = useChat(campaign);
const [input, setInput] = useState('');
const submit = () => { const t = input; setInput(''); void send(t); };
return (
<Page>
<PageHeader
title="Assistant Chat"
subtitle={`${campaign.name} · grounded in your campaign + ${getSystem(campaign.system).label} SRD`}
actions={
<>
<Button variant="ghost" onClick={() => void navigate({ to: '/assistant' })}>← Assistant</Button>
{messages.length > 0 && <Button variant="ghost" onClick={clear}>Clear</Button>}
</>
}
/>
{!canUseLlm ? (
<div className="rounded-lg border border-warning/40 bg-panel p-4 text-sm text-muted">
Turn on the assistant and add your provider API key in <strong>Settings</strong> to chat. The key stays on your device.
</div>
) : (
<div className="flex flex-col gap-3">
<div className="min-h-48 space-y-3 rounded-lg border border-line bg-panel p-3">
{messages.length === 0 && (
<p className="text-sm text-muted">Ask about rules, your party, encounters, or the campaign. Rules answers cite the SRD entries they used.</p>
)}
{messages.map((m, i) => (
<div key={i} className={cn('rounded-md px-3 py-2 text-sm', m.role === 'user' ? 'bg-elevated text-ink' : 'bg-surface-2 text-ink')}>
<div className="mb-0.5 text-[10px] uppercase tracking-wide text-muted">{m.role === 'user' ? 'You' : 'Assistant'}</div>
<div className="whitespace-pre-wrap leading-relaxed">{m.content}</div>
{m.citations && m.citations.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1" aria-label="Cited sources">
{m.citations.map((c) => (
<span key={`${c.kind}-${c.name}`} className="rounded-full border border-line px-1.5 py-0.5 text-[10px] text-muted" title={c.snippet}>
{c.name}
</span>
))}
</div>
)}
</div>
))}
{busy && <p className="text-sm text-muted" aria-live="polite">Thinking…</p>}
</div>
{error && <p className="text-sm text-danger" aria-live="polite">{error}</p>}
<div className="flex gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask the assistant… (Enter to send, Shift+Enter for a newline)"
rows={2}
aria-label="Chat message"
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } }}
className="min-h-12 flex-1 resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
<Button variant="primary" disabled={busy || !input.trim()} onClick={submit}>Send</Button>
</div>
</div>
)}
</Page>
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Brain } from 'lucide-react';
import type { Campaign, Encounter } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { Icon } from '@/components/ui/Icon';
import { cn } from '@/lib/cn';
import { useEncounterAdvisor } from './useEncounterAdvisor';
@@ -23,7 +23,7 @@ export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign;
data-testid="encounter-tip"
>
<div className="flex items-start gap-3">
<Brain size={18} className="shrink-0 text-muted" aria-hidden />
<span className="text-lg" aria-hidden><Icon name="Brain" size={18} /></span>
<div className="min-w-0 flex-1">
{theme ? (
<>
+118
View File
@@ -0,0 +1,118 @@
import { useState } from 'react';
import type { Campaign } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { useGenerators, type GeneratorDraft, type GenKind } from './useGenerators';
const KINDS: { value: GenKind; label: string }[] = [
{ value: 'npc', label: 'NPC' },
{ value: 'quest', label: 'Quest' },
{ value: 'lore', label: 'Lore note' },
{ value: 'names', label: 'Names' },
{ value: 'recap', label: 'Session recap' },
{ value: 'statblock', label: 'Homebrew statblock' },
];
export function GeneratorPanel({ campaign }: { campaign: Campaign }) {
const { canUseLlm, busy, error, draft, saved, generate, save, discard } = useGenerators(campaign);
const [kind, setKind] = useState<GenKind>('npc');
const [brief, setBrief] = useState('');
return (
<div className="rounded-lg border border-line bg-panel p-4">
<p className="mb-3 text-sm text-muted">Generate campaign content with AI, preview it, then save it into your world. Output is schema-validated before it’s written, and stays in your campaign’s system.</p>
{!canUseLlm ? (
<p className="text-sm text-muted">Enable the assistant and add a key in <strong>Settings</strong> to use generators.</p>
) : (
<>
<div className="flex flex-wrap items-end gap-2">
<label className="text-xs text-muted">Kind
<Select className="w-40" value={kind} onChange={(e) => setKind(e.target.value as GenKind)} aria-label="Generator kind">
{KINDS.map((k) => <option key={k.value} value={k.value}>{k.label}</option>)}
</Select>
</label>
<label className="min-w-48 flex-1 text-xs text-muted">Brief
<Input value={brief} onChange={(e) => setBrief(e.target.value)} placeholder="e.g. a suspicious harbormaster with a smuggling secret"
onKeyDown={(e) => { if (e.key === 'Enter') void generate(kind, brief); }} aria-label="Generation brief" />
</label>
<Button variant="primary" disabled={busy} onClick={() => void generate(kind, brief)}>{busy ? 'Generating…' : 'Generate'}</Button>
</div>
{error && <p className="mt-2 text-sm text-danger" aria-live="polite">{error}</p>}
{saved && <p className="mt-2 text-sm text-success" aria-live="polite">{saved}</p>}
{draft && (
<div className="mt-3 rounded-md border border-accent/30 bg-surface-2 p-3 text-sm">
<DraftPreview draft={draft} />
<div className="mt-2 flex gap-2">
{draft.kind !== 'names' && <Button size="sm" variant="primary" onClick={() => void save()}>Save to campaign</Button>}
<Button size="sm" variant="ghost" onClick={discard}>Discard</Button>
</div>
</div>
)}
</>
)}
</div>
);
}
function DraftPreview({ draft }: { draft: GeneratorDraft }) {
if (draft.kind === 'npc') {
const d = draft.data;
return (
<div>
<div className="font-display font-semibold text-ink">{d.name} <span className="text-xs text-muted">{[d.role, d.location, d.faction].filter(Boolean).join(' · ')} · {d.disposition}</span></div>
<p className="mt-1 whitespace-pre-wrap text-muted">{d.description}</p>
</div>
);
}
if (draft.kind === 'quest') {
const d = draft.data;
return (
<div>
<div className="font-display font-semibold text-ink">{d.title}{d.reward ? <span className="text-xs text-muted"> · reward: {d.reward}</span> : null}</div>
<p className="mt-1 whitespace-pre-wrap text-muted">{d.description}</p>
{d.objectives.length > 0 && <ul className="mt-1 list-disc pl-5 text-muted">{d.objectives.map((o, i) => <li key={i}>{o.text}</li>)}</ul>}
</div>
);
}
if (draft.kind === 'lore') {
const d = draft.data;
return (
<div>
<div className="font-display font-semibold text-ink">{d.title}</div>
{d.tags.length > 0 && <div className="mt-0.5 text-[10px] text-muted">{d.tags.join(', ')}</div>}
<p className="mt-1 whitespace-pre-wrap text-muted">{d.body}</p>
</div>
);
}
if (draft.kind === 'recap') {
const d = draft.data;
return (
<div>
<div className="font-display font-semibold text-ink">{d.title}</div>
<p className="mt-1 whitespace-pre-wrap text-muted">{d.body}</p>
{d.highlights.length > 0 && <ul className="mt-1 list-disc pl-5 text-muted">{d.highlights.map((h, i) => <li key={i}>{h}</li>)}</ul>}
</div>
);
}
if (draft.kind === 'statblock') {
const d = draft.data;
return (
<div>
<div className="font-display font-semibold text-ink">{d.name} <span className="text-xs text-muted">{[d.role, `rating ${d.rating}`, `AC ${d.ac}`, `HP ${d.hp}`].filter(Boolean).join(' · ')}</span></div>
{d.traits.length > 0 && <div className="mt-0.5 text-[10px] text-muted">{d.traits.join(', ')}</div>}
{d.description && <p className="mt-1 whitespace-pre-wrap text-muted">{d.description}</p>}
{d.abilities.length > 0 && <ul className="mt-1 list-disc pl-5 text-muted">{d.abilities.map((a, i) => <li key={i}><strong className="text-ink">{a.name}.</strong> {a.description}</li>)}</ul>}
{d.tactics && <p className="mt-1 whitespace-pre-wrap text-muted"><strong className="text-ink">Tactics:</strong> {d.tactics}</p>}
{d.narrative && <p className="mt-1 whitespace-pre-wrap text-muted">{d.narrative}</p>}
</div>
);
}
return (
<div>
<div className="font-display font-semibold text-ink">Suggested names</div>
<div className="mt-1 flex flex-wrap gap-1">
{draft.data.names.map((n) => <span key={n} className="rounded-full border border-line px-2 py-0.5 text-xs text-ink">{n}</span>)}
</div>
</div>
);
}
@@ -25,14 +25,22 @@ export function RollRequestCard({
const [badExpression, setBadExpression] = useState(false);
const roll = () => {
const result = rollAndShow({
expression: req.expression,
label: req.label,
...(req.dc !== undefined ? { dc: req.dc, system, rollKind: req.kind } : {}),
});
// The expression comes from the LLM — if it doesn't parse, say so instead of
// a dead button (and don't report a roll that never happened).
if (!result) { setBadExpression(true); return; }
// 5e only auto-crits attack rolls; 'damage'/'custom' requests grade as a
// plain check against the DC (the seam's default).
const rollType = req.kind === 'attack' || req.kind === 'save' || req.kind === 'check' ? req.kind : undefined;
let result: { total: number; degree?: Degree };
try {
result = rollAndShow({
expression: req.expression,
label: req.label,
...(req.dc !== undefined ? { dc: req.dc, system, ...(rollType ? { rollType } : {}) } : {}),
});
} catch {
// The expression comes from the LLM — if it doesn't parse, say so instead
// of a dead button (and don't report a roll that never happened).
setBadExpression(true);
return;
}
setDone(result);
onRolled(req, result);
};
@@ -24,7 +24,7 @@ function seedEncounter(): Encounter {
return encounterSchema.parse({
id: 'e1', campaignId: 'c1', name: 'Fight', status: 'active', round: 1, turnIndex: 0,
combatants: [
{ id: 'cb-mira', name: 'Mira', kind: 'pc', characterId: 'ch1', initiative: 15, ac: 14, hp: { current: 20, max: 20, temp: 0 }, concentrating: 'Bless' },
{ id: 'cb-mira', name: 'Mira', kind: 'pc', characterId: 'ch1', initiative: 15, ac: 14, hp: { current: 20, max: 20, temp: 0 }, concentrating: true, concentratingOn: 'Bless' },
{ id: 'cb-gob', name: 'Goblin', kind: 'monster', initiative: 12, ac: 13, hp: { current: 7, max: 7, temp: 0 } },
],
createdAt: 't', updatedAt: 't',
@@ -82,7 +82,7 @@ describe('useDirectorAction — approve-each apply bridge', () => {
});
it("falls back to the linked character's concentration when the combatant flag is unset", async () => {
await encountersRepo.mutate('e1', (e) => updateCombatant(e, 'cb-mira', { concentrating: null }));
await encountersRepo.mutate('e1', (e) => updateCombatant(e, 'cb-mira', { concentrating: false }));
const conc = { ...characters[0]!, concentration: { spellId: 'sp1', spellName: 'Bless' } };
const r = renderHook(() => useDirectorAction({ system: '5e', encounterId: 'e1', roster, characters: [conc] })).result;
const res = await r.current({ kind: 'damage', target: 'Mira', amount: 8 });
@@ -91,7 +91,7 @@ describe('useDirectorAction — approve-each apply bridge', () => {
});
it('uses a flat d20 with a note when no sheet is linked to the concentrating combatant', async () => {
await encountersRepo.mutate('e1', (e) => updateCombatant(e, 'cb-gob', { concentrating: 'Invisibility' }));
await encountersRepo.mutate('e1', (e) => updateCombatant(e, 'cb-gob', { concentrating: true, concentratingOn: 'Invisibility' }));
const r = await hook().current({ kind: 'damage', target: 'Goblin', amount: 4 });
expect(r.rollRequests).toHaveLength(1);
expect(r.rollRequests![0]!.expression).toBe('1d20');
@@ -128,7 +128,9 @@ describe('useDirectorAction — approve-each apply bridge', () => {
expect(c!.spellcasting.slots[0]!.current).toBe(1); // one slot spent
expect(c!.concentration?.spellName).toBe('Bless');
const enc = await encountersRepo.get('e1');
expect(enc!.combatants.find((x) => x.id === 'cb-mira')!.concentrating).toBe('Bless');
const mira = enc!.combatants.find((x) => x.id === 'cb-mira')!;
expect(mira.concentrating).toBe(true);
expect(mira.concentratingOn).toBe('Bless');
});
it("rejects a spell the caster doesn't have", async () => {
@@ -146,7 +146,7 @@ export function useDirectorAction(opts: {
// The combatant flag OR the linked character's concentration (set on cast) counts.
if (system === '5e' && dealt > 0) {
const pc = pcFor(c.name);
const spellName = c.concentrating || pc?.concentration?.spellName;
const spellName = c.concentratingOn ?? pc?.concentration?.spellName;
if (spellName) followUps.push(concentrationSave(c.name, spellName, dealt, pc ? conSaveMod(pc) : undefined));
}
let next = logEvent(updateCombatant(e, c.id, { hp: after.hp }), note);
@@ -232,7 +232,7 @@ export function useDirectorAction(opts: {
const conc = r.patch.concentration;
const actor = resolveActor(roster, action.caster);
if (encounterId && actor?.combatantId && conc) {
await encountersRepo.mutate(encounterId, (e) => updateCombatant(e, actor.combatantId!, { concentrating: conc.spellName }));
await encountersRepo.mutate(encounterId, (e) => updateCombatant(e, actor.combatantId!, { concentrating: true, concentratingOn: conc.spellName }));
}
return { ok: true, message: r.log.join(' ') || `${c.name} cast ${spell.name}.` };
}
@@ -266,7 +266,7 @@ export function useDirectorAction(opts: {
// Prefer cloning a same-named monster already in the fight (covers homebrew).
const template = e.combatants.find((c) => c.kind === 'monster' && norm(baseName(c.name)) === norm(want));
const fresh: Combatant = template
? { ...template, id: newId(), name: baseName(template.name), initiative: 0, hp: { current: template.hp.max, max: template.hp.max, temp: 0 }, conditions: [], concentrating: null }
? { ...template, id: newId(), name: baseName(template.name), initiative: 0, hp: { current: template.hp.max, max: template.hp.max, temp: 0 }, conditions: [], concentrating: false }
: entry
? bestiaryCombatant(system, entry)
: {
+69
View File
@@ -0,0 +1,69 @@
import { useState } from 'react';
import type { Campaign } from '@/lib/schemas';
import type { ChatTurn } from '@/lib/llm/types';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { buildCampaignContext } from '@/lib/assistant/context';
import { buildChatSystemPrompt } from '@/lib/assistant/prompts';
import { retrieveCompendium, type RetrievedEntry } from '@/lib/compendium';
import { useCharacters } from '@/features/characters/hooks';
import { useNotes, useNpcs, useQuests } from '@/features/world/hooks';
import { useEncounters } from '@/features/combat/hooks';
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
citations?: RetrievedEntry[];
}
/**
* Multi-turn chat hook (T-097/T-098). Grounds each turn in the campaign context
* (party/quests/notes/NPCs) + retrieved SRD entries, threads prior turns through
* the provider-agnostic complete()'s `history`, and attaches the citations used.
*/
export function useChat(campaign: Campaign) {
const characters = useCharacters(campaign.id);
const notes = useNotes(campaign.id);
const npcs = useNpcs(campaign.id);
const quests = useQuests(campaign.id);
const encounters = useEncounters(campaign.id);
const llmEnabled = useAssistantStore((s) => s.enabled);
// Trim the gate (T-094): a whitespace-only key must not count as configured.
const hasKey = useAssistantStore((s) => !!s.apiKey.trim());
const canUseLlm = llmEnabled && hasKey;
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const send = async (text: string) => {
const userText = text.trim();
if (!userText || busy || !canUseLlm) return;
setError(null);
const history: ChatTurn[] = messages.map((m) => ({ role: m.role, content: m.content }));
setMessages((prev) => [...prev, { role: 'user', content: userText }]);
setBusy(true);
try {
const ctx = buildCampaignContext({ campaign, characters, encounters, quests, notes, npcs });
const retrieved = await retrieveCompendium(campaign.system, userText).catch(() => [] as RetrievedEntry[]);
const system = buildChatSystemPrompt(ctx, retrieved);
const res = await complete(getLlmConfig(), { system, user: userText, history, maxTokens: 1500, timeoutMs: 60_000 });
if (res.ok && 'text' in res) {
setMessages((prev) => [...prev, {
role: 'assistant',
content: res.text.trim() || '(no reply)',
...(retrieved.length ? { citations: retrieved } : {}),
}]);
} else if (!res.ok) {
setError(res.message);
}
} catch {
setError('Something went wrong contacting the assistant.');
} finally {
setBusy(false);
}
};
const clear = () => { setMessages([]); setError(null); };
return { messages, busy, error, canUseLlm, send, clear };
}
+25 -7
View File
@@ -1,10 +1,11 @@
import { useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Campaign, Encounter } from '@/lib/schemas';
import { encountersRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
import { addCombatant, removeCombatant } from '@/lib/combat/engine';
import { computeBudget } from '@/lib/combat/budget';
import { loadMonsters, loadPf2e } from '@/lib/compendium';
import { loadCreatures } from '@/lib/compendium';
import { getSystem } from '@/lib/rules';
import { complete } from '@/lib/llm/client';
import { getLlmConfig } from '@/stores/assistantStore';
import { useAssistantStore } from '@/stores/assistantStore';
@@ -69,7 +70,8 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
const quests = useQuests(campaign.id);
const encounters = useEncounters(campaign.id);
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
// Trim the gate (T-094): a whitespace-only key must not count as configured.
const hasKey = useAssistantStore((s) => !!s.apiKey.trim());
const ctx = useMemo(
() => buildCampaignContext({ campaign, characters, encounters, quests, notes }),
@@ -94,14 +96,22 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
const [message, setMessage] = useState<string | null>(null);
const poolRef = useRef<Record<string, unknown>[]>([]);
// One AbortController per in-flight request (T-094): a new run (or unmount)
// cancels the previous call so stale completions can't clobber fresh state.
const abortRef = useRef<AbortController | null>(null);
useEffect(() => () => abortRef.current?.abort(), []);
const canUseLlm = llmEnabled && hasKey;
const run = async () => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setState('loading');
setMessage(null);
setSuggestion(undefined);
try {
const raw = (campaign.system === '5e' ? await loadMonsters() : await loadPf2e('creatures')) as unknown as Record<string, unknown>[];
const raw = (await loadCreatures(campaign.system)) as unknown as Record<string, unknown>[];
poolRef.current = raw;
const partyLevels = ctx.partyLevels;
if (partyLevels.length === 0) { setMessage('Add player characters first.'); setState('error'); return; }
@@ -122,7 +132,7 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
const seenBase = new Set<string>();
for (const c of monsterCombatants) {
const bn = baseName(c.name);
const rating = campaign.system === '5e' ? c.cr : c.level;
const rating = getSystem(campaign.system).creatureRating.of(c);
if (rating === undefined || seenBase.has(bn.toLowerCase())) continue;
seenBase.add(bn.toLowerCase());
existingCands.push({ name: bn, rating, ac: c.ac, hp: c.hp.max });
@@ -134,7 +144,8 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
if (canUseLlm && targetIsHarder) {
const prompt = buildBalancePrompt(ctx, { difficulty: current, targetDifficulty: target, candidates });
const res = await complete<BalanceSuggestion>(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: balanceSuggestionSchema });
const res = await complete<BalanceSuggestion>(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: balanceSuggestionSchema, signal: controller.signal });
if (controller.signal.aborted) return; // superseded by a newer run / unmounted
if (res.ok && 'data' in res) {
// Resolve names case-insensitively (the provider mangles casing) and
// canonicalize to the candidate's exact name so apply() can find it.
@@ -142,13 +153,20 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
const cand = candidates.find((c) => norm(c.name) === norm(a.name));
return cand ? [{ ...a, name: cand.name }] : [];
});
// Validate the destructive "remove" action before it can reach apply (T-094):
// only keep removals that name a monster actually in the current encounter
// (matched case-insensitively, like every other assistant resolver).
const validRemove = (res.data.remove ?? []).filter(
(r) => monsterCombatants.some((c) => norm(baseName(c.name)) === norm(r.name)),
);
if (valid.length) {
setSuggestion({ ...res.data, add: valid });
setSuggestion({ ...res.data, add: valid, remove: validRemove.length ? validRemove : undefined });
setSource('llm');
setState('ready');
return;
}
} else if (!res.ok) {
// Surface the full provider error, not just the terse kind code (T-086).
setMessage(`AI unavailable: ${res.message} Showing a deterministic pick.`);
}
}
+146
View File
@@ -0,0 +1,146 @@
import { useState } from 'react';
import type { ZodType } from 'zod';
import type { Campaign, Note, Npc, Quest } from '@/lib/schemas';
import { newId } from '@/lib/ids';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { buildCampaignContext, buildRecapCorpus } from '@/lib/assistant/context';
import {
buildGeneratorPrompt, buildRecapPrompt, buildStatblockPrompt,
npcDraftSchema, questDraftSchema, loreDraftSchema, nameSuggestionsSchema,
recapDraftSchema, statblockDraftSchema,
type GeneratorKind, type NpcDraft, type QuestDraft, type LoreDraft, type NameSuggestions,
type RecapDraft, type StatblockDraft,
} from '@/lib/assistant/generators';
import { npcsRepo, questsRepo, notesRepo, sessionLogRepo } from '@/lib/db/repositories';
import { useCharacters } from '@/features/characters/hooks';
import { useNotes, useNpcs, useQuests } from '@/features/world/hooks';
import { useEncounters } from '@/features/combat/hooks';
/** All generator actions: the worldbuilding kinds plus recap (T-100) and statblock (T-101). */
export type GenKind = GeneratorKind | 'recap' | 'statblock';
export type GeneratorDraft =
| { kind: 'npc'; data: NpcDraft }
| { kind: 'quest'; data: QuestDraft }
| { kind: 'lore'; data: LoreDraft }
| { kind: 'names'; data: NameSuggestions }
| { kind: 'recap'; data: RecapDraft }
| { kind: 'statblock'; data: StatblockDraft };
const ts = (): string => new Date().toISOString();
/** Render a homebrew statblock draft into a readable note body (T-101). */
function statblockToMarkdown(d: StatblockDraft): string {
const lines = [
`**${d.name}**${d.role ? ` — ${d.role}` : ''}`,
`Rating ${d.rating} · AC ${d.ac} · HP ${d.hp}${d.speed ? ` · Speed ${d.speed}` : ''}`,
d.traits.length ? `Traits: ${d.traits.join(', ')}` : '',
d.description ? `\n${d.description}` : '',
...d.abilities.map((a) => `- **${a.name}.** ${a.description}`),
d.tactics ? `\nTactics: ${d.tactics}` : '',
d.narrative ? `\n${d.narrative}` : '',
].filter(Boolean);
return lines.join('\n');
}
/**
* AI content generation hook (T-099). Generates a draft validated by a Zod
* schema (complete() does the safeParse), previews it, and on save maps it to a
* full entity written via the atomic repo.insert (campaignId stamped → cascade
* delete owns it). Nothing is persisted without an explicit Save.
*/
export function useGenerators(campaign: Campaign) {
const characters = useCharacters(campaign.id);
const notes = useNotes(campaign.id);
const npcs = useNpcs(campaign.id);
const quests = useQuests(campaign.id);
const encounters = useEncounters(campaign.id);
const llmEnabled = useAssistantStore((s) => s.enabled);
// Trim the gate (T-094): a whitespace-only key must not count as configured.
const hasKey = useAssistantStore((s) => !!s.apiKey.trim());
const canUseLlm = llmEnabled && hasKey;
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<GeneratorDraft | null>(null);
const [saved, setSaved] = useState<string | null>(null);
const schemaFor: Record<GenKind, ZodType<unknown>> = {
npc: npcDraftSchema as unknown as ZodType<unknown>,
quest: questDraftSchema as unknown as ZodType<unknown>,
lore: loreDraftSchema as unknown as ZodType<unknown>,
names: nameSuggestionsSchema as unknown as ZodType<unknown>,
recap: recapDraftSchema as unknown as ZodType<unknown>,
statblock: statblockDraftSchema as unknown as ZodType<unknown>,
};
const generate = async (kind: GenKind, brief: string) => {
if (!canUseLlm || busy) return;
setBusy(true); setError(null); setSaved(null); setDraft(null);
try {
const ctx = buildCampaignContext({ campaign, characters, encounters, quests, notes, npcs });
// Recap feeds actual note bodies AND the persisted session log (rolls + chat)
// to the model (T-100/M3) so it reflects real play events; statblock (T-101)
// and the worldbuilding kinds are grounded in the campaign context summary.
const sessionLog = kind === 'recap' ? await sessionLogRepo.all(campaign.id) : [];
const prompt = kind === 'recap'
? buildRecapPrompt(ctx, buildRecapCorpus({ notes, quests, sessionLog }), brief)
: kind === 'statblock'
? buildStatblockPrompt(ctx, brief)
: buildGeneratorPrompt(kind, ctx, brief);
const res = await complete(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: schemaFor[kind], maxTokens: 1400, timeoutMs: 60_000 });
if (res.ok && 'data' in res) setDraft({ kind, data: res.data } as GeneratorDraft);
else if (!res.ok) setError(res.message);
else setError('The model returned an unexpected response.');
} catch {
setError('Generation failed.');
} finally {
setBusy(false);
}
};
const save = async (): Promise<boolean> => {
if (!draft) return false;
const t = ts();
try {
if (draft.kind === 'npc') {
const d = draft.data;
const npc: Npc = { id: newId(), campaignId: campaign.id, name: d.name, role: d.role, location: d.location, faction: d.faction, status: 'alive', disposition: d.disposition, description: d.description, createdAt: t, updatedAt: t };
await npcsRepo.insert(npc);
setSaved(`Saved NPC “${d.name}”.`);
} else if (draft.kind === 'quest') {
const d = draft.data;
const quest: Quest = { id: newId(), campaignId: campaign.id, title: d.title, status: 'active', description: d.description, reward: d.reward, objectives: d.objectives.map((o) => ({ id: newId(), text: o.text, done: false })), createdAt: t, updatedAt: t };
await questsRepo.insert(quest);
setSaved(`Saved quest “${d.title}”.`);
} else if (draft.kind === 'lore') {
const d = draft.data;
const note: Note = { id: newId(), campaignId: campaign.id, title: d.title, body: d.body, tags: d.tags, createdAt: t, updatedAt: t };
await notesRepo.insert(note);
setSaved(`Saved note “${d.title}”.`);
} else if (draft.kind === 'recap') {
const d = draft.data;
const body = d.highlights.length ? `${d.body}\n\n${d.highlights.map((h) => `- ${h}`).join('\n')}` : d.body;
const note: Note = { id: newId(), campaignId: campaign.id, title: d.title, body, tags: ['recap'], createdAt: t, updatedAt: t };
await notesRepo.insert(note);
setSaved(`Saved recap “${d.title}”.`);
} else if (draft.kind === 'statblock') {
const d = draft.data;
const note: Note = { id: newId(), campaignId: campaign.id, title: `${d.name} (statblock)`, body: statblockToMarkdown(d), tags: ['statblock', 'homebrew'], createdAt: t, updatedAt: t };
await notesRepo.insert(note);
setSaved(`Saved statblock “${d.name}”.`);
} else {
return false; // names are suggestions, nothing to persist
}
setDraft(null);
return true;
} catch {
setError('Could not save — the generated content failed validation.');
return false;
}
};
const discard = () => { setDraft(null); setSaved(null); };
return { canUseLlm, busy, error, draft, saved, generate, save, discard };
}
+26 -5
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { Campaign, Character } from '@/lib/schemas';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
@@ -8,14 +8,28 @@ import {
type BuildRoute, type BuildStep,
} from '@/lib/assistant/prompts';
import { deterministicRoutes, deterministicSteps } from '@/lib/assistant/levelup';
import { getSystem } from '@/lib/rules';
export type AdvisorState = 'idle' | 'loading' | 'ready' | 'error';
export function useLevelUpAdvisor(campaign: Campaign, character: Character) {
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
// Trim the gate (T-094): a whitespace-only key must not count as configured.
const hasKey = useAssistantStore((s) => !!s.apiKey.trim());
const canUseLlm = llmEnabled && hasKey;
const nextLevel = Math.min(20, character.level + 1);
// One AbortController per in-flight request (T-094): a new run (or unmount)
// cancels the previous call so stale completions can't clobber fresh state.
const abortRef = useRef<AbortController | null>(null);
useEffect(() => () => abortRef.current?.abort(), []);
const nextController = () => {
abortRef.current?.abort();
const c = new AbortController();
abortRef.current = c;
return c;
};
// Level cap from the rules system (no hardcoded 20) — T-041.
const nextLevel = Math.min(getSystem(character.system).maxLevel, character.level + 1);
const ctx = useMemo(
() => buildCampaignContext({ campaign, characters: [character], encounters: [], quests: [], notes: [] }),
@@ -30,38 +44,45 @@ export function useLevelUpAdvisor(campaign: Campaign, character: Character) {
const [message, setMessage] = useState<string | null>(null);
const fetchRoutes = async () => {
const controller = nextController();
setState('loading');
setMessage(null);
setSteps(undefined);
setChosen(undefined);
if (canUseLlm) {
const prompt = buildLevelUpRoutesPrompt(ctx, character, nextLevel);
const res = await complete(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: buildRoutesSchema, maxTokens: 700 });
const res = await complete(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: buildRoutesSchema, maxTokens: 700, signal: controller.signal });
if (controller.signal.aborted) return; // superseded by a newer run / unmounted
if (res.ok && 'data' in res && res.data.routes.length) {
setRoutes(res.data.routes);
setSource('llm');
setState('ready');
return;
}
// Surface the full provider error, not just the terse kind code (T-086).
if (!res.ok) setMessage(`AI unavailable: ${res.message} Showing general routes.`);
}
if (controller.signal.aborted) return;
setRoutes(deterministicRoutes(campaign.system, character.className));
setSource('deterministic');
setState('ready');
};
const chooseRoute = async (title: string) => {
const controller = nextController();
setChosen(title);
setSteps(undefined);
if (canUseLlm) {
const prompt = buildLevelUpStepsPrompt(ctx, character, nextLevel, title);
const res = await complete(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: buildStepsSchema, maxTokens: 700 });
const res = await complete(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: buildStepsSchema, maxTokens: 700, signal: controller.signal });
if (controller.signal.aborted) return;
if (res.ok && 'data' in res && res.data.steps.length) {
setSteps(res.data.steps);
setSource('llm');
return;
}
}
if (controller.signal.aborted) return;
setSteps(deterministicSteps(campaign.system, character.className, title, nextLevel));
setSource('deterministic');
};
+9 -8
View File
@@ -1,10 +1,10 @@
import { type ReactNode, useState } from 'react';
import { useState, type ReactNode } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { ArrowRight, BookOpenText, CalendarDays, Map, RadioTower, ScrollText, Users } from 'lucide-react';
import { ArrowRight, BookOpenText, CalendarDays, ScrollText } from 'lucide-react';
import { cn } from '@/lib/cn';
import { campaignsRepo } from '@/lib/db/repositories';
import { seedSampleCampaign } from '@/lib/sample';
import { SYSTEM_OPTIONS } from '@/lib/rules';
import { SYSTEM_OPTIONS, getSystem } from '@/lib/rules';
import { useUiStore } from '@/stores/uiStore';
import { useCampaigns } from './hooks';
import type { Campaign } from '@/lib/schemas';
@@ -13,6 +13,7 @@ import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex';
import { Modal } from '@/components/ui/Modal';
import { Field, Input, Select, Textarea } from '@/components/ui/Input';
import { Icon } from '@/components/ui/Icon';
export function CampaignsPage() {
const campaigns = useCampaigns();
@@ -70,9 +71,9 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
</p>
<hr className="gilt-rule mx-auto mt-5 max-w-xs" />
<div className="mt-5 grid gap-2 text-left sm:grid-cols-3">
<FeatCard icon={<Users size={20} />} title="Build characters" desc="A friendly, data-driven builder for new players." />
<FeatCard icon={<Map size={20} />} title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." />
<FeatCard icon={<RadioTower size={20} />} title="Play live" desc="Host a room; players manage their own character." />
<FeatCard icon={<Icon name="Wand2" size={20} />} title="Build characters" desc="A friendly, data-driven builder for new players." />
<FeatCard icon={<Icon name="Map" size={20} />} title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." />
<FeatCard icon={<Icon name="Radio" size={20} />} title="Play live" desc="Host a room; players manage their own character." />
</div>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
<Button variant="primary" onClick={onCreate}>Start your first campaign</Button>
@@ -86,7 +87,7 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
function FeatCard({ icon, title, desc }: { icon: ReactNode; title: string; desc: string }) {
return (
<div className="rounded-xl border border-line bg-surface-2 p-3 transition-colors hover:border-line-strong">
<div className="text-accent" aria-hidden>{icon}</div>
<div className="text-xl text-accent" aria-hidden>{icon}</div>
<div className="mt-1 font-display font-semibold text-ink">{title}</div>
<div className="text-xs text-muted">{desc}</div>
</div>
@@ -105,7 +106,7 @@ function CampaignCard({ campaign, onEdit }: { campaign: Campaign; onEdit: () =>
};
const isActive = activeId === campaign.id;
const systemLabel = campaign.system === '5e' ? 'D&D 5e' : 'Pathfinder 2e';
const systemLabel = getSystem(campaign.system).label;
const created = new Date(campaign.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
+53 -28
View File
@@ -1,13 +1,15 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from '@tanstack/react-router';
import { ArrowLeft, Check, Shield, Gauge, Footprints, Award } from 'lucide-react';
import { ArrowLeft, Shield, Gauge, Footprints, Award } from 'lucide-react';
import type { Character, EquippedArmor } from '@/lib/schemas';
import { primaryClass } from '@/lib/schemas';
import type { AbilityKey, AbilityScores, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
import { getSystem, getClassDef, ABILITY_ABBR, abilityBreakdown, synthManualBuild, setManualTotal, setBuildBase, computeAbilities } from '@/lib/rules';
import { getSystem, getClassDef, ABILITY_ABBR, abilityBreakdown, synthManualBuild, setManualTotal, setBuildBase, computeAbilities, derivedArmorClass, withWornArmor } from '@/lib/rules';
import { allArmorMechanics5e, deriveEffectiveMaxHp, type ArmorMechanics } from '@/lib/mechanics';
import { loadPf2e } from '@/lib/compendium';
import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
import { printCharacterSheet } from '@/lib/io/characterSheetPdf';
import { exportCharacterPathbuilder, exportCharacterFoundry } from '@/lib/io/interchange';
import { encodeClaim } from '@/lib/sync/playerLink';
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
import { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
@@ -17,8 +19,9 @@ import { cn } from '@/lib/cn';
import { Page } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Icon } from '@/components/ui/Icon';
import { Badge, Meter } from '@/components/ui/Codex';
import { Input, Select } from '@/components/ui/Input';
import { Input, Select, Textarea } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { formatModifier } from '@/lib/format';
import { useCampaigns } from '@/features/campaigns/hooks';
@@ -37,7 +40,6 @@ import { LevelUpModal } from './sheet/LevelUpModal';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master', 'legendary'];
export function CharacterSheet({ character }: { character: Character }) {
@@ -125,17 +127,19 @@ export function CharacterSheet({ character }: { character: Character }) {
}, [character]);
const sys = getSystem(c.system);
const classDef = getClassDef(c.system, c.className);
const rulesInput: CharacterRulesInput = {
level: c.level,
abilities: c.abilities,
skillRanks: c.skillRanks as Record<string, ProficiencyRank>,
saveRanks: c.saveRanks as Partial<Record<AbilityKey, ProficiencyRank>>,
armorBonus: c.armorBonus,
...(c.equippedArmor ? { equippedArmor: c.equippedArmor } : {}),
perceptionRank: c.perceptionRank,
...(c.acRank ? { acRank: c.acRank } : {}),
...(c.classDcRank ? { classDcRank: c.classDcRank } : {}),
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}),
...(c.size ? { size: c.size } : {}),
...(c.armorProficiencyRank ? { armorProficiencyRank: c.armorProficiencyRank } : {}),
...(classDef?.unarmoredAbility ? { unarmoredAbility: classDef.unarmoredAbility } : {}),
};
// Ability-score source breakdown: use the persisted build, or synthesize a manual
@@ -153,7 +157,15 @@ export function CharacterSheet({ character }: { character: Character }) {
update({ abilityBuild: nb, abilities: computeAbilities(nb) });
};
const ac = sys.baseArmorClass(rulesInput);
// AC from equipped inventory gear, with the sheet's armor picker bridged in as
// a virtual gear item (real inventory armor wins when both are present).
const acInfo = derivedArmorClass(rulesInput, withWornArmor(c.inventory, c.equippedArmor, c.system), sys);
const ac = acInfo.ac;
const acHint = [
acInfo.armorName ?? 'unarmored',
acInfo.shieldBonus ? `+${acInfo.shieldBonus} shield` : null,
acInfo.misc ? `+${acInfo.misc} misc` : null,
].filter(Boolean).join(' · ');
const initiative = sys.initiativeModifier(rulesInput);
// PF2e Class DC: keyed to the class's key ability (fall back to STR for unknown classes).
const classKeyAbility: AbilityKey = getClassDef(c.system, primaryClass(c))?.keyAbilities[0] ?? 'str';
@@ -161,7 +173,7 @@ export function CharacterSheet({ character }: { character: Character }) {
const skills = sys.skillModifiers(rulesInput);
const saves = sys.saveModifiers(rulesInput);
const profLabel = c.system === '5e' ? `+${sys.proficiencyValue(c.level, 'trained')} prof` : `level ${c.level}`;
const ranks = c.system === 'pf2e' ? RANKS_PF2E : RANKS_5E;
const ranks = sys.proficiencyRanks;
return (
<Page>
@@ -220,14 +232,16 @@ export function CharacterSheet({ character }: { character: Character }) {
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
{c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? <><Check size={12} aria-hidden /> Link copied</> : 'Share with player'}</Button>}
{c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? <span className="inline-flex items-center gap-1">Link copied <Icon name="Check" size={14} className="inline" /></span> : 'Share with player'}</Button>}
<Button size="sm" variant="secondary" onClick={() => setLevelUp(true)}>Level up</Button>
<Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
{c.system === 'pf2e' && <Button size="sm" variant="ghost" onClick={() => exportCharacterPathbuilder(c)} title="Export as Pathbuilder 2e JSON">Pathbuilder</Button>}
<Button size="sm" variant="ghost" onClick={() => exportCharacterFoundry(c)} title="Export as a Foundry VTT actor">Foundry</Button>
<Button size="sm" variant="ghost" onClick={() => printCharacterSheet(c)} title="Print a formatted sheet / save as PDF">Print / PDF</Button>
</div>
</div>
<div className="paper-grain mb-6 grid gap-4 rounded-xl border border-line bg-panel p-4 sm:grid-cols-2 lg:grid-cols-4">
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
<Labeled label={sys.terms.ancestry}>
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
</Labeled>
{c.system === 'pf2e' && (
@@ -247,7 +261,7 @@ export function CharacterSheet({ character }: { character: Character }) {
value={c.className}
onChange={(e) => {
const className = e.target.value;
update({ className, classes: className ? [{ ...(c.classes[0] ?? { level: c.level }), className }] : [] });
update({ className, classes: className ? [{ ...(c.classes[0] ?? { level: c.level, subclass: '' }), className }] : [] });
}}
/>
</Labeled>
@@ -256,7 +270,7 @@ export function CharacterSheet({ character }: { character: Character }) {
value={c.level}
min={1}
max={20}
onChange={(level) => update({ level, classes: c.classes.length ? c.classes.map((e, i) => (i === 0 ? { ...e, level } : e)) : c.className ? [{ className: c.className, level }] : [] })}
onChange={(level) => update({ level, classes: c.classes.length ? c.classes.map((e, i) => (i === 0 ? { ...e, level } : e)) : c.className ? [{ className: c.className, level, subclass: c.subclass }] : [] })}
/>
</Labeled>
</>
@@ -274,19 +288,19 @@ export function CharacterSheet({ character }: { character: Character }) {
{/* Vital stats (PF2e adds a Class DC card) */}
<div className={cn('mb-6 grid gap-3', c.system === 'pf2e' ? 'sm:grid-cols-2 lg:grid-cols-4' : 'sm:grid-cols-3')}>
<StatCard label="Armor Class" value={ac} hint={c.equippedArmor ? `${c.equippedArmor.name}${c.armorBonus ? ` + ${c.armorBonus}` : ''}` : `10 + DEX${c.armorBonus ? ` + ${c.armorBonus}` : ''}`}>
<StatCard label="Armor Class" value={ac} hint={acHint}>
<div className="mt-2 flex flex-col items-center gap-2 text-xs text-muted">
{c.system === '5e' ? <ArmorPicker c={c} update={update} /> : <ArmorPickerPf2e c={c} update={update} />}
{c.system === 'pf2e' && (
<label className="flex items-center gap-2">
<span>Defense proficiency</span>
<Select className="w-auto py-1 text-xs" value={c.acRank ?? 'trained'} onChange={(e) => update({ acRank: e.target.value as ProficiencyRank })} aria-label="Armor proficiency rank">
<Select className="w-auto py-1 text-xs" value={c.armorProficiencyRank ?? 'trained'} onChange={(e) => update({ armorProficiencyRank: e.target.value as ProficiencyRank })} aria-label="Armor proficiency rank">
{RANKS_PF2E.filter((r) => r !== 'untrained').map((r) => <option key={r} value={r}>{rankLabel(r, 'pf2e')}</option>)}
</Select>
</label>
)}
<div className="flex items-center gap-2">
<span>Shield/misc bonus</span>
<span title="Flat bonus on top of equipped armor: magic, class features">Shield/misc bonus</span>
<NumberField className="w-16" value={c.armorBonus} onChange={(armorBonus) => update({ armorBonus })} aria-label="Armor bonus" />
</div>
</div>
@@ -323,21 +337,32 @@ export function CharacterSheet({ character }: { character: Character }) {
const mod = sys.abilityModifier(c.abilities[a]);
const bd = abilityBuildView[a];
return (
<button
key={a}
type="button"
onClick={() => setShowAbilityTable(true)}
className="flex flex-col items-center gap-0.5 rounded-xl border border-line bg-panel px-2 py-3 text-center hover:border-accent/50 transition-colors group"
>
<div key={a} className="flex flex-col items-center gap-0.5 rounded-xl border border-line bg-panel px-2 py-3 text-center">
<div className="smallcaps text-[10px] text-muted">{ABILITY_ABBR[a]}</div>
<div className="font-display text-2xl font-semibold leading-none text-accent-deep">{formatModifier(mod)}</div>
<div className="font-mono text-sm text-ink">{c.abilities[a]}</div>
<button
type="button"
onClick={() => rollCheck(mod, `${ABILITY_ABBR[a]} check`, { system: c.system })}
className="rounded px-1.5 font-display text-2xl font-semibold leading-none text-accent-deep transition-colors hover:bg-accent/10"
title={`Roll ${ABILITY_ABBR[a]} check`}
>
{formatModifier(mod)}
</button>
{/* Score edits go through the breakdown table (Manual adjustment) so
the ability build and the finals never desync. */}
<button
type="button"
onClick={() => setShowAbilityTable(true)}
className="rounded px-1 font-mono text-sm text-ink transition-colors hover:bg-accent/10"
title="Show the score breakdown"
>
{c.abilities[a]}
</button>
{bd.parts.length > 0 && (
<div className="mt-0.5 text-[9px] leading-tight text-muted/60">
{bd.base}{bd.parts.map((p) => ` ${p.delta >= 0 ? '+' : ''}${p.delta}`).join('')}
</div>
)}
</button>
</div>
);
})}
</div>
@@ -476,11 +501,11 @@ export function CharacterSheet({ character }: { character: Character }) {
{/* Notes */}
<section className="mb-6">
<SectionTitle>Notes</SectionTitle>
<textarea
<Textarea
value={c.notes}
onChange={(e) => update({ notes: e.target.value })}
placeholder="Backstory, bonds, reminders…"
className="min-h-32 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
className="min-h-32"
/>
</section>
@@ -585,7 +610,7 @@ function RankRow({
label: string;
modifier: number;
rank: ProficiencyRank;
ranks: ProficiencyRank[];
ranks: readonly ProficiencyRank[];
onRank: (r: ProficiencyRank) => void;
system: SystemId;
}) {
+18 -9
View File
@@ -1,28 +1,37 @@
import { Link, useParams } from '@tanstack/react-router';
import { useCharacter } from './hooks';
import { useParams, Link } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks';
import { charactersRepo } from '@/lib/db/repositories';
import { CharacterSheet } from './CharacterSheet';
import { Page, EmptyState } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
export function CharacterSheetPage() {
const { characterId } = useParams({ from: '/characters/$characterId' });
const character = useCharacter(characterId);
// `null` = still loading (the default); a resolved-but-missing row yields
// `undefined` from Dexie's `get`, which lets us tell "loading" from "deleted"
// (the bare useCharacter hook collapses both to undefined).
const character = useLiveQuery(() => charactersRepo.get(characterId), [characterId], null);
if (character === undefined) {
// Still loading (the hook returns null for a definitive miss).
// On an in-place id change (/characters/A → /characters/B) liveQuery keeps the
// PREVIOUS result until the new one resolves; treat that stale character as
// loading so we never flash the wrong sheet.
if (character === null || (character && character.id !== characterId)) {
return (
<Page>
<EmptyState title="Loading character…" />
</Page>
);
}
if (character === null) {
if (character === undefined) {
return (
<Page>
<EmptyState
title="Character not found"
hint="It may have been deleted, or the link is stale."
action={<Link to="/characters"><Button variant="secondary">Back to characters</Button></Link>}
hint="This character may have been deleted, or its link is out of date."
action={
<Link to="/characters" className="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-ink">
Back to characters
</Link>
}
/>
</Page>
);
+107 -2
View File
@@ -3,13 +3,15 @@ import { Link } from '@tanstack/react-router';
import { Heart, Shield } from 'lucide-react';
import { charactersRepo } from '@/lib/db/repositories';
import type { Campaign, Character } from '@/lib/schemas';
import { getSystem } from '@/lib/rules';
import { getSystem, derivedArmorClass, getClassDef, withWornArmor } from '@/lib/rules';
import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character';
import { importExternalCharacter, ExternalImportError, type ImportFormat } from '@/lib/import';
import { pickTextFile } from '@/lib/io/file';
import { useCharacters, useAllPcs } from './hooks';
import { useActiveCampaign } from '@/features/campaigns/hooks';
import { Page, PageHeader, EmptyState } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Field, Select, Textarea } from '@/components/ui/Input';
import { Avatar, Badge } from '@/components/ui/Codex';
import { Modal } from '@/components/ui/Modal';
import { CreationWizard } from './builder/CreationWizard';
@@ -25,6 +27,7 @@ function CharactersList({ campaign }: { campaign?: Campaign | undefined }) {
const pcs = useAllPcs();
const npcs = useCharacters(campaign?.id ?? '').filter((c) => c.kind === 'npc');
const [creating, setCreating] = useState(false);
const [externalImport, setExternalImport] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const importCharacter = async () => {
@@ -50,6 +53,9 @@ function CharactersList({ campaign }: { campaign?: Campaign | undefined }) {
<Button variant="secondary" onClick={importCharacter}>
Import
</Button>
<Button variant="secondary" onClick={() => setExternalImport(true)}>
Import from tool
</Button>
<Button variant="primary" onClick={() => setCreating(true)}>
+ New character
</Button>
@@ -81,6 +87,9 @@ function CharactersList({ campaign }: { campaign?: Campaign | undefined }) {
)}
{creating && <CreationWizard campaign={campaign} onClose={() => setCreating(false)} />}
{externalImport && (
<ExternalImportModal campaign={campaign} onClose={() => setExternalImport(false)} />
)}
{npcs.length === 0 && !campaign && pcs.length > 0 && (
<p className="mt-4 text-xs text-muted">Tip: select or create a campaign to add NPCs and link this roster.</p>
)}
@@ -88,6 +97,92 @@ function CharactersList({ campaign }: { campaign?: Campaign | undefined }) {
);
}
/**
* Import a character exported from D&D Beyond or Pathbuilder 2e (T-220 / T-221).
* Accepts pasted JSON or a loaded file; the format can be auto-detected or
* chosen, and the system is checked against the campaign before saving.
*/
function ExternalImportModal({ campaign, onClose }: { campaign?: Campaign | undefined; onClose: () => void }) {
const [text, setText] = useState('');
const [format, setFormat] = useState<ImportFormat>('auto');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const loadFile = async () => {
const t = await pickTextFile();
if (t !== null) {
setText(t);
setError(null);
}
};
const run = async () => {
setError(null);
setBusy(true);
try {
const character = importExternalCharacter(text, campaign?.id ?? '', format, campaign?.system);
await charactersRepo.insert(character);
onClose();
} catch (e) {
setError(e instanceof ExternalImportError ? e.message : 'Import failed.');
} finally {
setBusy(false);
}
};
return (
<Modal
open
onClose={onClose}
title="Import from another tool"
footer={
<>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" onClick={run} disabled={busy || text.trim() === ''}>
Import
</Button>
</>
}
>
<div className="space-y-3">
<p className="text-sm text-muted">
Paste the JSON from a D&amp;D Beyond character export or a Pathbuilder 2e export, or load a
file. Mapping is best-effort — review the imported sheet afterwards.
</p>
<Field label="Format">
<Select value={format} onChange={(e) => setFormat(e.target.value as ImportFormat)}>
<option value="auto">Auto-detect</option>
<option value="dndbeyond">D&amp;D Beyond (5e)</option>
<option value="pathbuilder">Pathbuilder 2e</option>
</Select>
</Field>
<Field label="Character JSON">
<Textarea
value={text}
onChange={(e) => {
setText(e.target.value);
setError(null);
}}
rows={8}
placeholder='{ "build": { ... } } or { "data": { ... } }'
className="font-mono text-xs"
/>
</Field>
<Button variant="secondary" size="sm" onClick={loadFile}>
Load from file…
</Button>
{error && (
<p className="rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
{error}
</p>
)}
</div>
</Modal>
);
}
function CharacterGroup({ title, items }: { title: string; items: Character[] }) {
if (items.length === 0) return null;
return (
@@ -104,7 +199,17 @@ function CharacterGroup({ title, items }: { title: string; items: Character[] })
function CharacterCard({ c }: { c: Character }) {
const [confirming, setConfirming] = useState(false);
const ac = getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus, ...(c.equippedArmor ? { equippedArmor: c.equippedArmor } : {}) });
const udAbility = getClassDef(c.system, c.className)?.unarmoredAbility;
const ac = derivedArmorClass(
{
level: c.level, abilities: c.abilities, armorBonus: c.armorBonus,
...(c.size ? { size: c.size } : {}),
...(c.armorProficiencyRank ? { armorProficiencyRank: c.armorProficiencyRank } : {}),
...(udAbility ? { unarmoredAbility: udAbility } : {}),
},
withWornArmor(c.inventory, c.equippedArmor, c.system),
getSystem(c.system),
).ac;
return (
<div className="paper-grain flex flex-col rounded-xl border border-line bg-panel transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-accent/50 hover:shadow-[0_8px_24px_-12px_var(--app-accent-glow)]">
@@ -1,17 +1,19 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { Lightbulb, RotateCcw } from 'lucide-react';
import { type Campaign, type Character, type SpellEntry, type AbilityBuild, type AbilityAdjustment, newSpellEntry } from '@/lib/schemas';
import { type Campaign, type Character, type CharacterFeat, type InventoryItem, type SpellEntry, type AbilityBuild, type AbilityAdjustment, newSpellEntry } from '@/lib/schemas';
import { charactersRepo } from '@/lib/db/repositories';
import {
getSystem, ABILITY_ABBR, ABILITY_LABELS, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
getSystem, ABILITY_ABBR, ABILITY_LABELS, buildCharacter, getClassDef, SYSTEM_OPTIONS,
pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw,
bumpRank, collectChoices,
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
bumpRank, collectChoices, cantripsKnown, leveledSpellsKnown, checkBuildLegality,
type AbilityKey, type AbilityScores, type CuratedAncestry, type CuratedBackground, type CuratedHeritage,
type FeatDef, type ProficiencyRank, type StartingItem, type SystemId,
} from '@/lib/rules';
import { pf2eSkillIncreaseLevels } from '@/lib/rules/pf2e/progression';
import { BuildLegality } from '../sheet/BuildLegality';
import { pf2eSkillIncreaseLevels, PF2E_SUBCLASSES } from '@/lib/rules/pf2e/progression';
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium';
import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e, loadFeats } from '@/lib/compendium';
import type { RulesetClass } from '@/lib/ruleset/normalize';
import { createRng } from '@/lib/rng';
import { newId } from '@/lib/ids';
@@ -20,18 +22,20 @@ import { briefOverview, dedupeByName } from './overview';
import { parseAsiBonuses, parseAsiChoices, makeSkillResolver, parseBackgroundSkills, parseTraitSkills, loreSkillKey, type AsiChoice } from './origin';
import { heritageMatchesAncestry } from './heritages';
import { normalize5eSpellOpt, normalizePf2eSpellOpt, maxOfferedSpellLevel, spellMatchesClass, type SpellOpt } from './spells';
import { PF2E_SUBCLASSES } from '@/lib/rules/pf2e/progression';
import { FeatPicker } from './FeatPicker';
import { DND5E_SUBCLASSES, dnd5eAsiLevels } from '@/lib/rules/dnd5e/progression';
import { Modal } from '@/components/ui/Modal';
import { Wizard5eAbilityTable, WizardPf2eAbilityTable } from '../AbilityScoreTable';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex';
import { Icon, type IconName } from '@/components/ui/Icon';
import { Field, Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { cn } from '@/lib/cn';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
type AbilityMethod = 'standard' | 'pointbuy' | 'roll' | 'manual';
type GearChoice = 'kit' | 'gold';
interface Origin {
name: string; desc: string; meta?: string; hp?: number; speed?: number;
@@ -51,6 +55,24 @@ interface Origin {
ancestryFlaw?: AbilityKey;
/** pf2e background boosts: choose-one options + free-boost count. */
backgroundBoosts?: { options: AbilityKey[]; free: number };
/** extra applied mechanics surfaced from the loader (PF2e ancestry JSON) (T-028) */
senses?: string[]; traits?: string[]; languages?: string[];
}
/** Map a curated starting-equipment item into a full, schema-valid inventory row (T-031). */
function toInventoryItem(it: StartingItem): InventoryItem {
return {
id: newId(),
name: it.name,
quantity: it.quantity ?? 1,
weight: it.weight ?? 0,
equipped: !!it.equip,
attuned: false,
description: it.description ?? '',
...(it.armor ? { armor: { category: it.armor.category, baseAc: it.armor.baseAc, ...(it.armor.maxDex !== undefined ? { maxDex: it.armor.maxDex } : {}) } } : {}),
...(it.weapon ? { weapon: { damageDice: it.weapon.damageDice, damageType: it.weapon.damageType ?? '', finesse: !!it.weapon.finesse, ranged: !!it.weapon.ranged, rank: it.weapon.rank ?? 'trained', itemBonus: 0, addAbilityToDamage: true } } : {}),
...(it.shield ? { shield: { acBonus: it.shield.acBonus } } : {}),
};
}
/** A class's "what it plays like" tag, to help newcomers pick. */
@@ -60,21 +82,37 @@ function playstyle(c: RulesetClass): string {
return 'Skirmisher';
}
/** Curated PF2e boosts ('free' markers + fixed keys) → the slot model's shape. */
function curatedAncestryBoosts(c: CuratedAncestry): { fixed: AbilityKey[]; free: number } | undefined {
if (!c.boosts?.length) return undefined;
return {
fixed: c.boosts.filter((b): b is AbilityKey => b !== 'free'),
free: c.boosts.filter((b) => b === 'free').length,
};
}
function curatedBackgroundBoosts(b: CuratedBackground): { options: AbilityKey[]; free: number } | undefined {
if (!b.boosts?.length) return undefined;
return {
options: b.boosts.filter((x): x is AbilityKey => x !== 'free'),
free: b.boosts.filter((x) => x === 'free').length,
};
}
// PF2e templates carry no ability array: scores come from boosts, and the boost
// seeding already favors the class key ability (reproducing the 18-key spread
// legally); a flat statline would bypass the boost system and be discarded.
const TEMPLATES: Record<string, { label: string; hint: string; className: string; ability?: AbilityScores }[]> = {
const TEMPLATES: Record<string, { icon: IconName; label: string; hint: string; className: string; ability?: AbilityScores }[]> = {
'5e': [
{ label: 'Stalwart Fighter', hint: 'Tough front-liner. Easy to play.', className: 'Fighter', ability: { str: 15, dex: 13, con: 14, int: 8, wis: 12, cha: 10 } },
{ label: 'Clever Wizard', hint: 'Versatile spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 13, int: 15, wis: 12, cha: 10 } },
{ label: 'Sneaky Rogue', hint: 'Skills + big sneak attacks.', className: 'Rogue', ability: { str: 10, dex: 15, con: 13, int: 12, wis: 14, cha: 8 } },
{ label: 'Helpful Cleric', hint: 'Heals and supports the party.', className: 'Cleric', ability: { str: 14, dex: 10, con: 13, int: 8, wis: 15, cha: 12 } },
{ icon: 'Shield', label: 'Stalwart Fighter', hint: 'Tough front-liner. Easy to play.', className: 'Fighter', ability: { str: 15, dex: 13, con: 14, int: 8, wis: 12, cha: 10 } },
{ icon: 'Flame', label: 'Clever Wizard', hint: 'Versatile spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 13, int: 15, wis: 12, cha: 10 } },
{ icon: 'Sword', label: 'Sneaky Rogue', hint: 'Skills + big sneak attacks.', className: 'Rogue', ability: { str: 10, dex: 15, con: 13, int: 12, wis: 14, cha: 8 } },
{ icon: 'Sparkles', label: 'Helpful Cleric', hint: 'Heals and supports the party.', className: 'Cleric', ability: { str: 14, dex: 10, con: 13, int: 8, wis: 15, cha: 12 } },
],
pf2e: [
{ label: 'Stalwart Fighter', hint: 'Best weapon proficiency in the game.', className: 'Fighter' },
{ label: 'Clever Wizard', hint: 'Prepared arcane spellcaster.', className: 'Wizard' },
{ label: 'Sneaky Rogue', hint: 'Skills, mobility, sneak attack.', className: 'Rogue' },
{ label: 'Healing Cleric', hint: 'Divine font of healing.', className: 'Cleric' },
{ icon: 'Shield', label: 'Stalwart Fighter', hint: 'Best weapon proficiency in the game.', className: 'Fighter' },
{ icon: 'Flame', label: 'Clever Wizard', hint: 'Prepared arcane spellcaster.', className: 'Wizard' },
{ icon: 'Sword', label: 'Sneaky Rogue', hint: 'Skills, mobility, sneak attack.', className: 'Rogue' },
{ icon: 'Sparkles', label: 'Healing Cleric', hint: 'Divine font of healing.', className: 'Cleric' },
],
};
@@ -135,7 +173,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
if (!on) return;
const strip = (s: string) => s.replace(/\*{2,3}[^*]+\*{2,3}\s*/g, '').trim();
setOrigins(rs.map((r) => {
const desc = [r.asi, r.vision, r.traits].filter(Boolean).map(strip).join('\n\n');
const desc = [r.asi, r.vision, r.traits].filter((x): x is string => Boolean(x)).map(strip).join('\n\n');
const speedFt = /(\d+) feet/.exec(r.speed)?.[1];
const asiBonuses = parseAsiBonuses(r.asi ?? '');
const asiChoices = parseAsiChoices(r.asi ?? '');
@@ -187,6 +225,10 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
ancestryBoosts,
...(ancestryFlaw ? { ancestryFlaw } : {}),
meta,
// T-028: surface senses (vision), traits, and languages for application at creation.
...(typeof r.vision === 'string' && r.vision && r.vision !== 'None' ? { senses: [String(r.vision).toLowerCase()] } : {}),
...(Array.isArray(r.trait) ? { traits: r.trait as string[] } : {}),
...(Array.isArray(r.language) ? { languages: r.language as string[] } : {}),
};
})));
}).catch(() => { if (on) setLoadError(true); });
@@ -223,9 +265,11 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
const [classSlug, setClassSlug] = useState('');
const [subclass, setSubclass] = useState('');
const [ancestry, setAncestry] = useState('');
// PF2e heritage — chosen at level 1, refines the ancestry. Loaded lazily; the data
// carries no ancestry link, so ownership is resolved in ./heritages (curated map +
// anchored name/text match); versatile heritages are selectable by any ancestry.
const [background, setBackground] = useState('');
const [gearChoice, setGearChoice] = useState<GearChoice>('kit');
// Heritage (PF2e, chosen at level 1) / subrace (5e). PF2e options are loaded
// lazily from data (ownership resolved in ./heritages); 5e options come from the
// curated ancestry table (T-030).
const [heritage, setHeritage] = useState('');
const [allHeritages, setAllHeritages] = useState<{ name: string; summary: string; text: string }[]>([]);
useEffect(() => {
@@ -246,7 +290,6 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
.filter((h) => heritageMatchesAncestry(h.name, h.text, ancestry, ancestryNames, versatileNames));
}, [system, ancestry, allHeritages, origins, versatileNames]);
useEffect(() => { setHeritage(''); }, [ancestry]); // a new ancestry invalidates the heritage
const [background, setBackground] = useState('');
const selectedClass = classes.find((c) => c.slug === classSlug) ?? null;
const selectedOrigin = origins.find((o) => o.name === ancestry) ?? null;
@@ -254,6 +297,48 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
const classDef = selectedClass ? getClassDef(system, selectedClass.name) : undefined;
const isCaster = !!selectedClass && selectedClass.caster !== 'none';
// Curated, structured ancestry/background mechanics (T-030), looked up by name.
// When present they back the creation tasks (boosts/flaw, subrace/heritage,
// senses, traits, languages, granted skills); otherwise the parsed data applies.
const curatedAncestryMap = useMemo(() => {
const m = new Map<string, CuratedAncestry>();
for (const a of sys.listAncestries()) m.set(a.name.toLowerCase(), a);
return m;
}, [sys]);
const curatedBackgroundMap = useMemo(() => {
const m = new Map<string, CuratedBackground>();
for (const b of sys.listBackgrounds()) m.set(b.name.toLowerCase(), b);
return m;
}, [sys]);
const curatedAncestry = ancestry ? curatedAncestryMap.get(ancestry.toLowerCase()) : undefined;
const curatedBg = background ? curatedBackgroundMap.get(background.toLowerCase()) : undefined;
// 5e subraces come from the curated table; PF2e heritages from the loaded data.
const curatedHeritageOptions: readonly CuratedHeritage[] = useMemo(
() => (system === '5e' ? curatedAncestry?.heritages ?? [] : []),
[system, curatedAncestry],
);
// The curated record for the chosen heritage/subrace (carries ability bonuses,
// senses, speed bonus), looked up by name for either system.
const selectedCuratedHeritage = useMemo(
() => curatedAncestry?.heritages?.find((h) => h.name === heritage),
[curatedAncestry, heritage],
);
// Final applied origin mechanics (T-028): curated first, else the loaded values,
// with the heritage's extras merged on top.
const originMech = useMemo(() => {
const c = curatedAncestry; const o = selectedOrigin; const h = selectedCuratedHeritage;
const dedupe = (xs: string[]) => [...new Set(xs.filter(Boolean))];
const senses = dedupe([...(c?.senses ?? o?.senses ?? []), ...(h?.senses ?? [])]);
const traits = dedupe([...(c?.traits ?? o?.traits ?? []), ...(h?.traits ?? [])]);
const languages = dedupe([...(c?.languages ?? o?.languages ?? [])]);
const baseSpeed = c?.speed ?? o?.speed;
const speed = baseSpeed !== undefined ? baseSpeed + (h?.speedBonus ?? 0) : undefined;
const hp = c?.hp ?? o?.hp;
const grantedSkills = c?.grantedSkills ?? [];
return { senses, traits, languages, speed, hp, grantedSkills };
}, [curatedAncestry, selectedOrigin, selectedCuratedHeritage]);
// ---- abilities ----
const [method, setMethod] = useState<AbilityMethod>('standard');
const [pool, setPool] = useState<number[]>([...STANDARD_ARRAY]);
@@ -267,10 +352,12 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
};
// ---- pf2e ability boosts (start at 10; ancestry/background/class/free boosts) ----
// Curated boost data (T-030) wins over the parsed AoN attribute strings.
const boostSlots = useMemo(() => {
if (system !== 'pf2e') return null;
const anc = selectedOrigin?.ancestryBoosts ?? { fixed: [], free: 0 };
const bg = selectedBackground?.backgroundBoosts ?? { options: [], free: 0 };
const anc = (curatedAncestry && curatedAncestryBoosts(curatedAncestry)) ?? selectedOrigin?.ancestryBoosts ?? { fixed: [], free: 0 };
const bg = (curatedBg && curatedBackgroundBoosts(curatedBg)) ?? selectedBackground?.backgroundBoosts ?? { options: [], free: 0 };
const flaw = curatedAncestry?.flaws?.[0] ?? selectedOrigin?.ancestryFlaw;
const keyOpts = (classDef?.keyAbilities ?? selectedClass?.keyAbilities ?? []) as AbilityKey[];
const slots: { id: string; label: string; options: AbilityKey[] }[] = [];
for (let i = 0; i < anc.free; i++) slots.push({ id: `anc-free-${i}`, label: 'Ancestry free boost', options: ABILITIES });
@@ -280,8 +367,8 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
for (let i = 0; i < 4; i++) slots.push({ id: `free-${i}`, label: 'Free boost', options: ABILITIES });
// Higher-level characters also gained 4 ability boosts at levels 5/10/15/20.
for (const L of [5, 10, 15, 20]) if (L <= level) for (let i = 0; i < 4; i++) slots.push({ id: `lvl${L}-${i}`, label: `Level ${L} boost`, options: ABILITIES });
return { fixed: anc.fixed, flaw: selectedOrigin?.ancestryFlaw, slots };
}, [system, selectedOrigin, selectedBackground, classDef, selectedClass, level]);
return { fixed: anc.fixed, flaw, slots };
}, [system, curatedAncestry, curatedBg, selectedOrigin, selectedBackground, classDef, selectedClass, level]);
const [boostPicks, setBoostPicks] = useState<Record<string, AbilityKey>>({});
// A boost slot's "source" — boosts within one source must target different abilities.
@@ -324,9 +411,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
// 5e: ability-score improvements gained by the input level for this class (4/8/12/16/19, +class extras).
const asiCount = system === '5e' && selectedClass ? dnd5eAsiLevels(selectedClass.name).filter((l) => l <= level).length : 0;
const [asiAlloc, setAsiAlloc] = useState<Partial<Record<AbilityKey, number>>>({});
// 5e RAW: each ASI may be swapped for a feat. The wizard has no feat picker, so
// this opt-out lets the player leave points unspent on purpose (feats go on the
// sheet after creation) instead of Next silently discarding them.
// 5e RAW: each ASI may be swapped for a feat. The Feats step covers picking them,
// so this opt-out lets the player leave points unspent on purpose instead of
// Next silently discarding them.
const [keepAsiForFeats, setKeepAsiForFeats] = useState(false);
useEffect(() => { setAsiAlloc({}); setKeepAsiForFeats(false); }, [classSlug, level, system]); // reset when class/level changes
const asiUsed = ABILITIES.reduce((n, a) => n + (asiAlloc[a] ?? 0), 0);
@@ -345,15 +432,19 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
const [racialChoicePicks, setRacialChoicePicks] = useState<Record<string, AbilityKey | ''>>({});
useEffect(() => { setRacialChoicePicks({}); }, [ancestry, system]);
const racialChoicesValid = racialChoiceSlots.every((s) => racialChoicePicks[s.id]);
// Fixed racial bonuses + the chosen ones — what the table's Race column shows.
// Fixed racial bonuses (curated table first, else parsed) + subrace bonuses +
// the chosen ones — what the table's Race column shows.
const racialBonuses = useMemo<Partial<AbilityScores>>(() => {
const out: Partial<AbilityScores> = { ...(selectedOrigin?.asiBonuses ?? {}) };
const out: Partial<AbilityScores> = { ...(curatedAncestry?.abilityBonuses ?? selectedOrigin?.asiBonuses ?? {}) };
for (const [k, v] of Object.entries(selectedCuratedHeritage?.abilityBonuses ?? {})) {
out[k as AbilityKey] = (out[k as AbilityKey] ?? 0) + (v ?? 0);
}
for (const s of racialChoiceSlots) {
const a = racialChoicePicks[s.id];
if (a) out[a] = (out[a] ?? 0) + s.amount;
}
return out;
}, [selectedOrigin, racialChoiceSlots, racialChoicePicks]);
}, [curatedAncestry, selectedCuratedHeritage, selectedOrigin, racialChoiceSlots, racialChoicePicks]);
const abilities = useMemo<AbilityScores>(() => {
if (system === 'pf2e') return pf2eAbilities ?? { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
@@ -382,7 +473,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
const base = {} as AbilityScores;
ABILITIES.forEach((a, i) => { base[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; });
const adjustments: AbilityAdjustment[] = [];
if (selectedOrigin?.asiBonuses) for (const a of ABILITIES) { const v = selectedOrigin.asiBonuses[a] ?? 0; if (v) adjustments.push({ label: 'Race', ability: a, kind: 'flat', amount: v }); }
const fixedRacial = curatedAncestry?.abilityBonuses ?? selectedOrigin?.asiBonuses;
if (fixedRacial) for (const a of ABILITIES) { const v = fixedRacial[a] ?? 0; if (v) adjustments.push({ label: 'Race', ability: a, kind: 'flat', amount: v }); }
if (selectedCuratedHeritage?.abilityBonuses) for (const a of ABILITIES) { const v = selectedCuratedHeritage.abilityBonuses[a] ?? 0; if (v) adjustments.push({ label: 'Subrace', ability: a, kind: 'flat', amount: v }); }
for (const s of racialChoiceSlots) { const a = racialChoicePicks[s.id]; if (a) adjustments.push({ label: 'Race (choice)', ability: a, kind: 'flat', amount: s.amount }); }
for (const a of ABILITIES) { const v = asiAlloc[a] ?? 0; if (v) adjustments.push({ label: 'ASI', ability: a, kind: 'flat', amount: v }); }
return { base, adjustments };
@@ -390,12 +483,12 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
// ---- skills ----
const [skills, setSkills] = useState<string[]>([]);
const intMod = abilityModifier(abilities.int);
// Prefer the curated class table (canonical free-choice count) over the parsed
// data file, which over-counts pf2e classes and mangles some 5e skill names.
// Bonus trained skills (PF2e: INT modifier) come through the rules seam.
const baseSkillCount = classDef?.skillCount ?? selectedClass?.skillCount ?? 2;
const skillCount = selectedClass
? baseSkillCount + (system === 'pf2e' ? Math.max(0, intMod) : 0)
? baseSkillCount + sys.bonusTrainedSkills({ level: 1, abilities })
: 2;
const skillOptions = useMemo(() => {
if (!selectedClass) return allSkillKeys;
@@ -419,23 +512,29 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
const bgSkillPicksValid = bgSkillGroups.length === 0 || (bgSkillPicks.length === bgSkillGroups.length && bgSkillPicks.every(Boolean));
// PF2e background Lore grants — persisted as custom lore:* skillRanks keys.
const loreGrants = useMemo(
() => (selectedBackground?.loreGrants ?? []).map((name) => ({ key: loreSkillKey(name), label: name })),
[selectedBackground],
);
// Curated backgrounds may add a Lore the parsed data missed.
const loreGrants = useMemo(() => {
const out = (selectedBackground?.loreGrants ?? []).map((n) => ({ key: loreSkillKey(n), label: n }));
if (curatedBg?.loreSkill && !out.some((l) => l.label.toLowerCase() === curatedBg.loreSkill!.toLowerCase())) {
out.push({ key: loreSkillKey(curatedBg.loreSkill), label: curatedBg.loreSkill });
}
return out;
}, [selectedBackground, curatedBg]);
// Skill proficiencies granted (trained) by ancestry/race + background — shown to the
// user so they don't waste free picks, and merged on top of class picks at build time.
const grantedSkillSources = useMemo(() => {
const out: { key: string; source: string }[] = [];
const add = (keys: string[] | undefined, source: string) => {
const add = (keys: string[] | undefined | readonly string[], source: string) => {
for (const k of keys ?? []) if (k && !out.some((o) => o.key === k)) out.push({ key: k, source });
};
add(originMech.grantedSkills, selectedOrigin?.name ?? (ancestry || (system === 'pf2e' ? 'Ancestry' : 'Race')));
add(selectedOrigin?.skillGrants, selectedOrigin?.name ?? (system === 'pf2e' ? 'Ancestry' : 'Race'));
add(curatedBg?.grantedSkills, selectedBackground?.name ?? (background || 'Background'));
add(selectedBackground?.skillGrants, selectedBackground?.name ?? 'Background');
add(bgSkillPicks.filter(Boolean), selectedBackground?.name ?? 'Background');
return out;
}, [system, selectedOrigin, selectedBackground, bgSkillPicks]);
}, [system, ancestry, background, originMech.grantedSkills, curatedBg, selectedOrigin, selectedBackground, bgSkillPicks]);
const grantedSkills = useMemo(() => grantedSkillSources.map((g) => g.key), [grantedSkillSources]);
// Free picks exclude already-granted skills, so the user can't waste a choice on one.
const freeSkillOptions = useMemo(() => skillOptions.filter((k) => !grantedSkills.includes(k)), [skillOptions, grantedSkills]);
@@ -451,7 +550,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
);
const expertiseCount = useMemo(() => {
if (system !== '5e' || !selectedClass) return 0;
const ch = collectChoices('5e', [{ className: selectedClass.name, level }]).find((x) => x.key.endsWith(':expertise'));
const ch = collectChoices('5e', [{ className: selectedClass.name, level, subclass: '' }]).find((x) => x.key.endsWith(':expertise'));
return ch?.count ?? 0;
}, [system, selectedClass, level]);
const [skillIncPicks, setSkillIncPicks] = useState<string[]>([]);
@@ -518,35 +617,104 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
else void loadPf2e('spells').then((ss) => on && setAllSpells(ss.map((s) => normalizePf2eSpellOpt(s as Record<string, unknown>)))).catch(() => { if (on) setLoadError(true); });
return () => { on = false; };
}, [isCaster, system, allSpells.length, loadTick]);
const toggleSpell = (s: SpellOpt) =>
setSpellPicks((prev) => (prev.some((x) => x.name === s.name) ? prev.filter((x) => x.name !== s.name) : [...prev, s]));
// ---- feats (T-036) ----
const [featDefs, setFeatDefs] = useState<FeatDef[]>([]);
const [featsLoading, setFeatsLoading] = useState(false);
const [featPicks, setFeatPicks] = useState<{ feat: FeatDef; choice?: { ability?: AbilityKey } }[]>([]);
useEffect(() => {
let on = true;
setFeatsLoading(true);
loadFeats(system)
.then((raw) => { if (on) setFeatDefs(getSystem(system).listFeats(raw)); })
.catch(() => { if (on) setFeatDefs([]); })
.finally(() => { if (on) setFeatsLoading(false); });
return () => { on = false; };
}, [system]);
// ---- subclass options (T-037): merge curated grants with the (already enriched)
// class data, so PF2e subclasses and 5e archetypes both show, with descriptions.
const subclassOptions = useMemo(() => {
const out = new Map<string, string>(); // name -> category label
if (selectedClass) {
for (const s of sys.listSubclasses(selectedClass.name)) out.set(s.name, s.category);
for (const s of selectedClass.subclasses) if (!out.has(s.name)) out.set(s.name, '');
}
return [...out.entries()].map(([cName, category]) => ({ name: cName, category }));
}, [sys, selectedClass]);
// ---- derived build ----
const built = useMemo(() => buildCharacter(system, {
className: selectedClass?.name ?? '', level, abilities, skillChoices: skills,
...(grantedSkills.length || loreGrants.length ? { grantedSkills: [...grantedSkills, ...loreGrants.map((l) => l.key)] } : {}),
...(selectedOrigin?.hp ? { ancestryHp: selectedOrigin.hp } : {}),
...(originMech.hp ? { ancestryHp: originMech.hp } : {}),
...(selectedClass ? { hitDieOverride: selectedClass.hitDie } : {}),
}), [system, selectedClass, level, abilities, skills, grantedSkills, loreGrants, selectedOrigin]);
}), [system, selectedClass, level, abilities, skills, grantedSkills, loreGrants, originMech.hp]);
// ---- spell selection constraints (T-032) ----
// Offer only what the class can cast: its spell list (5e) / tradition (PF2e),
// capped at the top slot rank actually available at this level (rank 10 for a
// level-19+ PF2e caster; cantrips only for a slot-less 5e half-caster at 1).
// capped at the top slot rank actually available at this level, and enforce
// cantrip / spells-known counts where the class fixes them.
const maxSpellLevel = useMemo(
() => maxOfferedSpellLevel(system, built.spellcasting.slots, built.spellcasting.pact),
[system, built],
);
const cantripCap = selectedClass ? cantripsKnown(system, selectedClass.name, level) : 0;
// undefined = no fixed "known" count (prepared casters / PF2e): a soft suggestion.
const leveledCap = selectedClass ? leveledSpellsKnown(system, selectedClass.name, level) : undefined;
const cantripPicks = spellPicks.filter((s) => s.level === 0).length;
const leveledPicks = spellPicks.filter((s) => s.level > 0).length;
const spellResults = useMemo(() => {
const q = spellQuery.trim().toLowerCase();
const maxLevel = maxOfferedSpellLevel(system, built.spellcasting.slots, built.spellcasting.pact);
const className = selectedClass?.name ?? '';
return allSpells
.filter((s) => s.level <= maxLevel && spellMatchesClass(s, system, className) && (!q || s.name.toLowerCase().includes(q)))
.filter((s) => {
const withinLevel = s.level === 0 || (s.level >= 1 && s.level <= maxSpellLevel);
if (!withinLevel) return false;
if (!spellMatchesClass(s, system, className)) return false;
if (q && !s.name.toLowerCase().includes(q)) return false;
return true;
})
.slice(0, 60);
}, [allSpells, spellQuery, system, built, selectedClass]);
}, [allSpells, spellQuery, system, selectedClass, maxSpellLevel]);
const spellAtCap = (s: SpellOpt): boolean =>
s.level === 0
? cantripCap > 0 && cantripPicks >= cantripCap
: leveledCap !== undefined && leveledPicks >= leveledCap;
const toggleSpell = (s: SpellOpt) =>
setSpellPicks((prev) => {
if (prev.some((x) => x.name === s.name)) return prev.filter((x) => x.name !== s.name);
// Enforce the cantrip / spells-known caps when adding (count from prev).
const cantrips = prev.filter((x) => x.level === 0).length;
const leveled = prev.filter((x) => x.level > 0).length;
if (s.level === 0) { if (cantripCap > 0 && cantrips >= cantripCap) return prev; }
else if (leveledCap !== undefined && leveled >= leveledCap) return prev;
return [...prev, s];
});
// Rough "how many should I pick" suggestion for the Spells step. Not enforced —
// just guidance: a few cantrips plus a handful of starting leveled spells.
const leveledSlots = useMemo(() => built.spellcasting.slots.reduce((n, s) => n + (s.level > 0 ? s.max : 0), 0), [built]);
const suggestedSpells = Math.max(4, leveledSlots + 2);
const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Details', 'Review'], [isCaster]);
// Build-legality check surfaced on the Review step (T-045).
const legalityIssues = useMemo(
() => (selectedClass
? checkBuildLegality({
level,
className: selectedClass.name,
abilities,
skillRanks: Object.fromEntries([...skills, ...grantedSkills].map((k) => [k, 'trained' as ProficiencyRank])),
spellcasting: { spells: spellPicks.map((s) => ({ level: s.level })) },
}, sys)
: []),
[selectedClass, level, abilities, skills, grantedSkills, spellPicks, sys],
);
const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Feats', 'Gear', 'Details', 'Review'], [isCaster]);
const stepName = STEPS[Math.min(step, STEPS.length - 1)]!;
const changeSystem = (next: SystemId) => {
@@ -560,8 +728,11 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
setSpellPicks([]);
setSpellQuery('');
setAllSpells([]); // force the spell loader (guarded on length) to refetch for the new system
setFeatPicks([]);
setAncestry('');
setBackground('');
setHeritage('');
setGearChoice('kit');
// Reset the ability method too — a PF2e→5e switch must not inherit a stale
// manual/point-buy spread left behind by a template or earlier edits.
setMethod('standard');
@@ -600,6 +771,8 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
&& expertisePicks.every(Boolean)
&& new Set(expertisePicks).size === expertisePicks.length,
Spells: true,
Feats: true,
Gear: true,
Details: true,
Review: true,
};
@@ -615,32 +788,95 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
system, name: name.trim() || selectedClass.name, kind,
ancestry: ancestry.trim(), className: selectedClass.name, level,
});
// For classes outside the curated tables (esp. PF2e), fill saves from data.
// Subclass mechanical grants (T-037) up to the starting level.
const grants = subclass ? sys.applySubclass(selectedClass.name, subclass, level) : undefined;
// For classes outside the curated tables (esp. PF2e), fill saves from data;
// a subclass may also grant save ranks.
const saveRanks: Record<string, ProficiencyRank> = { ...built.saveRanks };
if (Object.keys(saveRanks).length === 0 && selectedClass.saveRanks) {
for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank;
}
for (const [k, v] of Object.entries(grants?.saveRanks ?? {})) if (v) saveRanks[k] = v;
// Spells: chosen repertoire + subclass-granted/expanded spells (dedupe by name).
// Spell picks are cleared on class change, but only casters save spells.
const spells: SpellEntry[] = isCaster
? spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) }))
: [];
// Layer expertise (5e) and skill increases (pf2e) on top of the trained ranks.
const haveSpell = new Set(spells.map((s) => s.name.toLowerCase()));
for (const gs of grants?.expandedSpells ?? []) {
if (!haveSpell.has(gs.name.toLowerCase())) {
spells.push(newSpellEntry({ id: newId(), name: gs.name, level: Math.min(10, Math.max(0, gs.level)), notes: 'Subclass spell' }));
haveSpell.add(gs.name.toLowerCase());
}
}
// Layer expertise (5e), skill increases (pf2e), and subclass grants on top
// of the trained ranks.
const skillRanks: Record<string, ProficiencyRank> = { ...built.skillRanks };
for (const [k, v] of Object.entries(grants?.skillRanks ?? {})) if (v) skillRanks[k] = v;
for (const k of expertisePicks) if (k) skillRanks[k] = 'expert';
for (const k of skillIncPicks) if (k) skillRanks[k] = bumpRank(skillRanks[k] ?? 'untrained');
// Feats: chosen feats apply their ability bonuses (T-036); subclass features
// are stored as source:'subclass' feats so the sheet shows what they grant.
// The bonuses are also appended to the ability build so the sheet's
// per-source breakdown stays truthful.
let finalAbilities = abilities;
const abilityBuild = buildAbilityBuild();
const feats: CharacterFeat[] = [];
for (const pick of featPicks) {
const res = sys.applyFeat({ abilities: finalAbilities, level }, pick.feat, pick.choice);
finalAbilities = res.abilities;
for (const [k, v] of Object.entries(res.feat.abilityBonuses ?? {})) {
if (v) abilityBuild.adjustments.push({ label: res.feat.name, ability: k as AbilityKey, kind: 'flat', amount: Number(v) });
}
feats.push(res.feat);
}
for (const f of grants?.features ?? []) {
feats.push({ id: newId(), name: f.name, source: 'subclass', description: f.description, level: f.level, abilityBonuses: {} });
}
// Record the applied origin mechanics that have no dedicated sheet field
// (senses, traits, languages, background feat/feature) into notes (T-028).
// Background/heritage/alignment land in their own fields below.
const originLines: string[] = [];
if (originMech.senses.length) originLines.push(`Senses: ${originMech.senses.join(', ')}`);
if (originMech.traits.length) originLines.push(`Traits: ${originMech.traits.join(', ')}`);
if (originMech.languages.length) originLines.push(`Languages: ${originMech.languages.join(', ')}`);
if (curatedBg?.feat) originLines.push(`Background feat: ${curatedBg.feat}`);
if (curatedBg?.feature) originLines.push(`Background feature: ${curatedBg.feature}`);
const notes = originLines.join('\n');
// Starting equipment / gold (T-031): seed inventory + currency from the kit.
const kit = sys.startingKit(selectedClass.name);
const startingInventory: InventoryItem[] = gearChoice === 'kit' ? kit.items.map(toInventoryItem) : [];
const startingCurrency = { cp: 0, sp: 0, ep: 0, gp: gearChoice === 'gold' ? kit.gold : 0, pp: 0 };
await charactersRepo.update(created.id, {
...built,
abilities: finalAbilities,
abilityBuild,
subclass,
skillRanks,
abilityBuild: buildAbilityBuild(),
// Seed the multiclass model (T-055) with the starting class so later level-ups
// can add a second class; single-entry, so derived className/level are unchanged.
classes: [{ className: selectedClass.name, level, subclass }],
inventory: startingInventory,
currency: startingCurrency,
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }],
...(grants?.spellcastingAbility && !built.spellcastingAbility ? { spellcastingAbility: grants.spellcastingAbility } : {}),
...(grants?.spellcastingRank && !built.spellcastingRank ? { spellcastingRank: grants.spellcastingRank } : {}),
spellcasting: { ...built.spellcasting, spells },
...(feats.length ? { feats } : {}),
...(background ? { background } : {}),
...(heritage ? { heritage } : {}),
...(alignment.trim() ? { alignment: alignment.trim() } : {}),
...(appearance.trim() ? { appearance: appearance.trim() } : {}),
...(personality.trim() ? { personality: personality.trim() } : {}),
...(selectedOrigin?.speed ? { speed: selectedOrigin.speed } : {}),
...(notes ? { notes } : {}),
...(originMech.speed ? { speed: originMech.speed } : {}),
});
onClose();
void navigate({ to: '/characters/$characterId', params: { characterId: created.id } });
@@ -697,7 +933,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
<div className="mb-1 smallcaps">New here? Start from a ready-made hero</div>
<div className="flex flex-wrap gap-1">
{(TEMPLATES[system] ?? []).map((t) => (
<button key={t.label} onClick={() => applyTemplate(t)} title={t.hint} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent">{t.label}</button>
<button key={t.label} onClick={() => applyTemplate(t)} title={t.hint} className="inline-flex items-center gap-1 rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent"><Icon name={t.icon} size={14} />{t.label}</button>
))}
</div>
</div>
@@ -742,13 +978,13 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
);
})()}
{selectedClass && selectedClass.subclasses.length > 0 && (() => {
{selectedClass && subclassOptions.length > 0 && (() => {
const sc = selectedClass.subclasses.find((s) => s.name === subclass);
return (
<Field label={system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}>
<Field label={`${sys.terms.subclass} (optional)`}>
<Select value={subclass} onChange={(e) => setSubclass(e.target.value)}>
<option value="">— decide later —</option>
{selectedClass.subclasses.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
{subclassOptions.map((s) => <option key={s.name} value={s.name}>{s.name}{s.category ? ` (${s.category})` : ''}</option>)}
</Select>
{sc?.desc && (
<p className="mt-1 max-h-28 overflow-y-auto rounded-md border border-line bg-surface-2 p-2 text-xs text-muted">
@@ -767,7 +1003,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
<LoadFailed what={system === 'pf2e' ? 'ancestries & backgrounds' : 'races & backgrounds'} onRetry={retryLoad} />
)}
<div className="grid gap-4 sm:grid-cols-2">
<OriginPicker title={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
<OriginPicker title={sys.terms.ancestry} options={origins} value={ancestry} onPick={setAncestry} />
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
</div>
{system === 'pf2e' && ancestry.trim() && heritageOptions.length > 0 && (
@@ -783,6 +1019,22 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
)}
</div>
)}
{system === '5e' && curatedHeritageOptions.length > 0 && (
<Field label="Subrace (optional)">
<Select value={heritage} onChange={(e) => setHeritage(e.target.value)} aria-label="Subrace">
<option value="">— none —</option>
{curatedHeritageOptions.map((h) => <option key={h.name} value={h.name}>{h.name}</option>)}
</Select>
{selectedCuratedHeritage?.description && <p className="mt-1 text-xs text-muted">{selectedCuratedHeritage.description}</p>}
</Field>
)}
{(originMech.senses.length > 0 || originMech.traits.length > 0 || originMech.languages.length > 0) && (
<div className="rounded-md border border-line bg-surface-2 p-2 text-xs text-muted">
{originMech.senses.length > 0 && <div><span className="text-ink">Senses:</span> {originMech.senses.join(', ')}</div>}
{originMech.traits.length > 0 && <div><span className="text-ink">Traits:</span> {originMech.traits.join(', ')}</div>}
{originMech.languages.length > 0 && <div><span className="text-ink">Languages:</span> {originMech.languages.join(', ')}</div>}
</div>
)}
{!ancestry.trim() && (
<p className="text-xs text-warning">
No {system === 'pf2e' ? 'ancestry' : 'race'} selected — you can continue (handy for homebrew), but its ability bonuses, HP, speed, and skills won’t be applied.
@@ -942,7 +1194,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
</p>
<label className="mt-1 flex items-center gap-2 text-muted">
<input type="checkbox" checked={keepAsiForFeats} onChange={(e) => setKeepAsiForFeats(e.target.checked)} />
Leave {asiRemaining === 1 ? 'this point' : 'these points'} unspent — I’ll take {asiRemaining > 2 ? 'feats' : 'a feat'} instead (add it in the sheet’s Feats section after creation)
Leave {asiRemaining === 1 ? 'this point' : 'these points'} unspent — I’ll take {asiRemaining > 2 ? 'feats' : 'a feat'} instead (pick {asiRemaining > 2 ? 'them' : 'it'} on the Feats step)
</label>
</div>
)}
@@ -1082,7 +1334,10 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
{stepName === 'Spells' && (
<div>
<p className="mb-1 text-sm text-muted">Pick your cantrips and a few starting spells — about <span className="font-semibold text-ink">{suggestedSpells}</span> is a good start at level {level}. Search by name; you can always adjust on the sheet later.</p>
<p className="mb-1 text-sm text-muted">
Pick the spells your {selectedClass?.name ?? 'character'} starts knowing. The list is limited to your
{' '}{system === 'pf2e' ? 'tradition' : 'class spell list'} and the spell levels you can cast — about <span className="font-semibold text-ink">{suggestedSpells}</span> is a good start at level {level}. You can adjust on the sheet later.
</p>
<p className="mb-1 text-xs text-muted">
{(() => {
// Prepared casters re-select daily from a book/list; known casters have a
@@ -1096,16 +1351,32 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
);
})()}
</p>
<p className={cn('mb-2 text-xs', spellPicks.length > suggestedSpells + 4 ? 'text-warning' : 'text-muted')}>
{spellPicks.length} selected{spellPicks.length > suggestedSpells + 4 ? ' — that’s quite a few; you can trim some later if you like.' : ''}
</p>
{/* Count indicators: cantrips + leveled spells, against the class's caps. */}
<div className="mb-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">
{cantripCap > 0 && (
<span className={cn(cantripPicks > cantripCap ? 'text-warning' : 'text-muted')}>
Cantrips: <span className="font-semibold text-ink">{cantripPicks}</span> / {cantripCap}
</span>
)}
<span
className={cn(
leveledCap !== undefined
? (leveledPicks > leveledCap ? 'text-warning' : 'text-muted')
: (leveledPicks > suggestedSpells + 4 ? 'text-warning' : 'text-muted'),
)}
>
{system === 'pf2e' ? 'Spells' : 'Leveled spells'}: <span className="font-semibold text-ink">{leveledPicks}</span>
{leveledCap !== undefined ? ` / ${leveledCap}` : ` (about ${suggestedSpells} suggested)`}
</span>
</div>
<Input value={spellQuery} onChange={(e) => setSpellQuery(e.target.value)} placeholder="Search spells…" className="mb-2" aria-label="Search spells" />
<div className="grid max-h-64 grid-cols-1 gap-1 overflow-y-auto pr-1 sm:grid-cols-2">
{spellResults.map((s) => {
const checked = spellPicks.some((x) => x.name === s.name);
const disabled = !checked && spellAtCap(s);
return (
<label key={s.name} className={cn('flex items-center gap-2 rounded-md border px-2 py-1 text-sm', checked ? 'border-accent/60 bg-accent/5' : 'border-line')}>
<input type="checkbox" checked={checked} onChange={() => toggleSpell(s)} />
<label key={s.name} className={cn('flex items-center gap-2 rounded-md border px-2 py-1 text-sm', checked ? 'border-accent/60 bg-accent/5' : disabled ? 'border-line opacity-50' : 'border-line')}>
<input type="checkbox" checked={checked} disabled={disabled} onChange={() => toggleSpell(s)} />
<span className="flex-1 truncate text-ink">{s.name}</span>
<span className="text-[10px] text-muted">{s.level === 0 ? 'cantrip' : `lv ${s.level}`}</span>
</label>
@@ -1114,10 +1385,67 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
{allSpells.length === 0 && (loadError
? <LoadFailed what="spells" onRetry={retryLoad} />
: <p className="text-sm text-muted">Loading spells…</p>)}
{allSpells.length > 0 && spellResults.length === 0 && (
<p className="text-sm text-muted">No spells available at this level yet.</p>
)}
</div>
</div>
)}
{stepName === 'Feats' && (
<div>
<p className="mb-2 text-sm text-muted">
Optionally pick feats for your {selectedClass?.name ?? 'character'}. Feats whose prerequisites you don’t meet are disabled
{system === 'pf2e' ? '; PF2e feats also gate on their level.' : '.'} ({featPicks.length} selected — you can add more on the sheet later.)
</p>
{featPicks.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1">
{featPicks.map((p, i) => (
<span key={`${p.feat.id}-${i}`} className="inline-flex items-center gap-1 rounded-full border border-accent/40 bg-accent/5 px-2 py-0.5 text-xs text-ink">
{p.feat.name}{p.choice?.ability ? ` (+1 ${ABILITY_ABBR[p.choice.ability]})` : ''}
<button onClick={() => setFeatPicks((prev) => prev.filter((_, j) => j !== i))} aria-label={`Remove ${p.feat.name}`}><Icon name="X" size={14} /></button>
</span>
))}
</div>
)}
<FeatPicker
system={system}
feats={featDefs}
loading={featsLoading}
input={{ level, abilities }}
takenNames={new Set(featPicks.map((p) => p.feat.name))}
onPick={(feat, choice) => setFeatPicks((prev) => [...prev, { feat, ...(choice ? { choice } : {}) }])}
/>
</div>
)}
{stepName === 'Gear' && selectedClass && (() => {
const kit = sys.startingKit(selectedClass.name);
return (
<div className="space-y-3">
<p className="text-sm text-muted">Pick how your {selectedClass.name} starts out. Take the ready-made package, or take coin to shop for gear yourself later.</p>
<div className="flex gap-1" role="group" aria-label="Starting equipment choice">
{([['kit', 'Starting equipment'], ['gold', `Starting gold (${kit.gold} gp)`]] as const).map(([g, label]) => (
<button key={g} type="button" onClick={() => setGearChoice(g)} aria-pressed={gearChoice === g}
className={cn('rounded-md px-3 py-1.5 text-sm', gearChoice === g ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>{label}</button>
))}
</div>
{gearChoice === 'kit' ? (
<ul className="space-y-1 rounded-lg border border-line bg-surface p-3 text-sm">
{kit.items.map((it, i) => (
<li key={`${it.name}-${i}`} className="flex items-center justify-between">
<span className="text-ink">{it.name}{it.quantity && it.quantity > 1 ? ` ×${it.quantity}` : ''}</span>
{it.equip && <span className="rounded-full bg-elevated px-1.5 py-0.5 text-[10px] uppercase text-muted">equipped</span>}
</li>
))}
</ul>
) : (
<p className="rounded-lg border border-line bg-surface p-3 text-sm text-ink">You begin with <span className="font-semibold">{kit.gold} gp</span> and no gear — add equipment from the sheet or compendium afterward.</p>
)}
</div>
);
})()}
{stepName === 'Details' && (
<div className="space-y-3">
<p className="text-sm text-muted">Optional flavour — you can fill any of this in later on the sheet.</p>
@@ -1146,16 +1474,18 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
{stepName === 'Review' && selectedClass && (
<div className="space-y-3 text-sm">
<BuildLegality issues={legalityIssues} showOk />
<div className="rounded-lg border border-line bg-surface p-3">
<div className="font-medium text-ink">{name || selectedClass.name} — level {level} {[ancestry, selectedClass.name].filter(Boolean).join(' ')}{subclass ? ` (${subclass})` : ''}</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-center">
<Stat label="Max HP" value={built.hp.max} />
<Stat label="AC" value={sys.baseArmorClass({ level, abilities, armorBonus: 0 })} />
<Stat label="AC" value={sys.baseArmorClass({ level, abilities, armorBonus: 0, ...(built.armorProficiencyRank ? { armorProficiencyRank: built.armorProficiencyRank } : {}) })} />
</div>
</div>
<Review label="Abilities" value={ABILITIES.map((a) => `${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} />
{background && <Review label="Background" value={background} />}
{heritage && <Review label="Heritage" value={heritage} />}
{heritage && <Review label={system === '5e' ? 'Subrace' : 'Heritage'} value={heritage} />}
{originMech.senses.length > 0 && <Review label="Senses" value={originMech.senses.join(', ')} />}
<Review label="Trained skills" value={[
...skills.map(skillLabel),
...grantedSkillSources.map((g) => `${skillLabel(g.key)} (${g.source})`),
@@ -1163,6 +1493,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
].join(', ') || '—'} />
{built.spellcasting.slots.length > 0 && <Review label="Spell slots" value={built.spellcasting.slots.map((s) => `${s.max}×L${s.level}`).join(' ')} />}
{spellPicks.length > 0 && <Review label="Spells" value={spellPicks.map((s) => s.name).join(', ')} />}
{featPicks.length > 0 && <Review label="Feats" value={featPicks.map((p) => p.feat.name).join(', ')} />}
<Review label="Starting gear" value={gearChoice === 'gold' ? `${sys.startingKit(selectedClass.name).gold} gp` : sys.startingKit(selectedClass.name).items.map((it) => it.name).join(', ')} />
{originMech.speed !== undefined && <Review label="Speed" value={`${originMech.speed} ft`} />}
<p className="text-xs text-muted">You can fine-tune equipment, feats, and more on the sheet next.</p>
</div>
)}
@@ -0,0 +1,85 @@
import { useMemo, useState } from 'react';
import { getSystem, type AbilityKey, type CharacterRulesInput, type FeatDef, type FeatLike, type SystemId } from '@/lib/rules';
import { Input, Select } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Icon } from '@/components/ui/Icon';
/**
* Reusable feat selector (T-036). Filters by prerequisite via the RulesSystem
* (disabling unmet feats), surfaces half-feat ability bonuses + a choose-one
* dropdown, and calls onPick with the chosen feat (+ ability choice). Shared by
* the CreationWizard's Feats step and the LevelUpModal's feat branch.
*/
export function FeatPicker({ system, feats, input, takenNames, onPick, loading }: {
system: SystemId;
feats: FeatDef[];
input: CharacterRulesInput;
takenNames: Set<string>;
onPick: (feat: FeatDef, choice?: { ability?: AbilityKey }) => void;
loading?: boolean;
}) {
const sys = getSystem(system);
const [query, setQuery] = useState('');
const [choiceFor, setChoiceFor] = useState<Record<string, AbilityKey>>({});
// Already-taken feats, resolved from the loaded list, for PF2e archetype/dedication
// gating (T-055). The stored feat records carry no traits, so we recover them by
// name from the full FeatDef list and feed them into the prerequisite check.
const takenFeats = useMemo<FeatLike[]>(
() => feats.filter((f) => takenNames.has(f.name)).map((f) => ({
name: f.name,
...(f.traits ? { traits: f.traits } : {}),
...(f.archetypes ? { archetypes: f.archetypes } : {}),
})),
[feats, takenNames],
);
const gateInput = useMemo<CharacterRulesInput>(() => ({ ...input, takenFeats }), [input, takenFeats]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const list = q ? feats.filter((f) => f.name.toLowerCase().includes(q)) : feats;
return list.slice(0, 80);
}, [feats, query]);
if (loading) return <p className="text-sm text-muted">Loading feats…</p>;
if (feats.length === 0) return <p className="text-sm text-muted">No feats available for this system.</p>;
return (
<div className="space-y-2">
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search feats…" aria-label="Search feats" />
<ul className="max-h-72 space-y-1 overflow-auto rounded-md border border-line bg-surface p-1">
{filtered.map((f) => {
const { met, unmet } = sys.checkPrerequisites(f, gateInput);
const taken = takenNames.has(f.name);
const needsChoice = !!f.abilityChoice;
const choice = choiceFor[f.id] ?? f.abilityChoice?.from[0];
const bonuses = Object.entries(f.abilityBonuses);
return (
<li key={f.id} className="rounded-md p-2 hover:bg-elevated">
<div className="flex flex-wrap items-center gap-2">
<span className="font-display font-semibold text-ink">{f.name}</span>
{f.level !== undefined && <span className="text-[10px] text-muted">Lv {f.level}</span>}
{bonuses.length > 0 && <span className="text-[10px] text-info">{bonuses.map(([a, n]) => `+${n} ${a}`).join(', ')}</span>}
{!met && <span className="text-[10px] text-warning">needs {unmet.join(', ')}</span>}
{taken && <span className="inline-flex items-center gap-0.5 text-[10px] text-muted"><Icon name="Check" size={12} className="inline" /> taken</span>}
<span className="ml-auto flex items-center gap-1">
{needsChoice && (
<Select className="h-7 w-24 text-xs" aria-label={`${f.name} ability choice`}
value={choice} onChange={(e) => setChoiceFor((p) => ({ ...p, [f.id]: e.target.value as AbilityKey }))}>
{f.abilityChoice!.from.map((a) => <option key={a} value={a}>+{f.abilityChoice!.amount} {a}</option>)}
</Select>
)}
<Button size="sm" variant="primary" disabled={!met || taken}
onClick={() => onPick(f, needsChoice && choice ? { ability: choice } : undefined)}>
Take
</Button>
</span>
</div>
{f.prerequisiteText && <p className="mt-0.5 text-[11px] text-muted">Prereq: {f.prerequisiteText}</p>}
</li>
);
})}
</ul>
</div>
);
}
@@ -0,0 +1,130 @@
import { describe, it, expect } from 'vitest';
import type { AbilityScores } from '@/lib/rules';
import { getSystem } from '@/lib/rules';
import { parse5eAsi, parse5eSpeed, apply5eAsi, addAbilityBonuses, mapSkillNames, applyBoost, computePf2eAbilities } from './originMechanics';
describe('5e racial ASI parsing (T-027)', () => {
it('parses an ASI sentence into deltas', () => {
expect(parse5eAsi('Your Dexterity score increases by 2, and your Wisdom score increases by 1.')).toEqual({ dex: 2, wis: 1 });
expect(parse5eAsi('Your Constitution score increases by 2.')).toEqual({ con: 2 });
});
it('returns nothing for choice-based or empty ASIs', () => {
expect(parse5eAsi('Two ability scores of your choice increase by 1.')).toEqual({});
expect(parse5eAsi(undefined)).toEqual({});
});
it('applies ASIs capped at 20', () => {
const base: AbilityScores = { str: 10, dex: 19, con: 10, int: 10, wis: 20, cha: 10 };
const out = apply5eAsi(base, { dex: 2, wis: 1 });
expect(out.dex).toBe(20); // 19 + 2 capped at 20
expect(out.wis).toBe(20); // already 20
});
});
describe('5e speed + skill parsing', () => {
it('parses speed', () => {
expect(parse5eSpeed('base walking speed of 40 feet.')).toBe(40);
expect(parse5eSpeed('speed is 25 ft')).toBe(25);
expect(parse5eSpeed('')).toBeUndefined();
});
it('maps skill names to keys', () => {
const skills = [{ key: 'insight', label: 'Insight' }, { key: 'religion', label: 'Religion' }, { key: 'arcana', label: 'Arcana' }];
expect(mapSkillNames('Insight, Religion', skills)).toEqual(['insight', 'religion']);
expect(mapSkillNames(['Religion', 'Scribing Lore'], skills)).toEqual(['religion']); // Lore has no 5e key → dropped
});
});
describe('PF2e boost math (T-026/028)', () => {
it('applies +2 when ≤17 and +1 when ≥18', () => {
expect(applyBoost(10)).toBe(12);
expect(applyBoost(17)).toBe(19);
expect(applyBoost(18)).toBe(19);
});
it('produces a legal Dwarf Fighter array (not the 5e standard array)', () => {
// Dwarf: +Con +Wis +Free, flaw Cha; Fighter key Str; background Acolyte (Int/Wis).
const out = computePf2eAbilities({
ancestryBoosts: ['Constitution', 'Wisdom', 'Free'],
ancestryFlaws: ['Charisma'],
backgroundBoosts: ['Intelligence', 'Wisdom'],
classKeyAbilities: ['str'],
});
// Flaw: Cha 10 → 8. Every score is the result of boosts/flaws, never raw 10s-array.
expect(out.cha).toBe(8);
// Str gets the class boost (10 → 12) plus likely a free boost (it's the key ability).
expect(out.str).toBeGreaterThanOrEqual(12);
// Con gets the ancestry boost.
expect(out.con).toBeGreaterThanOrEqual(12);
// No score exceeds 18 at level 1 from boosts alone (each ability boosted at most once per batch).
for (const v of Object.values(out)) expect(v).toBeLessThanOrEqual(18);
// Total points spent are legal: sum should exceed a plain 60 (all-10s) after net boosts.
const sum = Object.values(out).reduce((a, b) => a + b, 0);
expect(sum).toBeGreaterThan(60);
});
it('never leaves all scores at the untouched baseline', () => {
const out = computePf2eAbilities({ ancestryBoosts: ['Strength', 'Free'], classKeyAbilities: ['dex'] });
expect(Object.values(out).some((v) => v !== 10)).toBe(true);
});
it('accepts ability abbreviations as well as full names (curated data uses "con")', () => {
const full = computePf2eAbilities({ ancestryBoosts: ['Constitution', 'Wisdom', 'Free'], ancestryFlaws: ['Charisma'], classKeyAbilities: ['str'] });
const abbr = computePf2eAbilities({ ancestryBoosts: ['con', 'wis', 'free'], ancestryFlaws: ['cha'], classKeyAbilities: ['str'] });
expect(abbr).toEqual(full);
expect(abbr.cha).toBe(8); // flaw applied via abbreviation
});
it('applies "two free boosts" ancestries (Human) via curated ["free","free"]', () => {
// Raw JSON prose ("Two free ability boosts") applies nothing; the curated
// ['free','free'] makes two boosts fire (T-028/030).
const out = computePf2eAbilities({ ancestryBoosts: ['free', 'free'], classKeyAbilities: ['str'] });
const sum = Object.values(out).reduce((a, b) => a + b, 0);
expect(sum).toBeGreaterThan(60); // baseline 60 + boosts
});
});
describe('addAbilityBonuses (heritage / fixed bonuses)', () => {
const base: AbilityScores = { str: 10, dex: 10, con: 10, int: 10, wis: 19, cha: 10 };
it('adds uncapped by default', () => {
expect(addAbilityBonuses(base, { wis: 1 }).wis).toBe(20);
expect(addAbilityBonuses(base, { wis: 5 }).wis).toBe(24);
});
it('respects a cap when given (5e ASI cap 20)', () => {
expect(addAbilityBonuses(base, { wis: 5 }, 20).wis).toBe(20);
});
});
describe('curated build data wired through RulesSystem (T-030/T-031)', () => {
it('5e exposes core races with structured ability bonuses + senses', () => {
const dwarf = getSystem('5e').listAncestries().find((a) => a.name === 'Dwarf');
expect(dwarf?.abilityBonuses).toEqual({ con: 2 });
expect(dwarf?.senses).toContain('darkvision');
expect((dwarf?.heritages ?? []).length).toBeGreaterThan(0);
});
it('pf2e curated ancestries carry boosts/flaw + heritages, applied legally', () => {
const elf = getSystem('pf2e').listAncestries().find((a) => a.name === 'Elf');
expect(elf?.boosts).toEqual(['dex', 'int', 'free']);
expect(elf?.flaws).toEqual(['con']);
// The curated boosts produce a legal PF2e array (boosts + flaw both consumed).
const out = computePf2eAbilities({ ancestryBoosts: elf!.boosts as string[], ancestryFlaws: elf!.flaws as string[], classKeyAbilities: ['dex'] });
expect(out.dex).toBeGreaterThanOrEqual(14); // ancestry + class boosts
expect(Math.max(...Object.values(out))).toBeLessThanOrEqual(18); // no over-boost at level 1
expect(Object.values(out).reduce((a, b) => a + b, 0)).toBeGreaterThan(60); // net positive
});
it('background tables grant trained skills (both systems)', () => {
expect(getSystem('5e').listBackgrounds().find((b) => b.name === 'Acolyte')?.grantedSkills).toEqual(['insight', 'religion']);
expect(getSystem('pf2e').listBackgrounds().find((b) => b.name === 'Acolyte')?.grantedSkills).toEqual(['religion']);
});
it('startingKit returns class gear with a real gold fallback', () => {
const fighter = getSystem('5e').startingKit('Fighter');
expect(fighter.items.length).toBeGreaterThan(0);
expect(fighter.items.some((i) => i.weapon)).toBe(true);
expect(fighter.gold).toBeGreaterThan(0);
// PF2e everyone gets 15 gp.
expect(getSystem('pf2e').startingKit('Wizard').gold).toBe(15);
// Unknown class still yields a usable default.
expect(getSystem('5e').startingKit('Homebrewer').items.length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,142 @@
import type { AbilityKey, AbilityScores } from '@/lib/rules';
/**
* Pure helpers that turn the (mostly free-text) origin data into the mechanical
* effects the builder must actually apply — racial ASIs, speed, and granted
* skills for 5e, and the PF2e boost/flaw math. Kept pure + tested because a wrong
* calculation here produces rules-ILLEGAL characters, the exact bug being fixed
* (T-026/027/029).
*/
const NAME_TO_KEY: Record<string, AbilityKey> = {
strength: 'str', dexterity: 'dex', constitution: 'con',
intelligence: 'int', wisdom: 'wis', charisma: 'cha',
};
/**
* Parse a 5e race ASI sentence into ability deltas, e.g.
* "Your Dexterity score increases by 2, and your Wisdom score increases by 1."
* → { dex: 2, wis: 1 }. Choice-based ASIs ("two abilities of your choice")
* carry no fixed delta and are intentionally not auto-applied.
*/
export function parse5eAsi(asi: string | undefined): Partial<Record<AbilityKey, number>> {
const out: Partial<Record<AbilityKey, number>> = {};
if (!asi) return out;
const re = /(strength|dexterity|constitution|intelligence|wisdom|charisma)\s+score\s+increases?\s+by\s+(\d+)/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(asi))) {
const key = NAME_TO_KEY[m[1]!.toLowerCase()];
if (key) out[key] = (out[key] ?? 0) + Number(m[2]);
}
return out;
}
/** Parse "...base walking speed of 40 feet." → 40. */
export function parse5eSpeed(speed: string | undefined): number | undefined {
const m = /(\d+)\s*(?:feet|ft)\b/i.exec(speed ?? '');
return m ? Number(m[1]) : undefined;
}
/** Add fixed ability bonuses to base scores, optionally capped (5e caps at 20). */
export function addAbilityBonuses(
base: AbilityScores,
bonuses: Partial<Record<AbilityKey, number>>,
cap = Infinity,
): AbilityScores {
const out = { ...base };
for (const k of Object.keys(bonuses) as AbilityKey[]) out[k] = Math.min(cap, out[k] + (bonuses[k] ?? 0));
return out;
}
/** Apply 5e racial ASIs to base scores, capped at 20 (PHB). */
export function apply5eAsi(base: AbilityScores, asi: Partial<Record<AbilityKey, number>>): AbilityScores {
return addAbilityBonuses(base, asi, 20);
}
/** Map free-text skill names ("Insight, Religion") to our skill keys. */
export function mapSkillNames(raw: string | string[] | undefined, skills: readonly { key: string; label: string }[]): string[] {
const names = Array.isArray(raw) ? raw : (raw ?? '').split(',');
const byLabel = new Map(skills.map((s) => [s.label.toLowerCase(), s.key]));
const out: string[] = [];
for (const n of names) {
const key = byLabel.get(n.trim().toLowerCase());
if (key && !out.includes(key)) out.push(key);
}
return out;
}
// ---------------- PF2e boosts ----------------
/** A single boost: +2 if the score is 17 or lower, otherwise +1 (Player Core). */
export function applyBoost(score: number): number {
return score + (score >= 18 ? 1 : 2);
}
const PF2E_DEFAULT_SPREAD: AbilityKey[] = ['con', 'dex', 'wis', 'str', 'int', 'cha'];
const ABBR_KEYS = new Set<string>(['str', 'dex', 'con', 'int', 'wis', 'cha']);
/** Accept a full name ("Constitution") or an abbreviation ("con") → AbilityKey. */
const toKey = (name: string): AbilityKey | undefined => {
const n = name.trim().toLowerCase();
return ABBR_KEYS.has(n) ? (n as AbilityKey) : NAME_TO_KEY[n];
};
export interface Pf2eBoostInput {
/** ancestry attribute boosts, e.g. ["Constitution","Wisdom","Free"] */
ancestryBoosts?: string[];
/** ancestry flaw(s), e.g. ["Charisma"] */
ancestryFlaws?: string[];
/** background boosts (a choice of two), e.g. ["Intelligence","Wisdom"] — first is auto-picked, plus one Free */
backgroundBoosts?: string[];
/** class key ability options; the first is auto-picked */
classKeyAbilities?: AbilityKey[];
}
/**
* Compute a LEGAL PF2e starting ability array (Player Core attribute rules):
* all scores start at 10; apply the ancestry flaw (−2), then ancestry/background/
* class boosts, then four level-1 free boosts. "Free"/choice boosts are
* auto-assigned toward the class key ability and a sensible spread (a legal array
* the player can refine), each free batch hitting distinct abilities. Each boost
* is +2 when ≤17, else +1; the array can never be the illegal 5e standard array.
*/
export function computePf2eAbilities(input: Pf2eBoostInput): AbilityScores {
const scores: AbilityScores = { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
const keyAbils = input.classKeyAbilities ?? [];
// 1. Ancestry flaw(s): a flat −2 (no partial rule).
for (const f of input.ancestryFlaws ?? []) { const k = toKey(f); if (k) scores[k] -= 2; }
const boost = (k: AbilityKey) => { scores[k] = applyBoost(scores[k]); };
// Priority order for auto-assigning free boosts: key abilities, then a spread.
const priority = (): AbilityKey[] => {
const seen = new Set<AbilityKey>();
const order: AbilityKey[] = [];
for (const k of [...keyAbils, ...PF2E_DEFAULT_SPREAD]) if (!seen.has(k)) { seen.add(k); order.push(k); }
return order;
};
// Pick `n` distinct abilities for a free batch, preferring higher priority.
const pickFree = (n: number, exclude: Set<AbilityKey> = new Set()): AbilityKey[] =>
priority().filter((k) => !exclude.has(k)).slice(0, n);
// 2. Ancestry boosts (named ones fixed; "Free" auto-assigned, distinct within the batch).
const ancestry = input.ancestryBoosts ?? [];
const fixedAncestry = ancestry.map(toKey).filter((k): k is AbilityKey => !!k);
for (const k of fixedAncestry) boost(k);
const ancestryFree = ancestry.filter((b) => b.trim().toLowerCase() === 'free').length;
for (const k of pickFree(ancestryFree, new Set(fixedAncestry))) boost(k);
// 3. Class key ability (auto-pick the first option).
if (keyAbils[0]) boost(keyAbils[0]);
// 4. Background: auto-pick the first listed boost + one free (distinct).
const bg = input.backgroundBoosts ?? [];
const bgFixed = toKey(bg[0] ?? '');
if (bgFixed) boost(bgFixed);
if (bg.length) for (const k of pickFree(1, new Set(bgFixed ? [bgFixed] : []))) boost(k);
// 5. Four level-1 free boosts, each to a distinct ability.
for (const k of pickFree(4)) boost(k);
return scores;
}
-8
View File
@@ -10,11 +10,3 @@ export function useCharacters(campaignId: string): Character[] {
export function useAllPcs(): Character[] {
return useLiveQuery(() => charactersRepo.listAllPcs(), [], []);
}
/**
* undefined = still loading; null = definitively not found. Mapping the miss to
* null lets pages show a real "not found" state instead of loading forever.
*/
export function useCharacter(id: string): Character | null | undefined {
return useLiveQuery(async () => (await charactersRepo.get(id)) ?? null, [id], undefined);
}
@@ -1,28 +1,28 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { newId } from '@/lib/ids';
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import { getSystem, ABILITY_ABBR, derivedAttacks } from '@/lib/rules';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
import type { Attack } from '@/lib/schemas';
import { formatModifier } from '@/lib/format';
import { rollAndShow } from '@/lib/useRoll';
import { critDamage } from '@/lib/dice/damage';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { Icon } from '@/components/ui/Icon';
import { SheetSection, type SectionProps } from './common';
import { rankLabel } from './labels';
import { WeaponPickerModal } from './WeaponPickerModal';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master', 'legendary'];
export function AttacksSection({ c, update }: SectionProps) {
const [name, setName] = useState('');
const [picking, setPicking] = useState(false);
const sys = getSystem(c.system);
const ranks = c.system === 'pf2e' ? RANKS_PF2E : RANKS_5E;
const ranks = sys.proficiencyRanks;
// skillRanks matters here: 5e passive Perception reads the perception skill's
// proficiency from it (omitting it silently dropped the proficiency bonus).
const rulesInput: CharacterRulesInput = {
@@ -30,6 +30,7 @@ export function AttacksSection({ c, update }: SectionProps) {
abilities: c.abilities,
skillRanks: c.skillRanks as Record<string, ProficiencyRank>,
};
const gearAttacks = derivedAttacks(rulesInput, c.inventory, sys);
const add = () => {
if (name.trim() === '') return;
@@ -50,9 +51,49 @@ export function AttacksSection({ c, update }: SectionProps) {
Passive Perception <span className="font-display text-lg font-semibold text-accent">{sys.passivePerception({ ...rulesInput, perceptionRank: c.perceptionRank })}</span>
</p>
{gearAttacks.length > 0 && (
<div className="mb-3">
<p className="mb-1.5 smallcaps text-[10px] text-muted">From equipped gear</p>
<ul className="space-y-2">
{gearAttacks.map((a) => (
<li key={a.id} className="flex flex-wrap items-center gap-2 rounded-md border border-line bg-elevated/40 px-3 py-2 text-sm">
<span className="min-w-28 flex-1 font-medium">{a.name}</span>
<Badge tone="default">{ABILITY_ABBR[a.ability]}</Badge>
<span className="ml-auto flex items-center gap-1 rounded bg-elevated px-2 py-1 text-sm">
<button
onClick={() => rollAndShow({ expression: `1d20${formatModifier(a.toHit)}`, label: `${a.name} — attack`, system: c.system, rollType: 'attack' })}
className="rounded px-1 font-display font-semibold text-accent hover:bg-accent/10"
title="Roll attack"
>
{formatModifier(a.toHit)}
</button>
<span className="text-muted">to hit ·</span>
<button
onClick={() => rollAndShow({ expression: a.damage, label: `${a.name} — damage` })}
className="rounded px-1 font-mono text-ink hover:bg-accent/10"
title="Roll damage"
>
{a.damage}{a.damageType ? ` ${a.damageType}` : ''}
</button>
<button
onClick={() => { const cd = critDamage(a.damage, c.system); rollAndShow({ expression: cd.expression, label: `${a.name} — crit damage`, ...(cd.multiplier !== 1 ? { multiplier: cd.multiplier } : {}) }); }}
className="rounded px-1 font-display font-semibold text-danger hover:bg-danger/10"
title="Roll critical damage"
aria-label="Roll critical damage"
>
<Icon name="Zap" />
</button>
</span>
</li>
))}
</ul>
<p className="mt-1 text-[11px] text-muted">Equip a weapon in Inventory to add it here automatically.</p>
</div>
)}
<div className="mb-3 flex items-end gap-2">
<label className="flex-1 min-w-40 text-xs text-muted">
New attack
New manual attack
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Longsword, Shortbow…" />
</label>
<Button variant="secondary" onClick={() => setPicking(true)}>From weapon…</Button>
@@ -117,8 +158,16 @@ export function AttacksSection({ c, update }: SectionProps) {
>
{result.damage}{a.damageType ? ` ${a.damageType}` : ''}
</button>
<button
onClick={() => { const cd = critDamage(result.damage, c.system); rollAndShow({ expression: cd.expression, label: `${a.name} — crit damage`, ...(cd.multiplier !== 1 ? { multiplier: cd.multiplier } : {}) }); }}
className="rounded px-1 font-display font-semibold text-danger hover:bg-danger/10"
title="Roll critical damage"
aria-label="Roll critical damage"
>
<Icon name="Zap" />
</button>
</span>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(a.id)} aria-label={`Remove ${a.name}`}><X size={14} aria-hidden /></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(a.id)} aria-label={`Remove ${a.name}`}><Icon name="X" /></Button>
</li>
);
})}
@@ -0,0 +1,51 @@
import type { LegalityIssue } from '@/lib/rules';
import { Icon } from '@/components/ui/Icon';
import { cn } from '@/lib/cn';
/**
* Presentational banner for build-legality issues (T-045). Errors are blocking
* problems (e.g. attunement over the cap); warnings are advisory. Renders nothing
* when the build is clean unless `showOk` asks for a confirmation note.
*/
export function BuildLegality({
issues,
showOk = false,
className,
}: {
issues: LegalityIssue[];
showOk?: boolean;
className?: string;
}) {
if (issues.length === 0) {
if (!showOk) return null;
return (
<div className={cn('rounded-md border border-success/40 bg-success/5 px-3 py-2 text-xs text-success', className)}>
Build looks legal.
</div>
);
}
const hasError = issues.some((i) => i.severity === 'error');
return (
<div
role="status"
className={cn(
'rounded-md border px-3 py-2 text-xs',
hasError ? 'border-danger/40 bg-danger/5' : 'border-warning/40 bg-warning/5',
className,
)}
>
<div className={cn('mb-1 font-semibold uppercase tracking-wide', hasError ? 'text-danger' : 'text-warning')}>
Build check
</div>
<ul className="space-y-0.5">
{issues.map((issue, i) => (
<li key={i} className={cn('flex gap-1.5', issue.severity === 'error' ? 'text-danger' : 'text-warning')}>
<span aria-hidden>{issue.severity === 'error' ? <Icon name="X" size={14} className="inline" /> : '!'}</span>
<span className="text-ink/90">{issue.message}</span>
</li>
))}
</ul>
</div>
);
}
@@ -14,7 +14,7 @@ export function ClassFeaturesSection({ c, update }: SectionProps) {
const classes: ClassEntry[] = c.classes.length
? c.classes
: c.className
? [{ className: c.className, level: c.level, ...(c.system === 'pf2e' ? { subclass: c.choices.find((ch) => ch.key.endsWith(':subclass'))?.values[0] } : {}) }]
? [{ className: c.className, level: c.level, subclass: (c.system === 'pf2e' ? c.choices.find((ch) => ch.key.endsWith(':subclass'))?.values[0] : c.subclass) ?? '' }]
: [];
const features = collectFeatures(c.system, classes);
const choices = collectChoices(c.system, classes);
@@ -30,7 +30,7 @@ export function ClassesEditor({ c, update }: { c: Character; update: (p: Partial
// Fall back to the legacy className/level for any character not yet on classes[].
const classes: ClassEntry[] = c.classes.length
? c.classes
: c.className ? [{ className: c.className, level: c.level }] : [];
: c.className ? [{ className: c.className, level: c.level, subclass: c.subclass }] : [];
const names = getClassNames('5e');
const total = classes.reduce((n, e) => n + (e.level || 0), 0);
@@ -51,7 +51,7 @@ export function ClassesEditor({ c, update }: { c: Character; update: (p: Partial
};
const patchEntry = (i: number, p: Partial<ClassEntry>) => commit(classes.map((e, j) => (j === i ? { ...e, ...p } : e)));
const addClass = () => commit([...classes, { className: names.find((n) => !classes.some((e) => e.className === n)) ?? 'Fighter', level: 1 }]);
const addClass = () => commit([...classes, { className: names.find((n) => !classes.some((e) => e.className === n)) ?? 'Fighter', level: 1, subclass: '' }]);
const removeClass = (i: number) => commit(classes.filter((_, j) => j !== i));
const recalcHp = () => {
@@ -75,13 +75,13 @@ export function ClassesEditor({ c, update }: { c: Character; update: (p: Partial
<div key={i} className="flex flex-wrap items-center gap-1.5 rounded-md border border-line bg-panel px-2 py-1.5">
<Input className="h-8 min-w-28 flex-1" list="dnd5e-class-names" value={e.className} onChange={(ev) => patchEntry(i, { className: ev.target.value })} aria-label={`Class ${i + 1}`} placeholder="Class" />
{subs.length > 0 ? (
<Select className="h-8 w-auto py-0 text-xs" value={e.subclass ?? ''} onChange={(ev) => patchEntry(i, { subclass: ev.target.value || undefined })} aria-label={`Subclass ${i + 1}`}>
<Select className="h-8 w-auto py-0 text-xs" value={e.subclass ?? ''} onChange={(ev) => patchEntry(i, { subclass: ev.target.value })} aria-label={`Subclass ${i + 1}`}>
<option value="">— subclass —</option>
{subs.some((s) => s.name === e.subclass) ? null : e.subclass ? <option value={e.subclass}>{e.subclass}</option> : null}
{subs.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
</Select>
) : (
<Input className="h-8 w-28 text-xs" value={e.subclass ?? ''} onChange={(ev) => patchEntry(i, { subclass: ev.target.value || undefined })} aria-label={`Subclass ${i + 1}`} placeholder="Subclass" />
<Input className="h-8 w-28 text-xs" value={e.subclass ?? ''} onChange={(ev) => patchEntry(i, { subclass: ev.target.value })} aria-label={`Subclass ${i + 1}`} placeholder="Subclass" />
)}
<label className="text-[10px] text-muted">lvl<NumberField className="ml-1 w-12" value={e.level} min={1} max={20} onChange={(v) => patchEntry(i, { level: v })} aria-label={`${e.className} level`} /></label>
{classes.length > 1 && (
@@ -1,12 +1,15 @@
import { useState } from 'react';
import { Minus, Plus, Skull, Star } from 'lucide-react';
import { Skull } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Icon } from '@/components/ui/Icon';
import { NumberField } from '@/components/ui/NumberField';
import { getSystem, type HeroResource } from '@/lib/rules';
import { maxDying, isDead, recoveryDc, knockOut, applyRecovery, heroPointRescue, rollDeathSave } from '@/lib/mechanics';
import { rollAndShow } from '@/lib/useRoll';
import type { Degree } from '@/lib/dice/check';
import type { Condition } from '@/lib/schemas/common';
import type { Defenses } from '@/lib/schemas';
import { SheetSection, type SectionProps } from './common';
function Pips({ count, filled, onToggle, label }: { count: number; filled: number; onToggle: (n: number) => void; label: string }) {
@@ -28,11 +31,35 @@ function Pips({ count, filled, onToggle, label }: { count: number; filled: numbe
);
}
/**
* Heroic meta-currency through the seam (M2-R8): Inspiration / Hero Points
* render from the same generic control — no system branch.
*/
function HeroField({ hero, d, setD }: { hero: HeroResource; d: Defenses; setD: (p: Partial<Defenses>) => void }) {
return (
<Field label={hero.label}>
<div className="flex items-center gap-2">
{hero.max === 1 ? (
<Button size="sm" variant={hero.of(d) > 0 ? 'primary' : 'secondary'} onClick={() => setD(hero.field(hero.of(d) > 0 ? 0 : 1))}>
{hero.of(d) > 0
? <span className="inline-flex items-center gap-1"><Icon name="Star" size={14} className="inline" />Inspired</span>
: `Grant ${hero.label.toLowerCase()}`}
</Button>
) : (
<Pips count={hero.max} filled={hero.of(d)} label={hero.label} onToggle={(n) => setD(hero.field(n))} />
)}
<span className="text-xs text-muted">{hero.spendEffect === 'advantage' ? 'spend for advantage' : 'spend to reroll'}</span>
</div>
</Field>
);
}
/** PF2e dying/wounded/doomed with knock-out + human-rolled recovery checks. */
function Pf2eDefenses({ c, update }: SectionProps) {
const d = c.defenses;
const [fromCrit, setFromCrit] = useState(false);
const dead = isDead(d.dying, d.doomed);
const hero = getSystem(c.system).heroResource;
const withUnconscious = (conds: Condition[]): Condition[] =>
conds.some((x) => x.name.trim().toLowerCase() === 'unconscious') ? conds : [...conds, { name: 'Unconscious' }];
@@ -77,14 +104,8 @@ function Pf2eDefenses({ c, update }: SectionProps) {
<Field label="Doomed">
<NumberField className="w-20" value={d.doomed} min={0} max={3} onChange={(v) => setDefenses({ doomed: v })} aria-label="Doomed value" />
</Field>
<Field label="Hero Points (max 3)">
<div className="flex items-center gap-2">
<Button size="icon" variant="ghost" onClick={() => setDefenses({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point"><Minus size={14} aria-hidden /></Button>
<span className="font-display text-xl font-semibold text-accent">{d.heroPoints}</span>
{/* PF2e RAW: a character can never have more than 3 Hero Points. */}
<Button size="icon" variant="ghost" disabled={d.heroPoints >= 3} onClick={() => setDefenses({ heroPoints: Math.min(3, d.heroPoints + 1) })} aria-label="Gain hero point"><Plus size={14} aria-hidden /></Button>
</div>
</Field>
{/* PF2e RAW: a character can never have more than 3 Hero Points (hero.max). */}
{hero && <HeroField hero={hero} d={d} setD={setDefenses} />}
<div className="sm:col-span-2 rounded-md border border-line bg-panel px-3 py-2">
{d.dying === 0 ? (
@@ -126,6 +147,8 @@ function Pf2eDefenses({ c, update }: SectionProps) {
export function DefensesSection({ c, update }: SectionProps) {
const d = c.defenses;
const setD = (p: Partial<typeof d>) => update({ defenses: { ...d, ...p } });
const sys = getSystem(c.system);
const hero = sys.heroResource;
// 5e death save: rolled ONLY from the explicit button below (never automatic).
// The d20 face drives the full PHB outcome via the tested rollDeathSave kernel.
const [deathMsg, setDeathMsg] = useState<string | null>(null);
@@ -139,7 +162,7 @@ export function DefensesSection({ c, update }: SectionProps) {
return (
<SheetSection title="Status & Defenses">
{c.system === '5e' ? (
{sys.deathAndDying.downedMechanic === 'death-saves' ? (
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Death Saves">
<div className="flex items-center gap-4">
@@ -171,11 +194,7 @@ export function DefensesSection({ c, update }: SectionProps) {
{deathMsg && <span className="text-xs text-muted" aria-live="polite">{deathMsg}</span>}
</div>
</Field>
<Field label="Inspiration">
<Button size="sm" variant={d.inspiration ? 'primary' : 'secondary'} onClick={() => setD({ inspiration: !d.inspiration })}>
{d.inspiration ? <><Star size={13} aria-hidden /> Inspired</> : 'Grant inspiration'}
</Button>
</Field>
{hero && <HeroField hero={hero} d={d} setD={setD} />}
<Field label="Exhaustion (0–6)">
<NumberField className="w-20" value={d.exhaustion} min={0} max={6} onChange={(v) => setD({ exhaustion: v })} aria-label="Exhaustion level" />
{d.exhaustion >= 4 && <span className="ml-2 text-[11px] text-warning">Max HP halved</span>}
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { loadFeats5e, loadPf2e } from '@/lib/compendium';
import type { CompendiumEntry, Feat5e } from '@/lib/compendium/types';
import { sourceLabel } from '@/lib/compendium/mpmb';
import { newId } from '@/lib/ids';
import type { SystemId } from '@/lib/rules';
import type { Feat } from '@/lib/schemas';
@@ -20,7 +19,8 @@ interface FeatRow {
}
function row5e(f: Feat5e): FeatRow {
const src = f.source?.[0]?.source ? sourceLabel(f.source[0].source) : '';
// loadFeats5e already expands source codes into human-readable attribution.
const src = f.source ?? '';
return { key: f.name, name: f.name, source: src, tag: src, description: f.description ?? '' };
}
@@ -53,7 +53,7 @@ export function FeatPickerModal({ system = '5e', onPick, onClose }: { system?: S
}, [feats, q]);
const pick = (f: FeatRow) => {
onPick({ id: newId(), name: f.name, source: f.source, description: f.description });
onPick({ id: newId(), name: f.name, source: f.source, description: f.description, abilityBonuses: {} });
onClose();
};
@@ -14,7 +14,7 @@ export function FeatsSection({ c, update }: SectionProps) {
const add = () => {
if (name.trim() === '') return;
const feat: Feat = { id: newId(), name: name.trim(), source: '', description: '' };
const feat: Feat = { id: newId(), name: name.trim(), source: '', description: '', abilityBonuses: {} };
update({ feats: [...c.feats, feat] });
setName('');
};
@@ -1,12 +1,15 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { newId } from '@/lib/ids';
import { getSystem } from '@/lib/rules';
import type { InventoryItem } from '@/lib/schemas';
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import type { AbilityKey, ProficiencyRank } from '@/lib/rules';
import type { InventoryItem, ItemArmor, ItemShield, ItemWeapon } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Icon } from '@/components/ui/Icon';
import { Badge } from '@/components/ui/Codex';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { Checkbox } from '@/components/ui/Checkbox';
import { cn } from '@/lib/cn';
import { SheetSection, type SectionProps } from './common';
@@ -18,16 +21,33 @@ const COINS = [
{ key: 'cp', label: 'CP' },
] as const;
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
type GearType = 'none' | 'weapon' | 'armor' | 'shield';
const gearTypeOf = (i: InventoryItem): GearType =>
i.weapon ? 'weapon' : i.armor ? 'armor' : i.shield ? 'shield' : 'none';
const defaultWeapon = (): ItemWeapon => ({
damageDice: '1d6', damageType: '', finesse: false, ranged: false, rank: 'trained', itemBonus: 0, addAbilityToDamage: true,
});
const defaultArmor = (): ItemArmor => ({ category: 'light', baseAc: 11 });
const defaultShield = (): ItemShield => ({ acBonus: 2 });
export function InventorySection({ c, update }: SectionProps) {
const [name, setName] = useState('');
const [qty, setQty] = useState(1);
const [weight, setWeight] = useState(0);
const [openGear, setOpenGear] = useState<string | null>(null);
const sys = getSystem(c.system);
const capacity = sys.carryingCapacity({ level: c.level, abilities: c.abilities });
const totalWeight = c.inventory.reduce((sum, i) => sum + i.quantity * i.weight, 0);
const encumbered = totalWeight > capacity.encumbered;
const overMax = totalWeight > capacity.max;
const ranks = sys.proficiencyRanks;
const load = sys.encumbrance({ level: c.level, abilities: c.abilities, ...(c.size ? { size: c.size } : {}) }, c.inventory);
const capacity = load.capacity;
// Attunement is system-specific (5e caps at 3; PF2e has none) — read it from the
// RulesSystem seam rather than branching on the system id (T-045).
const attunementLimit = sys.attunementLimit;
const attunedCount = c.inventory.filter((i) => i.attuned).length;
const addItem = () => {
if (name.trim() === '') return;
@@ -51,6 +71,20 @@ export function InventorySection({ c, update }: SectionProps) {
};
const removeItem = (id: string) => update({ inventory: c.inventory.filter((i) => i.id !== id) });
const setGearType = (item: InventoryItem, t: GearType) => {
const cleared: Partial<InventoryItem> = { weapon: undefined, armor: undefined, shield: undefined };
if (t === 'weapon') patchItem(item.id, { ...cleared, weapon: defaultWeapon(), equipped: true });
else if (t === 'armor') patchItem(item.id, { ...cleared, armor: defaultArmor(), equipped: true });
else if (t === 'shield') patchItem(item.id, { ...cleared, shield: defaultShield(), equipped: true });
else patchItem(item.id, cleared);
};
const patchWeapon = (item: InventoryItem, p: Partial<ItemWeapon>) =>
patchItem(item.id, { weapon: { ...(item.weapon ?? defaultWeapon()), ...p } });
const patchArmor = (item: InventoryItem, p: Partial<ItemArmor>) =>
patchItem(item.id, { armor: { ...(item.armor ?? defaultArmor()), ...p } });
const patchShield = (item: InventoryItem, p: Partial<ItemShield>) =>
patchItem(item.id, { shield: { ...(item.shield ?? defaultShield()), ...p } });
const setCoin = (key: (typeof COINS)[number]['key'], value: number) =>
update({ currency: { ...c.currency, [key]: value } });
@@ -73,11 +107,29 @@ export function InventorySection({ c, update }: SectionProps) {
</div>
{/* Encumbrance */}
<div className={cn('mb-3 text-sm', overMax ? 'text-danger' : encumbered ? 'text-warning' : 'text-muted')}>
Carried: <span className="font-medium">{totalWeight.toLocaleString()}</span> / {capacity.max.toLocaleString()} {capacity.unit}
{overMax ? ' — over maximum!' : encumbered ? ' — encumbered' : ''}
<div className={cn('mb-3 text-sm', load.tier === 'overloaded' || load.tier === 'heavily-encumbered' ? 'text-danger' : load.tier === 'encumbered' ? 'text-warning' : 'text-muted')}>
Carried: <span className="font-medium">{load.total.toLocaleString()}</span> / {capacity.max.toLocaleString()} {capacity.unit}
{load.tier === 'overloaded'
? ` — over maximum! (−${load.speedPenalty} ft speed)`
: load.tier === 'heavily-encumbered'
? ` — heavily encumbered (−${load.speedPenalty} ft speed)`
: load.tier === 'encumbered'
? ` — encumbered (−${load.speedPenalty} ft speed)`
: ''}
</div>
{/* Attunement cap (systems that have one) */}
{attunementLimit !== undefined && (
<div className={cn('mb-3 text-sm', attunedCount > attunementLimit ? 'text-danger' : attunedCount === attunementLimit ? 'text-warning' : 'text-muted')}>
Attunement: <span className="font-medium">{attunedCount}</span> / {attunementLimit}
{attunedCount > attunementLimit
? ' — over the limit! Un-attune an item.'
: attunedCount === attunementLimit
? ' — at the limit.'
: ''}
</div>
)}
{/* Add item */}
<div className="mb-3 flex flex-wrap items-end gap-2">
<label className="flex-1 min-w-40 text-xs text-muted">
@@ -100,29 +152,119 @@ export function InventorySection({ c, update }: SectionProps) {
<p className="text-sm text-muted">No items yet.</p>
) : (
<ul className="space-y-1">
{c.inventory.map((item) => (
<li key={item.id} className="flex flex-wrap items-center gap-2 rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
<Input
className="h-8 min-w-32 flex-1"
value={item.name}
onChange={(e) => patchItem(item.id, { name: e.target.value })}
aria-label="Item name"
/>
<label className="text-xs text-muted">×<NumberField className="ml-1 w-14 inline-block" value={item.quantity} min={0} onChange={(v) => patchItem(item.id, { quantity: v })} aria-label="Quantity" /></label>
<label className="text-xs text-muted">{item.weight * item.quantity} {capacity.unit}</label>
<label className="flex items-center gap-1 text-xs text-muted">
<input type="checkbox" checked={item.equipped} onChange={(e) => patchItem(item.id, { equipped: e.target.checked })} />
Equipped
</label>
{c.system === '5e' && (
<label className="flex items-center gap-1 text-xs text-muted">
<input type="checkbox" checked={item.attuned} onChange={(e) => patchItem(item.id, { attuned: e.target.checked })} />
Attuned
</label>
)}
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeItem(item.id)} aria-label={`Remove ${item.name}`}><X size={14} aria-hidden /></Button>
</li>
))}
{c.inventory.map((item) => {
const type = gearTypeOf(item);
const open = openGear === item.id;
return (
<li key={item.id} className="rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Input
className="h-8 min-w-32 flex-1"
value={item.name}
onChange={(e) => patchItem(item.id, { name: e.target.value })}
aria-label="Item name"
/>
{type !== 'none' && <Badge tone="gold">{type}</Badge>}
<label className="text-xs text-muted">×<NumberField className="ml-1 w-14 inline-block" value={item.quantity} min={0} onChange={(v) => patchItem(item.id, { quantity: v })} aria-label="Quantity" /></label>
<label className="text-xs text-muted">{item.weight * item.quantity} {capacity.unit}</label>
<label className="flex items-center gap-1 text-xs text-muted">
<Checkbox checked={item.equipped} onChange={(e) => patchItem(item.id, { equipped: e.target.checked })} />
Equipped
</label>
{attunementLimit !== undefined && (() => {
const blocked = !item.attuned && attunedCount >= attunementLimit;
return (
<label
className={cn('flex items-center gap-1 text-xs text-muted', blocked && 'opacity-50')}
title={blocked ? `You can attune to at most ${attunementLimit} items.` : undefined}
>
<Checkbox
checked={item.attuned}
disabled={blocked}
onChange={(e) => patchItem(item.id, { attuned: e.target.checked })}
/>
Attuned
</label>
);
})()}
<Button
size="sm"
variant={open ? 'secondary' : 'ghost'}
onClick={() => setOpenGear(open ? null : item.id)}
title="Set armor / weapon / shield stats so this item drives AC and attacks"
aria-label={`Gear stats for ${item.name}`}
>
<Icon name="Settings" size={14} className="inline" /> Gear
</Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeItem(item.id)} aria-label={`Remove ${item.name}`}><Icon name="X" /></Button>
</div>
{open && (
<div className="mt-2 space-y-2 rounded-md border border-dashed border-line bg-surface-2 p-2.5">
<div className="flex flex-wrap items-center gap-3 text-xs text-muted">
Acts as:
{(['none', 'weapon', 'armor', 'shield'] as GearType[]).map((t) => (
<label key={t} className="flex items-center gap-1">
<input type="radio" name={`gear-${item.id}`} checked={type === t} onChange={() => setGearType(item, t)} />
{t === 'none' ? 'Plain item' : t.charAt(0).toUpperCase() + t.slice(1)}
</label>
))}
</div>
{type === 'weapon' && item.weapon && (
<div className="flex flex-wrap items-end gap-2">
<label className="text-xs text-muted">dice<Input className="ml-1 inline-block h-8 w-20" value={item.weapon.damageDice} onChange={(e) => patchWeapon(item, { damageDice: e.target.value })} aria-label="Damage dice" /></label>
<label className="text-xs text-muted">type<Input className="ml-1 inline-block h-8 w-24" value={item.weapon.damageType} onChange={(e) => patchWeapon(item, { damageType: e.target.value })} aria-label="Damage type" placeholder="slashing" /></label>
<label className="text-xs text-muted">ability
<Select className="ml-1 w-auto py-1" value={item.weapon.ability ?? 'auto'} onChange={(e) => patchWeapon(item, { ability: e.target.value === 'auto' ? undefined : (e.target.value as AbilityKey) })} aria-label="Attack ability">
<option value="auto">auto</option>
{ABILITIES.map((ab) => <option key={ab} value={ab}>{ABILITY_ABBR[ab]}</option>)}
</Select>
</label>
<label className="text-xs text-muted">prof
<Select className="ml-1 w-auto py-1" value={item.weapon.rank} onChange={(e) => patchWeapon(item, { rank: e.target.value as ProficiencyRank })} aria-label="Weapon proficiency">
{ranks.map((r) => <option key={r} value={r}>{r}</option>)}
</Select>
</label>
<label className="text-xs text-muted">+item<NumberField className="ml-1 w-14" value={item.weapon.itemBonus} onChange={(v) => patchWeapon(item, { itemBonus: v })} aria-label="Item bonus" /></label>
<label className="flex items-center gap-1 text-xs text-muted"><Checkbox checked={item.weapon.finesse} onChange={(e) => patchWeapon(item, { finesse: e.target.checked })} />finesse</label>
<label className="flex items-center gap-1 text-xs text-muted"><Checkbox checked={item.weapon.ranged} onChange={(e) => patchWeapon(item, { ranged: e.target.checked })} />ranged</label>
<label className="flex items-center gap-1 text-xs text-muted"><Checkbox checked={item.weapon.addAbilityToDamage} onChange={(e) => patchWeapon(item, { addAbilityToDamage: e.target.checked })} />add ability to dmg</label>
</div>
)}
{type === 'armor' && item.armor && (
<div className="flex flex-wrap items-end gap-2">
<label className="text-xs text-muted">category
<Select className="ml-1 w-auto py-1" value={item.armor.category} onChange={(e) => patchArmor(item, { category: e.target.value as ItemArmor['category'] })} aria-label="Armor category">
<option value="light">light</option>
<option value="medium">medium</option>
<option value="heavy">heavy</option>
</Select>
</label>
<label className="text-xs text-muted">{c.system === 'pf2e' ? 'AC item bonus' : 'base AC'}<NumberField className="ml-1 w-16" value={item.armor.baseAc} min={0} onChange={(v) => patchArmor(item, { baseAc: v })} aria-label="Base AC" /></label>
<label className="flex items-center gap-1 text-xs text-muted">
<Checkbox checked={item.armor.maxDex !== undefined} onChange={(e) => patchArmor(item, { maxDex: e.target.checked ? (item.armor?.maxDex ?? 2) : undefined })} />
Dex cap
</label>
{item.armor.maxDex !== undefined && (
<NumberField className="w-14" value={item.armor.maxDex} min={0} max={10} onChange={(v) => patchArmor(item, { maxDex: v })} aria-label="Max Dex bonus" />
)}
</div>
)}
{type === 'shield' && item.shield && (
<label className="text-xs text-muted">AC bonus<NumberField className="ml-1 w-16" value={item.shield.acBonus} min={0} onChange={(v) => patchShield(item, { acBonus: v })} aria-label="Shield AC bonus" /></label>
)}
{type !== 'none' && !item.equipped && (
<p className="text-[11px] text-warning">Not equipped — it won’t affect AC or attacks until you check “Equipped”.</p>
)}
</div>
)}
</li>
);
})}
</ul>
)}
</SheetSection>
+320 -121
View File
@@ -1,22 +1,25 @@
import { useEffect, useMemo, useState } from 'react';
import type { Campaign, Character, ClassEntry, Feat, Spellcasting } from '@/lib/schemas';
import { normalizeClassMirror, totalLevel } from '@/lib/schemas';
import type { Campaign, Character, CharacterClassEntry, CharacterFeat, SpellEntry } from '@/lib/schemas';
import { characterClasses, newSpellEntry } from '@/lib/schemas';
import {
abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank,
planLevelUp, appendLevelIncreases, bumpRank, getClassDef, hitDiceResource,
type FeatDef, type FeatTrack, planLevelUp, appendLevelIncreases, bumpRank, getClassDef, hitDiceResource,
multiclassSpellcasting, type SpellSlot, pf2eAdvanceProficiencies,
collectChoices, collectFeatures, subclassPrompt, type UnlockedFeature,
} from '@/lib/rules';
import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression';
import { loadPf2e } from '@/lib/compendium';
import { loadFeats, loadPf2e } from '@/lib/compendium';
import { newId } from '@/lib/ids';
import { rollDice } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { FeatPickerModal } from './FeatPickerModal';
import { FeatPicker } from '@/features/characters/builder/FeatPicker';
import { LevelUpAdvisor } from './LevelUpAdvisor';
import { incAllowed, conRetroHpBonus } from './levelUpMath';
const TRACK_LABEL: Record<FeatTrack, string> = { class: 'Class feat', ancestry: 'Ancestry feat', skill: 'Skill feat', general: 'General feat' };
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
export function LevelUpModal({ character, onApply, onClose }: {
@@ -25,34 +28,90 @@ export function LevelUpModal({ character, onApply, onClose }: {
onClose: () => void;
}) {
const conMod = abilityModifier(character.abilities.con);
const sys = getSystem(character.system);
const is5e = character.system === '5e';
// 5e: level up a SPECIFIC class (or multiclass into a new one). pf2e stays single-class.
const classList: ClassEntry[] = useMemo(
() => (character.classes.length
? character.classes
: character.className ? [{ className: character.className, level: character.level }] : []),
[character.classes, character.className, character.level],
);
const NEW = '__new__';
const [levelClass, setLevelClass] = useState<string>(classList[0]?.className ?? character.className);
const [newClassName, setNewClassName] = useState<string>(getClassNames('5e').find((n) => !classList.some((e) => e.className === n)) ?? 'Fighter');
const levelingName = is5e ? (levelClass === NEW ? newClassName : levelClass) : character.className;
const classCurrentLevel = is5e ? (classList.find((e) => e.className === levelingName)?.level ?? 0) : character.level;
// Multiclassing (T-055): 5e characters can advance an existing class or take a
// new one. PF2e multiclasses via archetype Dedication feats (handled in the feat
// picker), so the class selector is 5e-only. The list is materialized so even a
// legacy single-class sheet (empty `classes`) participates.
const classesNow = useMemo(() => characterClasses(character), [character]);
const supportsClassChoice = is5e;
const sameClass = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase();
const total = totalLevel(character);
const plan = useMemo(
() => planLevelUp(character.system, levelingName, classCurrentLevel, conMod, Math.min(20, total + 1)),
[character.system, levelingName, classCurrentLevel, conMod, total],
const [targetClass, setTargetClass] = useState<string>(classesNow[0]?.className ?? character.className);
const existingTarget = classesNow.find((c) => sameClass(c.className, targetClass));
const isNewClass = !existingTarget;
const targetClassLevel = existingTarget?.level ?? 0; // class level BEFORE this level-up
// Other classes the character could multiclass into (not already taken).
const newClassOptions = useMemo(
() => (supportsClassChoice
? getClassNames(character.system).filter((n) => !classesNow.some((c) => sameClass(c.className, n)))
: []),
[supportsClassChoice, character.system, classesNow],
);
const sys = getSystem(character.system);
const def = getClassDef(character.system, levelingName);
// The class list AFTER this level-up (preview): +1 to the target class, or append it.
const previewClasses = useMemo<CharacterClassEntry[]>(
() => (isNewClass
? [...classesNow, { className: targetClass, level: 1, subclass: '' }]
: classesNow.map((c) => (sameClass(c.className, targetClass) ? { ...c, level: Math.min(20, c.level + 1) } : c))),
[classesNow, isNewClass, targetClass],
);
const totalLevelAfter = useMemo(
() => Math.min(20, previewClasses.reduce((s, c) => s + c.level, 0)),
[previewClasses],
);
// The 5th arg is the TOTAL character level after the level-up — for a multiclass
// sheet the 5e proficiency bonus keys off it, not the advanced class's level.
const plan = useMemo(
() => planLevelUp(character.system, targetClass, targetClassLevel, conMod, totalLevelAfter),
[character.system, targetClass, targetClassLevel, conMod, totalLevelAfter],
);
const def = getClassDef(character.system, targetClass);
// Only re-derive 5e slots when every class resolves to a known ClassDef — an
// off-list/homebrew class derives to [] and would wipe a manually maintained
// slot table (mirrors ClassesEditor.commit's anyUnknown guard).
const anyUnknown5e = useMemo(() => {
if (!is5e) return false;
const known = getClassNames('5e');
return previewClasses.some((c) => c.className && !known.includes(c.className));
}, [is5e, previewClasses]);
// Combined spell slots across all classes after this level-up (5e). PF2e keeps the
// single-class plan slots. This is the seam: all slot math lives in the rules layer.
const effective = useMemo<{ slots: SpellSlot[]; pact?: SpellSlot }>(() => {
if (is5e) {
if (anyUnknown5e) return { slots: [] };
const mc = multiclassSpellcasting('5e', previewClasses);
return { slots: mc.slots, ...(mc.pact ? { pact: mc.pact } : {}) };
}
return { slots: plan.slots ?? [], ...(plan.pact ? { pact: plan.pact } : {}) };
}, [is5e, anyUnknown5e, previewClasses, plan.slots, plan.pact]);
// 5e subclass selection (a class field, not a generic choice) when it first unlocks.
const sub5e = useMemo(() => {
if (!is5e) return undefined;
const p = subclassPrompt('5e', targetClass);
const entry = previewClasses.find((e) => sameClass(e.className, targetClass));
if (!p || !entry || entry.level < p.level || entry.subclass) return undefined;
return p;
}, [is5e, targetClass, previewClasses]);
const [subclassPick, setSubclassPick] = useState<string>('');
// The subclass that belongs to the class being advanced (per-class for multiclass);
// a subclass picked in THIS modal counts, so its unlocked grants land immediately.
const targetSubclass = existingTarget?.subclass || (sameClass(targetClass, character.className) ? character.subclass : '');
const effectiveSubclass = sub5e ? (subclassPick || sub5e.options[0] || '') : targetSubclass;
const [hpMethod, setHpMethod] = useState<'average' | 'roll'>('average');
// Rolled HP is rolled by an explicit button press (never silently inside Apply)
// so the player sees the die result before committing.
const [rolledHp, setRolledHp] = useState<{ face: number; total: number } | null>(null);
useEffect(() => { setRolledHp(null); }, [hpMethod, levelingName]);
useEffect(() => { setRolledHp(null); }, [hpMethod, targetClass]);
const rollHp = () => {
const face = rollDice(`1d${plan.hitDie}`, createRng()).total;
setRolledHp({ face, total: Math.max(1, face + conMod) });
@@ -65,32 +124,55 @@ export function LevelUpModal({ character, onApply, onClose }: {
const keyDefaults = def?.keyAbilities ?? ['str', 'dex'];
const [asiMode, setAsiMode] = useState<'asi' | 'feat'>('asi');
const [asiPicks, setAsiPicks] = useState<AbilityKey[]>([keyDefaults[0] ?? 'str', keyDefaults[1] ?? keyDefaults[0] ?? 'con']);
// Feat-instead-of-ASI (5e): picked right here, recorded onto the sheet on apply.
const [featPick, setFeatPick] = useState<Feat | null>(null);
const [featBrowse, setFeatBrowse] = useState(false);
// Boosts (pf2e): four distinct picks.
const [boostPicks, setBoostPicks] = useState<AbilityKey[]>(['str', 'dex', 'con', 'wis']);
// Skill increase (pf2e): one skill to bump.
const [skillKey, setSkillKey] = useState<string>(sys.skills[0]?.key ?? '');
// Classes after this level-up — drives feature/choice enumeration.
const nextClasses: ClassEntry[] = useMemo(() => {
if (is5e) {
return levelClass === NEW
? [...classList, { className: newClassName, level: 1 }]
: classList.map((e) => (e.className === levelingName ? { ...e, level: Math.min(20, e.level + 1) } : e));
}
return classList.length
? classList.map((e, i) => (i === 0 ? { ...e, level: plan.nextLevel } : e))
: [{ className: character.className, level: plan.nextLevel }];
}, [is5e, levelClass, newClassName, classList, levelingName, plan.nextLevel, character.className]);
// Feats (T-036): load the system's feat dataset for the picker.
const [featList, setFeatList] = useState<FeatDef[]>([]);
const [featsLoading, setFeatsLoading] = useState(false);
const [pickedFeat, setPickedFeat] = useState<{ feat: FeatDef; choice?: { ability?: AbilityKey } } | null>(null);
useEffect(() => {
let alive = true;
setFeatsLoading(true);
loadFeats(character.system)
.then((raw) => { if (alive) setFeatList(sys.listFeats(raw)); })
.catch(() => { if (alive) setFeatList([]); })
.finally(() => { if (alive) setFeatsLoading(false); });
return () => { alive = false; };
}, [character.system, sys]);
// PF2e per-level feat tracks (T-043/T-044): one feat per due track.
const featTracks = plan.choices.flatMap((c) => (c.kind === 'feat' && c.featType ? [c.featType] : []));
const [trackPicks, setTrackPicks] = useState<Partial<Record<FeatTrack, { feat: FeatDef; choice?: { ability?: AbilityKey } }>>>({});
const [activeTrack, setActiveTrack] = useState<FeatTrack>(featTracks[0] ?? 'class');
// Caster spell learning (T-043): add new spells/cantrips known at level-up.
const topRank = useMemo(
() => [...effective.slots, ...character.spellcasting.slots].reduce((m, s) => Math.max(m, s.level), 0),
[effective.slots, character.spellcasting.slots],
);
const isCaster = (def?.caster ?? 'none') !== 'none' || effective.slots.length > 0 || character.spellcasting.slots.length > 0;
const [spellName, setSpellName] = useState('');
const [spellRank, setSpellRank] = useState(0);
const [learned, setLearned] = useState<{ name: string; level: number }[]>([]);
// Subclass features/spells unlocked exactly at the new class level (T-037),
// keyed to the class being advanced and its (possibly just-picked) subclass.
const subGrants = useMemo(
() => (effectiveSubclass ? sys.applySubclass(targetClass, effectiveSubclass, plan.nextLevel) : undefined),
[sys, targetClass, effectiveSubclass, plan.nextLevel],
);
const newSubFeatures = subGrants?.features.filter((f) => f.level === plan.nextLevel) ?? [];
const newSubSpells = subGrants?.expandedSpells.filter((s) => (s.minCharLevel ?? 1) === plan.nextLevel) ?? [];
// Features GAINED at this level (read-only "what you get").
const featuresGained = useMemo(() => {
const k = (f: UnlockedFeature) => `${f.source}|${f.name}|${f.level}`;
const before = new Set(collectFeatures(character.system, classList).map(k));
return collectFeatures(character.system, nextClasses).filter((f) => !before.has(k(f)));
}, [character.system, classList, nextClasses]);
const before = new Set(collectFeatures(character.system, classesNow).map(k));
return collectFeatures(character.system, previewClasses).filter((f) => !before.has(k(f)));
}, [character.system, classesNow, previewClasses]);
// How many of a choice the character has already recorded (subclass also counts the
// class entry's own subclass field, set before the choices array existed).
@@ -100,31 +182,23 @@ export function LevelUpModal({ character, onApply, onClose }: {
const fromChoices = character.choices.find((c) => c.key === key)?.values.filter((v) => v.trim()).length ?? 0;
if (key.endsWith(':subclass')) {
const cn = key.slice(0, -':subclass'.length).toLowerCase();
return Math.max(fromChoices, nextClasses.some((e) => e.className.toLowerCase() === cn && e.subclass) ? 1 : 0);
return Math.max(fromChoices, previewClasses.some((e) => e.className.toLowerCase() === cn && e.subclass) ? 1 : 0);
}
return fromChoices;
};
// CHOICES still owed at the new level (Fighting Style, Pact Boon, Invocations, feats,
// pf2e subclass…) — the count not yet recorded.
// CHOICES still owed at the new level (Fighting Style, Pact Boon, Invocations,
// pf2e subclass…) — the count not yet recorded. PF2e per-level feat slots are
// handled by the dedicated feat-track picker, so they are filtered out here.
const pendingChoices = useMemo(
() => collectChoices(character.system, nextClasses)
() => collectChoices(character.system, previewClasses)
.filter((choice) => !(featTracks.length > 0 && /-feat$/.test(choice.key.split(':')[1] ?? '')))
.map((choice) => ({ choice, pick: Math.max(0, choice.count - resolvedCount(choice.key)) }))
.filter((x) => x.pick > 0),
// eslint-disable-next-line react-hooks/exhaustive-deps
[character.system, nextClasses, character.choices],
[character.system, previewClasses, character.choices, featTracks.length],
);
// 5e subclass selection (a class field, not a generic choice) when it first unlocks.
const sub5e = useMemo(() => {
if (!is5e) return undefined;
const p = subclassPrompt('5e', levelingName);
const entry = nextClasses.find((e) => e.className === levelingName);
if (!p || !entry || entry.level < p.level || entry.subclass) return undefined;
return p;
}, [is5e, levelingName, nextClasses]);
const [choicePicks, setChoicePicks] = useState<Record<string, string[]>>({});
const [subclassPick, setSubclassPick] = useState<string>('');
// PF2e feat-slot inputs autocomplete against the compendium so names land typo-free.
const [pf2eFeatNames, setPf2eFeatNames] = useState<string[]>([]);
const wantsFeatSuggestions = !is5e && pendingChoices.some(({ choice }) => !choice.options && /-feat$/.test(choice.key.split(':')[1] ?? ''));
@@ -150,7 +224,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
});
}, [pendingChoices]);
const atMax = total >= 20;
const atMax = character.level >= sys.maxLevel;
// 5e hard cap: an ASI can never push a score above 20.
const asiWouldExceed = Boolean(is5e && asi && asiMode === 'asi' && (() => {
@@ -167,7 +241,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
? 'An ASI can’t raise a score above 20 — adjust your picks.'
: skillIncCapped
? 'That skill can’t be increased yet (Master needs level 7+, Legendary 15+) — pick another.'
: asi && asiMode === 'feat' && !featPick
: asi && asiMode === 'feat' && !pickedFeat
? 'Pick a feat (or switch back to +2 abilities) before applying.'
: is5e && hpMethod === 'roll' && !rolledHp
? 'Roll your hit die before applying.'
@@ -175,7 +249,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
const apply = () => {
if (blockReason) return;
const gain = character.system === 'pf2e'
const gain = !sys.allowsHpRoll
? plan.hpGainAverage
: hpMethod === 'average' ? plan.hpGainAverage : (rolledHp?.total ?? plan.hpGainAverage);
@@ -190,20 +264,38 @@ export function LevelUpModal({ character, onApply, onClose }: {
({ build: abilityBuild, abilities } = appendLevelIncreases(abilityBuild, abilities, boostPicks, 'pf2e', `L${plan.nextLevel} boost`));
}
// Feats (T-036 / T-043 / T-044): the ASI-swap feat and PF2e track feats. The
// picker resolved prerequisites + half-feat ability choices; route the granted
// bonuses through the build too so the breakdown stays truthful.
const gainedFeats: CharacterFeat[] = [];
const applyFeatPick = (pick: { feat: FeatDef; choice?: { ability?: AbilityKey } }, source?: string) => {
const res = sys.applyFeat({ abilities, level: plan.nextLevel }, pick.feat, pick.choice);
const featBumps = Object.entries(res.feat.abilityBonuses ?? {}).flatMap(([k, n]) =>
Array.from({ length: Math.max(0, Number(n)) }, () => k as AbilityKey));
if (featBumps.length) {
({ build: abilityBuild, abilities } = appendLevelIncreases(abilityBuild, abilities, featBumps, '5e', `${res.feat.name} L${plan.nextLevel}`));
}
gainedFeats.push(source ? { ...res.feat, source } : res.feat);
};
if (asi && asiMode === 'feat' && pickedFeat) applyFeatPick(pickedFeat);
for (const track of featTracks) {
const pick = trackPicks[track];
if (pick) applyFeatPick(pick, `PF2e ${track} feat`);
}
// A CON increase applies retroactively: +1 max HP per character level per point
// of CON modifier gained. `gain` above used the OLD modifier, so the delta term
// covers every level including the new one. 5e multiclass: retro scales with the
// TOTAL character level, not the advanced class's level.
const newTotalLevel = is5e ? Math.min(20, total + 1) : plan.nextLevel;
const hpGain = gain + conRetroHpBonus(character.abilities.con, abilities.con, newTotalLevel);
const hpGain = gain + conRetroHpBonus(character.abilities.con, abilities.con, totalLevelAfter);
// Resolve a subclass pick (5e prompt or a pf2e `:subclass` choice) onto the class entry.
const subFromChoice = Object.entries(choicePicks)
.find(([k, v]) => k.endsWith(':subclass') && v.some((x) => x.trim()))?.[1].find((x) => x.trim())?.trim();
const subToApply = (sub5e ? (subclassPick || sub5e.options[0]) : undefined) ?? subFromChoice;
const nextClassesFinal = subToApply
? nextClasses.map((e) => (e.className === levelingName && !e.subclass ? { ...e, subclass: subToApply } : e))
: nextClasses;
? previewClasses.map((e) => (sameClass(e.className, targetClass) && !e.subclass ? { ...e, subclass: subToApply } : e))
: previewClasses;
// Merge newly-picked choices into the character's resolved-choices array.
const merged = character.choices.map((c) => ({ key: c.key, values: [...c.values] }));
@@ -216,41 +308,47 @@ export function LevelUpModal({ character, onApply, onClose }: {
}
const patch: Partial<Character> = {
// Persist the multiclass list; the schema transform derives className (primary)
// and the total character level from it (T-055).
classes: nextClassesFinal.map((c) => ({ className: c.className, level: c.level, subclass: c.subclass ?? '' })),
level: totalLevelAfter,
hp: { ...character.hp, max: character.hp.max + hpGain, current: character.hp.current + hpGain },
abilities,
...(abilityBuild ? { abilityBuild } : {}),
classes: nextClassesFinal,
...normalizeClassMirror({ classes: nextClassesFinal }),
choices: merged,
};
// Only re-derive 5e slots when every class resolves to a known ClassDef — an
// off-list/homebrew class derives to [] and would wipe a manually maintained
// slot table (mirrors ClassesEditor.commit's anyUnknown guard).
const known5eNames = getClassNames('5e');
const anyUnknown5e = is5e && nextClassesFinal.some((e) => e.className && !known5eNames.includes(e.className));
if (is5e && !anyUnknown5e) {
// Re-derive combined spell slots, preserving current values.
const derived = dnd5eClassesSlots(nextClassesFinal);
const slots = derived.slots.map((r) => {
const ex = character.spellcasting.slots.find((s) => s.level === r.level);
return { level: r.level, max: r.max, current: ex ? Math.min(r.max, ex.current) : r.max };
// Advance PF2e proficiency ranks for the new level (T-051): saves, Perception, and
// (for full casters) spell DC/attack rise at the primary class's fixed milestones.
// Floors on the current ranks, so it never lowers a manual respec value.
if (character.system === 'pf2e') {
const primary = previewClasses[0]?.className ?? character.className;
const adv = pf2eAdvanceProficiencies({
className: primary,
level: totalLevelAfter,
caster: getClassDef('pf2e', primary)?.caster,
saveRanks: character.saveRanks,
perceptionRank: character.perceptionRank,
spellcastingRank: character.spellcastingRank,
});
const spellcasting: Spellcasting = { ...character.spellcasting, slots };
if (derived.pact) spellcasting.pact = { ...derived.pact, current: Math.min(derived.pact.max, character.spellcasting.pact?.current ?? derived.pact.max) };
else delete spellcasting.pact;
patch.spellcasting = spellcasting;
} else if (!is5e && plan.slots) {
// Preserve spent slots on the pf2e path too (same rule as the 5e branch above):
// leveling up mid-day must not refill your expended slots.
const slots = plan.slots.map((r) => {
const ex = character.spellcasting.slots.find((s) => s.level === r.level);
return { ...r, current: ex ? Math.min(r.max, ex.current) : r.max };
});
const pact = plan.pact
? { ...plan.pact, current: Math.min(plan.pact.max, character.spellcasting.pact?.current ?? plan.pact.max) }
: undefined;
patch.spellcasting = { ...character.spellcasting, slots, ...(pact ? { pact } : {}) };
patch.saveRanks = adv.saveRanks;
patch.perceptionRank = adv.perceptionRank;
if (adv.spellcastingRank !== undefined) patch.spellcastingRank = adv.spellcastingRank;
}
if (effective.slots.length || effective.pact) {
// Merge new slots into the existing ones, PRESERVING spent slots: set the new
// max and only add the newly-gained slots to current — don't refill or wipe
// manual edits (T-042). A brand-new rank starts full. The slots come from the
// combined multiclass table for 5e, or the single-class plan for PF2e (T-055).
const mergeSlot = (ps: { level: number; max: number; current: number }, old?: { max: number; current: number }) =>
old ? { level: ps.level, max: ps.max, current: Math.min(ps.max, old.current + Math.max(0, ps.max - old.max)) } : { ...ps };
const slots = effective.slots.map((ps) => mergeSlot(ps, character.spellcasting.slots.find((s) => s.level === ps.level)));
patch.spellcasting = {
...character.spellcasting,
slots,
...(effective.pact ? { pact: mergeSlot(effective.pact, character.spellcasting.pact) } : {}),
};
}
if (skillInc && skillKey) {
@@ -276,20 +374,43 @@ export function LevelUpModal({ character, onApply, onClose }: {
}
// 5e: each level grants a Hit Die — keep the tracked resource in step.
if (is5e) {
const newTotal = Math.min(20, total + 1);
const i = character.resources.findIndex((r) => r.name.trim().toLowerCase() === 'hit dice');
patch.resources = i >= 0
? character.resources.map((r, j) => (j === i ? { ...r, max: r.max + 1, current: Math.min(r.max + 1, r.current + 1), recoverStep: Math.max(1, Math.floor(newTotal / 2)) } : r))
: [...character.resources, hitDiceResource(newTotal)];
? character.resources.map((r, j) => (j === i ? { ...r, max: r.max + 1, current: Math.min(r.max + 1, r.current + 1), recoverStep: Math.max(1, Math.floor(totalLevelAfter / 2)) } : r))
: [...character.resources, hitDiceResource(totalLevelAfter)];
}
if (asi && asiMode === 'feat' && featPick) {
patch.feats = [...character.feats, featPick];
// Subclass features unlocked at this level become source:'subclass' feats (T-037).
for (const f of newSubFeatures) {
gainedFeats.push({ id: newId(), name: f.name, source: 'subclass', description: f.description, level: plan.nextLevel, abilityBonuses: {} });
}
if (gainedFeats.length) patch.feats = [...character.feats, ...gainedFeats];
// Subclass spells newly available at this level (dedupe by name).
if (newSubSpells.length) {
const existing = patch.spellcasting ?? character.spellcasting;
const have = new Set(existing.spells.map((s) => s.name.toLowerCase()));
const added = newSubSpells
.filter((s) => !have.has(s.name.toLowerCase()))
.map((s) => newSpellEntry({ id: newId(), name: s.name, level: s.level, notes: 'Subclass spell' }));
if (added.length) patch.spellcasting = { ...existing, spells: [...existing.spells, ...added] };
}
// Newly learned spells/cantrips chosen this level (T-043; dedupe by name).
if (learned.length) {
const existing = patch.spellcasting ?? character.spellcasting;
const have = new Set(existing.spells.map((s) => s.name.toLowerCase()));
const added: SpellEntry[] = learned
.filter((s) => s.name.trim() && !have.has(s.name.trim().toLowerCase()))
.map((s) => newSpellEntry({ id: newId(), name: s.name.trim(), level: s.level, notes: `Learned at level ${plan.nextLevel}` }));
if (added.length) patch.spellcasting = { ...existing, spells: [...existing.spells, ...added] };
}
onApply(patch);
onClose();
};
const hpPreview = character.system === 'pf2e'
const hpPreview = !sys.allowsHpRoll
? `+${plan.hpGainAverage}`
: hpMethod === 'average' ? `+${plan.hpGainAverage}` : `1d${plan.hitDie} ${conMod >= 0 ? '+' : ''}${conMod}`;
@@ -297,13 +418,13 @@ export function LevelUpModal({ character, onApply, onClose }: {
<Modal
open
onClose={onClose}
title={is5e ? `Level up — total level ${Math.min(20, total + 1)}` : `Level up to ${plan.nextLevel}`}
title={`Level up to ${totalLevelAfter}`}
className="max-w-xl"
footer={
<>
{blockReason && !atMax && <span className="mr-auto self-center text-xs text-warning">{blockReason}</span>}
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" disabled={atMax || Boolean(blockReason)} onClick={apply}>Apply</Button>
<Button variant="primary" disabled={atMax || Boolean(blockReason)} onClick={apply}>Apply level {totalLevelAfter}</Button>
</>
}
>
@@ -311,28 +432,33 @@ export function LevelUpModal({ character, onApply, onClose }: {
<p className="text-sm text-warning">Already at level 20.</p>
) : (
<div className="space-y-4">
{/* Which class (5e multiclass) */}
{is5e && classList.length > 0 && (
{/* Class to advance (5e multiclassing, T-055) */}
{supportsClassChoice && (classesNow.length > 1 || newClassOptions.length > 0) && (
<section>
<h3 className="mb-1 smallcaps">Class to advance</h3>
<div className="flex flex-wrap items-center gap-2">
<Select value={levelClass} onChange={(e) => setLevelClass(e.target.value)} aria-label="Class to level up">
{classList.map((e) => <option key={e.className} value={e.className}>{e.className} {e.level} → {Math.min(20, e.level + 1)}</option>)}
<option value={NEW}>+ Multiclass into…</option>
</Select>
{levelClass === NEW && (
<Select value={newClassName} onChange={(e) => setNewClassName(e.target.value)} aria-label="New class">
{getClassNames('5e').filter((n) => !classList.some((e) => e.className === n)).map((n) => <option key={n} value={n}>{n}</option>)}
</Select>
<Select value={targetClass} onChange={(e) => setTargetClass(e.target.value)} aria-label="Class to advance">
{classesNow.map((c) => (
<option key={c.className} value={c.className}>{c.className} {c.level} → {Math.min(20, c.level + 1)}</option>
))}
{newClassOptions.length > 0 && (
<optgroup label="Multiclass into…">
{newClassOptions.map((n) => <option key={n} value={n}>{n} (new)</option>)}
</optgroup>
)}
</div>
</Select>
{(isNewClass || classesNow.length > 1) && (
<p className="mt-1 text-xs text-muted">
After: {previewClasses.map((c) => `${c.className} ${c.level}`).join(' / ')} — character level {totalLevelAfter}.
{isNewClass && ' New first level in this class uses its average HP (no max die).'}
</p>
)}
</section>
)}
{/* HP */}
<section>
<h3 className="mb-1 smallcaps">Hit points</h3>
{character.system === '5e' ? (
{sys.allowsHpRoll ? (
<div className="flex flex-wrap items-center gap-2">
<Select value={hpMethod} onChange={(e) => setHpMethod(e.target.value as 'average' | 'roll')} className="w-44" aria-label="HP method">
<option value="average">Average (+{plan.hpGainAverage})</option>
@@ -389,18 +515,24 @@ export function LevelUpModal({ character, onApply, onClose }: {
<span className="self-center text-xs text-muted">pick the same twice for +2</span>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
{featPick ? (
<span className="rounded-md border border-accent/50 bg-accent/5 px-2 py-1 text-sm text-ink">{featPick.name}</span>
) : (
<span className="text-sm text-muted">No feat chosen yet.</span>
<div className="space-y-2">
{pickedFeat && (
<p className="text-sm text-ink">Selected: <span className="font-semibold">{pickedFeat.feat.name}</span>
{pickedFeat.choice?.ability ? ` (+1 ${ABILITY_ABBR[pickedFeat.choice.ability]})` : ''}
{' '}<button className="text-xs text-accent underline" onClick={() => setPickedFeat(null)}>clear</button></p>
)}
<Button size="sm" variant="secondary" onClick={() => setFeatBrowse(true)}>{featPick ? 'Change feat…' : 'Browse feats…'}</Button>
<FeatPicker
system={character.system}
feats={featList}
loading={featsLoading}
input={{ level: plan.nextLevel, abilities: character.abilities }}
takenNames={new Set(character.feats.map((f) => f.name))}
onPick={(feat, choice) => setPickedFeat({ feat, ...(choice ? { choice } : {}) })}
/>
</div>
)}
</section>
)}
{featBrowse && <FeatPickerModal onPick={(f) => setFeatPick(f)} onClose={() => setFeatBrowse(false)} />}
{/* Boosts (pf2e) */}
{boosts && (
@@ -431,6 +563,64 @@ export function LevelUpModal({ character, onApply, onClose }: {
</section>
)}
{/* PF2e feat tracks (T-043 / T-044): one feat per track due this level */}
{featTracks.length > 0 && (
<section>
<h3 className="mb-1 smallcaps">
Feats — choose one per track ({featTracks.map((t) => TRACK_LABEL[t]).join(', ')})
</h3>
{Object.entries(trackPicks).map(([t, p]) => p && (
<p key={t} className="text-sm text-ink">
{TRACK_LABEL[t as FeatTrack]}: <span className="font-semibold">{p.feat.name}</span>
{p.choice?.ability ? ` (+1 ${ABILITY_ABBR[p.choice.ability]})` : ''}{' '}
<button className="text-xs text-accent underline" onClick={() => setTrackPicks((prev) => { const next = { ...prev }; delete next[t as FeatTrack]; return next; })}>clear</button>
</p>
))}
{featTracks.length > 1 && (
<Select className="my-1 w-48" value={activeTrack} onChange={(e) => setActiveTrack(e.target.value as FeatTrack)} aria-label="Feat track">
{featTracks.map((t) => <option key={t} value={t}>{TRACK_LABEL[t]}</option>)}
</Select>
)}
<FeatPicker
system={character.system}
feats={featList}
loading={featsLoading}
input={{ level: plan.nextLevel, abilities: character.abilities }}
takenNames={new Set([...character.feats.map((f) => f.name), ...Object.values(trackPicks).flatMap((p) => (p ? [p.feat.name] : []))])}
onPick={(feat, choice) => setTrackPicks((prev) => ({ ...prev, [activeTrack]: { feat, ...(choice ? { choice } : {}) } }))}
/>
</section>
)}
{/* Learn new spells / cantrips (T-043) */}
{isCaster && (
<section>
<h3 className="mb-1 smallcaps">Spells &amp; cantrips</h3>
{topRank > 0 && (
<p className="mb-1 text-xs text-muted">Access up to {character.system === 'pf2e' ? 'rank' : 'level'} {topRank}.</p>
)}
<div className="flex flex-wrap items-end gap-2">
<Input className="h-8 min-w-40 flex-1" value={spellName} placeholder="Spell or cantrip name" onChange={(e) => setSpellName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && spellName.trim()) { setLearned((l) => [...l, { name: spellName.trim(), level: spellRank }]); setSpellName(''); } }} aria-label="Spell name" />
<Select className="w-28" value={spellRank} onChange={(e) => setSpellRank(Number(e.target.value))} aria-label="Spell rank">
<option value={0}>cantrip</option>
{Array.from({ length: topRank }, (_, i) => i + 1).map((r) => <option key={r} value={r}>{character.system === 'pf2e' ? 'rank' : 'level'} {r}</option>)}
</Select>
<Button variant="secondary" onClick={() => { if (spellName.trim()) { setLearned((l) => [...l, { name: spellName.trim(), level: spellRank }]); setSpellName(''); } }}>Add</Button>
</div>
{learned.length > 0 && (
<ul className="mt-2 space-y-1">
{learned.map((s, i) => (
<li key={`${s.name}-${i}`} className="flex items-center gap-2 text-sm">
<span className="font-medium">{s.name}</span>
<span className="text-xs text-muted">{s.level === 0 ? 'cantrip' : `${character.system === 'pf2e' ? 'rank' : 'level'} ${s.level}`}</span>
<button className="text-xs text-danger underline" onClick={() => setLearned((l) => l.filter((_, j) => j !== i))}>remove</button>
</li>
))}
</ul>
)}
</section>
)}
{/* What you gain at this level (read-only) */}
{featuresGained.length > 0 && (
<section>
@@ -456,7 +646,16 @@ export function LevelUpModal({ character, onApply, onClose }: {
</section>
)}
{/* Choices still owed at this level (feats, fighting style, pact boon, …) */}
{/* Subclass features/spells gained at this level (T-037) */}
{(newSubFeatures.length > 0 || newSubSpells.length > 0) && (
<section className="rounded-md border border-accent/30 bg-accent-glow/40 p-2">
<h3 className="mb-1 smallcaps text-accent-deep">{effectiveSubclass} features at level {plan.nextLevel}</h3>
{newSubFeatures.map((f) => <p key={f.name} className="text-sm text-ink"><span className="font-semibold">{f.name}</span> — <span className="text-muted">{f.description}</span></p>)}
{newSubSpells.length > 0 && <p className="text-sm text-muted">Spells added: {newSubSpells.map((s) => s.name).join(', ')}</p>}
</section>
)}
{/* Choices still owed at this level (fighting style, pact boon, subclass, …) */}
{pendingChoices.length > 0 && (
<section>
<h3 className="mb-1 smallcaps">Choices to make</h3>
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { X, Minus, Plus } from 'lucide-react';
import { Minus, Plus } from 'lucide-react';
import { newId } from '@/lib/ids';
import { getSystem, applyRest } from '@/lib/rules';
import { spendResource, regainResource } from '@/lib/mechanics';
@@ -8,6 +8,7 @@ import type { CharacterResource } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { Icon } from '@/components/ui/Icon';
import { SheetSection, type SectionProps } from './common';
const RECOVERY_LABEL: Record<CharacterResource['recovery'], string> = {
@@ -87,7 +88,7 @@ export function ResourcesSection({ c, update }: SectionProps) {
<option key={k} value={k}>{RECOVERY_LABEL[k]}</option>
))}
</Select>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(r.id)} aria-label={`Remove ${r.name}`}><X size={14} aria-hidden /></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(r.id)} aria-label={`Remove ${r.name}`}><Icon name="X" /></Button>
</li>
))}
</ul>
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Sparkles, BrainCircuit, X, Minus, Plus } from 'lucide-react';
import { Sparkles, BrainCircuit, Minus, Plus } from 'lucide-react';
import { newId } from '@/lib/ids';
import { getSystem } from '@/lib/rules';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
@@ -10,6 +10,8 @@ import { formatModifier } from '@/lib/format';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { Checkbox } from '@/components/ui/Checkbox';
import { Icon } from '@/components/ui/Icon';
import { SheetSection, type SectionProps } from './common';
const CASTING_ABILITIES: { key: AbilityKey; label: string }[] = [
@@ -191,7 +193,7 @@ export function SpellcastingSection({ c, update }: SectionProps) {
</span>
{s.level > 0 && (
<label className="flex items-center gap-1 text-xs text-muted">
<input type="checkbox" checked={s.prepared} onChange={(e) => patchSpell(s.id, { prepared: e.target.checked })} />
<Checkbox checked={s.prepared} onChange={(e) => patchSpell(s.id, { prepared: e.target.checked })} />
Prepared
</label>
)}
@@ -217,7 +219,7 @@ export function SpellcastingSection({ c, update }: SectionProps) {
</div>
);
})()}
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}><X size={14} aria-hidden /></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}><Icon name="X" /></Button>
</li>
))}
</ul>
@@ -239,7 +241,7 @@ function PactSlotCard({ pact, maxRank, onPatch, onRemove }: {
<div className="flex items-center justify-center gap-1 text-[10px] uppercase text-muted">
<span title="Pact Magic — all slots are the same level and refresh on a short rest">Pact · Lv</span>
<NumberField className="w-10" value={pact.level} min={1} max={maxRank} onChange={(level) => onPatch({ level })} aria-label="Pact slot level" />
<Button size="icon" variant="ghost" className="h-5 w-5 text-danger" onClick={onRemove} aria-label="Remove pact slots"><X size={12} aria-hidden /></Button>
<Button size="icon" variant="ghost" className="h-5 w-5 text-danger" onClick={onRemove} aria-label="Remove pact slots"><Icon name="X" size={12} /></Button>
</div>
<div className="flex items-center gap-1">
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => onPatch({ current: Math.max(0, pact.current - 1) })} aria-label="Spend pact slot"><Minus size={14} aria-hidden /></Button>
+65
View File
@@ -0,0 +1,65 @@
import type { Character } from '@/lib/schemas';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
import { diffCharacters, significantRows } from './characterDiff';
/**
* Publish-conflict resolution (M2): the cloud already holds a newer copy of
* this character (edited on another device). Show WHAT differs, side by side,
* so "Overwrite" vs "Pull" is an informed choice instead of a coin flip.
*/
export function ConflictDialog({ local, cloud, busy, onOverwrite, onPull, onCancel }: {
local: Character;
cloud: Character;
busy?: boolean;
onOverwrite: () => void;
onPull: () => void;
onCancel: () => void;
}) {
const rows = significantRows(diffCharacters(local, cloud));
return (
<Modal
open
onClose={onCancel}
title={`Two copies of ${local.name}`}
footer={
<>
<Button variant="ghost" disabled={busy} onClick={onCancel}>Cancel</Button>
<Button variant="secondary" disabled={busy} onClick={onPull} title="Replace this device's copy with the cloud one">
Pull cloud copy
</Button>
<Button variant="danger" disabled={busy} onClick={onOverwrite} title="Publish this device's copy over the cloud one">
Overwrite cloud copy
</Button>
</>
}
>
<p className="mb-3 text-sm text-muted">
The cloud holds a newer copy of this character (edited on another device or by another session).
Pick which one wins — the other copy&rsquo;s differences below will be lost.
</p>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-muted">
<th className="py-1 pr-2 font-medium">Field</th>
<th className="py-1 pr-2 font-medium">This device</th>
<th className="py-1 font-medium">Cloud</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.label} className="border-t border-line">
<td className="py-1.5 pr-2 text-muted">{r.label}</td>
<td className={cn('py-1.5 pr-2', r.same ? 'text-muted' : 'font-medium text-ink')}>{r.local}</td>
<td className={cn('py-1.5', r.same ? 'text-muted' : 'font-medium text-accent-deep')}>{r.cloud}</td>
</tr>
))}
</tbody>
</table>
{rows.length <= 1 && (
<p className="mt-2 text-xs text-muted">The tracked fields match — the difference is in details not shown here.</p>
)}
</Modal>
);
}
+7 -3
View File
@@ -27,16 +27,20 @@ export function SyncStatusIndicator() {
return () => { window.removeEventListener('online', on); window.removeEventListener('offline', off); };
}, [setOnline]);
// The global connectivity badge (app header) already owns the generic "you're
// offline" message for everyone. This indicator is cloud-sync state, so it only
// speaks up when signed in — where "changes will sync when you reconnect" is
// meaningful — and stays silent otherwise (no duplicate offline banner).
if (!signedIn) return null;
if (!online) {
return (
<span className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-warning" title="You're offline — changes are saved locally and will sync when you reconnect." role="status">
<span className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-warning" title="Offline — your changes are saved locally and will sync to the cloud when you reconnect." role="status">
<WifiOff size={14} aria-hidden /> <span className="hidden sm:inline">Offline</span>
</span>
);
}
if (!signedIn) return null;
if (cloudSync === 'conflict') return <ConflictResolver />;
const map = {
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { characterSchema, type Character } from '@/lib/schemas';
import { diffCharacters, significantRows } from './characterDiff';
function mkChar(over: Record<string, unknown> = {}): Character {
return characterSchema.parse({
id: 'c1', campaignId: 'camp', kind: 'pc', system: '5e', name: 'Lia', className: 'Fighter',
ancestry: '', background: '', level: 3,
abilities: { str: 16, dex: 12, con: 14, int: 10, wis: 10, cha: 10 },
hp: { current: 20, max: 28, temp: 0 },
createdAt: '2026-07-01T10:00:00Z', updatedAt: '2026-07-01T10:00:00Z',
...over,
});
}
describe('diffCharacters (M2 conflict dialog)', () => {
it('flags differing fields and leaves matching ones marked same', () => {
const local = mkChar();
const cloud = mkChar({ hp: { current: 9, max: 28, temp: 3 }, level: 4, updatedAt: '2026-07-02T09:00:00Z' });
const rows = diffCharacters(local, cloud);
const by = (label: string) => rows.find((r) => r.label === label)!;
expect(by('Name').same).toBe(true);
expect(by('Level')).toMatchObject({ local: '3', cloud: '4', same: false });
expect(by('Hit points')).toMatchObject({ local: '20/28', cloud: '9/28 (+3)', same: false });
expect(by('Last edited').same).toBe(false);
});
it('significantRows keeps only differences plus Last edited for orientation', () => {
const local = mkChar();
const cloud = mkChar({ level: 4 });
const rows = significantRows(diffCharacters(local, cloud));
expect(rows.map((r) => r.label)).toEqual(['Level', 'Last edited']);
});
it('identical copies reduce to just the Last edited row', () => {
const rows = significantRows(diffCharacters(mkChar(), mkChar()));
expect(rows.map((r) => r.label)).toEqual(['Last edited']);
});
});
+43
View File
@@ -0,0 +1,43 @@
import type { Character } from '@/lib/schemas';
/**
* Field-level local-vs-cloud comparison for the publish-conflict dialog (M2).
* Compares the handful of fields players actually care about when deciding
* which copy wins — pure and unit-tested; the dialog just renders rows.
*/
export interface DiffRow {
label: string;
local: string;
cloud: string;
same: boolean;
}
const fmtHp = (c: Character) => `${c.hp.current}/${c.hp.max}${c.hp.temp ? ` (+${c.hp.temp})` : ''}`;
const fmtWhen = (iso: string) => (iso ? iso.slice(0, 16).replace('T', ' ') : '—');
export function diffCharacters(local: Character, cloud: Character): DiffRow[] {
const row = (label: string, l: string, c: string): DiffRow => ({ label, local: l, cloud: c, same: l === c });
const slots = (c: Character) =>
c.spellcasting.slots.filter((s) => s.max > 0).map((s) => `L${s.level} ${s.current}/${s.max}`).join(' · ') || '—';
const conds = (c: Character) => c.conditions.map((x) => x.name).join(', ') || 'none';
return [
row('Name', local.name, cloud.name),
row('Level', String(local.level), String(cloud.level)),
row('Hit points', fmtHp(local), fmtHp(cloud)),
row('Conditions', conds(local), conds(cloud)),
row('Spell slots', slots(local), slots(cloud)),
row('Inventory items', String(local.inventory.length), String(cloud.inventory.length)),
row('Feats', String(local.feats.length), String(cloud.feats.length)),
row('Notes length', `${local.notes.length} chars`, `${cloud.notes.length} chars`),
row('Last edited', fmtWhen(local.updatedAt), fmtWhen(cloud.updatedAt)),
];
}
/** Rows worth showing: everything that differs, plus Last edited for orientation. */
export function significantRows(rows: DiffRow[]): DiffRow[] {
const differing = rows.filter((r) => !r.same);
const lastEdited = rows.find((r) => r.label === 'Last edited');
if (differing.length === 0) return lastEdited ? [lastEdited] : [];
return differing.some((r) => r.label === 'Last edited') || !lastEdited ? differing : [...differing, lastEdited];
}
+124 -3
View File
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { Swords, X } from 'lucide-react';
import type { Campaign } from '@/lib/schemas';
import { Copy, Save, Swords, Users, Wand2, X } from 'lucide-react';
import type { Campaign, Encounter } from '@/lib/schemas';
import { encountersRepo } from '@/lib/db/repositories';
import { cloneCombatants, loadRosters, saveRoster, deleteRoster, type Roster } from '@/lib/combat/templates';
import { useUiStore } from '@/stores/uiStore';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
@@ -10,6 +11,7 @@ import { Modal } from '@/components/ui/Modal';
import { cn } from '@/lib/cn';
import { useEncounters, useEncounter } from './hooks';
import { EncounterTracker } from './EncounterTracker';
import { EncounterBuilder } from './EncounterBuilder';
import { RollFeed } from '@/features/player/RollFeed';
export function CombatPage() {
@@ -25,6 +27,14 @@ function Combat({ campaign }: { campaign: Campaign }) {
const selected = useEncounter(activeEncounterId);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [building, setBuilding] = useState(false);
// Reusable cross-campaign rosters (T-078), persisted in localStorage. Only those
// saved for this campaign's system are offered (5e CR vs PF2e level differ).
const [rosters, setRosters] = useState<Roster[]>(() => loadRosters());
const systemRosters = rosters.filter((r) => r.system === campaign.system);
const [savingRoster, setSavingRoster] = useState(false);
const [rosterName, setRosterName] = useState('');
// Only treat the selection as valid if it belongs to this campaign.
const validSelection = selected && selected.campaignId === campaign.id ? selected : undefined;
@@ -37,6 +47,29 @@ function Combat({ campaign }: { campaign: Campaign }) {
setCreating(false);
};
// Clone/duplicate an encounter into a fresh, runnable planning copy (T-078):
// combatants are reset to full HP with transient combat state cleared.
const duplicate = async (enc: Encounter) => {
const copy = await encountersRepo.create(campaign.id, `${enc.name} (copy)`);
await encountersRepo.save({ ...copy, combatants: cloneCombatants(enc.combatants) });
setActiveEncounter(copy.id);
};
// Save the selected encounter's combatants as a reusable roster (T-078).
const saveCurrentRoster = () => {
if (!validSelection || validSelection.combatants.length === 0) return;
setRosters(saveRoster(rosterName.trim() || validSelection.name, campaign.system, validSelection.combatants));
setRosterName('');
setSavingRoster(false);
};
// Load a roster into a brand-new encounter (T-078).
const loadRoster = async (roster: Roster) => {
const enc = await encountersRepo.create(campaign.id, roster.name);
await encountersRepo.save({ ...enc, combatants: cloneCombatants(roster.combatants) });
setActiveEncounter(enc.id);
};
return (
<Page>
<PageHeader
@@ -50,6 +83,33 @@ function Combat({ campaign }: { campaign: Campaign }) {
}
/>
{systemRosters.length > 0 && (
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel p-2">
<span className="smallcaps flex items-center gap-1 px-1 text-muted">
<Users size={13} aria-hidden /> Rosters
</span>
{systemRosters.map((r) => (
<span key={r.id} className="group inline-flex items-center gap-1 rounded-full border border-line bg-surface-2 py-0.5 pl-2.5 pr-1">
<button
className="text-xs font-medium text-ink hover:text-accent-deep"
onClick={() => loadRoster(r)}
title={`Load ${r.combatants.length} combatants into a new encounter`}
>
{r.name} <span className="text-muted">· {r.combatants.length}</span>
</button>
<button
className="text-muted hover:text-danger"
aria-label={`Delete roster ${r.name}`}
title="Delete roster"
onClick={() => setRosters(deleteRoster(r.id))}
>
<X size={12} aria-hidden />
</button>
</span>
))}
</div>
)}
{encounters.length === 0 ? (
<EmptyState
title="No encounters yet"
@@ -90,6 +150,14 @@ function Combat({ campaign }: { campaign: Campaign }) {
</span>
</span>
</button>
<button
className="px-1 text-muted opacity-0 transition-opacity hover:text-accent-deep group-hover:opacity-100"
aria-label={`Duplicate ${e.name}`}
title="Duplicate encounter"
onClick={() => duplicate(e)}
>
<Copy size={14} aria-hidden />
</button>
<button
className="px-1 text-muted opacity-0 transition-opacity hover:text-danger group-hover:opacity-100"
aria-label={`Delete ${e.name}`}
@@ -106,7 +174,22 @@ function Combat({ campaign }: { campaign: Campaign }) {
<div>
{validSelection ? (
<EncounterTracker encounter={validSelection} campaign={campaign} />
<>
<div className="mb-3 flex justify-end gap-2">
<Button
variant="ghost"
disabled={validSelection.combatants.length === 0}
onClick={() => setSavingRoster(true)}
title="Save these combatants as a reusable roster"
>
<Save size={15} aria-hidden /> Save as roster
</Button>
<Button variant="secondary" onClick={() => setBuilding(true)} title="Search the bestiary and build a balanced fight">
<Wand2 size={15} aria-hidden /> Build encounter
</Button>
</div>
<EncounterTracker encounter={validSelection} campaign={campaign} />
</>
) : (
<EmptyState title="Select an encounter" hint="Pick one from the list, or create a new one." />
)}
@@ -114,6 +197,16 @@ function Combat({ campaign }: { campaign: Campaign }) {
</div>
)}
{validSelection && (
<EncounterBuilder
campaign={campaign}
encounterId={validSelection.id}
encounterName={validSelection.name}
open={building}
onClose={() => setBuilding(false)}
/>
)}
{/* Players' live rolls reach the GM here while hosting a session. */}
<RollFeed />
@@ -140,6 +233,34 @@ function Combat({ campaign }: { campaign: Campaign }) {
placeholder="Ambush on the road"
/>
</Modal>
<Modal
open={savingRoster}
onClose={() => setSavingRoster(false)}
title="Save as roster"
footer={
<>
<Button variant="ghost" onClick={() => setSavingRoster(false)}>
Cancel
</Button>
<Button variant="primary" onClick={saveCurrentRoster}>
Save
</Button>
</>
}
>
<p className="mb-2 text-sm text-muted">
Save this encounter's {validSelection?.combatants.length ?? 0} combatants as a reusable roster you can
drop into a fresh fight (HP and conditions reset on load).
</p>
<Input
data-autofocus
value={rosterName}
onChange={(e) => setRosterName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && saveCurrentRoster()}
placeholder={validSelection?.name ?? 'Roster name'}
/>
</Modal>
</Page>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useState } from 'react';
import { Sparkles, X } from 'lucide-react';
import type { Campaign, Encounter } from '@/lib/schemas';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { retrieveCompendium, type RetrievedEntry } from '@/lib/compendium';
import { buildCopilotPrompt, summarizeCombatState } from '@/lib/assistant/copilot';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
/**
* GM copilot (D1, first slice): one-tap, SRD-grounded rules Q&A that already
* knows the live combat state — "can the ogre still take reactions while
* grappled?" answered mid-turn with citations, without leaving the tracker.
* BYO-key like the rest of the assistant; hidden when no model is configured.
*/
export function CopilotPanel({ campaign, encounter }: { campaign: Campaign; encounter: Encounter }) {
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey.trim());
const [open, setOpen] = useState(false);
const [question, setQuestion] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [answer, setAnswer] = useState<{ text: string; citations: RetrievedEntry[] } | null>(null);
if (!llmEnabled || !hasKey) return null;
const ask = async () => {
const q = question.trim();
if (!q || busy) return;
setBusy(true); setError(null); setAnswer(null);
try {
const retrieved = await retrieveCompendium(campaign.system, q).catch(() => [] as RetrievedEntry[]);
const prompt = buildCopilotPrompt({
system: campaign.system,
combatSummary: summarizeCombatState(encounter, campaign.system),
question: q,
retrieved,
});
const res = await complete(getLlmConfig(), { system: prompt.system, user: prompt.user, maxTokens: 500, timeoutMs: 45_000 });
if (res.ok && 'text' in res) setAnswer({ text: res.text, citations: retrieved });
else if (!res.ok) setError(res.message);
else setError('The model returned an unexpected response.');
} catch {
setError('The rules lookup failed.');
} finally {
setBusy(false);
}
};
if (!open) {
return (
<Button variant="ghost" onClick={() => setOpen(true)} title="Ask the rules copilot — it sees the current combat state">
<Sparkles size={15} aria-hidden /> Ask the rules
</Button>
);
}
return (
<div className="w-full rounded-xl border border-accent/40 bg-accent/5 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="smallcaps flex items-center gap-1.5 text-muted">
<Sparkles size={13} aria-hidden className="text-accent-deep" /> Rules copilot · sees this combat
</span>
<Button size="icon" variant="ghost" aria-label="Close copilot" onClick={() => setOpen(false)}><X size={14} aria-hidden /></Button>
</div>
<div className="flex gap-1">
<Input
className="flex-1"
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void ask(); }}
placeholder="Does the prone goblin have disadvantage on this attack?"
aria-label="Rules question"
/>
<Button variant="primary" size="sm" disabled={busy || !question.trim()} onClick={() => void ask()}>
{busy ? 'Checking…' : 'Ask'}
</Button>
</div>
{error && <p className="mt-2 text-sm text-danger">{error}</p>}
{answer && (
<div className="mt-2 rounded-md border border-line bg-panel p-2.5 text-sm">
<p className="whitespace-pre-wrap text-ink">{answer.text}</p>
{answer.citations.length > 0 && (
<p className="mt-1.5 flex flex-wrap gap-1">
{answer.citations.map((c) => (
<span key={`${c.kind}:${c.name}`} className="rounded-full border border-line bg-surface px-1.5 py-0.5 text-[10px] text-muted" title={c.snippet}>
{c.name}
</span>
))}
</p>
)}
</div>
)}
</div>
);
}
+268
View File
@@ -0,0 +1,268 @@
import { useEffect, useMemo, useState } from 'react';
import { Search, Plus, Minus, Wand2 } from 'lucide-react';
import type { Campaign, Combatant, CombatantStatBlock } from '@/lib/schemas';
import type { Monster } from '@/lib/compendium/types';
import { abilityModifier, getSystem, type SystemId } from '@/lib/rules';
import { loadCreatures } from '@/lib/compendium';
import { from5eMonster, fromPf2eCreature } from '@/lib/combat/statblock';
import { addCombatant } from '@/lib/combat/engine';
import { computeBudget, DIFFICULTY_COLOR, type BudgetMonster } from '@/lib/combat/budget';
import { generateRandomEncounter, DIFFICULTY_LADDER } from '@/lib/combat/randomEncounter';
import { encountersRepo } from '@/lib/db/repositories';
import { useCharacters } from '@/features/characters/hooks';
import { newId } from '@/lib/ids';
import { rollDice } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { cn } from '@/lib/cn';
/** Combat-relevant fields produced for a picked foe, mirroring the registry's toCombatant. */
type FoeStats = { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number; monsterRef?: string; statBlock?: CombatantStatBlock };
interface FoeEntry {
key: string;
name: string;
cr?: number;
level?: number;
ratingLabel: string;
stats: () => FoeStats;
}
function crLabel(cr: number | undefined): string {
if (cr === undefined) return '?';
if (cr === 0.125) return '1/8';
if (cr === 0.25) return '1/4';
if (cr === 0.5) return '1/2';
return String(cr);
}
/** Load the system's bestiary as normalized foe entries (decoupled from the compendium UI registry). */
async function loadFoes(system: SystemId): Promise<FoeEntry[]> {
const sys = getSystem(system);
const rows = await loadCreatures(system);
if (system === '5e') {
return (rows as unknown as Monster[]).map((m) => {
const cr = sys.creatureRating.of(m);
return {
key: m.slug ?? m.name,
name: m.name,
...(cr !== undefined ? { cr } : {}),
ratingLabel: `CR ${crLabel(cr)}`,
stats: () => ({
name: m.name, ac: m.armor_class ?? 10, hp: m.hit_points ?? 1,
initBonus: abilityModifier(m.dexterity ?? 10),
...(cr !== undefined ? { cr } : {}),
...(m.slug ? { monsterRef: m.slug } : {}),
statBlock: from5eMonster(m),
}),
};
});
}
return rows.map((r) => {
const level = sys.creatureRating.of(r as { cr?: number; level?: number });
return {
key: String(r['slug'] ?? r['id'] ?? r['name']),
name: String(r['name'] ?? 'Creature'),
...(level !== undefined ? { level } : {}),
ratingLabel: `Lv ${level ?? '?'}`,
stats: () => ({
name: String(r['name'] ?? 'Creature'), ac: Number(r['ac']) || 10, hp: Number(r['hp']) || 1,
initBonus: Number(r['perception']) || 0,
...(level !== undefined ? { level } : {}),
...(r['slug'] || r['id'] ? { monsterRef: String(r['slug'] ?? r['id']) } : {}),
statBlock: fromPf2eCreature(r),
}),
};
});
}
function statsToCombatant(stats: FoeStats): Combatant {
const initiative = rollDice('1d20', createRng()).total + stats.initBonus;
return {
id: newId(), name: stats.name, kind: 'monster',
initiative, initBonus: stats.initBonus, ac: stats.ac,
hp: { current: stats.hp, max: stats.hp, temp: 0 },
conditions: [], notes: '',
...(stats.cr !== undefined ? { cr: stats.cr } : {}),
...(stats.level !== undefined ? { level: stats.level } : {}),
...(stats.monsterRef ? { monsterRef: stats.monsterRef } : {}),
...(stats.statBlock ? { statBlock: stats.statBlock } : {}),
...(stats.statBlock?.legendaryActionsMax !== undefined ? { legendaryRemaining: stats.statBlock.legendaryActionsMax } : {}),
...(stats.statBlock?.legendaryResistanceMax !== undefined ? { legendaryResistanceRemaining: stats.statBlock.legendaryResistanceMax } : {}),
};
}
const MAX_RESULTS = 80;
/**
* Roster-vs-party encounter builder (T-071): search the bestiary, set quantities,
* and watch the live difficulty against the party via the tested computeBudget —
* then commit the whole roster into the encounter in one transaction.
*/
export function EncounterBuilder({ campaign, encounterId, encounterName, open, onClose }: {
campaign: Campaign;
encounterId: string;
encounterName: string;
open: boolean;
onClose: () => void;
}) {
const characters = useCharacters(campaign.id);
const partyLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level);
const [foes, setFoes] = useState<FoeEntry[]>([]);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState('');
const [roster, setRoster] = useState<Record<string, number>>({});
useEffect(() => {
if (!open) return;
let alive = true;
setLoading(true);
loadFoes(campaign.system)
.then((f) => { if (alive) setFoes(f); })
.catch(() => { if (alive) setFoes([]); })
.finally(() => { if (alive) setLoading(false); });
return () => { alive = false; };
}, [open, campaign.system]);
const byKey = useMemo(() => new Map(foes.map((f) => [f.key, f])), [foes]);
const results = useMemo(() => {
const q = query.trim().toLowerCase();
const matched = q ? foes.filter((f) => f.name.toLowerCase().includes(q)) : foes;
return matched.slice(0, MAX_RESULTS);
}, [foes, query]);
const rosterEntries = useMemo(
() => Object.entries(roster).filter(([, n]) => n > 0).map(([key, n]) => ({ foe: byKey.get(key), n })).filter((x): x is { foe: FoeEntry; n: number } => !!x.foe),
[roster, byKey],
);
const budget = useMemo(() => {
const sys = getSystem(campaign.system);
const monsters: BudgetMonster[] = rosterEntries.flatMap(({ foe, n }) =>
Array.from({ length: n }, () => sys.creatureRating.field(sys.creatureRating.of(foe))));
return monsters.length && partyLevels.length ? computeBudget(campaign.system, partyLevels, monsters) : null;
}, [rosterEntries, partyLevels, campaign.system]);
const totalCount = rosterEntries.reduce((s, { n }) => s + n, 0);
const bump = (key: string, delta: number) => setRoster((r) => ({ ...r, [key]: Math.max(0, (r[key] ?? 0) + delta) }));
// Random encounter (M3): fill the roster with a seeded pick that lands in the
// chosen difficulty band. Respects the current search filter, so "goblin" +
// Surprise me builds an all-goblinoid fight.
const ladder = DIFFICULTY_LADDER[campaign.system];
const [targetDifficulty, setTargetDifficulty] = useState(ladder[Math.floor(ladder.length / 2)]!);
const surprise = () => {
const q = query.trim().toLowerCase();
const candidates = (q ? foes.filter((f) => f.name.toLowerCase().includes(q)) : foes);
const res = generateRandomEncounter({
system: campaign.system, partyLevels, candidates, difficulty: targetDifficulty, rng: createRng(),
});
if (!res) return;
setRoster(Object.fromEntries(res.picks.map((p) => [p.candidate.key, p.count])));
};
const commit = async () => {
const toAdd = rosterEntries.flatMap(({ foe, n }) => Array.from({ length: n }, () => statsToCombatant(foe.stats())));
if (!toAdd.length) return;
await encountersRepo.mutate(encounterId, (e) => toAdd.reduce((enc, c) => addCombatant(enc, c), e));
setRoster({});
onClose();
};
return (
<Modal open={open} onClose={onClose} title={`Build encounter — ${encounterName}`} className="max-w-3xl"
footer={
<>
<Button variant="ghost" onClick={onClose}>Close</Button>
<Button variant="primary" disabled={totalCount === 0} onClick={commit}>
Add {totalCount || ''} creature{totalCount === 1 ? '' : 's'} to encounter
</Button>
</>
}
>
<div className="grid gap-4 sm:grid-cols-[1fr_260px]">
{/* Search + results */}
<div className="min-w-0">
<div className="relative mb-2">
<Search size={15} aria-hidden className="absolute left-2 top-1/2 -translate-y-1/2 text-faint" />
<Input className="pl-8" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search the bestiary…" data-autofocus />
</div>
{loading ? (
<p className="text-sm text-muted">Loading bestiary…</p>
) : results.length === 0 ? (
<p className="text-sm text-muted">No creatures match.</p>
) : (
<ul className="max-h-80 space-y-1 overflow-y-auto pr-1">
{results.map((f) => (
<li key={f.key} className="flex items-center gap-2 rounded-md border border-line bg-panel px-2 py-1 text-sm">
<span className="min-w-0 flex-1 truncate">{f.name}</span>
<span className="shrink-0 text-xs text-muted">{f.ratingLabel}</span>
<Button size="icon" variant="ghost" aria-label={`Add ${f.name}`} onClick={() => bump(f.key, 1)}>
<Plus size={15} aria-hidden />
</Button>
{(roster[f.key] ?? 0) > 0 && <span className="w-4 text-center text-xs font-semibold text-accent">{roster[f.key]}</span>}
</li>
))}
{foes.length > MAX_RESULTS && query.trim() === '' && (
<li className="px-2 py-1 text-[11px] text-faint">Showing first {MAX_RESULTS} of {foes.length} — search to narrow.</li>
)}
</ul>
)}
</div>
{/* Roster + live budget */}
<div className="space-y-3">
<div className="rounded-lg border border-line bg-surface-2 p-2.5">
<div className="smallcaps mb-1 text-[10px]">Random encounter</div>
<div className="flex items-center gap-1.5">
<Select className="h-8 flex-1 py-0 text-xs capitalize" value={targetDifficulty} onChange={(e) => setTargetDifficulty(e.target.value)} aria-label="Target difficulty">
{ladder.map((d) => <option key={d} value={d} className="capitalize">{d}</option>)}
</Select>
<Button size="sm" variant="secondary" disabled={loading || partyLevels.length === 0} onClick={surprise} title="Fill the roster with a random encounter at this difficulty (respects the search filter)">
<Wand2 size={14} aria-hidden /> Surprise me
</Button>
</div>
</div>
<div className="rounded-lg border border-line bg-surface-2 p-2.5">
<div className="smallcaps mb-1 text-[10px]">Difficulty vs party</div>
{partyLevels.length === 0 ? (
<p className="text-xs text-muted">Add PCs to this campaign to rate difficulty.</p>
) : budget ? (
<>
<div className={cn('font-display text-lg font-semibold capitalize', DIFFICULTY_COLOR[budget.difficulty])}>{budget.difficulty}</div>
<div className="font-mono text-xs text-muted">
{budget.totalXp.toLocaleString()} XP{campaign.system === '5e' ? ` · ${budget.ratingXp.toLocaleString()} adj` : ''} · {budget.awardPerCharacter.toLocaleString()}/PC
</div>
<div className="mt-1 text-[10px] text-faint">{partyLevels.length} PC{partyLevels.length === 1 ? '' : 's'} · {totalCount} foe{totalCount === 1 ? '' : 's'}</div>
</>
) : (
<p className="text-xs text-muted">Add creatures to see difficulty.</p>
)}
</div>
<div>
<div className="smallcaps mb-1 px-0.5 text-[10px]">Roster</div>
{rosterEntries.length === 0 ? (
<p className="text-xs text-muted">Nothing picked yet.</p>
) : (
<ul className="space-y-1">
{rosterEntries.map(({ foe, n }) => (
<li key={foe.key} className="flex items-center gap-1 rounded-md border border-line bg-panel px-2 py-1 text-sm">
<span className="min-w-0 flex-1 truncate">{foe.name}</span>
<Button size="icon" variant="ghost" aria-label={`Remove one ${foe.name}`} onClick={() => bump(foe.key, -1)}><Minus size={14} aria-hidden /></Button>
<span className="w-5 text-center text-xs font-semibold">{n}</span>
<Button size="icon" variant="ghost" aria-label={`Add one ${foe.name}`} onClick={() => bump(foe.key, 1)}><Plus size={14} aria-hidden /></Button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</Modal>
);
}

Some files were not shown because too many files have changed in this diff Show More