Documents current implementation status, Dragonfly session cache conventions, and adds the Dragonfly service to the local dev compose stack.
12 KiB
Campaign Manager — CLAUDE.md
Scaffold complete — implementation in progress. The source of truth for the design is campaign_manager_design_v7.md. Symbiotes Documentation.md is the TaleSpire platform API reference. NEXT_STEPS.md is the canonical implementation roadmap (9 phases).
Current State
Full project scaffold is in place as of 2026-03-10. All directories, configs, and stub files exist.
| Layer | Status |
|---|---|
| backend/campaign-service | Health endpoint live; auth routes stubbed (most return 501); POST /auth/refresh has a dev stub that issues a real JWT but has no DB session persistence |
| backend/content-service | Health endpoint only; all other routes stubbed |
| backend/common | AppResult error type, issue_access_token JWT helper |
| symbiote | All views scaffolded (Login, GroupList, GroupDetail, CharacterSheet, RollHistory); TS API events wired in main.ts; store.ts and api.ts exist |
| web | All SvelteKit routes scaffolded; API proxy endpoints exist (auth, campaign, content) |
| rulesets | types.ts, dsa5e/index.ts, generic/index.ts scaffolded |
Next priority: auth end-to-end (register → login → refresh → logout) in campaign-service. See NEXT_STEPS.md.
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 |
| Cache / Sessions | Dragonfly (Redis-compatible) | Refresh token sessions + auth rate limiting; available in ITSH cluster |
| 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.
Dragonfly (Cache / Sessions)
Connection: redis://<dragonfly-name>:6379 (standard Redis protocol). Use the redis crate in Rust.
| Use | Key pattern | TTL |
|---|---|---|
| Refresh token sessions | rt:<token_id> → user_id |
30 days |
| Auth rate limiting | rl:auth:<ip> → request count |
60 s sliding window |
Refresh token flow: on POST /auth/refresh, look up rt:<token_id> in Dragonfly; delete it (single-use); issue new pair and write new rt:<new_token_id>. On POST /auth/logout, delete rt:<token_id>. This replaces the dev stub that had no session persistence.
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
campaign-service/migrations/ # sqlx migrations for cm_users + cm_campaign DBs
content-service/migrations/ # sqlx migrations for cm_content DB
common/ # Shared AppResult, JWT helpers
rulesets/ # Shared ruleset plugins (plain TS — imported by both frontends)
scripts/
ci-local.sh # Local CI: pnpm install, builds, cargo fmt/check, docker compose config
NEXT_STEPS.md # 9-phase implementation roadmap
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