9.6 KiB
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:
- TaleSpire Symbiote — primary interface, runs inside the game
- Companion Web App — for out-of-game character/group management
- 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.tsentry point, a$currentViewstore drives which component is rendered. - All persistence via
TS.storage(notlocalStorage, which is unreliable across restarts) - Must suppress API calls during board transitions (client leave/join events)
manifest.jsonextras: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_datais 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 = trueis 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.
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/liturgiesgeneric— 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 viaPOST /auth/refreshif 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=Strictcookie. On hard refresh, silently callPOST /auth/refreshvia 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 HPnotset 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
// 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)
{
"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