# 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//` 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//` 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//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 ``. 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::gm` und `battle::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::gm → joins room battle::display [GM klickt "Token bewegen"] PATCH /battles/:id/tokens/:t → BattleService.moveToken → BattleGateway.broadcast(sessionId, 'token_moved', ...) → emit to room battle::gm ← GM-Client sieht Update → emit to room battle::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::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:] 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:** `` 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 `. ### 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.*