Files
Dimension-47/.planning/research/ARCHITECTURE.md

40 KiB

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.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)

// 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.tsxreact-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:

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

export class StartLevelUpDto {
  @IsString() @IsNotEmpty() characterId: string;
}

Pattern 6: Service-Worker-Update via postMessage

Wann: Wenn Live-Daten den Cache invalidieren sollen. Beispiel:

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