2930 lines
113 KiB
Markdown
2930 lines
113 KiB
Markdown
**TaleSpire**
|
|
|
|
**Campaign Manager Symbiote**
|
|
|
|
System Design & Architecture
|
|
|
|
v0.1 · March 2026
|
|
|
|
This document describes the full system design for the Campaign Manager
|
|
Symbiote --- a TaleSpire mod that provides authenticated, multi-group
|
|
campaign management with ruleset-agnostic character sheets and
|
|
integrated dice rolling.
|
|
|
|
**1. Overview**
|
|
|
|
The Campaign Manager Symbiote is a full-featured campaign and character
|
|
management tool embedded directly into TaleSpire. It allows players and
|
|
Game Masters to manage groups, characters, and session data from within
|
|
the game --- eliminating the need for external tools during play.
|
|
|
|
**1.1 Goals**
|
|
|
|
- Authenticated multi-user accounts with persistent data
|
|
|
|
- Flexible group model: one player → many groups, one group → many
|
|
players + one DM
|
|
|
|
- Character portability: one character can appear in multiple groups
|
|
or adventures
|
|
|
|
- Ruleset abstraction: a plugin-based system that supports D&D 5e
|
|
today and other systems tomorrow
|
|
|
|
- Native TaleSpire dice integration via the TS API
|
|
|
|
- Works entirely inside TaleSpire with no Alt+Tab required
|
|
|
|
**1.2 Non-Goals (v1)**
|
|
|
|
- Board state editing (placing tiles, moving minis) --- out of scope
|
|
per TaleSpire API limits
|
|
|
|
- Real-time collaborative editing of the same character sheet
|
|
simultaneously
|
|
|
|
- Mobile or standalone web app (Symbiote-only for now)
|
|
|
|
**2. System Architecture**
|
|
|
|
The system is split into three layers: the TaleSpire Symbiote
|
|
(frontend), a backend REST + WebSocket API, and a persistent database.
|
|
Because Symbiotes run in Chromium, the frontend is standard web tech
|
|
(HTML/CSS/JS or a bundled React app).
|
|
|
|
**2.1 High-Level Diagram**
|
|
|
|
+-----------------------------------+-----------------------------------+
|
|
| **TaleSpire Symbiote (Chromium)** | **Backend Service** |
|
|
+-----------------------------------+-----------------------------------+
|
|
| • Auth UI (login / register) | • Auth Service (JWT + refresh |
|
|
| | tokens) |
|
|
| • Group Dashboard | |
|
|
| | • Groups & Membership API |
|
|
| • Character Sheet Renderer | |
|
|
| | • Characters API |
|
|
| • Ruleset Plugin Loader | |
|
|
| | • Ruleset Registry |
|
|
| • Dice Integration (TS API) | |
|
|
| | • Dice History Store |
|
|
| • TS.sync for live updates | |
|
|
| | • PostgreSQL + Redis |
|
|
+-----------------------------------+-----------------------------------+
|
|
|
|
The Symbiote communicates with the backend over HTTPS. For live session
|
|
features (dice results visible to all group members) it upgrades to a
|
|
WebSocket connection authenticated with the same JWT.
|
|
|
|
**2.2 Symbiote Setup**
|
|
|
|
The manifest.json declares the entry point, enables the TS API (v0.1),
|
|
and requests the colorStyles, fonts, and diceFinder extras so the UI
|
|
matches TaleSpire\'s look and dice detection works on any page inside
|
|
the Symbiote.
|
|
|
|
> { \"apiVersion\": \"v0.1\",
|
|
>
|
|
> \"extras\": \[\"colorStyles\", \"fonts\", \"diceFinder\"\],
|
|
>
|
|
> \"interopId\": \"com.yourproject.campaign-manager\",
|
|
>
|
|
> \"runInBackground\": false,
|
|
>
|
|
> \"initializeEarly\": false }
|
|
|
|
Because board state can shift during play, the Symbiote listens for
|
|
client leave/join events and suppresses API calls during board
|
|
transitions, in line with the Symbiote lifetime guidelines.
|
|
|
|
**3. Authentication**
|
|
|
|
Authentication uses short-lived JWT access tokens (15 min) paired with
|
|
long-lived refresh tokens (30 days) stored in the Symbiote\'s TS storage
|
|
(not localStorage, which is unreliable across Symbiote restarts).
|
|
|
|
**3.1 Registration & Login Flow**
|
|
|
|
- User enters email + password in the Symbiote login screen
|
|
|
|
- POST /auth/register or POST /auth/login → returns { accessToken,
|
|
refreshToken, user }
|
|
|
|
- Tokens persisted via TS.storage so they survive Symbiote restarts
|
|
|
|
- On boot, Symbiote checks stored tokens; if access token expired,
|
|
silently refreshes via POST /auth/refresh
|
|
|
|
- On 401 from any API call, same silent refresh is attempted before
|
|
surfacing an error
|
|
|
|
**3.2 Token Storage**
|
|
|
|
TaleSpire\'s TS.storage API is used as the persistence layer. This is
|
|
per-installation, meaning tokens are not shared between the
|
|
mod-downloader install and any manual install of the same Symbiote
|
|
(consistent with TaleSpire\'s storage separation rules).
|
|
|
|
**3.3 Security Considerations**
|
|
|
|
- HTTPS required for all backend communication
|
|
|
|
- Passwords hashed server-side with bcrypt (cost factor ≥ 12)
|
|
|
|
- Refresh tokens are single-use and rotated on every refresh
|
|
|
|
- Rate limiting on auth endpoints: 10 requests / minute per IP
|
|
|
|
- Email verification required before joining or creating groups
|
|
|
|
**4. Data Model**
|
|
|
|
The schema is designed around the following cardinalities, which are
|
|
enforced at the API level:
|
|
|
|
- 1 Player → N Groups (via membership)
|
|
|
|
- 1 Group → N Players + 1 DM
|
|
|
|
- 1 Player → N Characters per Group
|
|
|
|
- 1 Character → N Groups (portable across adventures)
|
|
|
|
**4.1 users**
|
|
|
|
---------------------------------------------------------------------------
|
|
**Field** **Type** **Required** **Description**
|
|
---------------------- ---------------- -------------- --------------------
|
|
id UUID (PK) Yes Unique user
|
|
identifier
|
|
|
|
email VARCHAR(255) Yes Unique, used for
|
|
login
|
|
|
|
password_hash TEXT Yes bcrypt hash, never
|
|
returned by API
|
|
|
|
display_name VARCHAR(100) Yes Shown in group
|
|
member lists
|
|
|
|
email_verified BOOLEAN Yes Must be true to
|
|
join/create groups
|
|
|
|
created_at TIMESTAMPTZ Yes Account creation
|
|
timestamp
|
|
---------------------------------------------------------------------------
|
|
|
|
**4.2 groups**
|
|
|
|
---------------------------------------------------------------------------
|
|
**Field** **Type** **Required** **Description**
|
|
---------------------- ---------------- -------------- --------------------
|
|
id UUID (PK) Yes Unique group
|
|
identifier
|
|
|
|
name VARCHAR(150) Yes Campaign or
|
|
adventure name
|
|
|
|
dm_user_id UUID (FK → Yes Exactly one DM per
|
|
users) group
|
|
|
|
ruleset_id VARCHAR(100) Yes e.g. \'dnd5e\',
|
|
\'pathfinder2e\'
|
|
|
|
description TEXT No Campaign blurb /
|
|
notes
|
|
|
|
created_at TIMESTAMPTZ Yes
|
|
---------------------------------------------------------------------------
|
|
|
|
**4.3 group_members (join table)**
|
|
|
|
---------------------------------------------------------------------------
|
|
**Field** **Type** **Required** **Description**
|
|
---------------------- ---------------- -------------- --------------------
|
|
group_id UUID (FK → Yes Composite PK with
|
|
groups) user_id
|
|
|
|
user_id UUID (FK → Yes Composite PK with
|
|
users) group_id
|
|
|
|
role ENUM(player, dm) Yes DM is also stored
|
|
here for query
|
|
convenience
|
|
|
|
joined_at TIMESTAMPTZ Yes
|
|
---------------------------------------------------------------------------
|
|
|
|
**4.4 characters**
|
|
|
|
---------------------------------------------------------------------------
|
|
**Field** **Type** **Required** **Description**
|
|
---------------------- ---------------- -------------- --------------------
|
|
id UUID (PK) Yes
|
|
|
|
owner_user_id UUID (FK → Yes The player who owns
|
|
users) this character
|
|
|
|
ruleset_id VARCHAR(100) Yes Must match group
|
|
ruleset when
|
|
assigned
|
|
|
|
name VARCHAR(150) Yes Character name
|
|
|
|
sheet_data JSONB Yes Ruleset-specific
|
|
stats blob (see §6)
|
|
|
|
portrait_url TEXT No Optional character
|
|
portrait image URL
|
|
|
|
created_at TIMESTAMPTZ Yes
|
|
|
|
updated_at TIMESTAMPTZ Yes Updated on any
|
|
sheet_data change
|
|
---------------------------------------------------------------------------
|
|
|
|
**4.5 group_characters (assignment table)**
|
|
|
|
---------------------------------------------------------------------------
|
|
**Field** **Type** **Required** **Description**
|
|
---------------------- ---------------- -------------- --------------------
|
|
group_id UUID (FK → Yes Composite PK
|
|
groups)
|
|
|
|
character_id UUID (FK → Yes Composite PK
|
|
characters)
|
|
|
|
user_id UUID (FK → Yes Which player brought
|
|
users) this character
|
|
|
|
is_active BOOLEAN Yes false = retired /
|
|
benched for this
|
|
campaign
|
|
|
|
assigned_at TIMESTAMPTZ Yes
|
|
---------------------------------------------------------------------------
|
|
|
|
A character can be assigned to multiple groups simultaneously (one row
|
|
per group_characters). The owner retains full editing rights regardless
|
|
of which groups the character is in.
|
|
|
|
**5. REST API Reference**
|
|
|
|
Base URL: https://api.campaign-manager.example.com/v1 All endpoints
|
|
require Authorization: Bearer \<accessToken\> unless marked \[public\].
|
|
|
|
**5.1 Auth**
|
|
|
|
-------------------------------------------------------------------------
|
|
**Method + **Auth** **Description**
|
|
Path**
|
|
---------------- ----------- --------------------------------------------
|
|
POST Public Create a new user account
|
|
/auth/register
|
|
|
|
POST /auth/login Public Authenticate; returns JWT pair
|
|
|
|
POST Refresh Rotate refresh token; issue new access token
|
|
/auth/refresh token
|
|
|
|
POST Bearer Revoke refresh token
|
|
/auth/logout
|
|
-------------------------------------------------------------------------
|
|
|
|
**5.2 Groups**
|
|
|
|
---------------------------------------------------------------------------------
|
|
**Method + Path** **Description**
|
|
----------------------------- ---------------------------------------------------
|
|
GET /groups List all groups the authenticated user is a member
|
|
of (any role)
|
|
|
|
POST /groups Create a new group; caller becomes DM automatically
|
|
|
|
GET /groups/:id Get group detail including member list
|
|
|
|
PATCH /groups/:id Update name/description/ruleset (DM only)
|
|
|
|
DELETE /groups/:id Delete group (DM only); cascades membership &
|
|
character assignments
|
|
|
|
POST /groups/:id/invite DM invites a player by email; creates a pending
|
|
invite
|
|
|
|
POST /groups/:id/join/:token Player accepts an invite link
|
|
|
|
DELETE Remove player from group (DM or self-leave)
|
|
/groups/:id/members/:userId
|
|
---------------------------------------------------------------------------------
|
|
|
|
**5.3 Characters**
|
|
|
|
----------------------------------------------------------------------------------
|
|
**Method + Path** **Description**
|
|
------------------------------ ---------------------------------------------------
|
|
GET /characters List all characters owned by the authenticated user
|
|
|
|
POST /characters Create a new character (owner = caller)
|
|
|
|
GET /characters/:id Get full character including sheet_data
|
|
|
|
PUT /characters/:id Full sheet update; bumps updated_at
|
|
|
|
PATCH /characters/:id Partial update (e.g. HP change only)
|
|
|
|
DELETE /characters/:id Delete character; removes all group assignments
|
|
|
|
POST /groups/:id/characters Assign an owned character to this group
|
|
|
|
DELETE Unassign character from group
|
|
/groups/:gid/characters/:cid
|
|
|
|
GET /groups/:id/characters List all characters assigned to this group (DM sees
|
|
all; player sees own)
|
|
----------------------------------------------------------------------------------
|
|
|
|
**5.4 Dice History**
|
|
|
|
-----------------------------------------------------------------------
|
|
**Method + Path** **Description**
|
|
------------------- ---------------------------------------------------
|
|
POST Log a dice roll result (called automatically by
|
|
/groups/:id/rolls Symbiote)
|
|
|
|
GET Retrieve recent roll history for a group session
|
|
/groups/:id/rolls
|
|
-----------------------------------------------------------------------
|
|
|
|
**6. Ruleset Abstraction Layer**
|
|
|
|
The game logic is completely isolated behind a Ruleset interface. Each
|
|
supported game system ships as a ruleset plugin --- a self-contained JS
|
|
module that implements a fixed interface. The core Symbiote code never
|
|
touches system-specific rules directly.
|
|
|
|
**6.1 Ruleset Interface**
|
|
|
|
Every ruleset plugin must export the following contract:
|
|
|
|
> interface Ruleset {
|
|
>
|
|
> id: string // e.g. \'dnd5e\'
|
|
>
|
|
> name: string // e.g. \'D&D 5th Edition\'
|
|
>
|
|
> version: string // semver
|
|
>
|
|
> defaultSheet(): SheetData // blank character template
|
|
>
|
|
> validate(sheet: SheetData): ValidationResult
|
|
>
|
|
> getStatBlocks(sheet: SheetData): StatBlock\[\]
|
|
>
|
|
> getDiceActions(sheet: SheetData): DiceAction\[\]
|
|
>
|
|
> renderSheet(sheet: SheetData): VNode // framework-agnostic VDOM
|
|
>
|
|
> }
|
|
|
|
**6.2 SheetData**
|
|
|
|
sheet_data in the database is an opaque JSONB blob from the backend\'s
|
|
perspective. The ruleset plugin is the only code that reads or writes
|
|
it. This means adding a new ruleset requires zero database migrations
|
|
--- only a new plugin.
|
|
|
|
**6.3 DiceAction**
|
|
|
|
Rulesets expose dice actions so the core Symbiote can render roll
|
|
buttons without knowing the game system. Each action produces a
|
|
TaleSpire-compatible dice string:
|
|
|
|
> interface DiceAction {
|
|
>
|
|
> label: string // \'Attack Roll\', \'Perception Check\', etc.
|
|
>
|
|
> diceString: string // TaleSpire format: \'1d20+5\', \'2d6\', etc.
|
|
>
|
|
> category: string // \'attack\' \| \'skill\' \| \'save\' \| \'damage\'
|
|
> \| \'custom\'
|
|
>
|
|
> }
|
|
|
|
**6.4 Bundled Rulesets (v1)**
|
|
|
|
- dsa5e --- Das Schwarze Auge 5. Edition (The Dark Eye 5e): attributes
|
|
(MU, KL, IN, CH, FF, GE, KO, KK), derived values (LeP, AsP, KaP),
|
|
talents, combat techniques, special abilities, and liturgical/spell
|
|
pages
|
|
|
|
- generic --- A minimal fallback: name, HP, arbitrary key-value stat
|
|
pairs. Useful for one-shots or homebrew systems not yet covered by a
|
|
plugin.
|
|
|
|
Future rulesets (D&D 5e, Pathfinder 2e, Call of Cthulhu, etc.) are added
|
|
by dropping a new plugin into the rulesets/ directory and registering
|
|
its id --- zero database migrations required.
|
|
|
|
**7. Dice Integration**
|
|
|
|
Dice integration uses two complementary TaleSpire mechanisms: the
|
|
programmatic dice API and the diceFinder extra.
|
|
|
|
**7.1 Programmatic Rolls (Character Sheet Buttons)**
|
|
|
|
When a player clicks a dice action button on their character sheet, the
|
|
Symbiote calls the TaleSpire dice API directly with the dice string
|
|
produced by the ruleset plugin:
|
|
|
|
> const action = ruleset.getDiceActions(sheet).find(a =\> a.label ===
|
|
> \'Attack Roll\');
|
|
>
|
|
> await TS.dice.putDiceInHand(action.diceString);
|
|
|
|
This puts dice into the player\'s hand exactly as if they had dragged
|
|
them from the dice tray, preserving the native TaleSpire roll
|
|
experience.
|
|
|
|
**7.2 diceFinder (External Pages)**
|
|
|
|
When the Symbiote is used to load an external reference site (e.g. a
|
|
spell compendium), the diceFinder extra automatically detects text that
|
|
looks like a dice roll and makes it clickable --- no extra code
|
|
required.
|
|
|
|
**7.3 Roll History**
|
|
|
|
After a roll resolves, the Symbiote listens on the TS dice event source
|
|
and logs the result to the backend (POST /groups/:id/rolls). This gives
|
|
the DM a real-time roll history panel during the session, and players
|
|
can see their own recent rolls on the character sheet.
|
|
|
|
> TS.dice.onDiceResult.addListener(async (result) =\> {
|
|
>
|
|
> await api.post(\`/groups/\${currentGroup.id}/rolls\`, {
|
|
>
|
|
> character_id: activeCharacter.id,
|
|
>
|
|
> dice_string: result.diceString,
|
|
>
|
|
> total: result.total,
|
|
>
|
|
> breakdown: result.breakdown,
|
|
>
|
|
> });
|
|
>
|
|
> });
|
|
|
|
**8. Live Session Sync**
|
|
|
|
For features that need all group members to see updates in near
|
|
real-time (HP changes, roll history), the Symbiote uses a combination of
|
|
TaleSpire\'s own TS.sync and a backend WebSocket channel.
|
|
|
|
**8.1 TS.sync (same Symbiote, different clients)**
|
|
|
|
For lightweight state (e.g. \'player X rolled initiative\'), TS.sync
|
|
messages are broadcast to all clients running the same Symbiote in the
|
|
same board. This requires no backend infrastructure and has very low
|
|
latency.
|
|
|
|
**8.2 Backend WebSocket (persistent, cross-session)**
|
|
|
|
For data that must persist (HP updates, saved rolls), the Symbiote also
|
|
sends changes to the backend REST API. The backend can then push updates
|
|
to other connected clients via a WebSocket room keyed to the group ID.
|
|
|
|
**8.3 Conflict Resolution**
|
|
|
|
Last-write-wins is used for most character fields. For HP specifically,
|
|
delta updates (\'+5 HP\' rather than \'set HP to 45\') are preferred to
|
|
reduce merge conflicts during fast-paced combat rounds.
|
|
|
|
**9. Symbiote Frontend Structure**
|
|
|
|
The Symbiote is built as a single-page application (no navigation
|
|
between pages, as recommended by TaleSpire to avoid losing the injected
|
|
API). Views are swapped programmatically.
|
|
|
|
**9.1 View Routing**
|
|
|
|
- Login / Register → shown when no valid tokens in TS.storage
|
|
|
|
- Group List → home screen after login
|
|
|
|
- Group Detail → member list, character roster, DM tools
|
|
|
|
- Character Sheet → full sheet view with dice buttons
|
|
|
|
- Character Select → modal to assign an existing character to a group
|
|
|
|
- Roll History → side panel, always accessible during a session
|
|
|
|
**9.2 Styling**
|
|
|
|
The colorStyles extra is loaded so all colors reference TaleSpire CSS
|
|
variables (\--ts-background-primary, \--ts-color-primary, etc.), keeping
|
|
the UI consistent with the game\'s theme and automatically supporting
|
|
future high-contrast modes.
|
|
|
|
**9.3 Icons & Fonts**
|
|
|
|
The fonts extra provides OptimusPrinceps for headings (matching
|
|
TaleSpire\'s fantasy aesthetic) while body text uses a clean sans-serif.
|
|
The icons extra provides ts-icon-\* classes for action buttons (dice,
|
|
edit, delete) without requiring external icon libraries.
|
|
|
|
**10. Recommended Tech Stack**
|
|
|
|
------------------------------------------------------------------------
|
|
**Layer** **Technology** **Rationale**
|
|
------------------ ------------------- ---------------------------------
|
|
Symbiote UI Svelte Reactive, minimal bundle; no
|
|
virtual DOM overhead inside
|
|
Chromium
|
|
|
|
Auth JWT Stateless tokens; argon2id
|
|
(jsonwebtoken) + preferred over bcrypt for new
|
|
argon2 systems
|
|
|
|
Backend Rust + Axum Memory-safe, high-throughput;
|
|
strong async story with Tokio
|
|
|
|
ORM / Queries sqlx Compile-time checked queries
|
|
against PostgreSQL; no ORM magic
|
|
|
|
Database PostgreSQL 16 JSONB for sheet_data; strong
|
|
relational integrity; no PVC
|
|
needed in k8s
|
|
|
|
Object Storage S3-compatible (e.g. Portrait images and .cmchar
|
|
Cloudflare R2) exports; avoids PVC entirely in
|
|
Kubernetes
|
|
|
|
Hosting Kubernetes (no PVC) Stateless pods; all persistence
|
|
in Postgres + S3
|
|
------------------------------------------------------------------------
|
|
|
|
**11. Companion Web App**
|
|
|
|
The companion web app (campaign-manager.example.com) extends the
|
|
Campaign Manager beyond TaleSpire. Players can create and edit
|
|
characters, import them from external tools, and manage their group
|
|
memberships from any browser --- before or between sessions --- without
|
|
TaleSpire open. The Symbiote and web app share the same backend API and
|
|
the same user accounts; there is no separate auth system.
|
|
|
|
**11.1 Scope**
|
|
|
|
+-----------------------------------+-----------------------------------+
|
|
| **In Scope (Web App)** | **Symbiote Only** |
|
|
+===================================+===================================+
|
|
| Create and edit characters (full | Live dice rolling via TS API |
|
|
| sheet) | |
|
|
| | Real-time roll history during |
|
|
| Import characters from JSON / | session |
|
|
| .cmchar file | |
|
|
| | TS.sync group communication |
|
|
| Manual character entry by ruleset | |
|
|
| | Board-aware player/GM detection |
|
|
| Browse and manage groups | |
|
|
| | |
|
|
| Invite players and assign | |
|
|
| characters | |
|
|
| | |
|
|
| Export characters as .cmchar JSON | |
|
|
+-----------------------------------+-----------------------------------+
|
|
|
|
**11.2 Authentication**
|
|
|
|
The web app uses the same /auth/\* endpoints as the Symbiote. Access
|
|
tokens are held in memory only; refresh tokens are stored in an
|
|
HttpOnly, Secure, SameSite=Strict cookie --- more appropriate than
|
|
TS.storage (which is Symbiote-specific) and safer than localStorage.
|
|
|
|
- Login and register pages are fully public routes
|
|
|
|
- All other routes are protected; unauthenticated requests redirect to
|
|
/login
|
|
|
|
- On hard refresh, the app silently calls POST /auth/refresh via the
|
|
cookie before rendering any protected page
|
|
|
|
- Logout revokes the refresh token server-side and clears the cookie,
|
|
then redirects to /login
|
|
|
|
**11.3 Character Import**
|
|
|
|
The import flow accepts two sources: the Campaign Manager\'s own .cmchar
|
|
export format, and generic JSON from any external tool via a field
|
|
mapper. PDF, D&D Beyond, and other integrations are explicitly out of
|
|
scope for v1.
|
|
|
|
**11.3.1 Campaign Manager .cmchar Format**
|
|
|
|
Any character exported from the web app or Symbiote produces a .cmchar
|
|
file --- JSON with a fixed envelope. Importing one requires no field
|
|
mapping:
|
|
|
|
> {
|
|
>
|
|
> \"cm_version\": \"1.0\",
|
|
>
|
|
> \"ruleset_id\": \"dnd5e\",
|
|
>
|
|
> \"name\": \"Aelindra Swiftwind\",
|
|
>
|
|
> \"sheet_data\": { /\* ruleset-specific blob \*/ }
|
|
>
|
|
> }
|
|
|
|
The backend validates ruleset_id against the known registry, then passes
|
|
sheet_data through the ruleset plugin validate() call. Field-level
|
|
errors are surfaced in the UI before any record is created.
|
|
|
|
**11.3.2 Generic JSON Import (Field Mapper)**
|
|
|
|
For exports from Roll20, Foundry VTT, or custom spreadsheets, the web
|
|
app provides a two-column field mapper. The user uploads any JSON file;
|
|
the left column shows detected source fields, the right shows Campaign
|
|
Manager target fields for the chosen ruleset. The user connects them by
|
|
drag or dropdown, previews the mapped result, and confirms.
|
|
|
|
- Required target fields (name, ruleset) must be mapped before import
|
|
is allowed
|
|
|
|
- Unmapped optional fields are silently dropped --- no partial saves
|
|
are blocked
|
|
|
|
- A completed mapping can be saved as a named template for re-use
|
|
across future imports of the same tool\'s exports
|
|
|
|
- The same ruleset validate() call runs on the mapped result before
|
|
the character record is created
|
|
|
|
**11.3.3 Manual Entry**
|
|
|
|
A blank character is created by selecting a ruleset from a dropdown,
|
|
which immediately renders the empty sheet form. The form is produced by
|
|
the same ruleset plugin renderSheet() method used in the Symbiote, so
|
|
both surfaces are always in sync. Auto-save drafts to the backend every
|
|
30 seconds to protect against accidental tab closure.
|
|
|
|
**11.4 Character Editor**
|
|
|
|
The full character editor is a single-page form rendered by the active
|
|
ruleset plugin. Saving issues a PUT /characters/:id. Because both
|
|
clients share the backend, a character saved on the web app is
|
|
immediately available inside TaleSpire --- the Symbiote re-fetches on
|
|
load or on receiving a WebSocket push.
|
|
|
|
- Optimistic UI: changes are reflected locally immediately; on save
|
|
failure the previous value is restored with a toast notification
|
|
|
|
- Edit history: the backend retains the last 10 versions of sheet_data
|
|
per character, accessible via GET /characters/:id/history, so
|
|
accidental overwrites can be undone
|
|
|
|
- Portrait upload: drag-and-drop image accepted, validated for MIME
|
|
type and size (max 2 MB) client-side, then uploaded directly to
|
|
object storage via a backend-issued presigned URL; the resulting URL
|
|
is stored on the character record
|
|
|
|
**11.5 Group Management**
|
|
|
|
The group dashboard gives DMs and players a comfortable full-screen view
|
|
of their campaigns. Pages and their access rules are:
|
|
|
|
-----------------------------------------------------------------------------
|
|
**Route** **Access** **Description**
|
|
------------------------ ------------ ---------------------------------------
|
|
/groups All Card grid of every group the user
|
|
belongs to; create new group button
|
|
|
|
/groups/:id Member Member list with roles, character
|
|
roster per player, pending invites
|
|
|
|
/groups/:id/settings DM only Rename, change ruleset, transfer DM
|
|
role, delete group
|
|
|
|
/groups/:id/invite DM only Generate a shareable link or send
|
|
invite directly by email
|
|
|
|
/characters Owner Full character library; import, create,
|
|
export, or delete characters
|
|
|
|
/characters/:id Owner Full character editor rendered by the
|
|
ruleset plugin
|
|
|
|
/characters/:id/export Owner Download the character as a .cmchar
|
|
JSON file
|
|
-----------------------------------------------------------------------------
|
|
|
|
**11.6 Tech Stack**
|
|
|
|
------------------------------------------------------------------------
|
|
**Layer** **Technology** **Rationale**
|
|
---------------- ------------------ ------------------------------------
|
|
Framework Svelte + Vite Same component model as the Symbiote
|
|
UI; shared ruleset plugin code
|
|
|
|
Styling CSS custom Mirror the TS colorStyles variables;
|
|
properties both UIs stay visually consistent
|
|
|
|
Auth state Svelte store + No token ever touches JS memory
|
|
HttpOnly cookie beyond the active session
|
|
|
|
API layer Centralized fetch Shared with Symbiote wrapper;
|
|
client auto-refresh on 401
|
|
|
|
File handling File API + JSON parsed client-side before
|
|
FileReader upload; no server-side temp file
|
|
storage
|
|
|
|
Image upload Presigned S3 URL Client uploads direct to
|
|
(no PVC) S3-compatible storage; backend
|
|
stores URL only; k8s pods stay
|
|
stateless
|
|
|
|
Hosting Static CDN + Web app is a static build; shares
|
|
existing API the Axum API server with the
|
|
Symbiote
|
|
------------------------------------------------------------------------
|
|
|
|
**11.7 CORS & Security**
|
|
|
|
- Backend CORS allowlist: the web app domain and localhost:8080
|
|
(Symbiote dev mode). No wildcard origins.
|
|
|
|
- CSRF protection via SameSite=Strict on the refresh token cookie ---
|
|
no additional CSRF token required
|
|
|
|
- Content-Security-Policy: default-src \'self\'; connect-src
|
|
restricted to the known API origin; no inline scripts
|
|
|
|
- JSON import validation: files are parsed and schema-checked entirely
|
|
client-side before any data is sent to the backend
|
|
|
|
- Image uploads: MIME type checked against an allowlist (image/png,
|
|
image/jpeg, image/webp) and size capped at 2 MB client-side; backend
|
|
re-validates before issuing the presigned URL
|
|
|
|
**11.8 Sync Between Web App and Symbiote**
|
|
|
|
Both clients write to the same REST API so data is always consistent at
|
|
rest. For changes made while a session is active there are two
|
|
scenarios:
|
|
|
|
- HP change mid-combat (web app to Symbiote): PATCH /characters/:id
|
|
updates the backend; backend pushes a WebSocket event to the group
|
|
room; Symbiote receives and re-renders. Typical latency under 500
|
|
ms.
|
|
|
|
- Backstory edit between sessions (web app): no live sync needed. The
|
|
Symbiote fetches the latest sheet_data on its next startup.
|
|
|
|
The web app does not implement TS.sync --- that channel is internal to
|
|
TaleSpire. All cross-client communication routes through the backend
|
|
WebSocket room keyed to the group ID.
|
|
|
|
**13. Content Service**
|
|
|
|
The Content Service is a standalone Rust + Axum microservice with its
|
|
own PostgreSQL database, deployed independently of the Campaign Service.
|
|
It is the authoritative source for all ruleset reference data: monsters,
|
|
items, spells, talents, rules, rulebooks, and inter-content
|
|
dependencies. The Campaign Service calls the Content Service at runtime
|
|
to power searchable pickers, derived stat calculations, and rule
|
|
validation in the character editor.
|
|
|
|
**13.1 Why a Separate Service**
|
|
|
|
-----------------------------------------------------------------------
|
|
**Concern** **Rationale for Separation**
|
|
----------------------------------- -----------------------------------
|
|
Data ownership Ruleset content is shared global
|
|
data; character/group data is
|
|
per-user. Different access
|
|
patterns, different retention
|
|
rules.
|
|
|
|
Copyright sensitivity Rulebook text and official stat
|
|
blocks may carry licensing
|
|
restrictions. Isolating them in a
|
|
dedicated service makes it easier
|
|
to apply different access controls
|
|
or redact content per jurisdiction.
|
|
|
|
Scaling Content reads are high-volume and
|
|
cache-friendly. Campaign writes are
|
|
low-volume and transactional.
|
|
Independent scaling avoids
|
|
coupling.
|
|
|
|
Deployment cadence New ruleset content ships on its
|
|
own schedule, independently of auth
|
|
or character sheet fixes.
|
|
|
|
Rust workspace Each service is a separate crate in
|
|
a Cargo workspace, sharing common
|
|
types (content_types,
|
|
campaign_types) via internal
|
|
library crates.
|
|
-----------------------------------------------------------------------
|
|
|
|
**13.2 Content Data Model**
|
|
|
|
All content belongs to a ruleset and is versioned. The ruleset_id
|
|
foreign key ties every content record to a specific game system. The
|
|
schema is intentionally flat and generic at the top level, with a JSONB
|
|
data blob for system-specific fields --- the same pattern as sheet_data
|
|
in the Campaign Service.
|
|
|
|
**rulesets (content DB)**
|
|
|
|
---------------------------------------------------------------------------
|
|
**Field** **Type** **Required** **Description**
|
|
---------------------- ---------------- -------------- --------------------
|
|
id VARCHAR(100) Yes Slug, e.g.
|
|
(PK) \'dsa5e\', \'dnd5e\'
|
|
|
|
name VARCHAR(200) Yes Display name, e.g.
|
|
\'Das Schwarze Auge
|
|
5e\'
|
|
|
|
version VARCHAR(50) Yes Ruleset schema
|
|
version, semver
|
|
|
|
maintainer_notes TEXT No Shown in the editor
|
|
UI
|
|
|
|
created_at TIMESTAMPTZ Yes
|
|
---------------------------------------------------------------------------
|
|
|
|
**content_entries**
|
|
|
|
---------------------------------------------------------------------------------------------------
|
|
**Field** **Type** **Required** **Description**
|
|
---------------------- ---------------------------------------- -------------- --------------------
|
|
id UUID (PK) Yes
|
|
|
|
ruleset_id VARCHAR(100) (FK) Yes Links to rulesets.id
|
|
|
|
entry_type VARCHAR(50) Yes One of: spell,
|
|
talent, item,
|
|
monster, rule,
|
|
rulebook, condition,
|
|
trait,
|
|
special_ability
|
|
|
|
slug VARCHAR(200) Yes URL-safe unique
|
|
identifier within a
|
|
ruleset+type, e.g.
|
|
\'fireball\'
|
|
|
|
name VARCHAR(300) Yes Display name
|
|
|
|
source_book VARCHAR(200) No Publication
|
|
reference, e.g.
|
|
\'Core Rulebook
|
|
p.142\'
|
|
|
|
data JSONB Yes Type-specific
|
|
fields; validated
|
|
against entry_type
|
|
schema on write
|
|
|
|
status ENUM(draft,review,approved,deprecated) Yes Content moderation
|
|
state
|
|
|
|
created_by UUID (FK → users) Yes Author of the
|
|
submission
|
|
|
|
approved_by UUID (FK → users) No NULL until approved
|
|
|
|
created_at TIMESTAMPTZ Yes
|
|
|
|
updated_at TIMESTAMPTZ Yes
|
|
---------------------------------------------------------------------------------------------------
|
|
|
|
**content_dependencies**
|
|
|
|
---------------------------------------------------------------------------
|
|
**Field** **Type** **Required** **Description**
|
|
---------------------- ---------------- -------------- --------------------
|
|
from_entry_id UUID (FK) Yes Composite PK with
|
|
to_entry_id +
|
|
dep_type
|
|
|
|
to_entry_id UUID (FK) Yes The required entry
|
|
|
|
dep_type VARCHAR(50) Yes One of: requires,
|
|
excludes, grants,
|
|
replaces, modifies
|
|
|
|
condition JSONB No Optional predicate,
|
|
e.g. { min_level: 3
|
|
}
|
|
---------------------------------------------------------------------------
|
|
|
|
**ruleset_maintainers**
|
|
|
|
---------------------------------------------------------------------------
|
|
**Field** **Type** **Required** **Description**
|
|
---------------------- ---------------- -------------- --------------------
|
|
ruleset_id VARCHAR(100) Yes Composite PK
|
|
(FK)
|
|
|
|
user_id UUID (FK → Yes Composite PK
|
|
users)
|
|
|
|
can_approve BOOLEAN Yes true = can move
|
|
entries to approved
|
|
status
|
|
|
|
granted_by UUID (FK → Yes Admin who assigned
|
|
users) this role
|
|
|
|
granted_at TIMESTAMPTZ Yes
|
|
---------------------------------------------------------------------------
|
|
|
|
The content_dependencies table captures the dependency graph between
|
|
entries. For DSA5e this covers cases like: a special ability requires a
|
|
minimum talent level, a spell has a prerequisite spell, or two traits
|
|
are mutually exclusive. The Campaign Service walks this graph during
|
|
character validation.
|
|
|
|
**13.3 entry_type JSONB Schemas**
|
|
|
|
Each entry_type has a JSON Schema registered in the Content Service.
|
|
Writes are validated server-side against the schema for the declared
|
|
type. This means the Campaign Service and editor UI can always trust
|
|
that data blobs are well-formed. Example schemas for DSA5e:
|
|
|
|
**talent**
|
|
|
|
> { name, category, check_attributes: \[attr, attr, attr\],
|
|
>
|
|
> encumbrance: bool, improvement_cost: \'A\'\|\'B\'\|\'C\'\|\'D\' }
|
|
|
|
**spell**
|
|
|
|
> { name, tradition, property, casting_time, cost: { asp: int },
|
|
>
|
|
> range, duration, target, effect, enhancement_levels: \[\...\] }
|
|
|
|
**monster**
|
|
|
|
> { name, size, lep, ini, at, pa, rs, mr, gs,
|
|
>
|
|
> attacks: \[{ name, dice, damage_type }\], loot: \[\...\] }
|
|
|
|
**item**
|
|
|
|
> { name, item_type, weight, price, availability,
|
|
>
|
|
> combat_stats?: { at_mod, pa_mod, damage, reach } }
|
|
|
|
**13.4 Access Control & Moderation**
|
|
|
|
Content goes through a three-stage lifecycle: draft → review → approved.
|
|
Only approved entries are returned by the public read API that the
|
|
Campaign Service calls. This keeps unreviewed community submissions from
|
|
reaching character sheets.
|
|
|
|
-----------------------------------------------------------------------
|
|
**Role** **Permissions**
|
|
------------------- ---------------------------------------------------
|
|
Any registered user Submit new entries (status = draft); edit own draft
|
|
entries; view all approved entries
|
|
|
|
Ruleset maintainer All of the above, plus: move entries to review or
|
|
approved; edit any entry in their ruleset;
|
|
deprecate entries
|
|
|
|
Admin All of the above across all rulesets; grant/revoke
|
|
maintainer roles; delete entries; manage rulesets
|
|
-----------------------------------------------------------------------
|
|
|
|
- A maintainer can_approve flag distinguishes senior maintainers (who
|
|
can publish) from junior contributors (who can submit to review but
|
|
not approve)
|
|
|
|
- Approval of an entry that has unresolved dependency entries is
|
|
blocked --- all deps must be approved first
|
|
|
|
- Deprecation is soft: deprecated entries remain on existing character
|
|
sheets but are hidden from pickers in the editor
|
|
|
|
**13.5 Content Service API**
|
|
|
|
Base URL: https://content.campaign-manager.example.com/v1
|
|
|
|
**Public Read Endpoints (called by Campaign Service and web editors)**
|
|
|
|
---------------------------------------------------------------------------
|
|
**Method + Path** **Description**
|
|
----------------------------- ---------------------------------------------
|
|
GET /rulesets List all rulesets with entry counts
|
|
|
|
GET /rulesets/:id/entries Search entries; filter by entry_type, text
|
|
query, status. Returns approved only unless
|
|
caller is maintainer/admin.
|
|
|
|
GET /entries/:id Full entry including data blob
|
|
|
|
GET /entries/:id/dependencies Full resolved dependency graph (recursive)
|
|
|
|
POST Given a partial sheet_data, check all dep
|
|
/entries/:id/validate-sheet constraints. Returns list of violations.
|
|
---------------------------------------------------------------------------
|
|
|
|
**Authenticated Write Endpoints (users, maintainers, admins)**
|
|
|
|
--------------------------------------------------------------------------------
|
|
**Method + Path** **Description**
|
|
---------------------------------- ---------------------------------------------
|
|
POST /rulesets/:id/entries Submit a new entry (status = draft)
|
|
|
|
PATCH /entries/:id Edit an entry (own drafts, or
|
|
maintainer/admin for any)
|
|
|
|
POST /entries/:id/status Transition status (maintainer:
|
|
draft→review→approved; admin: any)
|
|
|
|
DELETE /entries/:id Hard delete (admin only; soft deprecation
|
|
preferred)
|
|
|
|
POST /entries/:id/dependencies Add a dependency edge
|
|
|
|
DELETE Remove a dependency edge
|
|
/entries/:id/dependencies/:depId
|
|
|
|
POST /rulesets/:id/maintainers Grant maintainer role (admin only)
|
|
--------------------------------------------------------------------------------
|
|
|
|
**13.6 Service-to-Service Communication**
|
|
|
|
The Campaign Service calls the Content Service over internal HTTP
|
|
(cluster-internal DNS in Kubernetes). Calls use a shared service account
|
|
JWT, not a user token. There are three integration points:
|
|
|
|
- Picker search: when a user opens a talent or spell picker in the
|
|
character editor, the Campaign Service proxies a GET
|
|
/rulesets/:id/entries request with the user\'s search term and
|
|
returns the results to the Svelte frontend. The frontend never calls
|
|
the Content Service directly.
|
|
|
|
- Derived stat calculation: the campaign service fetches the relevant
|
|
entries (weapon, talent, attributes) and passes them to the ruleset
|
|
plugin\'s calculate() function, which returns updated derived
|
|
values. The result is written back to sheet_data.
|
|
|
|
- Validation: on every character save (PUT /characters/:id), the
|
|
Campaign Service calls POST /entries/:id/validate-sheet for each
|
|
selected ability or item. Any violations are returned as a
|
|
structured error list before the save is committed.
|
|
|
|
Response caching: approved content is immutable once published. The
|
|
Campaign Service caches Content Service responses in-process (a simple
|
|
DashMap\<entry_id, CachedEntry\> in Rust) with a 5-minute TTL, reducing
|
|
cross-service latency for the most common picker lookups.
|
|
|
|
**13.7 Content Editor Web App**
|
|
|
|
A second web application (content.campaign-manager.example.com) provides
|
|
a full editing UI for the content database, separate from the character
|
|
management web app. It is a Svelte + Vite static build deployed to the
|
|
same CDN.
|
|
|
|
**Editor Pages**
|
|
|
|
-----------------------------------------------------------------------------------
|
|
**Route** **Access** **Description**
|
|
------------------------------ ------------ ---------------------------------------
|
|
/ All Ruleset browser; search across all
|
|
approved content
|
|
|
|
/:rulesetId All Ruleset overview: entry counts by type,
|
|
maintainer list, recent activity
|
|
|
|
/:rulesetId/entries All Filterable table of all entries; status
|
|
badges; click to view
|
|
|
|
/:rulesetId/entries/new Auth user Entry submission form; type selector
|
|
renders appropriate JSONB fields
|
|
|
|
/:rulesetId/entries/:id All Full entry view with dependency graph
|
|
(approved) / visualisation
|
|
Auth (draft)
|
|
|
|
/:rulesetId/entries/:id/edit Owner / Full entry editor with field validation
|
|
Maintainer / against entry_type schema
|
|
Admin
|
|
|
|
/review-queue Maintainer / All entries in draft or review status
|
|
Admin awaiting action
|
|
|
|
/admin/maintainers Admin Grant and revoke maintainer roles per
|
|
ruleset
|
|
-----------------------------------------------------------------------------------
|
|
|
|
**Dependency Graph View**
|
|
|
|
The entry detail page renders the dependency graph as an interactive SVG
|
|
using a force-directed layout. Nodes are colour-coded by entry_type;
|
|
edges are labelled with dep_type (requires, excludes, grants, replaces,
|
|
modifies). Clicking any node navigates to that entry. This gives
|
|
maintainers an immediate visual overview of complex prerequisite chains
|
|
--- especially important for DSA5e\'s special ability trees.
|
|
|
|
**Entry Form**
|
|
|
|
The submission and edit forms are data-driven: selecting an entry_type
|
|
loads the JSON Schema for that type and renders the appropriate fields
|
|
dynamically. Required fields are marked; enum fields render as
|
|
dropdowns; nested arrays (e.g. enhancement_levels on a spell) render as
|
|
repeatable field groups. Client-side schema validation gives inline
|
|
errors before the API is called.
|
|
|
|
**13.8 Storage --- No PVC**
|
|
|
|
The Content Service, like the Campaign Service, runs with no Persistent
|
|
Volume Claims in Kubernetes. All persistence is in the Content
|
|
Service\'s dedicated PostgreSQL instance. There is no file storage in
|
|
the Content Service --- rulebook PDFs or source images are out of scope;
|
|
the service stores only structured data.
|
|
|
|
**13.9 Shared Auth**
|
|
|
|
Both services validate JWTs issued by the same auth system (shared
|
|
public key). The user identity (user_id, email) and a roles claim are
|
|
embedded in the token. The roles claim includes an array of ruleset
|
|
maintainer grants, e.g.:
|
|
|
|
> { \"sub\": \"uuid\", \"roles\": \[\"maintainer:dsa5e\", \"admin\"\] }
|
|
|
|
This means the Content Service does not need to call the Campaign
|
|
Service to check permissions --- it reads the token directly. The
|
|
Campaign Service issues tokens; both services consume them.
|
|
|
|
**14. Deployment & Repository Layout**
|
|
|
|
The project is a monorepo with two top-level workspaces: a Cargo
|
|
workspace for the two Rust backend services, and a pnpm workspace for
|
|
the three Svelte frontend surfaces. The Symbiote is not a Docker image
|
|
--- it is a static file package distributed via mod.io. The two web
|
|
frontends and the two backend services each produce a Docker image, for
|
|
four images total.
|
|
|
|
**14.1 Monorepo Structure**
|
|
|
|
> campaign-manager/
|
|
>
|
|
> ├── backend/ \# Cargo workspace
|
|
>
|
|
> │ ├── Cargo.toml \# workspace root
|
|
>
|
|
> │ ├── common/ \# shared library crate
|
|
>
|
|
> │ │ └── src/ \# JWT types, error types, content_types, campaign_types
|
|
>
|
|
> │ ├── campaign-service/ \# binary crate
|
|
>
|
|
> │ │ ├── Cargo.toml
|
|
>
|
|
> │ │ ├── Dockerfile
|
|
>
|
|
> │ │ └── src/
|
|
>
|
|
> │ └── content-service/ \# binary crate
|
|
>
|
|
> │ ├── Cargo.toml
|
|
>
|
|
> │ ├── Dockerfile
|
|
>
|
|
> │ └── src/
|
|
>
|
|
> ├── frontend/ \# pnpm workspace
|
|
>
|
|
> │ ├── package.json \# workspace root
|
|
>
|
|
> │ ├── packages/
|
|
>
|
|
> │ │ ├── ruleset-core/ \# plugin interface + DSA5e plugin
|
|
>
|
|
> │ │ ├── ui-components/ \# shared Svelte components
|
|
>
|
|
> │ │ └── api-client/ \# shared fetch wrapper (auto-refresh)
|
|
>
|
|
> │ └── apps/
|
|
>
|
|
> │ ├── symbiote/ \# TaleSpire Symbiote --- NOT a Docker image
|
|
>
|
|
> │ │ ├── manifest.json
|
|
>
|
|
> │ │ └── src/
|
|
>
|
|
> │ ├── campaign-web/ \# Docker image
|
|
>
|
|
> │ │ ├── Dockerfile
|
|
>
|
|
> │ │ └── src/
|
|
>
|
|
> │ └── content-editor/ \# Docker image
|
|
>
|
|
> │ ├── Dockerfile
|
|
>
|
|
> │ └── src/
|
|
>
|
|
> ├── k8s/ \# Kubernetes manifests
|
|
>
|
|
> │ ├── campaign-service/
|
|
>
|
|
> │ ├── content-service/
|
|
>
|
|
> │ ├── campaign-web/
|
|
>
|
|
> │ ├── content-editor/
|
|
>
|
|
> │ └── ingress/
|
|
>
|
|
> └── .github/workflows/ \# CI/CD pipelines
|
|
|
|
**14.2 Docker Images**
|
|
|
|
All four Docker images use multi-stage builds. The Rust images compile
|
|
in a builder stage and copy only the final static binary into a minimal
|
|
runtime image, keeping production images small and attack-surface
|
|
minimal.
|
|
|
|
**14.2.1 campaign-service and content-service (Rust)**
|
|
|
|
> \# backend/campaign-service/Dockerfile
|
|
>
|
|
> FROM rust:1.77-slim AS builder
|
|
>
|
|
> WORKDIR /build
|
|
>
|
|
> COPY . .
|
|
>
|
|
> RUN cargo build \--release -p campaign-service
|
|
>
|
|
> FROM debian:bookworm-slim
|
|
>
|
|
> RUN apt-get update && apt-get install -y ca-certificates && rm -rf
|
|
> /var/lib/apt/lists/\*
|
|
>
|
|
> COPY \--from=builder /build/target/release/campaign-service
|
|
> /usr/local/bin/
|
|
>
|
|
> EXPOSE 8080
|
|
>
|
|
> CMD \[\"campaign-service\"\]
|
|
|
|
The content-service Dockerfile is identical with the binary name
|
|
swapped. Final images land under 30 MB. The Cargo workspace COPY means
|
|
both Dockerfiles copy the full workspace, so the common crate is always
|
|
available --- Docker layer caching on the dependency compile step keeps
|
|
rebuild times fast in CI.
|
|
|
|
**14.2.2 campaign-web and content-editor (Svelte)**
|
|
|
|
> \# frontend/apps/campaign-web/Dockerfile
|
|
>
|
|
> FROM node:20-slim AS builder
|
|
>
|
|
> RUN npm install -g pnpm
|
|
>
|
|
> WORKDIR /build
|
|
>
|
|
> COPY frontend/ .
|
|
>
|
|
> RUN pnpm install \--frozen-lockfile
|
|
>
|
|
> RUN pnpm \--filter campaign-web build
|
|
>
|
|
> FROM nginx:alpine
|
|
>
|
|
> COPY \--from=builder /build/apps/campaign-web/dist
|
|
> /usr/share/nginx/html
|
|
>
|
|
> COPY frontend/apps/campaign-web/nginx.conf
|
|
> /etc/nginx/conf.d/default.conf
|
|
>
|
|
> EXPOSE 80
|
|
|
|
The nginx.conf sets try_files \$uri \$uri/ /index.html for SPA routing
|
|
and adds cache headers: immutable long-cache for hashed assets, no-store
|
|
for index.html. The content-editor Dockerfile is identical with the app
|
|
name swapped.
|
|
|
|
**14.2.3 Image Summary**
|
|
|
|
-------------------------------------------------------------------------------
|
|
**Image** **Base** **Est. **Serves**
|
|
Size**
|
|
--------------------- ---------------------- ----------- ----------------------
|
|
campaign-service debian:bookworm-slim \~25 MB Auth, groups,
|
|
characters, dice
|
|
history
|
|
|
|
content-service debian:bookworm-slim \~25 MB Rulesets, entries,
|
|
dependencies,
|
|
moderation
|
|
|
|
campaign-web nginx:alpine \~15 MB Campaign management
|
|
Svelte SPA
|
|
|
|
content-editor nginx:alpine \~12 MB Content editor Svelte
|
|
SPA
|
|
|
|
symbiote N/A \~2 MB zip Distributed via mod.io
|
|
--- not a container
|
|
-------------------------------------------------------------------------------
|
|
|
|
**14.3 Kubernetes Manifests**
|
|
|
|
Each service gets its own directory under k8s/. The cluster has no PVCs
|
|
--- all state lives in managed PostgreSQL instances (one per service)
|
|
and S3-compatible object storage. Config and secrets are injected via
|
|
ConfigMaps and Secrets respectively; nothing sensitive is baked into an
|
|
image.
|
|
|
|
**14.3.1 campaign-service Deployment**
|
|
|
|
> \# k8s/campaign-service/deployment.yaml
|
|
>
|
|
> apiVersion: apps/v1
|
|
>
|
|
> kind: Deployment
|
|
>
|
|
> metadata:
|
|
>
|
|
> name: campaign-service
|
|
>
|
|
> spec:
|
|
>
|
|
> replicas: 2
|
|
>
|
|
> template:
|
|
>
|
|
> spec:
|
|
>
|
|
> containers:
|
|
>
|
|
> \- name: campaign-service
|
|
>
|
|
> image: ghcr.io/yourorg/campaign-service:latest
|
|
>
|
|
> ports:
|
|
>
|
|
> \- containerPort: 8080
|
|
>
|
|
> envFrom:
|
|
>
|
|
> \- configMapRef:
|
|
>
|
|
> name: campaign-config
|
|
>
|
|
> \- secretRef:
|
|
>
|
|
> name: campaign-secrets
|
|
>
|
|
> readinessProbe:
|
|
>
|
|
> httpGet: { path: /health, port: 8080 }
|
|
>
|
|
> resources:
|
|
>
|
|
> requests: { cpu: 100m, memory: 64Mi }
|
|
>
|
|
> limits: { cpu: 500m, memory: 256Mi }
|
|
|
|
The content-service Deployment is structured identically. Both services
|
|
expose a /health endpoint that checks database connectivity.
|
|
|
|
**14.3.2 ConfigMaps and Secrets**
|
|
|
|
---------------------------------------------------------------------------
|
|
**Key** **Kind** **Used by**
|
|
---------------------- ------------ ---------------------------------------
|
|
DATABASE_URL Secret campaign-service --- Postgres
|
|
connection string
|
|
|
|
CONTENT_DATABASE_URL Secret content-service --- Postgres connection
|
|
string
|
|
|
|
JWT_PRIVATE_KEY Secret campaign-service --- issues tokens
|
|
|
|
JWT_PUBLIC_KEY Secret Both services --- verifies tokens
|
|
|
|
S3_BUCKET / Secret campaign-service --- portrait uploads
|
|
S3_ENDPOINT / S3_KEY
|
|
|
|
CONTENT_SERVICE_URL ConfigMap campaign-service --- internal URL for
|
|
content calls
|
|
|
|
CORS_ORIGINS ConfigMap Both services --- allowlisted web app
|
|
origins
|
|
|
|
VITE_API_BASE_URL ConfigMap campaign-web, content-editor ---
|
|
injected at build time
|
|
---------------------------------------------------------------------------
|
|
|
|
**14.3.3 Services and Ingress**
|
|
|
|
> \# Internal cluster services (ClusterIP --- not externally reachable)
|
|
>
|
|
> campaign-service:8080 → campaign pods
|
|
>
|
|
> content-service:8080 → content pods
|
|
>
|
|
> \# Ingress (TLS terminated at ingress controller)
|
|
>
|
|
> api.campaign-manager.example.com → campaign-service
|
|
>
|
|
> content.campaign-manager.example.com → content-service (public read)
|
|
>
|
|
> \+ content-editor (nginx, static)
|
|
>
|
|
> campaign-manager.example.com → campaign-web (nginx, static)
|
|
|
|
The campaign-web and content-editor nginx pods are fronted by the same
|
|
ingress controller. Static asset paths (/assets/\*) have a separate
|
|
ingress rule that sets long cache headers at the ingress level in
|
|
addition to what nginx returns.
|
|
|
|
**14.4 Database Migrations**
|
|
|
|
Each Rust service owns its own migrations using sqlx-migrate. Migrations
|
|
run automatically at startup before the HTTP server binds, guaranteeing
|
|
the schema is always in sync with the binary. Rolling back is done by
|
|
deploying the previous image version; down-migrations are written for
|
|
every change.
|
|
|
|
> \# Embedded in each binary --- no separate init container required
|
|
>
|
|
> sqlx::migrate!(\'./migrations\').run(&pool).await?;
|
|
|
|
**14.5 CI/CD Pipelines**
|
|
|
|
GitHub Actions runs three pipelines on pull request and on merge to
|
|
main.
|
|
|
|
**backend.yml --- Rust workspace**
|
|
|
|
- cargo fmt \--check and cargo clippy \--deny warnings
|
|
|
|
- cargo test \--workspace (unit + integration tests against a test
|
|
Postgres spun up via a service container)
|
|
|
|
- cargo build \--release for both binaries
|
|
|
|
- docker build + push to GHCR for campaign-service and content-service
|
|
on merge to main
|
|
|
|
- kubectl rollout restart deployment/campaign-service and
|
|
content-service in the target cluster
|
|
|
|
**frontend.yml --- pnpm workspace**
|
|
|
|
- pnpm install \--frozen-lockfile
|
|
|
|
- pnpm \--filter ruleset-core test and pnpm \--filter api-client test
|
|
|
|
- pnpm \--filter campaign-web build and pnpm \--filter content-editor
|
|
build
|
|
|
|
- docker build + push to GHCR for campaign-web and content-editor on
|
|
merge to main
|
|
|
|
- kubectl rollout restart deployment/campaign-web and content-editor
|
|
on merge to main
|
|
|
|
**symbiote.yml --- mod.io release**
|
|
|
|
- pnpm \--filter symbiote build (outputs to apps/symbiote/dist/)
|
|
|
|
- zip -r symbiote-v\$VERSION.zip manifest.json dist/
|
|
|
|
- Upload to mod.io via the mod.io REST API using a repository secret
|
|
MOD_IO_API_KEY
|
|
|
|
- Triggered only on a version tag push (v\*.\*.\*) --- not on every
|
|
merge to main
|
|
|
|
- GitHub Release created automatically with the zip attached as an
|
|
asset
|
|
|
|
**14.6 Local Development**
|
|
|
|
A docker-compose.yml at the repo root spins up the two Postgres
|
|
databases and a MinIO instance (S3-compatible) for local development.
|
|
The Rust services and Svelte apps run natively on the host,
|
|
hot-reloading against the containerised datastores.
|
|
|
|
> services:
|
|
>
|
|
> campaign-db:
|
|
>
|
|
> image: postgres:16-alpine
|
|
>
|
|
> environment: { POSTGRES_DB: campaign, POSTGRES_PASSWORD: dev }
|
|
>
|
|
> ports: \[\'5432:5432\'\]
|
|
>
|
|
> content-db:
|
|
>
|
|
> image: postgres:16-alpine
|
|
>
|
|
> environment: { POSTGRES_DB: content, POSTGRES_PASSWORD: dev }
|
|
>
|
|
> ports: \[\'5433:5432\'\]
|
|
>
|
|
> minio:
|
|
>
|
|
> image: minio/minio
|
|
>
|
|
> command: server /data
|
|
>
|
|
> ports: \[\'9000:9000\', \'9001:9001\'\]
|
|
|
|
The Symbiote is developed by pointing TaleSpire at the local Symbiote
|
|
directory (apps/symbiote/) and running pnpm \--filter symbiote dev,
|
|
which writes changes into that directory on every save, triggering
|
|
TaleSpire\'s live-reload.
|
|
|
|
**15. Caching Strategy**
|
|
|
|
Caching is handled by two isolated Valkey instances --- one per backend
|
|
service --- plus HTTP cache headers for static frontend assets. Valkey
|
|
is a fully open-source Redis-compatible store; the Rust services connect
|
|
using the redis-rs crate. Each service also maintains a small in-process
|
|
L1 cache (moka) in front of Valkey for the hottest reads, giving a
|
|
two-tier cache hierarchy with no cross-service coupling.
|
|
|
|
**15.1 Two-Tier Cache Architecture**
|
|
|
|
-------------------------------------------------------------------------
|
|
**Tier** **Technology** **Scope** **Typical TTL**
|
|
------------ ------------------ ------------------ ----------------------
|
|
L1 moka (in-process) Single pod, 5--30 seconds
|
|
per-request dedup
|
|
|
|
L2 Valkey (shared All pods of one Minutes to hours, or
|
|
across pods) service until invalidated
|
|
|
|
L3 PostgreSQL Source of truth N/A
|
|
-------------------------------------------------------------------------
|
|
|
|
On a cache read: L1 is checked first. On L1 miss, L2 (Valkey) is
|
|
checked. On L2 miss, the database is queried and the result is written
|
|
back to both L1 and L2. On a cache write (active invalidation): the
|
|
database is updated first, then the Valkey key is deleted, then the L1
|
|
entry is evicted. The next read from any pod repopulates both tiers from
|
|
the fresh database value.
|
|
|
|
**15.2 Campaign Service Valkey**
|
|
|
|
The campaign-service Valkey instance handles three concerns: refresh
|
|
token storage, character sheet caching, and JWT public-key caching for
|
|
inbound content-service tokens.
|
|
|
|
**15.2.1 Refresh Token Store**
|
|
|
|
Refresh tokens are stored in Valkey with a TTL matching their validity
|
|
window (30 days). On logout or token rotation, the old token is deleted
|
|
immediately. This is the one case where Valkey is the primary store
|
|
rather than a cache --- the token record does not exist in PostgreSQL.
|
|
|
|
> \# Key pattern
|
|
>
|
|
> refresh_token:{token_hash} → { user_id, issued_at } TTL: 30d
|
|
>
|
|
> \# On login: SET refresh_token:{hash} {payload} EX 2592000
|
|
>
|
|
> \# On refresh: DEL refresh_token:{old_hash}
|
|
>
|
|
> \# SET refresh_token:{new_hash} {payload} EX 2592000
|
|
>
|
|
> \# On logout: DEL refresh_token:{hash}
|
|
|
|
Because refresh tokens are security-critical, this Valkey instance is
|
|
configured with appendonly yes (AOF persistence) so tokens survive a pod
|
|
restart. This is the one exception to the no-PVC rule --- the
|
|
campaign-service Valkey gets a small PVC (1 Gi) for AOF data.
|
|
|
|
**15.2.2 Character Sheet Cache**
|
|
|
|
Character sheets are read frequently during sessions (Symbiote polls on
|
|
reconnect; web app loads on navigation) but written infrequently. Active
|
|
invalidation on write ensures any pod always serves the current sheet.
|
|
|
|
> \# Key pattern
|
|
>
|
|
> character:{character_id} → JSON sheet payload TTL: 1h (safety
|
|
> fallback)
|
|
>
|
|
> \# On GET /characters/:id: check L1 → check Valkey → query DB →
|
|
> backfill
|
|
>
|
|
> \# On PUT/PATCH /characters/:id: write DB → DEL character:{id} → evict
|
|
> L1
|
|
|
|
The 1-hour TTL is a safety fallback only --- active invalidation means
|
|
the key is deleted on every write, so stale reads should not occur in
|
|
practice. The TTL catches any edge case where invalidation fails (e.g. a
|
|
pod crash mid-write).
|
|
|
|
**15.2.3 Group Membership Cache**
|
|
|
|
Permission checks on group endpoints (is this user a member? are they
|
|
the DM?) are performed on nearly every request. Caching the membership
|
|
record avoids a database round-trip on each check.
|
|
|
|
> \# Key pattern
|
|
>
|
|
> group_member:{group_id}:{user_id} → { role } TTL: 10m
|
|
>
|
|
> \# Invalidated on: member add, member remove, DM transfer
|
|
|
|
**15.3 Content Service Valkey**
|
|
|
|
The content-service Valkey instance caches content entries, dependency
|
|
graphs, and ruleset metadata. Content is read extremely frequently
|
|
(every picker interaction, every character save validation) but changes
|
|
only when a maintainer approves or edits an entry. Active invalidation
|
|
on write makes this straightforward.
|
|
|
|
**15.3.1 Entry Cache**
|
|
|
|
> \# Key pattern
|
|
>
|
|
> entry:{entry_id} → full entry JSON TTL: 24h
|
|
>
|
|
> entry_list:{ruleset_id}:{type} → list of entry IDs TTL: 10m
|
|
>
|
|
> entry_search:{ruleset_id}:{hash_of_query} → result list TTL: 5m
|
|
|
|
Individual entry records are cached with a long TTL because active
|
|
invalidation handles freshness. Search result lists use a shorter TTL
|
|
since they are harder to invalidate precisely --- a new approval could
|
|
change any search result.
|
|
|
|
**15.3.2 Active Invalidation on Approval**
|
|
|
|
When a maintainer transitions an entry to approved, or edits an existing
|
|
approved entry, the content-service invalidates aggressively:
|
|
|
|
> // In Rust --- on entry status change or edit
|
|
>
|
|
> valkey.del(format!(\"entry:{}\", entry_id)).await?;
|
|
>
|
|
> valkey.del(format!(\"entry_list:{}:{}\", ruleset_id,
|
|
> entry_type)).await?;
|
|
>
|
|
> // Flush all search keys for this ruleset (pattern delete)
|
|
>
|
|
> let keys: Vec\<String\> = valkey.keys(
|
|
>
|
|
> format!(\"entry_search:{}:\*\", ruleset_id)
|
|
>
|
|
> ).await?;
|
|
>
|
|
> if !keys.is_empty() {
|
|
>
|
|
> valkey.del(keys).await?;
|
|
>
|
|
> }
|
|
|
|
Pattern deletes (KEYS) are used here because the search key space is
|
|
small and writes are infrequent. For a large-scale deployment this could
|
|
be replaced with a dedicated search invalidation topic, but for v1 the
|
|
pattern scan is acceptable.
|
|
|
|
**15.3.3 Dependency Graph Cache**
|
|
|
|
Dependency graphs are the most expensive reads --- resolving a deep
|
|
prerequisite chain requires recursive joins. The resolved graph for each
|
|
entry is cached as a single serialised JSON blob.
|
|
|
|
> \# Key pattern
|
|
>
|
|
> dep_graph:{entry_id} → resolved graph JSON TTL: 24h
|
|
>
|
|
> \# Invalidated when: the entry itself changes, OR any entry in its
|
|
>
|
|
> \# dependency graph changes. On any dep edge add/remove:
|
|
>
|
|
> \# DEL dep_graph:{from_entry_id}
|
|
>
|
|
> \# DEL dep_graph:{to_entry_id} (graph may be traversed from either
|
|
> end)
|
|
|
|
**15.3.4 Ruleset Metadata Cache**
|
|
|
|
> ruleset:{ruleset_id} → ruleset metadata JSON TTL: 1h
|
|
>
|
|
> ruleset_list → list of all rulesets TTL: 1h
|
|
|
|
Ruleset records change rarely (new rulesets are infrequent). A 1-hour
|
|
TTL with active invalidation on any ruleset write is sufficient.
|
|
|
|
**15.4 Key Naming Conventions**
|
|
|
|
Both services follow a consistent key naming scheme to make debugging
|
|
and monitoring straightforward:
|
|
|
|
-------------------------------------------------------------------------------------
|
|
**Pattern** **Service** **Notes**
|
|
---------------------------------------- ------------- ------------------------------
|
|
refresh_token:{hash} Campaign Security-critical; AOF
|
|
persisted
|
|
|
|
character:{id} Campaign Active invalidation on every
|
|
write
|
|
|
|
group_member:{group_id}:{user_id} Campaign Short TTL; invalidated on
|
|
membership change
|
|
|
|
entry:{id} Content Active invalidation on
|
|
approval/edit
|
|
|
|
entry_list:{ruleset_id}:{type} Content Active invalidation on any
|
|
entry change in ruleset
|
|
|
|
entry_search:{ruleset_id}:{query_hash} Content Short TTL; pattern-deleted on
|
|
ruleset changes
|
|
|
|
dep_graph:{id} Content Active invalidation on dep
|
|
edge changes
|
|
|
|
ruleset:{id} / ruleset_list Content TTL-based; active invalidation
|
|
on ruleset write
|
|
-------------------------------------------------------------------------------------
|
|
|
|
**15.5 Kubernetes Deployment**
|
|
|
|
Each Valkey instance runs as a single-replica StatefulSet with a PVC.
|
|
The campaign-service Valkey requires AOF persistence for refresh tokens;
|
|
the content-service Valkey uses RDB snapshots only (content is fully
|
|
recoverable from the database on a cold start).
|
|
|
|
> \# k8s/campaign-valkey/statefulset.yaml (abbreviated)
|
|
>
|
|
> kind: StatefulSet
|
|
>
|
|
> metadata:
|
|
>
|
|
> name: campaign-valkey
|
|
>
|
|
> spec:
|
|
>
|
|
> replicas: 1
|
|
>
|
|
> template:
|
|
>
|
|
> spec:
|
|
>
|
|
> containers:
|
|
>
|
|
> \- name: valkey
|
|
>
|
|
> image: valkey/valkey:7-alpine
|
|
>
|
|
> args: \[\"\--appendonly\", \"yes\"\] \# AOF for campaign-valkey
|
|
>
|
|
> volumeMounts:
|
|
>
|
|
> \- name: data
|
|
>
|
|
> mountPath: /data
|
|
>
|
|
> volumeClaimTemplates:
|
|
>
|
|
> \- metadata:
|
|
>
|
|
> name: data
|
|
>
|
|
> spec:
|
|
>
|
|
> accessModes: \[ReadWriteOnce\]
|
|
>
|
|
> resources:
|
|
>
|
|
> requests:
|
|
>
|
|
> storage: 1Gi
|
|
|
|
The content-service Valkey StatefulSet is identical except \--appendonly
|
|
yes is omitted. Its PVC is 512 Mi --- content cache is warm within
|
|
minutes of a cold start from the database.
|
|
|
|
--------------------------------------------------------------------------------------------
|
|
**Instance** **Persistence** **PVC** **AOF** **RDB** **Used by**
|
|
------------------- ----------------- ------------ ----------- ---------- ------------------
|
|
campaign-valkey Required (tokens) 1 Gi Yes Yes campaign-service
|
|
|
|
content-valkey Warm-up only 512 Mi No Yes content-service
|
|
--------------------------------------------------------------------------------------------
|
|
|
|
**15.6 Static Asset Caching (Frontend)**
|
|
|
|
The two nginx containers (campaign-web, content-editor) serve Svelte
|
|
build output where all JS/CSS filenames include a content hash (e.g.
|
|
main.a3f92c.js). This enables aggressive CDN caching with safe
|
|
long-lived headers.
|
|
|
|
-------------------------------------------------------------------------
|
|
**Path pattern** **Cache-Control** **Rationale**
|
|
------------------ ------------------- ----------------------------------
|
|
/assets/\* max-age=31536000, Content-hashed filenames; safe to
|
|
(hashed) immutable cache for 1 year
|
|
|
|
/index.html no-store Must always be fresh so new asset
|
|
hashes are discovered
|
|
|
|
/manifest.json no-store TaleSpire re-reads this to detect
|
|
(Symbiote) updates
|
|
|
|
API responses no-store Dynamic data; never cached at CDN
|
|
(/v1/\*) or browser
|
|
-------------------------------------------------------------------------
|
|
|
|
A CDN (e.g. Cloudflare) can be placed in front of both nginx
|
|
deployments. Because index.html is no-store, a new deployment is picked
|
|
up by all clients on their next page load without a cache purge step.
|
|
|
|
**15.7 Cache Miss Behaviour & Resilience**
|
|
|
|
Both services are designed to function correctly with Valkey completely
|
|
unavailable --- degraded performance but no outage. Every cache read is
|
|
wrapped in a fallback to the database:
|
|
|
|
> // Rust pseudocode --- applies to both services
|
|
>
|
|
> async fn get_entry(id: Uuid, valkey: &Valkey, db: &PgPool) -\>
|
|
> Result\<Entry\> {
|
|
>
|
|
> // L1: in-process moka cache
|
|
>
|
|
> if let Some(entry) = L1_CACHE.get(&id) { return Ok(entry); }
|
|
>
|
|
> // L2: Valkey
|
|
>
|
|
> match valkey.get::\<Entry\>(&format!(\"entry:{id}\")).await {
|
|
>
|
|
> Ok(Some(entry)) =\> { L1_CACHE.insert(id, entry.clone()); return
|
|
> Ok(entry); }
|
|
>
|
|
> Ok(None) =\> {} // cache miss --- fall through
|
|
>
|
|
> Err(\_) =\> {} // Valkey unavailable --- fall through
|
|
>
|
|
> }
|
|
>
|
|
> // L3: database
|
|
>
|
|
> let entry = db.query_entry(id).await?;
|
|
>
|
|
> let \_ = valkey.set_ex(&format!(\"entry:{id}\"), &entry, 86400).await;
|
|
>
|
|
> L1_CACHE.insert(id, entry.clone());
|
|
>
|
|
> Ok(entry)
|
|
>
|
|
> }
|
|
|
|
Valkey errors are logged as warnings but never propagated as request
|
|
errors. A Valkey outage increases database load but does not cause
|
|
user-facing failures.
|
|
|
|
**16. Implementation Plan**
|
|
|
|
This plan is optimised for a solo developer targeting the fastest path
|
|
to something playable at the table. Each phase ends with a concrete,
|
|
usable milestone. Later phases are intentionally deferred until the
|
|
earlier ones are proven --- complexity is introduced only when it is
|
|
needed.
|
|
|
|
Assumed starting point: Kubernetes cluster available; Rust and Svelte
|
|
are new. The plan accounts for a learning curve on both.
|
|
|
|
**16.1 Phase Overview**
|
|
|
|
--------------------------------------------------------------------------
|
|
**\#** **Phase** **Milestone** **Defers**
|
|
-------- ---------------- ------------------------- ----------------------
|
|
0 Foundations Monorepo builds locally; Everything else
|
|
CI runs; DB migrations
|
|
apply
|
|
|
|
1 Vertical Slice One player can log in, Groups, multi-user,
|
|
see a DSA5e sheet, roll web app, content
|
|
dice in TaleSpire service, Valkey
|
|
|
|
2 Groups & DM creates group, invites Web app, content
|
|
Sessions players, all see each service, Valkey,
|
|
other\'s sheets campaign journal
|
|
|
|
3 Campaign Web App Characters created and Content service,
|
|
edited in browser; JSON Valkey, pickers,
|
|
import works auto-calc
|
|
|
|
4 Caching Valkey added to both Content service
|
|
services; character
|
|
sheets and tokens cached
|
|
|
|
5 Content Service DSA5e entries in the Community
|
|
content DB; pickers and contributions,
|
|
validation live in editor maintainer roles
|
|
|
|
6 Polish & Campaign journal, offline ---
|
|
Community mode, mod.io publish,
|
|
community content
|
|
submissions
|
|
--------------------------------------------------------------------------
|
|
|
|
**16.2 Phase 0 --- Foundations**
|
|
|
|
Goal: a working monorepo skeleton where both Rust services compile, both
|
|
Svelte apps build, CI passes, and database migrations apply cleanly. No
|
|
features yet --- this phase is about eliminating all infrastructure
|
|
friction before writing any product code.
|
|
|
|
**Tasks**
|
|
|
|
- Initialise Cargo workspace with campaign-service, content-service,
|
|
and common crates. Add sqlx, axum, tokio, and serde as dependencies.
|
|
Confirm cargo build \--release works.
|
|
|
|
- Initialise pnpm workspace with packages/ruleset-core,
|
|
packages/ui-components, packages/api-client and apps/symbiote,
|
|
apps/campaign-web, apps/content-editor. Confirm pnpm build runs
|
|
across all.
|
|
|
|
- Write the docker-compose.yml with campaign-db (port 5432),
|
|
content-db (port 5433), and MinIO (port 9000). Confirm docker
|
|
compose up starts cleanly.
|
|
|
|
- Write the first sqlx migration for campaign-service (users table
|
|
only). Confirm sqlx migrate run applies it against the local DB.
|
|
|
|
- Set up GitHub Actions: backend.yml (fmt + clippy + test),
|
|
frontend.yml (build check). Confirm both pass on an empty push.
|
|
|
|
- Write Dockerfiles for all four images. Confirm docker build succeeds
|
|
locally for each.
|
|
|
|
- Deploy a placeholder health-check endpoint (GET /health → 200) for
|
|
each Rust service to the k8s cluster. Confirm kubectl get pods shows
|
|
them running.
|
|
|
|
**Acceptance Criteria**
|
|
|
|
- cargo test \--workspace passes with zero warnings
|
|
|
|
- pnpm build succeeds for all three apps
|
|
|
|
- Both services reachable at their k8s ingress URLs returning 200 on
|
|
/health
|
|
|
|
- CI pipeline is green on main
|
|
|
|
**Rust Learning Focus for This Phase**
|
|
|
|
- Understand the Cargo workspace model --- crates, dependencies,
|
|
feature flags
|
|
|
|
- Get comfortable with axum\'s Router and basic handler signatures
|
|
|
|
- Understand sqlx\'s compile-time query checking and how to run
|
|
migrations
|
|
|
|
**16.3 Phase 1 --- Vertical Slice**
|
|
|
|
Goal: one player can register, log in, open the Symbiote in TaleSpire,
|
|
see their DSA5e character sheet, and click a button to put dice in their
|
|
hand. This is the core loop. Everything else in the project exists to
|
|
support and extend this.
|
|
|
|
**Backend (campaign-service)**
|
|
|
|
- Add users table migration. Implement POST /auth/register and POST
|
|
/auth/login returning a JWT pair. Store refresh tokens in Postgres
|
|
for now (Valkey comes in Phase 4).
|
|
|
|
- Add characters table migration with sheet_data JSONB. Implement
|
|
GET/POST/PUT /characters endpoints. No group logic yet ---
|
|
characters are owned by a user and that is enough.
|
|
|
|
- Implement POST /auth/refresh and POST /auth/logout (revoke refresh
|
|
token row in DB).
|
|
|
|
- Write integration tests for the full auth flow using a test
|
|
database.
|
|
|
|
**Ruleset Core (packages/ruleset-core)**
|
|
|
|
- Define the TypeScript Ruleset interface: id, name, defaultSheet(),
|
|
validate(), getStatBlocks(), getDiceActions(), renderSheet().
|
|
|
|
- Implement the dsa5e plugin: attributes (MU, KL, IN, CH, FF, GE, KO,
|
|
KK), derived values (LeP, AsP, KaP), talents, and a minimal set of
|
|
combat dice actions. Keep it small --- a playable sheet, not a
|
|
complete implementation.
|
|
|
|
- Write unit tests for the dsa5e plugin\'s validate() and
|
|
getDiceActions() against a sample sheet fixture.
|
|
|
|
**Symbiote (apps/symbiote)**
|
|
|
|
- Wire up the TS API hasInitialized event. On init, check TS.storage
|
|
for tokens. If none, show a login form.
|
|
|
|
- Implement login: POST /auth/login via the api-client package, store
|
|
tokens in TS.storage, navigate to the sheet view.
|
|
|
|
- Implement the character sheet view: fetch GET /characters (filter to
|
|
the user\'s first character), pass sheet_data to the dsa5e plugin\'s
|
|
renderSheet(), display the result.
|
|
|
|
- Implement dice action buttons: each DiceAction from getDiceActions()
|
|
renders as a button that calls
|
|
TS.dice.putDiceInHand(action.diceString).
|
|
|
|
- Register a TS dice result listener and log rolls to the console
|
|
(dice history UI comes in Phase 2).
|
|
|
|
- Test the full loop manually in TaleSpire: register → login → see
|
|
sheet → click roll button → dice appear in hand.
|
|
|
|
**Acceptance Criteria**
|
|
|
|
- New user can register and log in via the Symbiote
|
|
|
|
- DSA5e character sheet renders with correct attribute and derived
|
|
value fields
|
|
|
|
- At least three dice action buttons work (e.g. attribute check 3d6,
|
|
melee attack, dodge)
|
|
|
|
- Clicking a dice button puts the correct dice string in the TaleSpire
|
|
dice hand
|
|
|
|
- Token refresh works silently --- a 15-minute access token expiry
|
|
does not log the user out
|
|
|
|
**Svelte Learning Focus for This Phase**
|
|
|
|
- Understand Svelte\'s reactivity model (\$: declarations, stores)
|
|
|
|
- Get comfortable with component props and event dispatch
|
|
|
|
- Understand how Vite handles imports across the pnpm workspace
|
|
packages
|
|
|
|
**16.4 Phase 2 --- Groups & Sessions**
|
|
|
|
Goal: a DM can create a group, invite players, and during a session
|
|
everyone can see each other\'s sheets and live roll history in the
|
|
Symbiote.
|
|
|
|
**Backend (campaign-service)**
|
|
|
|
- Add groups, group_members, and group_characters migrations.
|
|
|
|
- Implement full groups API: POST /groups, GET /groups, GET
|
|
/groups/:id, PATCH /groups/:id, DELETE /groups/:id.
|
|
|
|
- Implement invite flow: POST /groups/:id/invite (generates a token),
|
|
POST /groups/:id/join/:token.
|
|
|
|
- Implement GET/POST/DELETE /groups/:id/characters for character
|
|
assignment.
|
|
|
|
- Implement POST /groups/:id/rolls and GET /groups/:id/rolls for dice
|
|
history.
|
|
|
|
- Add WebSocket support to axum (axum::extract::ws). Implement a group
|
|
room: authenticate via query param JWT, join room by group_id,
|
|
broadcast roll events to all connected clients in the room.
|
|
|
|
**Symbiote**
|
|
|
|
- Add a group selection screen after login: list groups, allow
|
|
creating or joining one.
|
|
|
|
- On entering a group as DM: show all members\' character sheets in a
|
|
sidebar. On entering as player: show own sheet plus a compact view
|
|
of party members\' HP.
|
|
|
|
- Connect to the WebSocket roll room on group entry. Render a live
|
|
roll history panel (character name, dice string, result, timestamp).
|
|
|
|
- On dice result event from TS API, POST to /groups/:id/rolls and emit
|
|
via WebSocket so all clients see it in real time.
|
|
|
|
- Handle board switch events: suppress API calls while TS signals a
|
|
board transition; reconnect WebSocket after rejoining.
|
|
|
|
**Acceptance Criteria**
|
|
|
|
- DM creates a group and shares an invite link; player joins
|
|
successfully
|
|
|
|
- Both clients see the same roll history panel update live when either
|
|
player rolls
|
|
|
|
- DM can see all character sheets; player sees own sheet plus party HP
|
|
bars
|
|
|
|
- Group survives a board switch without losing connection or state
|
|
|
|
**16.5 Phase 3 --- Campaign Web App**
|
|
|
|
Goal: players can manage their characters and groups from a browser
|
|
before the session. JSON import lets players bring characters from other
|
|
tools.
|
|
|
|
**campaign-web (apps/campaign-web)**
|
|
|
|
- Set up SvelteKit with file-based routing. Configure the HttpOnly
|
|
cookie auth flow (login page, silent refresh on load, protected
|
|
route guard).
|
|
|
|
- Build the group dashboard (/groups): card grid, create group button,
|
|
invite flow.
|
|
|
|
- Build the character library (/characters): list all owned
|
|
characters, create new, delete.
|
|
|
|
- Build the character editor (/characters/:id): full DSA5e sheet form
|
|
rendered by the dsa5e plugin\'s renderSheet(), auto-save every 30
|
|
seconds, optimistic UI on field changes.
|
|
|
|
- Build the JSON import flow: file picker, detect .cmchar vs generic
|
|
JSON, render field mapper for generic JSON, run validate() before
|
|
saving, show field-level errors.
|
|
|
|
- Build the character export: GET /characters/:id → download as
|
|
.cmchar file.
|
|
|
|
- Add portrait upload: drag-and-drop to a file input, validate MIME +
|
|
size client-side, request a presigned URL from the backend, upload
|
|
directly to MinIO/S3.
|
|
|
|
**Backend additions**
|
|
|
|
- Add presigned URL endpoint: POST /characters/:id/portrait-upload-url
|
|
→ returns a signed S3 PUT URL valid for 5 minutes.
|
|
|
|
- Add GET /characters/:id/history endpoint returning last 10
|
|
sheet_data versions (store versions in a character_history table ---
|
|
add the migration now).
|
|
|
|
- Configure CORS to allow the campaign-web origin in addition to
|
|
localhost:8080.
|
|
|
|
**Acceptance Criteria**
|
|
|
|
- Player registers on the web app, creates a DSA5e character, and it
|
|
appears immediately in the Symbiote
|
|
|
|
- .cmchar export from the web app imports cleanly back into a fresh
|
|
account
|
|
|
|
- Generic JSON import with a custom field mapping creates a valid
|
|
character
|
|
|
|
- Portrait upload appears on the character sheet in the Symbiote
|
|
|
|
**16.6 Phase 4 --- Caching**
|
|
|
|
Goal: add Valkey to both services, move refresh tokens out of Postgres,
|
|
and cache character sheets and content entries. The system should handle
|
|
a session with no database reads for hot paths.
|
|
|
|
**Tasks**
|
|
|
|
- Deploy campaign-valkey StatefulSet (AOF, 1 Gi PVC) and
|
|
content-valkey StatefulSet (RDB only, 512 Mi PVC) to the k8s
|
|
cluster.
|
|
|
|
- Add redis-rs and moka as dependencies to both Rust crates.
|
|
|
|
- campaign-service: migrate refresh token storage from Postgres to
|
|
Valkey. Add the L1/L2 read-through wrapper for GET /characters/:id
|
|
and group membership checks. Add active invalidation DEL calls to
|
|
PUT/PATCH /characters/:id and all group membership mutation
|
|
endpoints.
|
|
|
|
- content-service: add L1/L2 read-through for GET
|
|
/rulesets/:id/entries and GET /entries/:id. Add active invalidation
|
|
on all entry write endpoints.
|
|
|
|
- Write a load test (using k6 or oha) against GET /characters/:id with
|
|
a warm cache. Target: p99 \< 10 ms.
|
|
|
|
- Confirm Valkey-down behaviour: stop the Valkey pod, verify requests
|
|
still succeed (slower, DB fallback), restart Valkey, verify cache
|
|
warms back up.
|
|
|
|
**Acceptance Criteria**
|
|
|
|
- p99 latency on cached character reads under 10 ms
|
|
|
|
- Valkey pod restart causes no user-facing errors
|
|
|
|
- Refresh token revocation (logout) takes effect immediately on all
|
|
pods
|
|
|
|
**16.7 Phase 5 --- Content Service**
|
|
|
|
Goal: DSA5e reference data lives in the content DB. The character editor
|
|
uses searchable pickers for talents, spells, and items. Saves are
|
|
validated against the dependency graph.
|
|
|
|
**Tasks**
|
|
|
|
- Set up the content-service binary with its own Postgres DB and
|
|
Valkey. Write the baseline migrations: rulesets, content_entries,
|
|
content_dependencies, ruleset_maintainers.
|
|
|
|
- Seed the DSA5e ruleset with a complete set of talents, a
|
|
representative set of spells and special abilities, and common
|
|
items. Write a seed script that reads from a JSON fixture file so it
|
|
is repeatable.
|
|
|
|
- Implement the full Content Service API (§13.5): public read
|
|
endpoints, authenticated write endpoints, status transition
|
|
endpoint.
|
|
|
|
- Implement POST /entries/:id/validate-sheet --- walk the dependency
|
|
graph for all entry IDs referenced in the sheet, return a structured
|
|
list of violations.
|
|
|
|
- campaign-service: add the internal service-to-service HTTP client
|
|
(reqwest). Add the in-process DashMap cache for content responses.
|
|
Wire picker search and pre-save validation into PUT /characters/:id.
|
|
|
|
- campaign-web: replace plain text fields in the character editor with
|
|
searchable pickers for talents, spells, and items. Render validation
|
|
errors returned by the backend.
|
|
|
|
- Deploy content-service to k8s. Configure the cluster-internal DNS so
|
|
campaign-service can reach content-service:8080 without going
|
|
through the public ingress.
|
|
|
|
**Acceptance Criteria**
|
|
|
|
- Talent picker in the character editor searches and returns correct
|
|
DSA5e results
|
|
|
|
- Selecting a special ability with an unmet prerequisite shows a
|
|
validation error
|
|
|
|
- Content service is not reachable from outside the cluster (ClusterIP
|
|
only for write endpoints)
|
|
|
|
**16.8 Phase 6 --- Polish & Community**
|
|
|
|
Goal: the project is ready for public mod.io release and community
|
|
content contributions.
|
|
|
|
**Tasks**
|
|
|
|
- Campaign journal: add journal_entries table migration to
|
|
campaign-service. Build the journal UI in both the Symbiote (read +
|
|
write during session) and campaign-web (full editor between
|
|
sessions).
|
|
|
|
- Offline mode: implement TS.storage sheet caching in the Symbiote.
|
|
Detect network failures and show a read-only cached sheet with an
|
|
offline banner. Queue writes and flush on reconnect.
|
|
|
|
- Content editor web app (apps/content-editor): build the full editing
|
|
UI --- entry browser, submission form, review queue, dependency
|
|
graph visualisation (SVG force-directed layout using d3 or similar).
|
|
|
|
- Community contributions: open up the content service write endpoints
|
|
to registered users (draft submissions). Recruit DSA5e maintainers.
|
|
|
|
- mod.io release: package the Symbiote, write a good description and
|
|
screenshots, upload via the mod.io API. Set up the symbiote.yml CI
|
|
pipeline for automated releases on version tags.
|
|
|
|
- Ruleset marketplace groundwork: add a ruleset submission flow for
|
|
community-created rulesets. Define the review process.
|
|
|
|
**Acceptance Criteria**
|
|
|
|
- Symbiote is listed and installable from the TaleSpire in-game mod
|
|
browser
|
|
|
|
- Offline sheet is readable after disabling the network mid-session
|
|
|
|
- A community member can submit a DSA5e talent entry and a maintainer
|
|
can approve it through the content editor UI
|
|
|
|
**16.9 Recommended Learning Path**
|
|
|
|
As a solo developer new to Rust and Svelte, the following sequence will
|
|
reduce frustration and false starts:
|
|
|
|
**Before Phase 0**
|
|
|
|
- Rust: work through chapters 1--10 of The Rust Book (ownership,
|
|
borrowing, structs, enums, error handling). Do not skip chapter 9
|
|
(error handling) --- it is essential for writing axum handlers.
|
|
|
|
- Svelte: complete the official Svelte tutorial (svelte.dev/tutorial).
|
|
Focus on reactivity, stores, and component composition. One
|
|
afternoon is enough.
|
|
|
|
- axum: read the axum examples on GitHub (hello world, JWT auth,
|
|
WebSocket). The tokio async model will feel unfamiliar at first ---
|
|
lean on .await and let the compiler guide you.
|
|
|
|
- sqlx: read the sqlx README and try the query!() macro against a
|
|
local Postgres. The compile-time checking is its main value ---
|
|
understand why it requires a live DB at compile time (or use the
|
|
offline mode with sqlx prepare).
|
|
|
|
**During Phase 1**
|
|
|
|
- Use thiserror for defining error types and implement axum\'s
|
|
IntoResponse for them. This pattern will carry through the entire
|
|
codebase.
|
|
|
|
- Write integration tests from the start --- axum has excellent test
|
|
utilities (TestClient). Tests will catch borrow checker issues
|
|
before they become production bugs.
|
|
|
|
- For Svelte, start with a flat component structure. Introduce stores
|
|
only when you need shared state across components that are not
|
|
parent/child.
|
|
|
|
**General**
|
|
|
|
- Resist the urge to add complexity early. The generic ruleset
|
|
interface, the content service, and Valkey all exist in the design
|
|
but should not be built until the phase that introduces them.
|
|
|
|
- Commit at the end of every working session. Use conventional commits
|
|
(feat:, fix:, chore:) --- the CI pipeline and future changelog
|
|
generation will benefit.
|
|
|
|
- When stuck on the borrow checker: step back, simplify, and re-read
|
|
the relevant Rust Book chapter. Fighting the borrow checker usually
|
|
means the design needs adjusting, not the syntax.
|
|
|
|
**16.10 Estimated Phase Effort (Solo)**
|
|
|
|
These estimates assume part-time development (evenings and weekends) and
|
|
a learning curve on Rust and Svelte. They are intentionally
|
|
conservative.
|
|
|
|
----------------------------------------------------------------------------
|
|
**\#** **Phase** **Est. **Complexity** **Risk**
|
|
Weeks**
|
|
-------- ---------------- ------------- ---------------- -------------------
|
|
0 Foundations 1--2 Low Low --- mostly
|
|
tooling and config
|
|
|
|
1 Vertical Slice 4--6 High High --- Rust +
|
|
Svelte + TS API all
|
|
new at once
|
|
|
|
2 Groups & 3--4 Medium Medium ---
|
|
Sessions WebSocket in Rust
|
|
is well-documented
|
|
|
|
3 Campaign Web App 3--5 Medium Low --- Svelte will
|
|
be familiar by now
|
|
|
|
4 Caching 1--2 Low Low ---
|
|
well-defined scope,
|
|
good Rust crate
|
|
support
|
|
|
|
5 Content Service 4--6 High Medium ---
|
|
dependency graph
|
|
logic is the hard
|
|
part
|
|
|
|
6 Polish & 3--5 Medium Low --- features
|
|
Community are additive, no
|
|
structural changes
|
|
----------------------------------------------------------------------------
|
|
|
|
Total estimated range: 19--30 weeks of part-time work to a full public
|
|
release. Phases 0--2 (playable at the table) can be reached in roughly
|
|
8--12 weeks.
|
|
|
|
**12. Resolved Decisions & Planned Features**
|
|
|
|
**12.1 Resolved**
|
|
|
|
- DM character visibility: DMs can read full sheet_data for all
|
|
characters in their group. The GET /groups/:id/characters endpoint
|
|
returns complete sheet objects to DM-role callers and owner-only
|
|
data to player-role callers.
|
|
|
|
- Multi-DM: not in scope for v1. The data model enforces exactly one
|
|
dm_user_id per group. This can be revisited post-launch.
|
|
|
|
**12.2 Planned for v2**
|
|
|
|
- Campaign Journal: a group-level shared notes space. Stored as
|
|
structured Markdown in a new journal_entries table (group_id,
|
|
author_user_id, content, created_at). Visible to all group members;
|
|
editable by the author and the DM. Surfaced in both the web app and
|
|
the Symbiote.
|
|
|
|
- Offline Support: TS.storage caches the last-fetched sheet_data per
|
|
character. The Symbiote detects network failure and renders a
|
|
read-only cached sheet with a banner indicating offline status.
|
|
Writes are queued and flushed on reconnect.
|
|
|
|
- Mod.io publishing: once stable, package and submit to mod.io so
|
|
players can install via the TaleSpire in-game browser.
|
|
|
|
- Ruleset marketplace: a community registry where plugin authors can
|
|
publish new game systems independently of the core project.
|
|
|
|
- Additional rulesets: D&D 5e, Pathfinder 2e, and Call of Cthulhu are
|
|
natural follow-ons after the DSA5e baseline proves the plugin
|
|
contract.
|
|
|
|
**13. Content Database**
|
|
|
|
The content database is a second PostgreSQL instance connected to the
|
|
same Axum service via a dedicated sqlx connection pool. It is entirely
|
|
separate from the campaign database: no foreign keys cross between them,
|
|
and the two pools are independently configured, migrated, and backed up.
|
|
The campaign database owns users, groups, and characters. The content
|
|
database owns every piece of game-system knowledge --- rulebooks, rules,
|
|
items, monsters, spells, talents, and the dependency graph that links
|
|
them together.
|
|
|
|
This separation means the content database can be iterated, exported, or
|
|
replaced without touching campaign data, and it can eventually serve
|
|
multiple front-ends (the character editor, a standalone compendium site,
|
|
a Symbiote reference panel) with identical data.
|
|
|
|
**13.1 Two-Pool Architecture**
|
|
|
|
At startup, the Axum service initialises two sqlx PgPool instances from
|
|
separate connection strings --- one for each database. Route handlers
|
|
that need content data receive the content pool via Axum\'s dependency
|
|
injection (State extractor). No handler ever touches both pools in the
|
|
same transaction; cross-database consistency is managed at the
|
|
application layer, not the database layer.
|
|
|
|
> // axum State setup (simplified)
|
|
>
|
|
> struct AppState {
|
|
>
|
|
> campaign_db: PgPool, // DATABASE_URL
|
|
>
|
|
> content_db: PgPool, // CONTENT_DATABASE_URL
|
|
>
|
|
> }
|
|
|
|
Both databases run as separate Kubernetes StatefulSets backed by a
|
|
managed Postgres service (or separate PVCs if self-hosting). No PVC is
|
|
needed on the Axum pods themselves --- they remain fully stateless.
|
|
|
|
**13.2 Schema**
|
|
|
|
**13.2.1 rulesets**
|
|
|
|
---------------------------------------------------------------------------
|
|
**Field** **Type** **Required** **Description**
|
|
---------------------- ---------------- -------------- --------------------
|
|
id VARCHAR(100) Yes e.g. \'dsa5e\',
|
|
(PK) \'dnd5e\'. Matches
|
|
ruleset_id in the
|
|
campaign DB.
|
|
|
|
name VARCHAR(150) Yes Display name, e.g.
|
|
\'Das Schwarze Auge
|
|
5. Edition\'
|
|
|
|
version VARCHAR(50) Yes Content schema
|
|
version, semver
|
|
|
|
description TEXT No
|
|
|
|
published BOOLEAN Yes false = draft; only
|
|
maintainers can see
|
|
---------------------------------------------------------------------------
|
|
|
|
**13.2.2 ruleset_roles (contributor access)**
|
|
|
|
------------------------------------------------------------------------------
|
|
**Field** **Type** **Required** **Description**
|
|
---------------------- ------------------- -------------- --------------------
|
|
ruleset_id VARCHAR(100) (FK) Yes Composite PK
|
|
|
|
user_id UUID (FK → Yes Composite PK.
|
|
campaign.users) Cross-DB join done
|
|
in application code.
|
|
|
|
role ENUM(contributor, Yes contributor = submit
|
|
maintainer) only; maintainer =
|
|
approve + publish
|
|
|
|
granted_at TIMESTAMPTZ Yes
|
|
|
|
granted_by UUID Yes user_id of the
|
|
maintainer who
|
|
granted this role
|
|
------------------------------------------------------------------------------
|
|
|
|
**13.2.3 content_entries**
|
|
|
|
------------------------------------------------------------------------------
|
|
**Field** **Type** **Required** **Description**
|
|
---------------------- ---------------- -------------- -----------------------
|
|
id UUID (PK) Yes
|
|
|
|
ruleset_id VARCHAR(100) (FK Yes
|
|
→ rulesets)
|
|
|
|
entry_type VARCHAR(80) Yes e.g. \'talent\',
|
|
\'combat_technique\',
|
|
\'spell\', \'item\',
|
|
\'monster\', \'rule\',
|
|
\'special_ability\'
|
|
|
|
slug VARCHAR(200) Yes URL-safe identifier,
|
|
unique per
|
|
ruleset+type. e.g.
|
|
\'acrobatics\'
|
|
|
|
name VARCHAR(200) Yes Display name
|
|
|
|
data JSONB Yes Type-specific payload
|
|
validated by the
|
|
ruleset plugin schema
|
|
|
|
source_ref VARCHAR(200) No Rulebook + page, e.g.
|
|
\'WdS p.42\'
|
|
|
|
status ENUM(draft, Yes Drives the contribution
|
|
review, workflow
|
|
published,
|
|
rejected)
|
|
|
|
created_by UUID Yes user_id of the original
|
|
author
|
|
|
|
created_at TIMESTAMPTZ Yes
|
|
|
|
updated_at TIMESTAMPTZ Yes
|
|
------------------------------------------------------------------------------
|
|
|
|
**13.2.4 content_revisions (edit history)**
|
|
|
|
-----------------------------------------------------------------------------
|
|
**Field** **Type** **Required** **Description**
|
|
---------------------- ------------------ -------------- --------------------
|
|
id UUID (PK) Yes
|
|
|
|
entry_id UUID (FK → Yes
|
|
content_entries)
|
|
|
|
data_snapshot JSONB Yes Full copy of data at
|
|
this point in time
|
|
|
|
changed_by UUID Yes user_id
|
|
|
|
change_note TEXT No Optional commit
|
|
message from the
|
|
editor
|
|
|
|
created_at TIMESTAMPTZ Yes
|
|
-----------------------------------------------------------------------------
|
|
|
|
**13.2.5 content_dependencies (graph edges)**
|
|
|
|
-----------------------------------------------------------------------------
|
|
**Field** **Type** **Required** **Description**
|
|
---------------------- ------------------ -------------- --------------------
|
|
from_entry_id UUID (FK → Yes The entry that
|
|
content_entries) requires something
|
|
|
|
to_entry_id UUID (FK → Yes The entry that is
|
|
content_entries) required
|
|
|
|
dep_type ENUM(requires, Yes requires = hard
|
|
enhances, prerequisite;
|
|
conflicts) enhances = optional
|
|
synergy; conflicts =
|
|
mutually exclusive
|
|
|
|
condition TEXT No Human-readable
|
|
condition
|
|
description, e.g.
|
|
\'FF 13 or higher\'
|
|
-----------------------------------------------------------------------------
|
|
|
|
**13.2.6 rulebook_sections (structured lore/rules text)**
|
|
|
|
---------------------------------------------------------------------------
|
|
**Field** **Type** **Required** **Description**
|
|
---------------------- ---------------- -------------- --------------------
|
|
id UUID (PK) Yes
|
|
|
|
ruleset_id VARCHAR(100) (FK Yes
|
|
→ rulesets)
|
|
|
|
title VARCHAR(300) Yes Section heading
|
|
|
|
body TEXT Yes Markdown-formatted
|
|
rules text
|
|
|
|
parent_section_id UUID (FK, No Enables nested
|
|
nullable) chapter structure
|
|
|
|
order_index INTEGER Yes Sort order within
|
|
parent
|
|
|
|
status ENUM(draft, Yes
|
|
published)
|
|
|
|
created_by UUID Yes
|
|
|
|
created_at TIMESTAMPTZ Yes
|
|
---------------------------------------------------------------------------
|
|
|
|
**13.3 Contribution Roles**
|
|
|
|
Two roles govern who can do what to a given ruleset\'s content. Roles
|
|
are per-ruleset --- a user can be a maintainer for DSA5e and merely a
|
|
contributor for a community homebrew system.
|
|
|
|
------------------------------------------------------------------------
|
|
**Role** **Assigned by** **Capabilities**
|
|
--------------- ------------------ -------------------------------------
|
|
Any registered --- Submit new entries or edits (status =
|
|
user draft → review)
|
|
|
|
contributor Maintainer Submit entries; view all drafts for
|
|
the ruleset; cannot approve or
|
|
publish
|
|
|
|
maintainer Admin or other Approve, reject, or publish any
|
|
maintainer entry; edit without review; manage
|
|
contributor list
|
|
|
|
admin System Full access to all rulesets; can
|
|
create rulesets, promote maintainers
|
|
------------------------------------------------------------------------
|
|
|
|
**13.4 Contribution & Review Workflow**
|
|
|
|
- Any registered user submits a new entry or edit. The entry lands in
|
|
status = \'review\'. The submitter cannot publish it themselves.
|
|
|
|
- A maintainer for that ruleset reviews the entry in the content
|
|
editor. They can approve (→ published), reject (→ rejected with a
|
|
note), or request changes (entry returns to draft with a comment
|
|
thread).
|
|
|
|
- Maintainers can also edit and publish directly without the review
|
|
step, for trusted bulk imports or corrections.
|
|
|
|
- All state transitions are recorded in content_revisions so the full
|
|
editorial history is auditable.
|
|
|
|
- Published entries are immediately available to the character editor
|
|
and pickers. Drafts and review-queue entries are only visible to
|
|
contributors and maintainers of that ruleset.
|
|
|
|
**13.5 Integration with the Character Editor**
|
|
|
|
The character editor in the web app (and Symbiote) integrates the
|
|
content database through three distinct mechanisms, all driven by the
|
|
active ruleset plugin.
|
|
|
|
**13.5.1 Searchable Pickers**
|
|
|
|
When a field in the character sheet requires selecting a game entity (a
|
|
talent, weapon, spell, special ability, etc.), the editor renders a
|
|
picker component rather than a free-text input. The picker calls:
|
|
|
|
> GET
|
|
> /content/{ruleset_id}/entries?type={entry_type}&q={search_term}&limit=20
|
|
|
|
Results are paginated and returned with name, slug, source_ref, and a
|
|
summary excerpt from the data blob. Selecting an entry writes only its
|
|
id and slug to sheet_data --- the full entry is always fetched fresh
|
|
from the content DB at render time, so corrections to the content
|
|
database are immediately reflected on existing characters without sheet
|
|
migration.
|
|
|
|
**13.5.2 Auto-Calculation of Derived Stats**
|
|
|
|
When sheet_data changes, the ruleset plugin\'s calculate() method runs
|
|
client-side and updates all derived values in memory before the sheet
|
|
re-renders. The calculation engine reads base attributes from sheet_data
|
|
and looks up any content-database entries (e.g. a talent\'s base
|
|
attribute formula) to produce final values.
|
|
|
|
> interface Ruleset {
|
|
>
|
|
> // \... existing methods \...
|
|
>
|
|
> calculate(sheet: SheetData, entries: EntryMap): DerivedStats
|
|
>
|
|
> }
|
|
|
|
EntryMap is a pre-fetched, client-side cache of all content entries
|
|
referenced by the current sheet, loaded once on sheet open and
|
|
invalidated on content DB version bump. This means calculations are
|
|
synchronous and instant --- no round-trip required during editing.
|
|
|
|
**13.5.3 Rule Validation**
|
|
|
|
Before saving, the ruleset plugin\'s validate() method checks the full
|
|
sheet against both the structural schema and the dependency graph.
|
|
Prerequisite checking walks the content_dependencies edges:
|
|
|
|
- \'requires\' edges: the character must meet the condition (e.g.
|
|
attribute threshold) or already have the prerequisite entry on their
|
|
sheet
|
|
|
|
- \'conflicts\' edges: if the character has entry A, entry B cannot be
|
|
added
|
|
|
|
- \'enhances\' edges: informational only; shown as suggestions in the
|
|
UI but never block saving
|
|
|
|
Validation errors are displayed inline next to the offending field, with
|
|
a link to the dependency entry in the compendium so the player
|
|
understands why the rule applies.
|
|
|
|
**13.6 Content API Endpoints**
|
|
|
|
-------------------------------------------------------------------------------------------------
|
|
**Method + Path** **Auth** **Description**
|
|
----------------------------------------------- -------------- ----------------------------------
|
|
GET /content/:ruleset_id/entries Any user Search/list published entries.
|
|
Supports ?type=, ?q=, ?limit=,
|
|
?offset=
|
|
|
|
GET /content/:ruleset_id/entries/:id Any user Single entry with full data,
|
|
source_ref, and dependency list
|
|
|
|
GET Any user Full dependency subgraph for an
|
|
/content/:ruleset_id/entries/:id/dependencies entry (BFS up to configurable
|
|
depth)
|
|
|
|
POST /content/:ruleset_id/entries Registered Submit a new entry (status =
|
|
user review)
|
|
|
|
PUT /content/:ruleset_id/entries/:id Contributor+ Submit an edit (status → review
|
|
unless caller is maintainer)
|
|
|
|
POST /content/:ruleset_id/entries/:id/approve Maintainer Approve and publish an entry
|
|
|
|
POST /content/:ruleset_id/entries/:id/reject Maintainer Reject with a reason string
|
|
|
|
GET /content/:ruleset_id/entries/:id/revisions Contributor+ Full revision history for an entry
|
|
|
|
GET /content/:ruleset_id/sections Any user Rulebook sections (tree structure
|
|
via parent_section_id)
|
|
|
|
POST /content/:ruleset_id/sections Maintainer Create a new rulebook section
|
|
|
|
PUT /content/:ruleset_id/sections/:id Maintainer Edit section body or title
|
|
|
|
GET /content/rulesets Any user List all published rulesets (id,
|
|
name, version)
|
|
|
|
POST /content/rulesets Admin Create a new ruleset entry
|
|
|
|
GET /content/:ruleset_id/queue Contributor+ Review queue: all entries in
|
|
status = review for this ruleset
|
|
-------------------------------------------------------------------------------------------------
|
|
|
|
**13.7 Content Editor Web App**
|
|
|
|
The content editor is a separate Svelte app
|
|
(content.campaign-manager.example.com) that shares the same auth system
|
|
and Axum backend. It is the primary tool for maintainers and
|
|
contributors to manage ruleset content. The character editor web app
|
|
embeds read-only pickers that link back to the content editor for
|
|
details.
|
|
|
|
**13.7.1 Routes**
|
|
|
|
-----------------------------------------------------------------------------
|
|
**Route** **Description**
|
|
------------------------------- ---------------------------------------------
|
|
/ Ruleset selector; shows all rulesets the user
|
|
has any role in, plus public ones
|
|
|
|
/:ruleset_id/entries Searchable, filterable entry list. Toggle
|
|
between published and review queue.
|
|
|
|
/:ruleset_id/entries/new Entry creation form with type selector;
|
|
fields driven by ruleset plugin schema
|
|
|
|
/:ruleset_id/entries/:id Entry detail: full data view, dependency
|
|
graph visualisation, revision history
|
|
|
|
/:ruleset_id/entries/:id/edit Edit form with diff preview against last
|
|
published version
|
|
|
|
/:ruleset_id/queue Maintainer review queue: one-click
|
|
approve/reject with comment
|
|
|
|
/:ruleset_id/sections Rulebook section tree with drag-to-reorder;
|
|
Markdown editor for body
|
|
|
|
/:ruleset_id/contributors Maintainer-only: manage contributor and
|
|
maintainer list
|
|
-----------------------------------------------------------------------------
|
|
|
|
**13.7.2 Dependency Graph View**
|
|
|
|
The entry detail page renders an interactive force-directed graph of the
|
|
selected entry\'s dependency neighbourhood (requires / enhances /
|
|
conflicts edges up to 2 hops). This gives maintainers and contributors
|
|
an at-a-glance view of how tightly coupled an entry is before editing
|
|
it, and helps players browsing the compendium understand build paths.
|
|
|
|
**13.7.3 Schema-Driven Forms**
|
|
|
|
Entry creation and edit forms are generated from JSON Schema definitions
|
|
exported by the ruleset plugin (one schema per entry_type). This means
|
|
the content editor never needs to know DSA5e-specific field names --- it
|
|
reads the schema, renders the appropriate inputs (numeric, text, enum
|
|
dropdown, multi-select for dependencies), and validates client-side
|
|
before submitting. Adding a new entry type to a ruleset only requires
|
|
updating the plugin schema; the editor UI adapts automatically.
|
|
|
|
**13.8 Kubernetes & Storage Notes**
|
|
|
|
The content database runs as a second StatefulSet (or managed Postgres
|
|
instance) alongside the campaign database. Both are accessed by the same
|
|
stateless Axum pods --- no PVC on the application tier. Object storage
|
|
(S3-compatible) is used for any binary assets referenced by content
|
|
entries (token images, item artwork) using the same presigned-URL
|
|
pattern as character portraits.
|
|
|
|
- Two separate DATABASE_URL / CONTENT_DATABASE_URL environment
|
|
variables injected as Kubernetes Secrets
|
|
|
|
- sqlx migrations for each database live in separate
|
|
/migrations/campaign and /migrations/content directories and are run
|
|
independently at startup
|
|
|
|
- Read replicas can be added per-database independently; the content
|
|
DB is read-heavy and benefits from a replica for picker queries
|
|
during active sessions
|
|
|
|
- Backup schedules can differ: campaign DB (user data) backed up
|
|
continuously; content DB (curated reference data) backed up daily
|
|
|
|
**14. Resolved Decisions & Planned Features**
|
|
|
|
**14.1 Resolved**
|
|
|
|
- DM character visibility: DMs can read full sheet_data for all
|
|
characters in their group. The GET /groups/:id/characters endpoint
|
|
returns complete sheet objects to DM-role callers and owner-only
|
|
data to player-role callers.
|
|
|
|
- Multi-DM: not in scope for v1. The data model enforces exactly one
|
|
dm_user_id per group. This can be revisited post-launch.
|
|
|
|
**14.2 Planned for v2**
|
|
|
|
- Campaign Journal: a group-level shared notes space. Stored as
|
|
structured Markdown in a new journal_entries table (group_id,
|
|
author_user_id, content, created_at). Visible to all group members;
|
|
editable by the author and the DM. Surfaced in both the web app and
|
|
the Symbiote.
|
|
|
|
- Offline Support: TS.storage caches the last-fetched sheet_data per
|
|
character. The Symbiote detects network failure and renders a
|
|
read-only cached sheet with a banner indicating offline status.
|
|
Writes are queued and flushed on reconnect.
|
|
|
|
- Mod.io publishing: once stable, package and submit to mod.io so
|
|
players can install via the TaleSpire in-game browser.
|
|
|
|
- Ruleset marketplace: a community registry where plugin authors can
|
|
publish new game systems independently of the core project.
|
|
|
|
- Additional rulesets: D&D 5e, Pathfinder 2e, and Call of Cthulhu are
|
|
natural follow-ons after the DSA5e baseline proves the plugin
|
|
contract.
|
|
|
|
End of Document
|