746 lines
40 KiB
Markdown
746 lines
40 KiB
Markdown
# Architecture Patterns
|
|
|
|
**Domain:** Dimension47 — TTRPG-Plattform (PF2e), Brownfield-Erweiterung
|
|
**Researched:** 2026-04-27
|
|
**Scope:** Additive Architektur für PWA + Multi-Screen-Battle + Dice/Chat + GM-Live-Tools + Obsidian-Vault + Level-Up
|
|
**Confidence:** HIGH (basiert auf gemappter Bestands-Codebase + bekannten Web-Patterns)
|
|
|
|
> Lese-Hinweis: Dieses Dokument ergänzt `.planning/codebase/ARCHITECTURE.md` (Bestand). Es definiert nur, was **neu** dazukommt und wie es sich an bestehende Patterns andockt. Keine Re-Definition existierender Layer.
|
|
|
|
---
|
|
|
|
## 1. Architektur-Leitplanken (gelten für alles unten)
|
|
|
|
Der Bestand zementiert sechs Patterns, die jede neue Komponente respektieren muss:
|
|
|
|
1. **NestJS-Modul = Feature-Schnitt**: Jedes neue Feature bekommt ein eigenes Modul unter `server/src/modules/<feature>/` mit `controller.ts`, `service.ts`, optional `gateway.ts`, optional `dto/`. Wird in `app.module.ts` importiert.
|
|
2. **React-Feature-Schnitt**: Jedes neue UI-Feature bekommt `client/src/features/<feature>/` mit `components/`, optional `hooks/`, `index.ts` als Barrel.
|
|
3. **Shared Hooks zentralisiert**: WebSocket-Hooks und übergreifende Hooks gehören nach `client/src/shared/hooks/`. Lokale Feature-Hooks bleiben in `features/<feature>/hooks/`.
|
|
4. **Daten in DB, nicht in JSON**: Jede neue Entity ist Prisma-Modell mit Migration. JSON-Dateien sind nur Seed-Quellen.
|
|
5. **Migrations statt `db push`**: Schema-Änderungen ausschließlich `prisma migrate dev` mit sprechendem Namen (`add_dice_rolls`, `add_push_subscriptions`).
|
|
6. **Gateway-Convention**: WebSocket-Gateways nutzen Namespace pro Feature (`/characters`, `/battles`), JWT in `handshake.auth.token`, Room pro Resource-ID, `forwardRef` zwischen Service und Gateway, `Logger` mit Klassennamen.
|
|
|
|
Alles weitere folgt aus diesen Leitplanken.
|
|
|
|
---
|
|
|
|
## 2. Recommended Architecture (additiv, pro Feature)
|
|
|
|
### 2.1 Übersicht: Was kommt neu dazu
|
|
|
|
```
|
|
NEU (Backend Module) NEU (Frontend Features) NEU (Prisma Modelle)
|
|
───────────────────────── ────────────────────── ────────────────────
|
|
modules/leveling/ features/leveling/ LevelUpSession
|
|
modules/push/ features/push/ PushSubscription
|
|
modules/dice/ features/dice/ DiceRoll
|
|
modules/chat/ features/chat/ ChatMessage
|
|
modules/vault/ features/vault/ VaultConfig
|
|
features/battle/display/ VaultNoteCache
|
|
BattleEffect
|
|
(BattleSession +turnTokenId)
|
|
GEÄNDERT
|
|
─────────────────────────
|
|
modules/battle/ (neue Events) features/battle/ (display Migrations
|
|
modules/characters/ (Level-Up route, GM mode flag, init ─────────
|
|
Hooks ins Service) tracker UI, effects UI) add_level_up_sessions
|
|
modules/auth/ (vapid, role features/characters/ add_push_subscriptions
|
|
broadcast helper) (level-up tab/wizard) add_dice_and_chat
|
|
shared/hooks/ add_battle_effects
|
|
use-dice-socket.ts add_vault_config
|
|
use-chat-socket.ts add_battle_session_turn_pointer
|
|
shared/sw/
|
|
service-worker.ts (Vite PWA)
|
|
```
|
|
|
|
### 2.2 Neue NestJS-Module (Component Boundaries)
|
|
|
|
| Modul | Verantwortung | Exports | Dependencies |
|
|
|-------|---------------|---------|--------------|
|
|
| `LevelingModule` | Draft-Session erzeugen, validieren (PF2e-Regeln), commit/abort, Snapshot des Vorher-Stands | `LevelingService` | `CharactersModule` (für Recompute), `FeatsModule`, `PrismaModule` |
|
|
| `PushModule` | VAPID-Setup, `POST /push/subscribe`, `DELETE /push/subscribe`, `sendToUser`, `sendToCampaign` (Helper für andere Services) | `PushService` | `PrismaModule`, `web-push` lib |
|
|
| `DiceModule` | PF2e-Notation parsen (`1d20+7`, Crits), Roll persistieren, Broadcast | `DiceService`, `DiceGateway` | `PrismaModule`, `CampaignsModule` (Access-Check) |
|
|
| `ChatModule` | Append-only Chat-Messages, Pagination, Broadcast, Roll-Embed-Verweis | `ChatService`, `ChatGateway` | `PrismaModule`, `CampaignsModule`, `DiceModule` (für Embed-Lookup) |
|
|
| `VaultModule` | Vault-Config (per Campaign), Markdown-Liste, Markdown-Read, Wikilink-Resolve, Bild-Proxy, optional Cache-Sweep | `VaultService` | `PrismaModule`, `CampaignsModule`, evtl. `axios`/WebDAV-Client |
|
|
|
|
**Geänderte Bestandsmodule:**
|
|
|
|
- `CharactersModule`: Bekommt `LevelingModule` als Konsument; eigener Service exponiert `recomputeStatsAfterLevelUp()` als Hook-Punkt; `CharactersGateway` bekommt neuen Update-Type `'level_up_committed'`.
|
|
- `BattleModule`: `BattleGateway` bekommt neue Events (`effect_added`, `effect_removed`, `effect_updated`, `turn_advanced`, `viewer_role` informativ). `BattleService` bekommt CRUD für `BattleEffect` und Turn-Pointer.
|
|
- `AuthModule`: Bekommt einen schmalen `BroadcastHelper` (oder dieser lebt im `PushModule`), der Userlisten pro Campaign-Rolle auflöst — wird von Push, Chat, Dice genutzt.
|
|
|
|
### 2.3 Neue Prisma-Modelle (kompakt, Felder auf das Wesentliche)
|
|
|
|
```prisma
|
|
// Migration: add_level_up_sessions
|
|
model LevelUpSession {
|
|
id String @id @default(uuid())
|
|
characterId String
|
|
fromLevel Int
|
|
toLevel Int
|
|
state String // "DRAFT" | "COMMITTED" | "ABORTED"
|
|
draft Json // Wahl-Pfad (Boosts, Feats, Skills, Class Features)
|
|
snapshotBefore Json // Vorher-Stand für Audit/Undo (committed: read-only)
|
|
createdAt DateTime @default(now())
|
|
committedAt DateTime?
|
|
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
|
@@index([characterId])
|
|
@@index([characterId, state])
|
|
}
|
|
|
|
// Migration: add_push_subscriptions
|
|
model PushSubscription {
|
|
id String @id @default(uuid())
|
|
userId String
|
|
endpoint String @unique // Browser-eindeutig
|
|
p256dh String
|
|
authKey String
|
|
userAgent String?
|
|
createdAt DateTime @default(now())
|
|
lastSeenAt DateTime @default(now())
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
@@index([userId])
|
|
}
|
|
|
|
// Migration: add_dice_and_chat
|
|
model DiceRoll {
|
|
id String @id @default(uuid())
|
|
campaignId String
|
|
battleSessionId String? // null → außerhalb des Battle
|
|
characterId String? // null → GM/anonymer Roll
|
|
userId String // wer hat gewürfelt
|
|
expression String // "1d20+7"
|
|
parsed Json // strukturiertes Parse-Ergebnis
|
|
rolls Json // [[14], [3], ...] pro Würfel
|
|
total Int
|
|
isCrit Boolean @default(false)
|
|
isFumble Boolean @default(false)
|
|
label String? // "Athletics check", "Longsword damage"
|
|
createdAt DateTime @default(now())
|
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
|
battleSession BattleSession? @relation(fields: [battleSessionId], references: [id], onDelete: SetNull)
|
|
character Character? @relation(fields: [characterId], references: [id], onDelete: SetNull)
|
|
user User @relation(fields: [userId], references: [id])
|
|
chatMessages ChatMessage[] // Embedding via embedRollId
|
|
@@index([campaignId, createdAt])
|
|
@@index([battleSessionId, createdAt])
|
|
}
|
|
|
|
model ChatMessage {
|
|
id String @id @default(uuid())
|
|
campaignId String
|
|
battleSessionId String?
|
|
userId String
|
|
type String // "TEXT" | "SYSTEM" | "GM_PING"
|
|
body String // Markdown light
|
|
embedRollId String? // FK auf DiceRoll, falls Roll gepostet wurde
|
|
recipientUserId String? // gezielte GM-Nachricht; null → an alle in Room
|
|
createdAt DateTime @default(now())
|
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
|
battleSession BattleSession? @relation(fields: [battleSessionId], references: [id], onDelete: SetNull)
|
|
user User @relation(fields: [userId], references: [id])
|
|
embedRoll DiceRoll? @relation(fields: [embedRollId], references: [id])
|
|
@@index([campaignId, createdAt])
|
|
@@index([battleSessionId, createdAt])
|
|
}
|
|
|
|
// Migration: add_battle_effects + add_battle_session_turn_pointer
|
|
model BattleEffect {
|
|
id String @id @default(uuid())
|
|
battleTokenId String
|
|
name String
|
|
nameGerman String?
|
|
kind String // "BUFF" | "DEBUFF" | "AURA" | "MARKER"
|
|
value Int? // z.B. Stufe von Frightened
|
|
rounds Int? // verbleibende Runden, null = unbegrenzt
|
|
source String?
|
|
battleToken BattleToken @relation(fields: [battleTokenId], references: [id], onDelete: Cascade)
|
|
@@index([battleTokenId])
|
|
}
|
|
// BattleSession bekommt: turnTokenId String? (welcher Token ist gerade dran)
|
|
// BattleToken bekommt: effects BattleEffect[]
|
|
|
|
// Migration: add_vault_config
|
|
model VaultConfig {
|
|
id String @id @default(uuid())
|
|
campaignId String @unique
|
|
transport String // "WEBDAV_PROXY" | "GIT_PULL" | "HTTP_DIRECT"
|
|
endpointUrl String
|
|
authSecretRef String? // Referenz auf separates Secret-Storage; nicht plain
|
|
rootPath String? // Subpfad im Vault
|
|
lastSyncAt DateTime?
|
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
|
}
|
|
|
|
model VaultNoteCache {
|
|
id String @id @default(uuid())
|
|
campaignId String
|
|
path String // relative Note-Path z.B. "Lore/Stadt-Ironvale.md"
|
|
content String // Markdown
|
|
contentHash String
|
|
fetchedAt DateTime @default(now())
|
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
|
@@unique([campaignId, path])
|
|
@@index([campaignId])
|
|
}
|
|
```
|
|
|
|
> Hinweis Push-Storage: VAPID-Public/Private liegen in `.env` (`VAPID_PUBLIC`, `VAPID_PRIVATE`, `VAPID_SUBJECT`). Endpoint-Tabelle hält **nur** Subscriptions, keine Schlüssel.
|
|
|
|
---
|
|
|
|
## 3. Komponenten-Boundaries pro Feature-Bereich
|
|
|
|
### 3.1 Level-Up
|
|
|
|
**Backend**
|
|
- `modules/leveling/leveling.module.ts`
|
|
- `modules/leveling/leveling.controller.ts`
|
|
- `POST /characters/:id/level-up/start` → erzeugt `LevelUpSession` (state=DRAFT), kopiert Snapshot
|
|
- `PATCH /characters/:id/level-up/draft` → Wahl im Draft updaten (idempotent, validiert)
|
|
- `POST /characters/:id/level-up/commit` → Transaktion: Draft anwenden, Stat-Recompute, state=COMMITTED, Gateway-Broadcast `level_up_committed`
|
|
- `POST /characters/:id/level-up/abort` → state=ABORTED, kein Charakter-Mutation
|
|
- `GET /characters/:id/level-up/session` → aktiver DRAFT (für Wizard-Resume)
|
|
- `leveling.service.ts`: PF2e-Regelvalidierung (Boosts alle 5 Levels, Skill-Increases je Klasse, Feat-Prerequisites, Class-Features pro Level), nutzt `FeatsService`.
|
|
- **Existierender `CharactersService`** bekommt `applyLevelUp(characterId, draft)` als idempotente Transaktion und `recomputeDerivedStats(characterId)` (HP-Max, Save-Boni, AC-Bonus).
|
|
- **Existierender `CharactersGateway`** bekommt `level_up_committed` als Update-Type (in `CharacterUpdatePayload['type']` ergänzen).
|
|
|
|
**Frontend**
|
|
- `features/leveling/components/level-up-wizard.tsx` (Modal/Dialog mit Schritten: Class Features → Boosts → Class Feat → Skill Increases → General/Skill Feat → Review)
|
|
- `features/leveling/components/level-up-step-*.tsx` (ein File pro Step)
|
|
- `features/leveling/hooks/use-level-up-session.ts` (lokaler Hook, lädt/patchet DRAFT, lokaler optimistischer State)
|
|
- Trigger sitzt im bestehenden `character-sheet-page.tsx` als „Stufe steigen"-Button neben Level-Anzeige.
|
|
|
|
**Datenfluss (User klickt „Stufe steigen" → andere Spieler sehen neuen Level):**
|
|
```
|
|
[character-sheet-page] "Stufe steigen"
|
|
└─ POST /characters/:id/level-up/start (LevelingController)
|
|
└─ LevelUpSession DRAFT erzeugt (LevelingService → Prisma)
|
|
◀─ Wizard öffnet
|
|
[Wizard Step] User wählt Boost/Feat
|
|
└─ PATCH /characters/:id/level-up/draft (validiert, kein Char-Mutate)
|
|
◀─ aktualisierter Draft zurück
|
|
[Wizard Review] User klickt „Bestätigen"
|
|
└─ POST /characters/:id/level-up/commit
|
|
└─ Transaktion:
|
|
• LevelingService.commit(draft)
|
|
• CharactersService.applyLevelUp(...)
|
|
• CharactersService.recomputeDerivedStats(...)
|
|
• CharactersGateway.broadcast({type:'level_up_committed', data:{level, hpMax, ...}})
|
|
◀─ neuer Charakter-State
|
|
[andere Clients] hören 'level_up_committed' via use-character-socket
|
|
└─ React Query invalidate(character) + UI Refresh
|
|
```
|
|
|
|
### 3.2 PWA + Service Worker
|
|
|
|
**Frontend (zentral, kein eigenes Feature-Modul, aber dedizierte Files)**
|
|
- Plugin: `vite-plugin-pwa` in `vite.config.ts`
|
|
- `client/public/manifest.webmanifest` (Name, Icons, theme_color = `#c26dbc`, display=standalone, start_url=`/`)
|
|
- `client/src/sw/service-worker.ts` (custom SW via Workbox, von Vite gebündelt)
|
|
- `client/src/shared/hooks/use-pwa-update.ts` (zeigt „Neue Version verfügbar"-Banner)
|
|
- `client/src/shared/lib/sw-cache-bridge.ts` (Helper, der über `postMessage` mit dem SW redet — Cache invalidieren bei Socket-Events)
|
|
|
|
**Caching-Strategien (pro Route):**
|
|
|
|
| Route-Pattern | Strategie | Warum |
|
|
|---|---|---|
|
|
| `*.js`, `*.css`, `*.png`, `*.svg` (Vite-Assets) | CacheFirst, immutable | Vite hash-named, sicher cachebar |
|
|
| `GET /api/equipment*` | StaleWhileRevalidate | 5.482 Items, ändern sich quasi nie, schneller Listen-View |
|
|
| `GET /api/feats*` | StaleWhileRevalidate | identisch |
|
|
| `GET /api/campaigns/:id/characters/:id` | NetworkFirst mit 3s-Timeout, fallback Cache | Live-Daten bevorzugt, Offline-Read als Sicherheitsnetz |
|
|
| `GET /api/campaigns/:id/characters/:id/feats` | NetworkFirst | s.o. |
|
|
| `GET /api/translations*` | StaleWhileRevalidate | gecached, aber refresh on background |
|
|
| `GET /api/vault/*` (Markdown) | StaleWhileRevalidate | Vault-Inhalt darf leicht stale sein |
|
|
| `POST/PATCH/PUT/DELETE *` | NetworkOnly | Mutationen niemals cachen |
|
|
| `POST /api/auth/*` | NetworkOnly | Auth nie cachen |
|
|
| `*/socket.io/*` | NetworkOnly (vom SW ausgenommen) | WebSocket geht nicht durch SW-Caching |
|
|
|
|
**JWT-Header-Interaktion:** Token kommt aus localStorage/sessionStorage und wird vom API-Client (`shared/lib/api.ts`) als Authorization-Header gesetzt. Der SW darf die Authorization-Header nicht entfernen — Workbox passiert sie standardmäßig durch. **Wichtig**: Antworten mit `Authorization`-Header werden im Cache gespeichert; das ist akzeptabel, weil der SW-Cache pro Browser-Profil ist und der App ohnehin Single-User pro Browser ist (Family-Gaming-Use-Case). Falls mehrere User denselben Browser teilen, muss der Cache bei Login-Wechsel geleert werden (Hook in `useAuthStore.logout()`).
|
|
|
|
**Manifest-Hosting:** `client/public/manifest.webmanifest` wird von Vite mit-deployed; in `index.html` als `<link rel="manifest" href="/manifest.webmanifest">`. Icons in `client/public/icons/pwa-*.png` (192, 512, maskable).
|
|
|
|
### 3.3 Web Push
|
|
|
|
**Backend**
|
|
- `modules/push/push.module.ts`
|
|
- `push.controller.ts`:
|
|
- `POST /push/subscribe` (Body: endpoint, keys.p256dh, keys.auth, userAgent) → upsert auf `endpoint`-unique
|
|
- `DELETE /push/subscribe/:id` → User entfernt eigenes Device
|
|
- `GET /push/vapid-public-key` (public, oder beim ersten Bootstrap-Call mitgeben)
|
|
- `push.service.ts`:
|
|
- `sendToUser(userId, payload)`
|
|
- `sendToCampaign(campaignId, role?, payload)` — joined `CampaignMember` + ggf. role-filter
|
|
- `sendToBattleSession(sessionId, role?, payload)`
|
|
- **Lifecycle**: Bei Send-Fehler `410 Gone` oder `404` → Subscription löschen. Bei `429` exponential backoff. Bei Erfolg `lastSeenAt = now()`.
|
|
- TTL pro Push-Typ: GM-Ping = 30s (kurz, soll nicht später schlummernd ankommen), System-Notification = 24h.
|
|
|
|
**Frontend**
|
|
- `features/push/components/notification-permission-prompt.tsx` — wird beim ersten Visit nach Login gezeigt, wenn `Notification.permission === 'default'`.
|
|
- `features/push/hooks/use-push-subscription.ts` — registriert SW, holt VAPID-Key, abonniert, sendet an `POST /push/subscribe`. Hook idempotent — kein Doppel-Subscribe.
|
|
|
|
**GM-Ping-Datenfluss:**
|
|
```
|
|
[GM klickt "Ping an alle"]
|
|
└─ POST /push/ping body:{campaignId, role:'PLAYER', message}
|
|
└─ PushService.sendToCampaign(...)
|
|
└─ webPush.sendNotification(sub, payload) je Subscription
|
|
[Browser] zeigt Notification, klick → öffnet App auf /campaigns/:id
|
|
```
|
|
|
|
### 3.4 Multi-Screen-Battle (GM-Laptop + Tisch-Display)
|
|
|
|
**Empfehlung: Option (b) — separate Display-Route + serverseitiger Read-Only-Filter.**
|
|
|
|
Begründung gegen die Alternativen:
|
|
- (a) Same-component mit `mode`-Prop ist anfällig: GM-only-Daten landen im React-Tree des Display-Clients. Ein neugieriger Spieler mit Browser-Devtools könnte sie sehen. **Sicherheit fail.**
|
|
- (c) BroadcastChannel funktioniert nur same-origin/same-browser-profile. Sobald das Tisch-Display ein eigener Browser/Pi ist (was real ist), bricht das. Plus: kein Persistenz-Vorteil gegenüber WebSocket. **Skaliert nicht.**
|
|
- (b) Eigene Route `/campaigns/:id/battle/display` mit eigenem Bundle-Subset, Server-API liefert nur reduziertes DTO. Display-Client darf sich mit Auth-Token verbinden, aber Server filtert GM-only-Felder weg (z.B. NPC-statBlock-Klartext, GM-Notizen, Effekt-Quellen). Damit ist Display-Mode auch für Spieler-Devices sicher zugänglich, falls jemand „mein Handy soll auch nur die Map zeigen" nutzt.
|
|
|
|
**Backend**
|
|
- `BattleController`: neuer Endpoint `GET /battles/:id/display-view` → DTO ohne GM-Felder.
|
|
- `BattleGateway`: bei `joinBattle`-Event akzeptiert `viewerMode: 'GM' | 'DISPLAY'`. Server speichert das pro Socket. Bei Broadcasts unterscheidet Gateway zwei Sub-Rooms: `battle:<id>:gm` und `battle:<id>:display`. Kritische Events (z.B. `gm_note_added`) gehen nur in den GM-Sub-Room.
|
|
|
|
**Frontend**
|
|
- `features/battle/components/battle-display-page.tsx` — eigene Route, kein Sidebar, kein Inventar-Panel, fullscreen Map, große Initiative-Liste, Token-Status nur Public-Felder.
|
|
- `features/battle/components/battle-page.tsx` (existiert) — bekommt Toolbar-Erweiterungen (Effects-Picker, Initiative-Sortier-UI, „Display-Tab in neuem Fenster öffnen"-Button mit deep-link zu `/campaigns/:id/battle/display?session=X`).
|
|
- `features/battle/components/initiative-tracker.tsx` (neu) — sortierte Liste, wer ist dran (highlighted), prev/next-Buttons (GM-only).
|
|
- `features/battle/components/effect-pill.tsx` (neu) — gerendert auf Token + in Display-Liste.
|
|
- `features/battle/components/effect-add-modal.tsx` (neu, GM-only).
|
|
|
|
**Route-Tabelle (Erweiterung von App.tsx):**
|
|
```
|
|
+ /campaigns/:id/battle/display → BattleDisplayPage (eigener Layout-Mode, kein Navbar)
|
|
+ /campaigns/:id/dice → DiceLogPage (oder als Sidebar im Battle/Character-View)
|
|
+ /campaigns/:id/chat → ChatPage (oder Slide-in-Panel)
|
|
+ /campaigns/:id/vault → VaultBrowserPage
|
|
+ /campaigns/:id/vault/* → VaultNotePage (Wildcard-Pfad)
|
|
```
|
|
|
|
### 3.5 Würfeln & Chat
|
|
|
|
**Empfehlung: zwei Tabellen + zwei Gateways oder ein gemeinsamer Gateway. Polymorphes Schema (`MessageType` mit verschiedenen Spalten in einem Table) **abgelehnt** — getrennte Tabellen bleiben sauberer (DiceRoll hat strukturierte Daten, ChatMessage ist Text+Embed).**
|
|
|
|
**Gateway-Frage:**
|
|
- Ein **eigener `DiceGateway` (Namespace `/dice`)** und **eigener `ChatGateway` (Namespace `/chat`)** — konsistent mit der Bestands-Architektur (jedes Feature eigener Namespace).
|
|
- Nicht in `CharactersGateway` einbauen: Character-Updates sind charakter-bezogen (Room = characterId). Chat/Dice sind campaign- und session-bezogen (Room = campaignId oder battleSessionId). Verschiedene Rooms → verschiedene Gateways.
|
|
|
|
**Datenmodell-Wahl: getrennte Tabellen, ChatMessage hat `embedRollId` als FK auf DiceRoll.** Vorteile gegenüber polymorphem Schema:
|
|
- DiceRoll ist append-only, hochfrequent, klar typisiert (`expression`, `total`, `isCrit`).
|
|
- ChatMessage ist append-only, niederfrequent, Markdown-Body.
|
|
- Pagination ist pro Tabelle eigenständig (DiceLog vs ChatLog können separat gerendert werden).
|
|
- Einbettung über FK ist sauber: Chat-Renderer joint optional `embedRoll` und rendert eine Roll-Card.
|
|
|
|
**Pagination-Pattern:** Cursor-basiert über `createdAt` + `id` (deterministisch). Endpoint `GET /campaigns/:id/dice?cursor=...&limit=50`. Default 50 letzte Einträge. WebSocket pusht neue Einträge live, Pagination lädt historisch.
|
|
|
|
**Frontend**
|
|
- `features/dice/components/dice-roller.tsx` — Eingabefeld + Quick-Buttons (d20, d4, d6, d8, d10, d12, d100).
|
|
- `features/dice/components/dice-log.tsx` — Liste der Rolls, virtualisiert für Scroll.
|
|
- `features/dice/utils/parse-pf2e-notation.ts` — Pure Function: `"1d20+7"` → strukturiert (Anzahl, Sides, Mod). Krit/Fumble erst auf Server berechnet (single source of truth).
|
|
- `features/chat/components/chat-panel.tsx` — Slide-in / Sidebar, Eingabe + Liste.
|
|
- `features/chat/components/message-card.tsx` — rendert Text + optionales `embedRoll`.
|
|
- `shared/hooks/use-dice-socket.ts`, `shared/hooks/use-chat-socket.ts` — analog zu `use-character-socket.ts`.
|
|
|
|
### 3.6 Obsidian-Vault (Read-Only)
|
|
|
|
**Empfehlung: Transport-Option 1 (WebDAV-Proxy via NestJS) als Default.** Begründung im Vergleich:
|
|
|
|
| Option | Browser-Sec | Server-Last | Setup-Komplexität | Auth-Modell | Verdict |
|
|
|---|---|---|---|---|---|
|
|
| WebDAV-Proxy via NestJS | Vault nie direkt im Browser, JWT vor allem | Mittel (Stream-Proxy) | Niedrig (libs vorhanden) | Eigener JWT, Vault-Creds in `.env` | **Default** |
|
|
| Direkter HTTP+CORS | Vault muss CORS-konfigurierbar sein, Auth-Header doppelt | Niedrig | Hoch (CORS, Vault-Auth-Header in Browser) | Vault-Auth direkt im Browser → Token-Leak-Risiko | Verworfen |
|
|
| Git-Pull periodisch | Vault muss Git-Repo sein | Niedrig nach Pull, hoch bei Pull | Mittel (cron, conflict-handling) | SSH-Key am Server | Optional, wenn Vault Git-basiert |
|
|
| Snapshot-Upload (Zip) | Sehr sicher | Niedrig | Mittel (User-Workflow) | Kein Vault-Zugriff nötig | Optional, wenn kein Server beim User |
|
|
|
|
**Self-hosted Charakter:** User hat eigenen Server. WebDAV-Proxy ist ideal — Vault liegt auf dem User-Server (z.B. Synology, Nextcloud, eigener nginx mit `mod_dav`), NestJS verbindet sich mit Service-Account, Browser sieht nur saubere Dimension47-API. Gleiches Sec-Modell wie Equipment-API.
|
|
|
|
**Backend**
|
|
- `VaultService.list(campaignId, dirPath)` → ruft WebDAV `PROPFIND`, gibt Files+Subdirs zurück.
|
|
- `VaultService.read(campaignId, filePath)` → ruft WebDAV `GET`, parst Markdown, resolviert `[[Wikilinks]]` per Index-Lookup, gibt `{ content, frontmatter, links: [{name, path?}] }` zurück. Cached in `VaultNoteCache`.
|
|
- `VaultService.image(campaignId, imagePath)` → streamt Bild über NestJS-Response.
|
|
- `GET /vault/:campaignId/list?path=...`, `GET /vault/:campaignId/note?path=...`, `GET /vault/:campaignId/image?path=...`, `GET /vault/:campaignId/search?q=...`.
|
|
|
|
**Frontend**
|
|
- `features/vault/components/vault-browser-page.tsx` — Folder-Tree links, Note-Reader rechts.
|
|
- `features/vault/components/note-renderer.tsx` — `react-markdown` + `remark-gfm` + Custom-Plugin für `[[Wikilink]]` (klickbar zu interner Vault-Route).
|
|
- `features/vault/utils/wikilink-plugin.ts` — Regex-basierter Remark-Plugin.
|
|
- `features/vault/hooks/use-vault-note.ts` — React Query, kombiniert mit SW-Cache (StaleWhileRevalidate).
|
|
|
|
### 3.7 GM-Live-Tools
|
|
|
|
Größtenteils **keine neuen Module**. Existierende `CharactersService.updateHp/addCondition/...` haben bereits GM-Bypass für Owner-Check (`checkCharacterAccess` lässt GM passieren). Was fehlt ist UI:
|
|
|
|
**Frontend**
|
|
- `features/campaigns/components/gm-control-panel.tsx` — Sidebar im `campaign-detail-page.tsx`, listet alle Spieler-Charaktere mit Quick-Actions (HP+/-, Condition setzen, Geld geben, Push-Ping senden).
|
|
- Re-use existierender Modals: `add-condition-modal`, `add-item-modal`, `hp-control` mit `gmMode`-Prop (zeigt Charakter-Selector).
|
|
|
|
**Backend**
|
|
- `PushService.sendToUser(userId, {type:'GM_PING', message})` für gezielte Pings — bereits in PushModule.
|
|
- Keine neuen Endpoints — bestehende Character-Endpoints reichen, da GM bereits berechtigt ist.
|
|
|
|
---
|
|
|
|
## 4. Datenfluss: Übergreifende Patterns
|
|
|
|
### 4.1 PWA-Cache-Invalidation bei Socket-Push
|
|
|
|
Wenn der SW `/api/.../characters/:id` cached und ein HP-Update via WebSocket reinkommt, ist der Cache veraltet. Lösung **kombiniert**:
|
|
|
|
1. **Primärer Pfad — Reaktiv via postMessage:** Beim Empfang eines `character_update`-Events ruft `use-character-socket.ts` einen Helper `invalidateCacheForCharacter(characterId)`, der per `navigator.serviceWorker.controller.postMessage({type:'INVALIDATE', urls:[...]})` an den SW signalisiert. SW löscht die betroffenen Cache-Einträge.
|
|
|
|
2. **Sekundärer Pfad — NetworkFirst-Strategie für Live-Daten:** Charakter-Endpoints nutzen NetworkFirst statt StaleWhileRevalidate. Online → frische Daten, Offline → Cache-Fallback. Damit ist auch ohne postMessage-Sync das schlimmste, was passiert, ein 3-Sekunden-Timeout, dann Cache.
|
|
|
|
3. **Statische Daten** (Equipment, Feats) bleiben StaleWhileRevalidate — die ändern sich nur bei DB-Reseed, nicht zur Laufzeit.
|
|
|
|
**Anti-Pattern explizit ablehnen:** Cache-Bypass „wenn WebSocket connected" zu komplex und race-condition-anfällig. NetworkFirst + postMessage-Invalidation reicht.
|
|
|
|
### 4.2 Level-Up Datenfluss (vollständig)
|
|
|
|
Bereits in 3.1 oben. Schlüsselpunkte:
|
|
- DRAFT-State macht Undo trivial (einfach Session löschen, Charakter unverändert).
|
|
- Commit ist eine einzige Prisma-Transaktion: `applyLevelUp` + `recomputeDerivedStats` + `LevelUpSession.state = COMMITTED` + Gateway-Broadcast.
|
|
- Snapshot-Before in `LevelUpSession.snapshotBefore` erlaubt theoretisch nachträgliches Undo eines committed Level-Ups (out of scope für jetzt, aber Daten sind da).
|
|
|
|
### 4.3 Multi-Screen-Battle Datenfluss
|
|
|
|
```
|
|
[GM-Client] [Display-Client]
|
|
Socket.io connect Socket.io connect
|
|
emit 'joinBattle' {id, viewerMode:'GM'} emit 'joinBattle' {id, viewerMode:'DISPLAY'}
|
|
→ joins room battle:<id>:gm → joins room battle:<id>:display
|
|
|
|
[GM klickt "Token bewegen"]
|
|
PATCH /battles/:id/tokens/:t
|
|
→ BattleService.moveToken
|
|
→ BattleGateway.broadcast(sessionId, 'token_moved', ...)
|
|
→ emit to room battle:<id>:gm ← GM-Client sieht Update
|
|
→ emit to room battle:<id>:display ← Display-Client sieht Update
|
|
|
|
[GM klickt "GM-Notiz hinzufügen" (kein Display-Event)]
|
|
PATCH /battles/:id/gm-note
|
|
→ BattleGateway.broadcast(sessionId, 'gm_note_added', ..., onlyRole:'GM')
|
|
→ emit nur an battle:<id>:gm
|
|
```
|
|
|
|
### 4.4 Dice + Chat Datenfluss
|
|
|
|
```
|
|
[Spieler tippt "1d20+7"]
|
|
POST /campaigns/:id/dice body:{expression, characterId, label}
|
|
→ DiceService.roll(...) ← würfelt serverseitig (kein Client-Cheat)
|
|
→ Prisma DiceRoll insert
|
|
→ DiceGateway.broadcast(campaignId, 'roll', diceRoll)
|
|
[alle Clients in Room campaign:<id>]
|
|
use-dice-socket.ts: onRoll(diceRoll)
|
|
→ React Query: queryClient.setQueryData(['diceLog', campaignId], (old) => [diceRoll, ...old])
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Patterns to Follow
|
|
|
|
### Pattern 1: Neues Feature = neues Modul + neues Feature-Dir
|
|
**Wann:** Immer für eigenständige Domänen (Push, Dice, Chat, Vault, Leveling).
|
|
**Beispiel:**
|
|
```ts
|
|
// server/src/modules/dice/dice.module.ts
|
|
@Module({
|
|
imports: [PrismaModule, AuthModule, CampaignsModule],
|
|
controllers: [DiceController],
|
|
providers: [DiceService, DiceGateway],
|
|
exports: [DiceService],
|
|
})
|
|
export class DiceModule {}
|
|
```
|
|
|
|
### Pattern 2: Gateway-Authentifizierung (existiert, weiter nutzen)
|
|
**Wann:** Jeder neue Gateway.
|
|
**Beispiel:** Kopiervorlage `characters.gateway.ts` → JWT in `handshake.auth.token`, User-Lookup, Disconnect bei Fail. Für Battle-Display: zusätzlich `viewerMode` aus `handshake.auth` lesen und Sub-Room zuweisen.
|
|
|
|
### Pattern 3: Service-Method = Access-Check + Mutation + Broadcast
|
|
**Wann:** Jede Mutation.
|
|
**Beispiel:** `CharactersService.updateHp` Reihenfolge:
|
|
1. `checkCampaignAccess(userId, campaignId)`
|
|
2. `checkCharacterAccess(userId, characterId)`
|
|
3. Prisma-Update
|
|
4. `gateway.broadcast(...)`
|
|
|
|
### Pattern 4: WebSocket-Hook mit Singleton + Ref-Counting
|
|
**Wann:** Jeder neue Frontend-WebSocket-Konsument.
|
|
**Beispiel:** `use-dice-socket.ts` folgt Pattern aus `use-character-socket.ts` — globaler Socket pro Namespace, `subscriberCount` für Cleanup.
|
|
|
|
### Pattern 5: DTO-Validation mit class-validator
|
|
**Wann:** Jeder neue Endpoint.
|
|
**Beispiel:**
|
|
```ts
|
|
export class StartLevelUpDto {
|
|
@IsString() @IsNotEmpty() characterId: string;
|
|
}
|
|
```
|
|
|
|
### Pattern 6: Service-Worker-Update via postMessage
|
|
**Wann:** Wenn Live-Daten den Cache invalidieren sollen.
|
|
**Beispiel:**
|
|
```ts
|
|
// shared/lib/sw-cache-bridge.ts
|
|
export function invalidateSwCache(urls: string[]) {
|
|
navigator.serviceWorker?.controller?.postMessage({type:'INVALIDATE', urls});
|
|
}
|
|
// In SW:
|
|
self.addEventListener('message', (e) => {
|
|
if (e.data?.type === 'INVALIDATE') {
|
|
e.data.urls.forEach(url => caches.open('api-cache').then(c => c.delete(url)));
|
|
}
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Anti-Patterns to Avoid
|
|
|
|
### Anti-Pattern 1: Polymorphic-Table für Dice+Chat
|
|
**Was:** Eine `Message`-Tabelle mit `type: TEXT|ROLL|SYSTEM` und 20 nullable Spalten.
|
|
**Warum schlecht:** Spalten-Sparsity, jede neue MessageType-Variante explodiert das Schema, Pagination wird umständlich (DiceLog will nur Rolls, joint trotzdem alles), keine sauberen FK-Constraints.
|
|
**Stattdessen:** Zwei Tabellen `DiceRoll` + `ChatMessage`, FK `embedRollId` für Embeds.
|
|
|
|
### Anti-Pattern 2: Display-Mode als React-Prop am gleichen Komponentenbaum
|
|
**Was:** `<BattlePage mode="display" />` versteckt GM-Controls per CSS.
|
|
**Warum schlecht:** GM-only-Daten landen via React Query im Display-Client. Devtools zeigen sie. Sicherheits-Leak.
|
|
**Stattdessen:** Eigene Route + serverseitig gefiltertes DTO + Sub-Rooms im Gateway.
|
|
|
|
### Anti-Pattern 3: Service-Worker cached Mutationen
|
|
**Was:** SW cached POST/PATCH-Responses oder ersetzt fehlgeschlagene Mutations durch optimistic offline-queue.
|
|
**Warum schlecht:** PROJECT.md definiert „Offline-Bearbeiten ist explizit raus". Konflikt-Logik wäre teuer, Spielsessions sind online.
|
|
**Stattdessen:** SW NetworkOnly für alle Mutationen. Offline-Mode = nur Lesen.
|
|
|
|
### Anti-Pattern 4: Roll-Berechnung im Client
|
|
**Was:** Frontend würfelt `Math.random()` und schickt Ergebnis an Server.
|
|
**Warum schlecht:** Spieler kann lokal manipulieren. PF2e-Werte (Crits, Persistent Damage) müssen autoritativ sein.
|
|
**Stattdessen:** Server-side Roll. Client schickt nur `expression` + Kontext, Server würfelt + persistiert + broadcastet.
|
|
|
|
### Anti-Pattern 5: Vault-Credentials im Browser
|
|
**Was:** WebDAV-User/Password im React-Code, Browser ruft Vault direkt.
|
|
**Warum schlecht:** Token-Leak in Browser-Devtools, CORS-Konfig am Vault, Rotation unmöglich.
|
|
**Stattdessen:** Vault-Creds in `.env` am NestJS-Server, Browser spricht nur via Dimension47-JWT mit `/vault/*`-Endpoints.
|
|
|
|
### Anti-Pattern 6: `db push` bei Schema-Änderung
|
|
**Was:** Schnell-Fix bei Schema-Änderung mit `prisma db push`.
|
|
**Warum schlecht:** Verstößt gegen CLAUDE.md, keine Versionierung, kann nicht ausgerollt werden.
|
|
**Stattdessen:** Immer `prisma migrate dev --name <descriptive>`.
|
|
|
|
### Anti-Pattern 7: GM-Ping ohne Push-Subscription
|
|
**Was:** GM-Ping nur als Toast im aufgemachten Browser-Tab.
|
|
**Warum schlecht:** Spieler sieht es nicht, wenn Tab nicht aktiv ist. Use-Case ist gerade, dass Spieler aufmerksam wird.
|
|
**Stattdessen:** Push-Notification via Service-Worker. Toast als zusätzlicher Inline-Hinweis, nicht als Ersatz.
|
|
|
|
---
|
|
|
|
## 7. Build Order / Dependencies (kritisch für Roadmap)
|
|
|
|
**Empfohlene Phasen-Reihenfolge** (begründet, nicht beliebig):
|
|
|
|
```
|
|
Phase A: Level-Up
|
|
├─ keine externen Abhängigkeiten
|
|
├─ erweitert nur CharactersService + CharactersGateway (Bestand)
|
|
├─ neues Modul LevelingModule, neue Migration LevelUpSession
|
|
└─ kein PWA/Push nötig
|
|
──> liefert: erstes komplettes neues Feature, validiert das Pattern „Bestand erweitern + neues Modul"
|
|
|
|
Phase B: PWA Foundation (ohne Push)
|
|
├─ vite-plugin-pwa, manifest, service-worker, install-prompt
|
|
├─ Caching-Strategien für existierende Endpoints
|
|
├─ keine Backend-Änderungen
|
|
└─ blockiert: Push (braucht SW), Vault-Offline-Cache (braucht SW)
|
|
──> liefert: installierbare App, Offline-Read
|
|
|
|
Phase C: Web Push
|
|
├─ braucht Phase B (Service-Worker existiert)
|
|
├─ neues Modul PushModule, neue Migration PushSubscription
|
|
├─ Permission-Prompt im Frontend
|
|
└─ blockiert: GM-Ping in GM-Live-Tools, Battle-Turn-Alert
|
|
──> liefert: Notification-Pfad steht
|
|
|
|
Phase D: Dice + Chat (parallel zu C möglich, aber nach B)
|
|
├─ braucht keine Push (Toast für Inline-Anzeige reicht intern)
|
|
├─ neue Module DiceModule, ChatModule, neue Migration add_dice_and_chat
|
|
├─ neue Gateways /dice, /chat
|
|
├─ neue UI-Panels
|
|
└─ blockiert: Battle-Turn-Alert (kombiniert Dice mit Battle-Events)
|
|
──> liefert: In-App-Würfel + Chat überall
|
|
|
|
Phase E: Battle-Ausbau (Display-Mode, Initiative-Tracker, Effekte)
|
|
├─ braucht keine Push, aber profitiert von Phase D (Dice-Anbindung in Initiative)
|
|
├─ erweitert BattleGateway um neue Events
|
|
├─ neue Route /battle/:id/display, neuer DTO-Filter
|
|
├─ neue Migration add_battle_effects + turnTokenId
|
|
└─ blockiert: nichts
|
|
──> liefert: zwei sichere Sichten auf den Battle, vollständiger Initiative-Flow
|
|
|
|
Phase F: GM-Live-Tools
|
|
├─ braucht Phase C (Push für GM-Ping) + Phase D (Chat für gezielte Nachrichten)
|
|
├─ kein neues Backend-Modul, kein neues Schema
|
|
├─ neue UI: gm-control-panel, GM-Mode in bestehenden Modals
|
|
└─ blockiert: nichts
|
|
──> liefert: GM hat zentrales Cockpit
|
|
|
|
Phase G: Obsidian-Vault
|
|
├─ braucht Phase B (SW für Offline-Cache der Notes)
|
|
├─ neues Modul VaultModule, neue Migration add_vault_config + VaultNoteCache
|
|
├─ neue Routen + neuer Frontend-Bereich
|
|
├─ unabhängig vom Rest
|
|
└─ blockiert: nichts
|
|
──> liefert: Read-Only Vault im Browser
|
|
```
|
|
|
|
**Dependency-Graph:**
|
|
```
|
|
A (Level-Up) ──── unabhängig
|
|
B (PWA) ──── unabhängig ─┐
|
|
C (Push) ──── braucht B │
|
|
D (Dice/Chat) ──── unabhängig ├── nichts blockiert E zwingend, aber D hilft Initiative
|
|
E (Battle-Ausbau) ──── (nutzt D opt) │
|
|
F (GM-Live-Tools) ──── braucht C, D ─┘
|
|
G (Vault) ──── braucht B
|
|
```
|
|
|
|
**Anti-Reihenfolge (zu vermeiden):**
|
|
- Push vor PWA → ohne SW kein Push, Hängepartie.
|
|
- GM-Live-Tools vor Push/Chat → halbe Lösung, GM-Ping fehlt.
|
|
- Battle-Display vor Dice → User-Test zeigt: ohne sichtbare Initiative-Rolls fehlt Wert.
|
|
|
|
---
|
|
|
|
## 8. Integration mit Bestand: Was wird wo angefasst
|
|
|
|
### Migration-Liste (in der Reihenfolge unten ausführen)
|
|
|
|
| # | Migration | Phase | Modelle |
|
|
|---|---|---|---|
|
|
| 1 | `add_level_up_sessions` | A | LevelUpSession |
|
|
| 2 | `add_push_subscriptions` | C | PushSubscription |
|
|
| 3 | `add_dice_and_chat` | D | DiceRoll, ChatMessage |
|
|
| 4 | `add_battle_effects_and_turn_pointer` | E | BattleEffect, BattleSession.turnTokenId |
|
|
| 5 | `add_vault_config` | G | VaultConfig, VaultNoteCache |
|
|
|
|
### Bestandsdateien, die geändert werden (nicht neu)
|
|
|
|
| Datei | Phase | Änderung |
|
|
|---|---|---|
|
|
| `server/src/app.module.ts` | A,C,D,G | Neue Module importieren (LevelingModule, PushModule, DiceModule, ChatModule, VaultModule) |
|
|
| `server/src/modules/characters/characters.service.ts` | A | Neue Methoden `applyLevelUp`, `recomputeDerivedStats` |
|
|
| `server/src/modules/characters/characters.gateway.ts` | A | Update-Type `'level_up_committed'` ergänzen |
|
|
| `server/src/modules/battle/battle.gateway.ts` | E | Sub-Rooms (`:gm`, `:display`), neue Events `effect_*`, `turn_advanced` |
|
|
| `server/src/modules/battle/battle.service.ts` | E | CRUD für `BattleEffect`, Turn-Pointer setzen |
|
|
| `server/prisma/schema.prisma` | A,C,D,E,G | Neue Modelle ergänzen + `BattleSession.turnTokenId`, `BattleToken.effects` |
|
|
| `client/src/App.tsx` | E,G | Neue Routen `/battle/display`, `/vault`, `/vault/*` |
|
|
| `client/vite.config.ts` | B | `vite-plugin-pwa`-Konfig |
|
|
| `client/public/manifest.webmanifest` | B | Neu anlegen |
|
|
| `client/src/features/auth/hooks/use-auth-store.ts` | B | Bei `logout()` SW-Cache leeren |
|
|
| `client/src/features/characters/components/character-sheet-page.tsx` | A | „Stufe steigen"-Button + Wizard-Mount |
|
|
| `client/src/features/battle/components/battle-page.tsx` | E | Initiative-Tracker mounten, Effects-Toolbar, „Display öffnen"-Button |
|
|
| `client/src/shared/lib/api.ts` | A,C,D,G | Neue API-Calls (Leveling, Push, Dice, Chat, Vault) |
|
|
| `client/src/shared/types/index.ts` | A,C,D,E,G | Neue Interfaces |
|
|
|
|
### Net-neue Dateien (zentrale Übersicht)
|
|
|
|
```
|
|
server/src/modules/leveling/
|
|
leveling.module.ts, leveling.controller.ts, leveling.service.ts
|
|
dto/start-level-up.dto.ts, dto/update-draft.dto.ts
|
|
|
|
server/src/modules/push/
|
|
push.module.ts, push.controller.ts, push.service.ts
|
|
dto/subscribe.dto.ts
|
|
|
|
server/src/modules/dice/
|
|
dice.module.ts, dice.controller.ts, dice.service.ts, dice.gateway.ts
|
|
utils/parse-pf2e-notation.ts (server-seitig autoritativ)
|
|
dto/roll.dto.ts
|
|
|
|
server/src/modules/chat/
|
|
chat.module.ts, chat.controller.ts, chat.service.ts, chat.gateway.ts
|
|
dto/send-message.dto.ts
|
|
|
|
server/src/modules/vault/
|
|
vault.module.ts, vault.controller.ts, vault.service.ts
|
|
utils/wikilink-resolver.ts, utils/webdav-client.ts (oder Adapter pro Transport)
|
|
dto/vault-config.dto.ts
|
|
|
|
client/src/features/leveling/
|
|
components/level-up-wizard.tsx + step-*.tsx
|
|
hooks/use-level-up-session.ts
|
|
index.ts
|
|
|
|
client/src/features/push/
|
|
components/notification-permission-prompt.tsx
|
|
hooks/use-push-subscription.ts
|
|
index.ts
|
|
|
|
client/src/features/dice/
|
|
components/dice-roller.tsx, dice-log.tsx
|
|
utils/parse-pf2e-notation.ts (Client-Spiegel, nur fürs Pre-Validation)
|
|
index.ts
|
|
|
|
client/src/features/chat/
|
|
components/chat-panel.tsx, message-card.tsx
|
|
index.ts
|
|
|
|
client/src/features/vault/
|
|
components/vault-browser-page.tsx, vault-note-page.tsx, note-renderer.tsx
|
|
utils/wikilink-plugin.ts
|
|
hooks/use-vault-note.ts
|
|
index.ts
|
|
|
|
client/src/features/battle/components/
|
|
battle-display-page.tsx, initiative-tracker.tsx, effect-pill.tsx, effect-add-modal.tsx
|
|
|
|
client/src/shared/hooks/
|
|
use-dice-socket.ts, use-chat-socket.ts, use-pwa-update.ts
|
|
|
|
client/src/shared/lib/
|
|
sw-cache-bridge.ts
|
|
|
|
client/src/sw/
|
|
service-worker.ts (Workbox-customized)
|
|
|
|
client/public/
|
|
manifest.webmanifest
|
|
icons/pwa-192.png, pwa-512.png, pwa-maskable-512.png
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Scalability-Betrachtung (kontextspezifisch)
|
|
|
|
| Concern | Single Group (4-6 User, aktueller Use-Case) | 10 Gruppen (selbst-gehostete Forks) | Anmerkung |
|
|
|---|---|---|---|
|
|
| WebSocket-Connections | <30 gleichzeitig | <300 | Socket.io single-server reicht klar |
|
|
| DiceRoll-Volumen | ~200/Session, ~2k/Monat | ~20k/Monat | Index `(campaignId, createdAt)` reicht; kein Archivierungsdruck |
|
|
| ChatMessage-Volumen | <1k/Session | <10k/Monat | identisch |
|
|
| PushSubscription-Volumen | ~10 Devices | ~100 | Trivial |
|
|
| Vault-Cache-Größe | <100MB pro Campaign | <1GB | DB-storage, alternativ FS-cache; bei >1GB FS-Cache erwägen |
|
|
| Multi-Tenant | nicht gefordert (Out-of-Scope) | nicht gefordert | self-hosted-Modell |
|
|
|
|
Keine horizontale Skalierung nötig. Single-Server reicht. Bei späterem Multi-Tenant müsste Socket.io mit Redis-Adapter laufen — explizit Out-of-Scope.
|
|
|
|
---
|
|
|
|
## 10. Sources
|
|
|
|
- `.planning/codebase/ARCHITECTURE.md` — Bestand der Layer und Patterns (Primärquelle, HIGH)
|
|
- `.planning/codebase/STRUCTURE.md` — Verzeichnis-Konventionen (HIGH)
|
|
- `.planning/codebase/INTEGRATIONS.md` — Auth, WebSocket, Pathbuilder (HIGH)
|
|
- `.planning/PROJECT.md` — Constraints, Out-of-Scope, Validated/Active (HIGH)
|
|
- `server/prisma/schema.prisma` — bestehende Modelle, Beziehungen (HIGH)
|
|
- `server/src/modules/characters/characters.gateway.ts`, `battle/battle.gateway.ts` — bestehende Gateway-Patterns (HIGH)
|
|
- `server/src/app.module.ts` — Modul-Wiring (HIGH)
|
|
- `client/src/App.tsx` — Routing-Konvention (HIGH)
|
|
- Web Push Spec / VAPID — RFC 8030 + RFC 8292 (Standard, MEDIUM, allgemein bekannt)
|
|
- Workbox/vite-plugin-pwa — etabliertes Pattern für Vite-Apps (MEDIUM)
|
|
- Socket.io Rooms + Namespaces — bekannte Standards (HIGH, identisch mit Bestandsnutzung)
|
|
|
|
---
|
|
|
|
*Architecture research: 2026-04-27 — additive design, respektiert NestJS-Modul-Schnitt und React-Feature-Schnitt des Bestands.*
|