Files
campaign-manager/CLAUDE.md

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:

  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/RegisterGroup ListGroup DetailCharacter SheetRoll 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.

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

// 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