Files
campaign-manager/CLAUDE.md

248 lines
9.6 KiB
Markdown

# Campaign Manager — CLAUDE.md
This project is in the **architecture/design phase**. No implementation code exists yet. The source of truth is `campaign_manager_design_v7.md`. `Symbiotes Documentation.md` is the TaleSpire platform API reference.
---
## What We're Building
A TaleSpire **Campaign Manager Symbiote** — a mod embedded inside TaleSpire (Chromium-based) that provides authenticated multi-group campaign management with ruleset-agnostic character sheets and native dice integration.
Three clients, one backend:
1. **TaleSpire Symbiote** — primary interface, runs inside the game
2. **Companion Web App** — for out-of-game character/group management
3. **Backend API** — shared by both clients
---
## Technology Stack
| Layer | Technology | Notes |
|---|---|---|
| Symbiote UI | Svelte + Vite (no Kit) | Plain Svelte for minimal bundle size; TaleSpire runs locally and the Symbiote shares resources with the game. Programmatic view-switching via a `$currentView` store — no router. |
| Web App | SvelteKit | Full SvelteKit with `adapter-static` for CDN deploy; SvelteKit's router and SSR are appropriate here since it runs in the user's own browser. Shares ruleset plugin code with the Symbiote. |
| Backend | Rust + Axum + Tokio | Async, memory-safe, high-throughput |
| DB queries | sqlx | Compile-time checked SQL, no ORM |
| Database | PostgreSQL 16 | JSONB for sheet_data; strong relational integrity |
| Auth | JWT + argon2id | argon2id preferred over bcrypt for new code |
| Object Storage | S3-compatible (Cloudflare R2) | Portrait images and .cmchar exports; keeps k8s pods stateless |
| Hosting | Kubernetes (no PVC) | Stateless pods; all persistence in Postgres + S3 |
---
## Architecture
### Three-Layer System
```
TaleSpire Symbiote (Chromium) ←→ Backend (Rust/Axum) ←→ PostgreSQL
Companion Web App ←→ (same API)
```
- Symbiote communicates over HTTPS; upgrades to WebSocket for live session features
- Web app and Symbiote share the same `/auth/*` endpoints and user accounts — no separate auth
### Symbiote Constraints (from TaleSpire API)
- Single-page application — no real page navigation; views are swapped programmatically to preserve the injected TS API
- **No SvelteKit for the Symbiote** — the Symbiote runs inside TaleSpire's local Chromium process and shares system resources with the game. SvelteKit's router/hydration bundle is unnecessary overhead. Use plain Svelte + Vite: one `main.ts` entry point, a `$currentView` store drives which component is rendered.
- All persistence via `TS.storage` (not `localStorage`, which is unreliable across restarts)
- Must suppress API calls during board transitions (client leave/join events)
- `manifest.json` extras: `colorStyles`, `fonts`, `diceFinder`
### Views (Symbiote)
`Login/Register``Group List``Group Detail``Character Sheet``Roll History panel`
### Web App Routes
| Route | Access |
|---|---|
| `/groups` | All members |
| `/groups/:id` | Members |
| `/groups/:id/settings` | DM only |
| `/characters` | Owner |
| `/characters/:id` | Owner |
---
## Data Model
### Key Schema Decisions
- `sheet_data` is **opaque JSONB** — the backend never reads it; only ruleset plugins do. Adding a new ruleset requires **zero DB migrations**.
- Characters are **portable**: one character can belong to multiple groups via `group_characters`.
- Exactly **one DM per group** (`groups.dm_user_id` + `group_members.role`).
- `email_verified = true` is required before joining or creating groups.
### Core Tables
```
users id (UUID PK), email, password_hash, display_name, email_verified, created_at
groups id (UUID PK), name, dm_user_id (FK), ruleset_id, description, created_at
group_members group_id + user_id (composite PK), role ENUM(player, dm), joined_at
characters id (UUID PK), owner_user_id (FK), ruleset_id, name, sheet_data (JSONB),
portrait_url, created_at, updated_at
group_characters group_id + character_id (composite PK), user_id (FK), is_active, assigned_at
```
---
## REST API
Base URL: `https://api.campaign-manager.example.com/v1`
All endpoints require `Authorization: Bearer <accessToken>` unless marked public.
### Auth
| Method + Path | Auth | Description |
|---|---|---|
| `POST /auth/register` | Public | Create account |
| `POST /auth/login` | Public | Returns JWT pair |
| `POST /auth/refresh` | Refresh token | Rotate refresh token |
| `POST /auth/logout` | Bearer | Revoke refresh token |
### Groups
`GET /groups` · `POST /groups` · `GET /groups/:id` · `PATCH /groups/:id` · `DELETE /groups/:id`
`POST /groups/:id/invite` · `POST /groups/:id/join/:token` · `DELETE /groups/:id/members/:userId`
### Characters
`GET /characters` · `POST /characters` · `GET /characters/:id`
`PUT /characters/:id` · `PATCH /characters/:id` · `DELETE /characters/:id`
`POST /groups/:id/characters` · `DELETE /groups/:gid/characters/:cid` · `GET /groups/:id/characters`
`GET /characters/:id/history` (last 10 versions of sheet_data)
### Dice / Sessions
`POST /groups/:id/rolls` · `GET /groups/:id/rolls`
### WebSocket
One room per group ID, authenticated with the same JWT. Used for HP updates and roll history push.
---
## Ruleset Plugin Interface
Game logic is fully isolated behind a `Ruleset` interface. The core app never touches system-specific rules.
```typescript
interface Ruleset {
id: string // e.g. 'dsa5e'
name: string
version: string // semver
defaultSheet(): SheetData
validate(sheet: SheetData): ValidationResult
getStatBlocks(sheet: SheetData): StatBlock[]
getDiceActions(sheet: SheetData): DiceAction[]
renderSheet(sheet: SheetData): VNode
}
interface DiceAction {
label: string // 'Attack Roll', 'Perception Check', etc.
diceString: string // TaleSpire format: '1d20+5', '2d6'
category: string // 'attack' | 'skill' | 'save' | 'damage' | 'custom'
}
```
Plugins live in a `rulesets/` directory. Adding one = dropping in a file + registering its id. No DB changes.
**Bundled rulesets (v1):**
- `dsa5e` — Das Schwarze Auge 5e (The Dark Eye): attributes, derived values, talents, combat techniques, special abilities, spells/liturgies
- `generic` — Minimal fallback: name, HP, arbitrary key-value pairs
---
## Key Conventions
### Auth Flow
- **Symbiote**: tokens stored in `TS.storage`. On boot, check stored tokens; silently refresh via `POST /auth/refresh` if access token is expired. On any 401, attempt silent refresh before surfacing an error.
- **Web App**: access token in memory only; refresh token in `HttpOnly; Secure; SameSite=Strict` cookie. On hard refresh, silently call `POST /auth/refresh` via cookie before rendering protected routes.
- JWT access tokens: 15-minute lifetime. Refresh tokens: 30-day lifetime, **single-use with rotation**.
- Rate limit on auth endpoints: 10 req/min per IP.
### Sync Strategy
- **`TS.sync`** — lightweight broadcast to all clients on the same board running the same Symbiote. Use for transient state (e.g. initiative announcements). No backend needed.
- **WebSocket room (group-keyed)** — for persistent data (HP changes, saved rolls). Backend pushes updates to other connected clients.
- **Conflict resolution**: last-write-wins for most fields. HP uses **delta updates** (`+5 HP` not `set HP to 45`) to reduce conflicts during combat.
### Character Sheet Saves (Web App)
- Optimistic UI: changes applied locally immediately; rollback with toast on failure.
- Auto-save draft to backend every 30 seconds.
### Dice Integration
```javascript
// Programmatic roll from character sheet button
await TS.dice.putDiceInHand(action.diceString);
// Listen for resolved results and log to backend
TS.dice.onDiceResult.addListener(async (result) => {
await api.post(`/groups/${currentGroup.id}/rolls`, { ... });
});
```
`diceFinder` extra automatically makes dice-notation text clickable on any page loaded in the Symbiote — no extra code.
### Character Import (.cmchar format)
```json
{
"cm_version": "1.0",
"ruleset_id": "dsa5e",
"name": "Character Name",
"sheet_data": { }
}
```
Importing runs `ruleset.validate(sheet_data)` before creating any record. Field-level errors are surfaced in the UI.
### Portrait Upload
Client validates MIME type and size (max 2 MB) before upload. Backend issues a presigned S3 URL; client uploads directly to object storage. Backend stores only the resulting URL. k8s pods remain stateless.
### Styling
Use TaleSpire CSS variables (`--ts-background-primary`, `--ts-color-primary`, etc.) via the `colorStyles` extra. Use `OptimusPrinceps` font (via `fonts` extra) for headings. Use `ts-icon-*` classes for action buttons.
---
## Project Structure
```
campaign-manager/
symbiote/ # Plain Svelte + Vite — TaleSpire Symbiote (lean bundle)
vite.config.ts
src/
main.ts # mounts <App />, waits for TS.hasInitialized
App.svelte # $currentView store drives view switching
views/ # Login, GroupList, GroupDetail, CharacterSheet, RollHistory
lib/
api.ts # shared fetch client (auto-refresh on 401)
store.ts # auth + session state
dist/ # Symbiote file root (index.html + manifest.json go here)
web/ # SvelteKit — Companion web app
svelte.config.js # adapter-static for CDN deploy
src/
routes/
lib/
backend/ # Rust + Axum
rulesets/ # Shared ruleset plugins (plain TS — imported by both frontends)
```
---
## Out of Scope (v1)
- Board state editing (tiles, minis) — TaleSpire API doesn't expose this
- Real-time collaborative editing of the same sheet simultaneously
- Mobile app
- PDF / D&D Beyond imports