# 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 ` 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 , 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