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:
- NestJS-Modul = Feature-Schnitt: Jedes neue Feature bekommt ein eigenes Modul unter
server/src/modules/<feature>/mitcontroller.ts,service.ts, optionalgateway.ts, optionaldto/. Wird inapp.module.tsimportiert. - React-Feature-Schnitt: Jedes neue UI-Feature bekommt
client/src/features/<feature>/mitcomponents/, optionalhooks/,index.tsals Barrel. - Shared Hooks zentralisiert: WebSocket-Hooks und übergreifende Hooks gehören nach
client/src/shared/hooks/. Lokale Feature-Hooks bleiben infeatures/<feature>/hooks/. - Daten in DB, nicht in JSON: Jede neue Entity ist Prisma-Modell mit Migration. JSON-Dateien sind nur Seed-Quellen.
- Migrations statt
db push: Schema-Änderungen ausschließlichprisma migrate devmit sprechendem Namen (add_dice_rolls,add_push_subscriptions). - Gateway-Convention: WebSocket-Gateways nutzen Namespace pro Feature (
/characters,/battles), JWT inhandshake.auth.token, Room pro Resource-ID,forwardRefzwischen Service und Gateway,Loggermit 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: BekommtLevelingModuleals Konsument; eigener Service exponiertrecomputeStatsAfterLevelUp()als Hook-Punkt;CharactersGatewaybekommt neuen Update-Type'level_up_committed'.BattleModule:BattleGatewaybekommt neue Events (effect_added,effect_removed,effect_updated,turn_advanced,viewer_roleinformativ).BattleServicebekommt CRUD fürBattleEffectund Turn-Pointer.AuthModule: Bekommt einen schmalenBroadcastHelper(oder dieser lebt imPushModule), 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.tsmodules/leveling/leveling.controller.tsPOST /characters/:id/level-up/start→ erzeugtLevelUpSession(state=DRAFT), kopiert SnapshotPATCH /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-Broadcastlevel_up_committedPOST /characters/:id/level-up/abort→ state=ABORTED, kein Charakter-MutationGET /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), nutztFeatsService.- Existierender
CharactersServicebekommtapplyLevelUp(characterId, draft)als idempotente Transaktion undrecomputeDerivedStats(characterId)(HP-Max, Save-Boni, AC-Bonus). - Existierender
CharactersGatewaybekommtlevel_up_committedals Update-Type (inCharacterUpdatePayload['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.tsxals „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-pwainvite.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 überpostMessagemit 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.tspush.controller.ts:POST /push/subscribe(Body: endpoint, keys.p256dh, keys.auth, userAgent) → upsert aufendpoint-uniqueDELETE /push/subscribe/:id→ User entfernt eigenes DeviceGET /push/vapid-public-key(public, oder beim ersten Bootstrap-Call mitgeben)
push.service.ts:sendToUser(userId, payload)sendToCampaign(campaignId, role?, payload)— joinedCampaignMember+ ggf. role-filtersendToBattleSession(sessionId, role?, payload)- Lifecycle: Bei Send-Fehler
410 Goneoder404→ Subscription löschen. Bei429exponential backoff. Bei ErfolglastSeenAt = 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, wennNotification.permission === 'default'.features/push/hooks/use-push-subscription.ts— registriert SW, holt VAPID-Key, abonniert, sendet anPOST /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/displaymit 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 EndpointGET /battles/:id/display-view→ DTO ohne GM-Felder.BattleGateway: beijoinBattle-Event akzeptiertviewerMode: 'GM' | 'DISPLAY'. Server speichert das pro Socket. Bei Broadcasts unterscheidet Gateway zwei Sub-Rooms:battle:<id>:gmundbattle:<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 eigenerChatGateway(Namespace/chat) — konsistent mit der Bestands-Architektur (jedes Feature eigener Namespace). - Nicht in
CharactersGatewayeinbauen: 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
embedRollund 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 + optionalesembedRoll.shared/hooks/use-dice-socket.ts,shared/hooks/use-chat-socket.ts— analog zuuse-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 WebDAVPROPFIND, gibt Files+Subdirs zurück.VaultService.read(campaignId, filePath)→ ruft WebDAVGET, parst Markdown, resolviert[[Wikilinks]]per Index-Lookup, gibt{ content, frontmatter, links: [{name, path?}] }zurück. Cached inVaultNoteCache.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 imcampaign-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-controlmitgmMode-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:
-
Primärer Pfad — Reaktiv via postMessage: Beim Empfang eines
character_update-Events ruftuse-character-socket.tseinen HelperinvalidateCacheForCharacter(characterId), der pernavigator.serviceWorker.controller.postMessage({type:'INVALIDATE', urls:[...]})an den SW signalisiert. SW löscht die betroffenen Cache-Einträge. -
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.
-
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.snapshotBeforeerlaubt 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:
checkCampaignAccess(userId, campaignId)checkCharacterAccess(userId, characterId)- Prisma-Update
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.