Files
Dimension-47/.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md

1124 lines
92 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 1: Level-Up (PF2e regelkonform) — Research
**Researched:** 2026-04-27
**Domain:** PF2e rules-correct character level-up with wizard UI, prereq DSL, recompute pipeline, atomic persistence, snapshot history, Free Archetype variant, and spellcaster progression — built on the existing NestJS 11 / React 19 / Prisma 7 / PostgreSQL stack
**Confidence:** HIGH (codebase patterns, package versions, prereq corpus all verified locally; PF2e rules cited from Archives of Nethys; Foundry pf2e schema verified against live JSON; remaining LOW items called out explicitly)
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked 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).
- **D-04:** Datenquelle der Prereq-Strings: bestehende `Feat.prerequisites: String?`-Spalte (existiert bereits in `server/prisma/schema.prisma:560`).
- **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". 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 → Skill-Increase → Klassentalent → Fertigkeitstalent → Allgemein-Talent → Ancestry-Talent → FA-Slot → 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. KEIN Live-Update pro Schritt.
- **D-13:** Permissions: Owner UND GM der Kampagne dürfen Level-Up starten. Pattern: `checkCharacterAccess` (Owner OR GM-of-campaign).
- **D-14:** Offene DRAFTs ohne Commit: bleiben unbegrenzt offen. Ein DRAFT pro Charakter (Constraint).
**Klassen+Spellcaster-Daten (LVL-08, LVL-14)**
- **D-15:** Neue Prisma-Tabelle `ClassProgression` pro `(className, level)` mit `grants: String[]`, `proficiencyChanges: Json`, `spellSlotIncrement: Json?`, `cantripIncrement: Int?`, `repertoireIncrement: Int?`, `choiceType: String?`, `choiceOptionsRef: String?`.
- **D-16:** Class-Scope v1: 16 Core+APG Klassen (Alchemist, Barbarian, Bard, Champion, Cleric, Druid, Fighter, Investigator, Monk, Oracle, Ranger, Rogue, Sorcerer, Swashbuckler, Witch, Wizard).
- **D-17:** Datenquelle für Seed: Foundry PF2e-System Repository (`https://github.com/foundryvtt/pf2e`). TS-Seed-Script (`server/prisma/seed-class-progression.ts`).
- **D-18:** Spellcaster-Repertoire-Increment (LVL-14, spontane Caster): eigener Wizard-Schritt. Slot-Increment automatisch.
- **D-19:** Wahl-Klassenmerkmale (Cleric Doctrine, Wizard School, Fighter Weapon Mastery, etc.): Wizard erkennt `choiceType` und schiebt dynamischen Sub-Schritt ein.
### Claude's Discretion
- Konkretes UI-Layout des Step-by-Step-Modals (Header, Footer-Buttons, Progress-Indicator) — fixiert in `01-UI-SPEC.md`
- Konkrete Spaltenstruktur des Review-Schritts — fixiert in `01-UI-SPEC.md`
- DRAFT-API-Endpoint-Naming — empfohlen unten in §Architecture Patterns
- JSON-Format des Snapshot-Vorher
- Genaue Error-Messages auf Deutsch
- Test-Granularität (mindestens: Boost-Cap-Logik, Skill-Increase-Cap, Prereq-Evaluator pro Pattern; integration-test für Commit-Transaktion)
### Deferred Ideas (OUT OF SCOPE — do NOT plan)
- Level-up-Historie-Ansicht im Charakterbogen (LVL-V2-01)
- Reverse-Level-Up (LVL-V2-02)
- Variant-Class-Features (Rogue Eldritch Trickster, Druid Wild Order Variants)
- Multi-Classing
- Live-Recompute-Vorschau in jedem Wizard-Schritt
- Remaster-Klassen (Magus, Summoner, Kineticist, Thaumaturge, Gunslinger, Inventor, Psychic) — v2
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| LVL-01 | Wizard zeigt alle level-relevanten Wahlpunkte | §Architecture Patterns (Wizard State Machine), §Standard Stack (XState v5), §UI-SPEC.md (already approved) |
| LVL-02 | Boosts L5/10/15/20: 4 Boosts, +2 unter 18, +1 ab 18 | §Standard Stack (`apply-attribute-boost.ts` pure module), §Pitfall #8 mitigation, §Validation Architecture (boost-cap unit tests) |
| LVL-03 | Klassentalent jedes geraden Levels mit Filter | §Standard Stack (PrereqEvaluator), §Code Examples (feat filter query) |
| LVL-04 | Fertigkeitstalent jedes geraden Levels mit Filter | §Standard Stack (PrereqEvaluator), §Code Examples |
| LVL-05 | Allgemein-Talent L3/7/11/15/19 (Skill-Talente erlaubt) | §Code Examples (general-or-skill filter) |
| LVL-06 | Skill-Increase L3+ mit Cap-Regel (T→E ab 3, E→M ab 7, M→L ab 15) | §Standard Stack (`skill-increase-cap.ts` pure module), §Validation Architecture |
| LVL-07 | Ancestry-Talent L5/9/13/17 mit Filter | §Standard Stack (PrereqEvaluator), §Architecture Patterns |
| LVL-08 | Klassenmerkmale automatisch aus Class-Progression-Tabelle | §Standard Stack (ClassProgression table + seed), §Code Examples (Foundry pf2e schema mapping) |
| LVL-09 | Prereq-DSL-Evaluator; nicht-evaluierbar = Warnung | §Standard Stack (PrereqEvaluator module), §Code Examples (parser grammar), §Pitfall coverage |
| LVL-10 | Auto-Recompute HP-Max, Saves, AC, Klassen-DC, Wahrnehmung | §Standard Stack (`recompute-derived-stats.ts`), §Architecture Patterns (Recompute Pipeline), §Pitfall #9 mitigation |
| LVL-11 | DRAFT-Session jederzeit abbrechbar / rückgängig vor Commit | §Standard Stack (LevelUpSession Prisma model), §Architecture Patterns (REST endpoints) |
| LVL-12 | Atomic Commit + Snapshot-Before in JSON | §Architecture Patterns (Atomic Commit Transaction), §Code Examples (Prisma `$transaction`), §Pitfall #9 mitigation |
| LVL-13 | Free-Archetype-Toggle pro Charakter | §Standard Stack (boolean field on Character), §Architecture Patterns (FA-Slot step) |
| LVL-14 | Spellcaster: Slot-/Cantrip-/Repertoire-Progression | §Standard Stack (spell-progression module), §Code Examples (Foundry classfeatures spellcasting JSON) |
| LVL-15 | Deutsche Übersetzung für Prereq-Texte und Klassenmerkmal-Beschreibungen via bestehender Translation-Pipeline | §Architecture Patterns (Translation integration), §Existing Code (TranslationsService) |
</phase_requirements>
## Project Constraints (from CLAUDE.md)
These directives from `./CLAUDE.md` are non-negotiable and the planner must verify compliance for every task:
| Constraint | How it constrains the plan |
|-----------|----------------------------|
| **TypeScript strict, keine `any`** | All new modules (`leveling/*.ts`, prereq-evaluator, recompute) must be fully typed. NestJS-eslint allows `any` (lint config disables `no-explicit-any`), but CLAUDE.md overrides — refuse `any`. |
| **Quality vor Geschwindigkeit, kein Quick-Fix** | Plan tasks for proper Prisma migrations, full unit-test coverage of pure modules, full Foundry-data-driven seed script — not stubbed-in tables. |
| **Daten in Datenbank, nichts zur Laufzeit aus JSON-Dateien** | `ClassProgression` data MUST live in Postgres after seed, never read from JSON files at runtime. |
| **Prisma `migrate dev`, niemals `db push`** | New tables get a real migration file under `server/prisma/migrations/`. |
| **Deutsche UI durchgehend, keine Emojis, nur Lucide Icons** | Already enforced in `01-UI-SPEC.md` — planner must keep all wizard copy German. |
| **Mobile-First, Touch-Targets ≥44px** | Already enforced in `01-UI-SPEC.md` (`h-11 w-11` on +/- buttons, stepper-dot wrappers). |
| **WebSocket: ein Broadcast nach Commit, nicht mehrere kleine Updates** | The new `level_up_committed` `CharacterUpdatePayload` variant is emitted exactly once at the end of the commit transaction (Pitfall #9). |
| **Test-Disziplin etabliert in dieser Phase** | First tests in the codebase ever — set the bar. Planner must include Wave-0 task to establish Jest infrastructure (it exists in `server/package.json` but has zero unit-test files alongside source). |
| **Self-hosted single-tenant — kein Multi-Tenant-Bedarf** | No Tenant scoping needed; permission model stays GM-or-Owner. |
| **Push-Plattform = Web Push, kein Native-Wrapper** | Not relevant to this phase. |
## Summary
Phase 1 builds a regelkonform PF2e Level-Up wizard on the existing Dimension47 stack (NestJS 11.0.1 + React 19.2.0 + Prisma 7.2.0 + PostgreSQL + Socket.io 4.8.3 + Tailwind v4). The implementation is six-layered: (1) two new Prisma tables — `LevelUpSession` (DRAFT JSON-state per character with a partial unique index forcing one DRAFT at a time) and `ClassProgression` (one row per `(className, level)` with grants/proficiencyChanges/spell-slot/repertoire/choiceType columns); (2) a TS seed script `server/prisma/seed-class-progression.ts` that pulls from the Foundry pf2e repo (Apache 2.0 + OGL 1.0a + Paizo Community Use Policy — verified) at build time, transforms its JSON into our schema, and remains idempotent across re-runs; (3) four pure-function modules with full Jest unit-test coverage (`apply-attribute-boost.ts`, `skill-increase-cap.ts`, `prereq-evaluator.ts`, `recompute-derived-stats.ts`); (4) a NestJS `LevelingModule` exposing four REST endpoints (POST start / PATCH state / POST commit / DELETE discard) using `prisma.$transaction([...])` for the atomic commit; (5) a React `level-up-wizard.tsx` modal already specified component-by-component in `01-UI-SPEC.md`, recommended to drive its dynamic conditional steps with **plain `useReducer` + a JSON-serializable state shape** (XState v5 is overkill for a linear wizard whose only branching is "is this step applicable at level N for this class"); (6) one new `level_up_committed` variant on `CharacterUpdatePayload` broadcast exactly once after commit.
The single highest-risk item is the **Foundry pf2e data shape**: class JSONs (`packs/classes/<class>.json`) reference compendium UUIDs (`Compendium.pf2e.classfeatures.Item.<Name>`) and the actual mechanical progressions (spell slot tables, proficiency-rank-by-level for Fighter weapon mastery, etc.) live in **separate compendium files** that the seed script must follow and join. Spell-slot progression is **not** in the class JSON — it lives in classfeatures like `wizard-spellcasting.json` and is encoded textually in the description, NOT as machine-readable rules. This means the seed script will need a small per-class hand-curated overlay of spell-slot/cantrip tables for the spontaneous + prepared casters in scope (covered in §Code Examples and §Open Questions).
The Prereq-DSL evaluator is high-confidence: a 5,625-feat corpus already lives in `server/prisma/data/featlevels.json`, of which 3,393 (60.3%) carry prereqs. A pattern audit (§Code Examples) shows pure skill-rank covers 345 feats, skill-rank-with-or/and another ~125, heritage refs 112, ancestry/instinct/cause/order/muse refs ~132, deity refs 29, age/ethnicity 11. With D-01's evaluable patterns (skill rank, feat possession, level, class, ancestry, heritage), realistic mechanical coverage is **70-80% of all feats**, with the remainder triggering the warning path (D-03). This matches the CONTEXT.md estimate.
**Primary recommendation:** Implement in five waves — Wave 0 (Jest infrastructure + Prisma migration scaffolding), Wave 1 (pure-function modules + their tests, no DB), Wave 2 (`ClassProgression` seed pipeline against Foundry data), Wave 3 (`LevelingModule` REST + atomic commit + `CharactersGateway` extension), Wave 4 (React wizard against the already-approved `01-UI-SPEC.md`). Use plain `useReducer` for wizard state — not XState — because the conditional-step logic is a pure function of `(targetLevel, characterClass, hasFA, isCaster)` and serializes trivially to JSON for DRAFT persistence.
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Prereq DSL parsing/evaluation | API / Backend | — | Evaluator runs at wizard step (server filters feat list) AND at Pathbuilder import (D-05) — must be one shared TS module. Pure function, no DB calls itself, but called from server services. |
| Level-up DRAFT persistence | Database / Storage | API / Backend | `LevelUpSession.state: Json` survives reload + cross-device (D-11). Database is the source of truth; backend exposes the CRUD on it. |
| Live recompute (HP-Max, AC, Saves, DC, Wahrnehmung) | API / Backend | — | Pure deterministic function on the server side, called inside the commit transaction. Kept off the client to prevent diff drift — Review screen renders backend-computed numbers (D-12). |
| Wizard step state (which step / which choice / draft serialization) | Browser / Client | API / Backend | Step navigation, "selected this card", "boost-counts so far" are client UI state; PATCHed up to the DRAFT row on each step transition for cross-device resume. |
| Commit atomicity | API / Backend | Database / Storage | `prisma.$transaction([...])` wraps snapshot insert + character mutations + history insert + session delete + broadcast (Pitfall #9). |
| Single broadcast `level_up_committed` | API / Backend | — | After transaction commit, the gateway emits exactly one event; client reconciles. Not the client's job to compose the event. |
| Pathbuilder-import prereq violations banner | API / Backend | Browser / Client | Server runs evaluator during import (D-05/D-06), persists violation list (proposal: `Character.prereqViolations: Json?`), client renders the banner from server data. |
| Class progression data | Database / Storage | Build/Seed | `ClassProgression` table seeded once from Foundry pf2e (D-15/D-17), read by both server (commit recompute) and indirectly via API for the wizard (the wizard only sees server-derived "what choices are available"). |
| Class-feature choice options (Cleric Doctrine, Wizard School) | Database / Storage | API / Backend | Stored in `ClassProgression.choiceOptionsRef` with the actual options seeded as part of the same pipeline. Server resolves the ref → option list when the wizard requests it. |
| Translation of new prereq + class-feature text | API / Backend | Database / Storage | Existing `TranslationsService` + `Translation` table (D-15 carry-forward) handles batch translation on demand from Claude API, cached in Postgres. No new table needed. |
## Standard Stack
### Core (already installed — no new packages)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `@nestjs/common` | 11.0.1 | LevelingModule + Controller + DTOs | Existing module-per-feature pattern — no choice |
| `@nestjs/websockets` | 11.1.12 | Extension of CharactersGateway with `level_up_committed` event type | Existing gateway pattern; event union is just expanded |
| `@prisma/client` | 7.2.0 | LevelUpSession + ClassProgression + transactional commit | Existing ORM — `$transaction([...])` directly supports atomic multi-table writes [VERIFIED: Prisma docs] |
| `class-validator` | 0.14.3 | DTO validation for the four new REST endpoints | Existing pattern in `server/src/modules/characters/dto/` |
| `class-transformer` | 0.5.1 | DTO transform | Existing pattern |
| `@nestjs/jwt` | 11.0.2 | Reuse existing JwtAuthGuard for the new endpoints | Existing pattern; no new auth surface |
| `react` | 19.2.0 | Wizard component | Existing |
| `react-dom` | 19.2.0 | — | Existing |
| `framer-motion` | 12.26.2 | Step transitions (already declared in UI-SPEC) | Already installed via UI-SPEC |
| `lucide-react` | 0.562.0 | Icons (already declared in UI-SPEC) | Existing |
| `clsx` + `tailwind-merge` | 2.1.1 / 3.4.0 | Class composition (`cn()` helper already in `client/src/shared/lib/utils.ts`) | Existing |
| `tailwindcss` | 4.1.18 | Styling | Existing |
| `@tanstack/react-query` | 5.90.19 | Wizard's REST mutations (start / patch / commit / discard) | Existing — use `useMutation` for each endpoint |
| `axios` | 1.13.2 | HTTP transport via existing `client/src/shared/lib/api.ts` | Existing |
### Supporting (already installed — for the Wave-0 test infrastructure)
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `jest` | 30.0.0 | Test runner for the four pure-function modules | Wave 1 — first real unit tests in this codebase [VERIFIED: `server/package.json:88-104` and `npm view jest version` → 30.3.0 latest, we are on 30.0.0 which is fine] |
| `ts-jest` | 29.x | TS compile shim for Jest | Existing in package.json (`testRegex: ".*\\.spec\\.ts$"`) |
| `@nestjs/testing` | 11.x | Test module builder for the LevelingService integration test | Existing |
| `supertest` | 7.0.0 | HTTP integration test for commit endpoint | Existing |
### Considered and rejected
| Considered | Rejected because |
|------------|------------------|
| **XState v5** (`xstate` 5.30.0 + `@xstate/react` 6.1.0 [VERIFIED: npm view 2026-04-27]) for the wizard state machine | Wizard is a linear flow with conditional step visibility. The branching is `(level, class, hasFA, isCaster) → list of steps`, computed once at start. No complex parallel states, no orthogonal regions, no nested machines. `useReducer` + a typed `WizardState` discriminated union covers it in ~80 lines, serializes natively to JSON for DRAFT persistence, and stays in the React idiom the rest of the codebase uses. XState would add ~30KB minified + a learning curve for what is fundamentally a list of steps with a cursor. **Recommend `useReducer`** — keep XState in the toolbox if a future wave proves the state space is actually graph-shaped. |
| **`zod`** (4.3.6) for runtime schema validation of the wizard JSON-state on commit | The codebase uses `class-validator` + `class-transformer` (existing convention). Adding `zod` for one feature creates two parallel validation cultures. Use class-validator decorators on the DTOs; for the in-flight wizard state inside `LevelUpSession.state`, persist it as `Json` and validate the shape in a hand-written guard function in TypeScript (the shape is fully under our control — no third-party schemas). |
| **`simple-git`** (3.36.0) inside the seed script to clone Foundry pf2e | Adds a dev dependency for a one-shot operation. Use plain `child_process.execSync('git clone ...')` or, better, instruct devs to clone the foundry repo to a known path under `server/prisma/data/foundry-pf2e/` (gitignored) and have the seed read JSONs from there. This keeps the seed script offline-capable and version-pinnable (the dev controls which Foundry tag is checked out). |
| **`p-limit`** for concurrency control inside the seed script | At ~16 classes × ~30 features each ≈ 500 file reads total, no concurrency limiting needed. Plain `await Promise.all()` over a small batch. |
**Installation:** None new. Phase 1 ships with zero new npm dependencies on either client or server.
**Version verification (HIGH confidence):** All listed versions confirmed against `client/package.json` + `server/package.json` + (where bumped) `npm view <pkg> version` on 2026-04-27. Jest 30.0.0 is current major (latest 30.3.0); XState 5.30.0 / @xstate/react 6.1.0 verified but not adopted; zod 4.3.6 verified but not adopted.
## Architecture Patterns
### System Architecture Diagram
```
┌─────────────────────────────────────────┐
│ User on Charakter-Sheet │
│ clicks "Stufe steigen" (or "Fortsetzen")│
└──────────────────┬──────────────────────┘
│ HTTP POST
┌────────────────────────────────────────────────┐
│ POST /characters/:id/level-up │
│ LevelUpController.startSession() │
└──────────────────┬─────────────────────────────┘
┌───────────────────────▼───────────────────────────┐
│ LevelingService.startOrResume() │
│ • checkCharacterAccess (Owner OR GM) │
│ • check level < 20 │
│ • find existing OPEN LevelUpSession (one max) │
│ • else create new with default state │
└───────────────────────┬───────────────────────────┘
┌──────────────────────────────────────────┐
│ LevelUpSession (DB) │
│ characterId, targetLevel, state: Json, │
│ committedAt: null │
└──────────────────┬───────────────────────┘
│ session returned
┌──────────────────────────────────────────┐
│ React: <LevelUpWizard sessionId=...> │
│ derives step list from │
│ computeApplicableSteps( │
│ targetLevel, class, hasFA, isCaster │
│ ) │
└──────────┬───────────────────────────────┘
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌────────────────┐ ┌────────────────┐
│ Step: Boost │ │ Step: Talent │ │ Step: Skill │
│ apply- │ │ filter via │ │ increase-cap │
│ attribute- │ │ PrereqEvaluator│ │ check │
│ boost.ts │ │ on Feat list │ │ │
└──────┬───────┘ └────────┬───────┘ └────────┬───────┘
│ │ │
└────────────────┬──┴───────────────────┘
│ each step: PATCH /level-up/:sessionId
┌─────────────────────────────────────┐
│ LevelingService.patchState() │
│ • merge partial into session.state │
│ • validate guard fn │
│ • return updated session │
└────────────────┬────────────────────┘
┌─────────────────────────────────────┐
│ User reaches Review step │
│ Wizard requests preview: │
│ GET /level-up/:sessionId/preview │
│ → recomputeDerivedStats( │
│ characterSnapshot, choices, │
│ progression │
│ ) │
│ Vorher/Nachher rendered │
└────────────────┬────────────────────┘
│ user clicks "Bestätigen"
┌─────────────────────────────────────┐
│ POST /level-up/:sessionId/commit │
│ LevelingService.commit() │
│ prisma.$transaction([ │
│ 1. INSERT LevelUpHistory │
│ (snapshot of full character) │
│ 2. UPDATE Character (level, hp, │
│ …) │
│ 3. UPSERT CharacterAbility rows │
│ 4. UPSERT CharacterSkill rows │
│ 5. INSERT CharacterFeat rows │
│ 6. UPSERT CharacterResource │
│ (spell slots / focus / cantrip)│
│ 7. UPDATE LevelUpSession │
│ SET committedAt = now() │
│ (alt: DELETE — see §Decision) │
│ ]) │
│ charactersGateway.broadcast( │
│ {type:'level_up_committed', │
│ data: newDerivedStats + level} │
│ ) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Other connected clients receive │
│ ONE WebSocket event; │
│ react-query invalidates the │
│ character query → fresh refetch. │
└─────────────────────────────────────┘
```
Pathbuilder-import side-channel (D-05/D-06):
```
Pathbuilder-import.service.importCharacter()
→ after creating Character + CharacterFeat rows
→ for each CharacterFeat: load Feat.prerequisites string
→ PrereqEvaluator.evaluate(prereqString, characterContext)
→ { ok: true } | { ok: false, reason } | { unknown: true, raw }
→ collect failed (ok:false) entries
→ write Character.prereqViolations: Json (new column)
→ Banner reads this column on character-sheet load
```
### Recommended Project Structure (kebab-case files per CONVENTIONS.md)
```
server/
├── prisma/
│ ├── migrations/
│ │ └── YYYYMMDDHHMMSS_add_level_up_sessions_and_class_progression/
│ │ └── migration.sql # raw SQL incl. partial unique index
│ ├── seed-class-progression.ts # NEW — Foundry pf2e → ClassProgression
│ └── data/
│ └── foundry-pf2e/ # gitignored — manual git clone
│ └── packs/classes/... # source for seed
└── src/
├── modules/
│ ├── characters/
│ │ ├── characters.gateway.ts # EXTENDED: union type adds 'level_up_committed'
│ │ ├── characters.service.ts # untouched (or trivially: expose getCharacterFullSnapshot)
│ │ └── pathbuilder-import.service.ts # EXTENDED: PrereqEvaluator call after import
│ └── leveling/ # NEW MODULE
│ ├── leveling.module.ts
│ ├── leveling.controller.ts # 4 REST endpoints
│ ├── leveling.service.ts # orchestration: start/patch/commit/discard
│ ├── dto/
│ │ ├── start-level-up.dto.ts
│ │ ├── patch-level-up.dto.ts
│ │ ├── commit-level-up.dto.ts
│ │ └── level-up-state.dto.ts # the in-flight JSON shape (also used in TS guards)
│ ├── lib/ # PURE-FUNCTION MODULES (no DI, all unit-tested)
│ │ ├── apply-attribute-boost.ts
│ │ ├── apply-attribute-boost.spec.ts
│ │ ├── skill-increase-cap.ts
│ │ ├── skill-increase-cap.spec.ts
│ │ ├── prereq-evaluator.ts
│ │ ├── prereq-evaluator.spec.ts
│ │ ├── recompute-derived-stats.ts
│ │ ├── recompute-derived-stats.spec.ts
│ │ ├── compute-applicable-steps.ts # used by both server + client
│ │ └── compute-applicable-steps.spec.ts
│ ├── feat-filter.service.ts # uses PrereqEvaluator + Prisma
│ └── leveling.service.spec.ts # integration: full commit transaction
client/
└── src/
└── features/
└── characters/
└── components/
├── character-sheet-page.tsx # EXTENDED: add "Stufe steigen" header button + DRAFT banner mount + violations banner mount
└── level-up/ # NEW FOLDER (UI-SPEC §"Component File Plan")
├── level-up-wizard.tsx
├── level-up-step-class-features.tsx
├── level-up-step-class-feature-choice.tsx
├── level-up-step-boost.tsx
├── level-up-step-skill-increase.tsx
├── level-up-step-feat-class.tsx
├── level-up-step-feat-skill.tsx
├── level-up-step-feat-general.tsx
├── level-up-step-feat-ancestry.tsx
├── level-up-step-feat-archetype.tsx
├── level-up-step-spellcaster.tsx
├── level-up-step-review.tsx
├── level-up-choice-card.tsx
├── level-up-prereq-confirm-dialog.tsx
├── level-up-resume-banner.tsx
├── level-up-violations-banner.tsx
├── use-level-up-session.ts # react-query hook stack
└── wizard-state-reducer.ts # plain useReducer + types
```
### Pattern 1 — Wizard State as `useReducer` + JSON-Serializable Discriminated Union
**What:** A single typed reducer drives the wizard. State is one TypeScript object, fully JSON-serializable, that maps 1:1 to `LevelUpSession.state` in the database.
**When to use:** This wizard. Conditional steps are pure functions of `(targetLevel, class, hasFA, isCaster)` computed once at start; back-navigation is index-based; Review-step revalidation is a re-walk of already-stored choices. No need for parallel/orthogonal/historical state — XState v5 would be overkill.
**Example:**
```ts
// client/src/features/characters/components/level-up/wizard-state-reducer.ts
export type StepKind =
| 'class-features' // auto, no input
| 'class-feature-choice' // dynamic per ClassProgression.choiceType
| 'boost' // L5/10/15/20
| 'skill-increase' // L3+
| 'feat-class' // even levels
| 'feat-skill' // even levels
| 'feat-general' // L3/7/11/15/19
| 'feat-ancestry' // L5/9/13/17
| 'feat-archetype' // when hasFA
| 'spellcaster' // when isCaster (slot or repertoire)
| 'review';
export type WizardState = {
sessionId: string;
targetLevel: number;
steps: StepKind[]; // computed once at start
currentIdx: number;
choices: {
boostTargets?: ('STR'|'DEX'|'CON'|'INT'|'WIS'|'CHA')[]; // exactly 4 distinct
skillIncrease?: { skillName: string; toRank: 'TRAINED'|'EXPERT'|'MASTER'|'LEGENDARY' };
featClassId?: string;
featSkillId?: string;
featGeneralId?: string;
featAncestryId?: string;
featArchetypeId?: string;
classFeatureChoices?: Record<string, string>; // choiceKey → optionId
spellcasterRepertoirePicks?: string[]; // spell IDs for spontaneous casters
};
acknowledgedNonEvaluablePrereqs: string[]; // featIds the user confirmed
revisionMode: { fromStep: number } | null; // tracks "Ändern" → "Zurück zur Übersicht"
};
export type WizardEvent =
| { type: 'GO_NEXT' }
| { type: 'GO_PREV' }
| { type: 'GO_TO_STEP'; idx: number; revisionFrom?: number }
| { type: 'SET_BOOST_TARGETS'; targets: WizardState['choices']['boostTargets'] }
| { type: 'SET_SKILL_INCREASE'; pick: WizardState['choices']['skillIncrease'] }
| { type: 'SET_FEAT'; slot: 'class'|'skill'|'general'|'ancestry'|'archetype'; featId: string }
| { type: 'SET_CLASS_FEATURE_CHOICE'; key: string; optionId: string }
| { type: 'SET_REPERTOIRE_PICKS'; picks: string[] }
| { type: 'ACKNOWLEDGE_PREREQ_WARNING'; featId: string }
| { type: 'RETURN_TO_REVIEW' }; // triggers chain re-validation
export function wizardReducer(state: WizardState, ev: WizardEvent): WizardState { /* ... */ }
```
[ASSUMED: this exact shape — the planner may refine field names; structure is pure recommendation based on `01-UI-SPEC.md` requirements.]
Persistence: every reducer step that produces a "real" choice (not just navigation) triggers `useMutation(patchSession)` with the new `state.choices` slice. Navigation-only events stay client-only.
### Pattern 2 — Atomic Commit with `prisma.$transaction([...])` (Pitfall #9)
**What:** Five+ tightly-coupled mutations become one all-or-nothing operation; the `level_up_committed` broadcast happens only after the transaction returns.
**When to use:** The single commit endpoint.
**Example:**
```ts
// server/src/modules/leveling/leveling.service.ts (excerpt)
async commit(sessionId: string, userId: string) {
const session = await this.loadSessionAndAuthorize(sessionId, userId);
const character = await this.loadCharacterFull(session.characterId);
const progression = await this.loadProgression(character.classId, session.targetLevel);
// 1. Pure recompute (no DB writes yet)
const newDerived = recomputeDerivedStats(character, session.state, progression);
const snapshotJson = buildSnapshotJson(character); // full character + relations subset
// 2. Atomic transaction
const result = await this.prisma.$transaction([
this.prisma.levelUpHistory.create({
data: {
characterId: character.id,
levelFrom: character.level,
levelTo: session.targetLevel,
snapshotBefore: snapshotJson,
choices: session.state,
committedAt: new Date(),
},
}),
this.prisma.character.update({
where: { id: character.id },
data: {
level: session.targetLevel,
hpMax: newDerived.hpMax,
// hpCurrent: NOT touched — Pitfall #9 docs the rule (HP-Max increase = new headroom, not heal)
},
}),
// ...batch CharacterAbility upserts (boost targets)
// ...batch CharacterSkill upserts (skill-increase + class-grant proficiency changes)
// ...batch CharacterFeat creates (5 talent slots possibly + class-feature-choice picks)
// ...batch CharacterResource upserts (spell slots, cantrips, focus)
// ...batch CharacterSpell creates (repertoire picks for spontaneous casters)
this.prisma.levelUpSession.update({
where: { id: sessionId },
data: { committedAt: new Date() }, // soft-archive (preferred) — see §Decision
}),
]);
// 3. SINGLE broadcast (Pitfall #9 + ROADMAP First-Phase Note)
this.charactersGateway.broadcastCharacterUpdate(character.id, {
characterId: character.id,
type: 'level_up_committed',
data: {
level: session.targetLevel,
derived: newDerived, // hpMax, ac, classDc, perception, fortitude, reflex, will
},
});
return result;
}
```
**Decision: keep committed sessions OR delete them?** Recommend **keep with `committedAt` set** (soft-archive). Reasons: (a) the partial unique index on `(characterId) WHERE committedAt IS NULL` already gives the "one DRAFT per character" constraint without deletion; (b) committed sessions are valuable audit trail (they tell you exactly which choices the user made for level N, separate from the snapshot-before in `LevelUpHistory`); (c) deletion makes the partial-index logic implicit and harder to debug. The deferred `LVL-V2-01` "Level-up Historie-Ansicht" reads exactly this kind of data.
### Pattern 3 — Pure-Function Module + Adjacent Spec File
**What:** All math (boost cap, skill increase cap, prereq evaluation, recompute) lives in `server/src/modules/leveling/lib/*.ts` with **no NestJS imports, no Prisma, no I/O**. Each has a `.spec.ts` next to it.
**When to use:** All four core logic modules in this phase. Pattern is also the cross-cutting precedent for all future phases per ROADMAP First-Phase Note.
**Example:**
```ts
// server/src/modules/leveling/lib/apply-attribute-boost.ts
export type AbilityScore = number;
/** Applies a single PF2e attribute boost. +2 if score < 18, +1 if score >= 18 (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. */
export function isValidBoostSet(targets: readonly string[]): boolean {
return targets.length === 4 && new Set(targets).size === 4;
}
```
```ts
// server/src/modules/leveling/lib/apply-attribute-boost.spec.ts
import { applyAttributeBoost, isValidBoostSet } from './apply-attribute-boost';
describe('applyAttributeBoost', () => {
it('adds +2 when below 18', () => expect(applyAttributeBoost(10)).toBe(12));
it('adds +2 when 17 (still below cap)', () => expect(applyAttributeBoost(17)).toBe(19)); // 17 → 19; the next boost would hit 20 from 19? No — at 18 the rule kicks in. Test with 17 keeps +2.
it('adds +1 when exactly 18 (cap rule)', () => expect(applyAttributeBoost(18)).toBe(19));
it('adds +1 when above 18', () => expect(applyAttributeBoost(20)).toBe(21));
it('handles edge: very high (24)', () => expect(applyAttributeBoost(24)).toBe(25));
});
describe('isValidBoostSet', () => {
it('accepts 4 distinct', () => expect(isValidBoostSet(['STR','DEX','CON','INT'])).toBe(true));
it('rejects duplicate', () => expect(isValidBoostSet(['STR','STR','CON','INT'])).toBe(false));
it('rejects too few', () => expect(isValidBoostSet(['STR','DEX','CON'])).toBe(false));
it('rejects too many', () => expect(isValidBoostSet(['STR','DEX','CON','INT','WIS'])).toBe(false));
});
```
### Pattern 4 — Prereq DSL Evaluator: Parser + Evaluator + Formatter (D-01..D-04)
**What:** Three layers in one file (`prereq-evaluator.ts`), each independently testable.
**Grammar (concrete from real corpus — see §Code Examples for the audit):**
```
PREREQUISITE := CLAUSE ( ';' CLAUSE )* // semicolon = AND
CLAUSE := DISJUNCT ( ',' DISJUNCT )* | DISJUNCT
DISJUNCT := ATOM ( ' or ' ATOM )* // 'or' = OR; comma usually = AND inside a clause; case "comma-separated alternatives" handled by heuristic
ATOM := SKILL_RANK | FEAT_REF | LEVEL_REF | CLASS_REF | ANCESTRY_REF | HERITAGE_REF | UNKNOWN
SKILL_RANK := /(trained|expert|master|legendary) in (.+?)/i
FEAT_REF := /(?:^|; )([A-Z][a-zA-Z' ]+)$/ // bare capitalized phrase, looked up in Feat table
LEVEL_REF := /level (\d+)/i
CLASS_REF := one of {Alchemist, Barbarian, … (16 classes)}
ANCESTRY_REF := one of {Human, Elf, Dwarf, … (51 ancestries)}
HERITAGE_REF := /(.+?) heritage/i
UNKNOWN := anything else → returns { unknown: true, raw }
```
The parser produces a tree; the evaluator walks it against `CharacterContext = { level, classId, ancestryId, heritageId, abilities, skills: Map<string,Proficiency>, feats: Set<featId> }`. The formatter produces a German human-readable reason string for the warning UI (D-03).
**Example (excerpt):**
```ts
export type EvalResult =
| { ok: true }
| { ok: false; reason: string } // evaluable AND fails
| { unknown: true; raw: string }; // not evaluable → triggers warning (D-03)
export function evaluatePrereq(prereqString: string | null, ctx: CharacterContext): EvalResult {
if (!prereqString) return { ok: true };
const tree = parsePrereq(prereqString);
if (tree.kind === 'unknown') return { unknown: true, raw: prereqString };
return evaluateTree(tree, ctx);
}
```
Evaluator returns `{ unknown: true, ... }` aggressively — when ANY atom is non-classifiable (e.g. "follower of Asmodeus", "spontaneous spellcaster"), the entire clause is marked unknown (kein Hard-Block per D-03). This is conservative and matches D-03's intent: when in doubt, ask the user.
### Pattern 5 — Recompute Derived Stats: Pure Pipeline
**What:** A pure function `recomputeDerivedStats(character, choices, progression) → DerivedStats` that the commit transaction calls AND the Review-step preview endpoint calls.
**Inputs:**
- Full Character snapshot (level, ancestry, class, abilities, skills, feats, hpCurrent, hpTemp)
- The wizard's `choices` (boost targets, skill-increase pick, etc.) — to know what's about to change
- ClassProgression row for `(className, targetLevel)` — for proficiency increases granted at this level
**Outputs:**
```ts
type DerivedStats = {
level: number;
hpMax: number; // recomputed: ancestryHP + (classHP + conMod) × level + bonusPerLevel × level
ac: number; // 10 + dexCap-clamped DEX-mod + armor.ac + proficiencyBonus(armor)
classDc: number; // 10 + keyAbility-mod + proficiencyBonus(class) — class-specific
perception: number; // wisMod + proficiencyBonus(perception)
fortitude: number; // conMod + proficiencyBonus(fort)
reflex: number; // dexMod + proficiencyBonus(ref)
will: number; // wisMod + proficiencyBonus(will)
};
```
**Boost-cap-at-18 enforcement** (Pitfall #8): the new ability scores are computed in this same module BEFORE the derived stats — `applyAttributeBoost()` is called per-target. The whole pipeline is one composition; Vorher-Nachher numbers in Review come from the same code path that the commit will write.
### Pattern 6 — Pathbuilder Auto-Detect for Free Archetype (D-09)
**Heuristic:** Pathbuilder JSON does not have an explicit `freeArchetype: true` flag in the typical export. The reliable signals are:
1. **Extra Class-Feat-Slot count.** Pathbuilder export (`build.feats`) contains feat entries tagged with their feat-type. If the count of `Class`-typed feats at any even level is 2 or more, OR if any feat is tagged `Archetype` at a level where the character would not otherwise have a class-feat slot for it, treat FA as active.
2. **Presence of any Archetype feat.** Any feat with feat-type `Archetype` and level > 1 is a strong indicator (without FA, the character would have spent their normal class-feat-slot for it — possible but suspicious if there are also full class feats at every even level).
Recommend the heuristic: `auto-detect FA = true if (count of Archetype-typed feats >= 2) OR (any even level has 2+ Class-or-Archetype feats)`. Set `Character.freeArchetype: true` on auto-detect; expose the toggle in character settings so the player can flip it.
**[ASSUMED: heuristic specifics — based on common Pathbuilder export shape; verify against an actual FA-enabled character export before locking. If the user has even one FA-enabled character on hand, run the seed script's auto-detect against it during Wave 0 verification.]**
### Anti-Patterns to Avoid
- **Live recompute on every wizard step.** D-12 explicitly forbids this. Recompute runs ONCE at the Review step and ONCE at commit. Keeps the wizard cheap, avoids per-PATCH transaction load, and prevents Review numbers from drifting from commit numbers.
- **Computing prereqs client-side.** The PrereqEvaluator runs on the server inside the feat-list endpoint that the wizard hits per step. Reasoning: (a) keeps the evaluator behind the auth boundary; (b) client never needs the full Feat table; (c) D-05 mandates the same evaluator runs at Pathbuilder import (server-only context); centralizing avoids two implementations that drift.
- **Putting `level_up_committed` data into multiple small broadcasts.** ROADMAP First-Phase Note pins this — single broadcast after transaction returns. Compose the full DerivedStats payload, emit once.
- **`db push` for the new tables.** CLAUDE.md and PROJECT.md explicitly forbid; use `prisma migrate dev` to author a real migration file.
- **`any` types in the wizard reducer or in the prereq evaluator.** TypeScript strict required (CLAUDE.md). The reducer's `WizardEvent` union and the evaluator's `EvalResult` union must be discriminated unions.
- **Reading Foundry pf2e JSON files at runtime.** D-15/D-17 mandate seed-time only. The runtime reads from `ClassProgression` rows.
- **Auto-removing dependent feats on retrain (Pitfall #10).** Out of scope for this phase — Reverse-Level-Up is `LVL-V2-02` deferred. But the schema still needs to allow it later: `LevelUpHistory` is append-only.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Atomic multi-table commit | A try/catch sequence of `await prisma.x.update(...)` | `prisma.$transaction([...])` array form | Prisma transactions roll back ALL writes on any failure; the manual sequence cannot guarantee consistency on partial-failure (Pitfall #9). [VERIFIED: Prisma 7 `$transaction([...])` is the same array-form supported since v2.] |
| One-DRAFT-per-character constraint | Application-level "first delete then insert" inside the start endpoint | A **partial unique index in the migration SQL** (`CREATE UNIQUE INDEX … WHERE committedAt IS NULL`) | Race-condition safe; enforced at DB level; the start-endpoint's "find or create" stays simple. Prisma schema cannot express partial indexes — must be raw SQL in the migration file. [VERIFIED: Prisma 7 docs do not list partial unique indexes as expressible via `@@unique`; raw SQL migration is the documented workaround.] |
| Step-validity logic at every step | A growing `if-else` cascade in `level-up-wizard.tsx` | `computeApplicableSteps(targetLevel, class, hasFA, isCaster): StepKind[]` — pure function, unit-tested | One source of truth for "which steps are visible at level N for class X with FA flag" — also lets the server reject patches to non-applicable steps. |
| Class progression tables as TypeScript constants | A 1500-line `class-progression-data.ts` | The `ClassProgression` Prisma table seeded from Foundry pf2e | Self-correcting (re-run the seed when Foundry updates); keeps mechanical data out of source files (CLAUDE.md philosophy: "Daten in Datenbank"); searchable; enables the planner-deferred `LVL-V2-01` history view to JOIN against it cleanly. |
| Spell-slot progression formulas hand-coded per class | `if (className === 'Wizard' && level >= 3) slots[1]++; …` | A small **per-caster overlay table** (also in seed script) since Foundry classfeatures encode slot tables in description text not machine-readable rules | The Foundry data is machine-readable for class-feature *grants* but **not** for slot tables — those live in description prose. A hand-curated overlay (10-12 prepared casters + spontaneous casters) is the honest answer. See §Open Questions Q1. |
| Pathbuilder FA-detection regex over the raw JSON | Ad-hoc `JSON.stringify(build).includes('FA')` | Structured count of `feat-type === 'Archetype'` entries in `build.feats` | Pathbuilder export is structured array-of-feat-tuples (verified in `pathbuilder-import.service.ts:248-272`); query the structure, not the string. |
| Date-relative "vor 3 Tagen" formatting in the resume banner | Hand-built switch statement | Existing project pattern — likely already a util, otherwise `Intl.RelativeTimeFormat` (zero-dependency, native) | Native `Intl` is German-aware; avoids `date-fns` or `dayjs` for one usage. |
**Key insight:** Every "data table" in this phase has a public source (Foundry pf2e). Hand-rolling them = (a) goes stale, (b) burns dev time, (c) violates the "Daten in Datenbank" principle. Conversely, every "math" function in this phase is short, deterministic, and should be hand-rolled with full unit-test coverage — these are the bug surfaces (boost cap, skill cap, recompute, prereq grammar).
## Common Pitfalls
### Pitfall 1: Boost-cap-at-18 silently corrupts ability scores (PITFALLS.md #8)
**What goes wrong:** STR 18 + boost = 20, not the correct 19. Character is permanently overpowered. Bug spreads to HP, AC, attacks, saves, DC.
**Why it happens:** Devs write `score + 2` everywhere, miss the +1-above-18 rule.
**How to avoid:** Centralize in `applyAttributeBoost(current): number` — the only function in the codebase that increments an ability score. Recompute calls it. Boost-step UI's "wird {newScore}" preview calls it. Unit-tested with edge cases at 17, 18, 19, 24.
**Warning signs:** Any character with ability > 18 at level 5; commit-time integration test should fail loudly.
### Pitfall 2: Recompute side effects mutate `hpCurrent` (PITFALLS.md #9)
**What goes wrong:** Player at 12/40 HP. CON-boost recomputes HP-Max to 50. Code accidentally sets `hpCurrent = 50` ("you healed!"). Or worse: a future Reverse-Level-Up drops `hpMax` below `hpCurrent` and silently underflows.
**Why it happens:** Devs reach for `hpCurrent = hpMax` or `Math.min(hpCurrent, hpMax)` reflexively.
**How to avoid:** Document and enforce: **commit transaction NEVER touches `hpCurrent`**. New HP-Max is new headroom; player heals through normal HP rules later. The `prisma.character.update` call writes only `level`, `hpMax`, and other strictly-derived fields. Add a unit test that asserts `hpCurrent` is absent from the update payload.
**Warning signs:** Player's HP fully refilled after Level-Up.
### Pitfall 3: Skill-Increase History gets lost (PITFALLS.md #11)
**What goes wrong:** Future Reverse-Level-Up cannot know which skill was the level-15 increase if the only stored value is `final proficiency = MASTER`.
**How to avoid:** The wizard's `choices.skillIncrease` is captured in `LevelUpHistory.choices` JSON for every level. The `committedAt` LevelUpSession also retains the choice. So at history time, you can reconstruct: walk all `LevelUpHistory` rows for the character in level order, replay each `choices.skillIncrease` to derive "at level N, skill X went from rank A to B". This phase doesn't build the Reverse feature, but it **must persist enough data** for the future to reconstruct it.
**Warning signs:** N/A this phase — this is a forward-compatibility check on the schema.
### Pitfall 4: Feat retrain orphans (PITFALLS.md #10)
**What goes wrong:** A feat depends on another feat as prereq; if the prereq feat is removed (Reverse-Level-Up), the dependent feat now violates its prereq invisibly.
**Status this phase:** Reverse-Level-Up is deferred (LVL-V2-02). For Phase 1, this is **purely a forward-compat concern** — schema must allow Pitfall #10's solution later. Specifically: the `Character.prereqViolations: Json?` column we add for D-06 (import banner) is **the same column** Reverse-Level-Up will use to surface orphan feats post-retrain. Build it once, use it twice.
### Pitfall 5: Foundry pf2e data shape changes between checkouts
**What goes wrong:** Seed script written against `pf2e@7.x.0` breaks against `pf2e@8.0.0` because Foundry restructures `system.classFeatLevels.value` into something else.
**How to avoid:** Pin a specific Foundry pf2e tag in the seed script's README; the script fails loudly with "expected key X not found" if shape drifts; rerun the seed against a new tag explicitly. Don't auto-track HEAD.
**Warning signs:** Seed script silently produces empty `ClassProgression` rows.
### Pitfall 6: Spell-slot progression encoded in description prose, not rules
**What goes wrong:** Seed script tries to extract spell-slot progression from `wizard-spellcasting.json` and finds only narrative text ("you can prepare X spells of grade Y at level Z…"). [VERIFIED: WebFetch of `wizard-spellcasting.json` confirms `rules` array does NOT contain slot increments.]
**How to avoid:** Hand-curate a small `spell-slot-progression.ts` overlay with the canonical PF2e tables for each of the 16 in-scope classes that cast. Don't try to NLP-parse prose. The overlay is small (≤20 rows × 16 classes ≈ 320 lines) and stable (PF2e doesn't change slot tables between printings).
**Warning signs:** Spellcaster level-ups produce zero new slots in tests.
### Pitfall 7: Per-event JWT validation overload (PITFALLS.md #17)
**What goes wrong:** N/A this phase — the new endpoints are REST (one auth check per HTTP request), and the `level_up_committed` event is a server-emitted broadcast (no inbound socket message handler).
**Status:** No exposure here; called out only because the cross-cutting WebSocket discipline applies to future phases.
### Pitfall 8: Cascading deletes wipe history (PITFALLS.md #16)
**What goes wrong:** `LevelUpHistory` with `onDelete: Cascade` would lose all history when a character is deleted.
**How to avoid:** Decision per relation:
- `LevelUpSession → Character`: `onDelete: Cascade` — DRAFT is meaningless without character.
- `LevelUpHistory → Character`: `onDelete: Cascade` (acceptable — character deletion is destructive in this self-hosted-single-group app; no soft-delete pattern exists for Character today). Document this in the migration SQL comment.
- `ClassProgression`: no FK to Character — independent reference table.
## Code Examples
Verified patterns from official sources and the existing codebase.
### Example 1 — Foundry pf2e Class JSON Shape
[VERIFIED: WebFetch `https://raw.githubusercontent.com/foundryvtt/pf2e/master/packs/classes/fighter.json` 2026-04-27]
```jsonc
// packs/classes/fighter.json — top-level
{
"_id": "...",
"name": "Fighter",
"type": "class",
"system": {
"hp": 10,
"perception": 2, // Proficiency rank (0=untrained, 1=trained, 2=expert, …)
"keyAbility": ["dex", "str"],
"spellcasting": 0,
"attacks": { "simple": 2, "martial": 2, "advanced": 1, "unarmed": 2, "other": { "rank": 0, "name": "" } },
"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"
}
// … more keyed by random ID, each with level + uuid
}
}
}
```
**Mapping to our `ClassProgression` schema:**
- One row per `(className, level)` for level 1..20.
- `grants: String[]` ← collect all `items.<id>` entries with that level → use the bare name (`"Fighter Weapon Mastery"`).
- `proficiencyChanges: Json` ← the seed script must consult **classfeatures compendium files** (not the class JSON) for level-gated proficiency bumps (e.g., Fighter's "Weapon Mastery" at L5 grants martial→Master). For simple grants where Foundry doesn't carry mechanical proficiency data, the seed script applies a hand-curated overlay (one map per class) — see Pitfall #6.
- `spellSlotIncrement / cantripIncrement / repertoireIncrement` ← from a hand-curated per-class overlay (the Foundry classfeature for spellcasting carries this data only as prose; verified for Wizard).
- `choiceType: String?` ← presence indicator, e.g. `"doctrine"` for Cleric L1, `"school"` for Wizard L1, `"weapon-mastery-group"` for Fighter L5.
- `choiceOptionsRef: String?` ← lookup key into a small seed-time options table.
### Example 2 — Real Prereq Strings (from `server/prisma/data/featlevels.json`, audited 2026-04-27)
**Audit:** 5,625 feats total, 3,393 (60.3%) have non-empty prereqs.
**Sub-classification of the 3,393 feats with prereqs:**
| Pattern | Count | Sample | D-01 Evaluable? |
|---------|-------|--------|-----------------|
| Pure single-skill rank: `^(Trained\|Expert\|Master\|Legendary) in [Skill]$` | 345 | `"Trained in Acrobatics"` | YES — direct table lookup |
| Skill-rank composite (or/and via comma): `Trained in X, Y, or Z` / `expert in a skill with the Recall Knowledge action` | 125 | `"Trained in Arcana, Trained in Nature, Trained in Occultism, or Trained in Religion"` | YES (the disjunctive forms; the "skill with Recall Knowledge action" form is UNKNOWN — too semantic) |
| Heritage refs: `^… heritage` / `… heritage or … heritage` | 112 | `"Unbreakable Goblin heritage"` | YES — heritage is on Character.heritageId |
| Class-feature subchoice (instinct/cause/order/muse/bloodline/doctrine/school) | 132 | `"animal order"`, `"enigma muse"`, `"flame order"`, `"grandeur cause"` | PARTIAL — evaluable IFF we persist class-feature-choice picks from D-19 commit-time. Phase 1 should persist these in `CharacterFeat` (with `source: CLASS`) so the evaluator can find them via name-match. |
| Spellcasting / cantrip refs: `spellcasting class feature`, `spellcaster`, `divine spells`, `cantrip` | 65 | `"spellcasting class feature"`, `"bloodline that grants divine spells; you follow a deity"` | NO — D-02 explicitly defers these to UNKNOWN/warning |
| Class with brackets `[Druid] animal order` | 4 | `"[Druid] animal order"` | YES — strip brackets, treat as class-then-subchoice |
| Deity refs: `worship`, `follower of`, `deity` | 29 | `"worshipper of Droskar"`, `"deity with a simple… favored weapon"` | NO — D-02 |
| Age / ethnicity: `at least 100 years old`, `Tian-Dan ethnicity` | 11 | `"at least 100 years old"`, `"Nidalese ethnicity"` | NO — these aren't tracked on Character |
| Vision / sense traits: `low-light vision`, `darkvision`, `scent` | 11 | `"low-light vision"` | PARTIAL — heritage often grants these; for now mark UNKNOWN |
| Multi-clause (semicolon AND): `Trained in Deception; Trained in Stealth`, `Charisma +2; Strength +2` | 637 | `"Champion Dedication: Charisma +2; Strength +2"` (note: "Charisma +2" is an ability minimum which the evaluator should support — see grammar above) | MOSTLY YES — semicolon = AND combinator, walk both sides |
| Multi-clause (comma alternation): `Trained in Lore, Mountain Lore` | 123 | varies | PARTIAL — comma is ambiguous (sometimes AND, sometimes OR-list). Recommend: comma inside a "Trained in X, Y, or Z" pattern is OR-list; comma between full atoms is AND. |
| Bare feat ref (single capitalized phrase): `"Fascinating Performance"`, `"Power Attack"`, `"Base Kinesis"` | ~150 | `"Fascinating Performance"` | YES — Feat-name lookup against `Feat` table; if found, check `CharacterFeat` |
**Estimated evaluable coverage:** ~70-80% of the 3,393 prereqed feats produce `{ ok: true | ok: false }`. The remainder (deity/spellcasting/ethnicity/age/vision/semantic-skill refs) produce `{ unknown: true }` and trigger D-03's warning UI — exactly the design.
### Example 3 — Prisma schema additions (proposed for the migration SQL)
```prisma
// server/prisma/schema.prisma — APPENDED, not modifying existing models
model LevelUpSession {
id String @id @default(uuid())
characterId String
targetLevel Int // currentLevel + 1, captured at start so server can validate
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 added in raw migration SQL:
// CREATE UNIQUE INDEX "LevelUpSession_oneDraftPerCharacter"
// ON "LevelUpSession"("characterId") WHERE "committedAt" IS NULL;
}
model LevelUpHistory {
id String @id @default(uuid())
characterId String
levelFrom Int
levelTo Int
snapshotBefore Json // full character + relations subset captured at commit
choices Json // the wizard state.choices at commit
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 // "Fighter", "Wizard", … one of the 16 D-16 names
level Int // 1..20
grants String[] // e.g. ["Bravery", "WeaponSpecialization"] — feature names
proficiencyChanges Json // { fortitude: "EXPERT", reflex: "EXPERT", … } at THIS level
spellSlotIncrement Json? // { tradition: "ARCANE", level: 3, count: 1 } per slot bumped
cantripIncrement Int? // count of new cantrips at this level (rare past L1)
repertoireIncrement Int? // count of new repertoire spells (spontaneous casters)
choiceType String? // "doctrine" | "school" | "weapon-mastery" | null
choiceOptionsRef String? // key into ClassFeatureOptions seed table
@@unique([className, level])
@@index([className])
}
model ClassFeatureOption {
id String @id @default(uuid())
optionsRef String // matches ClassProgression.choiceOptionsRef
optionKey String // e.g. "destruction-doctrine"
name String
nameGerman String?
description String // English; German via TranslationsService at runtime
// Mechanical effect application is hand-coded per choiceType in commit-time recompute.
@@unique([optionsRef, optionKey])
@@index([optionsRef])
}
// On Character — APPENDED fields
model Character {
// ... existing fields unchanged ...
freeArchetype Boolean @default(false) // D-08
prereqViolations Json? // D-06 — { violations: [{featId, featName, prereqText}] }
// levelUpSessions and histories are reverse relations:
levelUpSessions LevelUpSession[]
levelUpHistories LevelUpHistory[]
}
```
### Example 4 — REST endpoint shape (D-13 access pattern follows existing `checkCharacterAccess`)
```ts
// server/src/modules/leveling/leveling.controller.ts (proposed)
@Controller()
@UseGuards(JwtAuthGuard)
export class LevelingController {
constructor(private leveling: LevelingService) {}
@Post('characters/:characterId/level-up')
start(@Param('characterId') characterId: string, @Req() req: AuthRequest) {
return this.leveling.startOrResume(characterId, req.user.id);
}
@Patch('level-up/:sessionId')
patch(@Param('sessionId') sessionId: string, @Body() dto: PatchLevelUpDto, @Req() req: AuthRequest) {
return this.leveling.patchState(sessionId, req.user.id, dto);
}
@Get('level-up/:sessionId/preview')
preview(@Param('sessionId') sessionId: string, @Req() req: AuthRequest) {
return this.leveling.computePreview(sessionId, req.user.id);
}
@Post('level-up/:sessionId/commit')
commit(@Param('sessionId') sessionId: string, @Req() req: AuthRequest) {
return this.leveling.commit(sessionId, req.user.id);
}
@Delete('level-up/:sessionId')
discard(@Param('sessionId') sessionId: string, @Req() req: AuthRequest) {
return this.leveling.discard(sessionId, req.user.id);
}
}
```
Each method ultimately calls into `CharactersService.checkCharacterAccess(characterId, userId, requireOwnership=false)` — but with the requirement loosened: D-13 says GM-of-campaign also can level-up. `checkCharacterAccess` already permits GM by passing `requireOwnership=true` (yes — re-reading lines 73-78 of `characters.service.ts`: when `requireOwnership=true` it allows `isOwner || isGM`). So `requireOwnership: true` is the right call here — it semantically means "owner-or-GM" in this codebase.
### Example 5 — `level_up_committed` payload shape
```ts
// server/src/modules/characters/characters.gateway.ts:24 — UPDATE the union type:
export interface CharacterUpdatePayload {
characterId: string;
type: 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status'
| 'rest' | 'alchemy_vials' | 'alchemy_formulas' | 'alchemy_prepared' | 'alchemy_state'
| 'dying'
| 'level_up_committed'; // NEW
data: any; // (existing — keep until typed-event refactor in a future phase)
}
// Payload for type='level_up_committed':
type LevelUpCommittedData = {
level: number; // new level
derived: {
hpMax: number;
ac: number;
classDc: number;
perception: number;
fortitude: number;
reflex: number;
will: number;
};
// Intentionally NOT including the full character — clients react-query-invalidate the character query
// and refetch via REST. WebSocket payload stays small; full state stays canonical via REST. (Pitfall #15 mitigation)
};
```
The client's `use-character-socket.ts` adds an `onLevelUpCommitted` callback that calls `queryClient.invalidateQueries(['character', characterId])`.
### Example 6 — Spell-slot overlay (proposed shape for the seed script)
```ts
// server/prisma/data/spell-slot-overlays.ts — hand-curated, in source control
// Each entry says: at THIS level, this caster gains THIS many slots of THIS grade.
export const SPELL_SLOT_OVERLAY: Record<string, Array<{
level: number;
spellSlotIncrement?: { tradition: SpellTradition; spellLevel: number; count: number };
cantripIncrement?: number;
repertoireIncrement?: number;
}>> = {
Wizard: [
{ level: 1, cantripIncrement: 5 }, // L1: 5 cantrips
{ level: 1, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 2 } }, // L1: 2 grade-1 slots
{ level: 2, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 1 } }, // L2: +1 grade-1 (3 total)
{ level: 3, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 2, count: 2 } },
// … through L20 from the canonical PF2e table
],
Sorcerer: [
{ level: 1, cantripIncrement: 5 },
{ level: 1, spellSlotIncrement: { tradition: 'ARCANE'/* or set per bloodline */, spellLevel: 1, count: 3 } },
{ level: 2, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 1 } },
// … and Sorcerer-specific repertoireIncrement
{ level: 3, repertoireIncrement: 1 },
// …
],
// … 12-14 more casters from D-16 scope
};
```
The seed script merges this overlay into `ClassProgression` rows. Splitting it out keeps the Foundry-derived seed (mostly grant + choice info) separate from the hand-curated table data.
## State of the Art
| Old approach | Current approach | When changed | Impact |
|--------------|------------------|--------------|--------|
| Per-event JWT decode in WebSocket handlers | Decode once on connect, cache `socket.data.userId` | Already in Dimension47 (`characters.gateway.ts:71-86`) | Already standard — no change needed |
| Hand-coded class progression in TS constants | DB-backed `ClassProgression` table seeded from canonical source (Foundry pf2e) | Adopted in this phase | Aligns with CLAUDE.md's "Daten in Datenbank" |
| `db push` for development | `prisma migrate dev` — real migration files | CLAUDE.md enforced from project start | No exception this phase |
| Free-text feat prerequisites (just text in the description) | Dedicated `Feat.prerequisites: String?` column with structured DSL evaluator | Already half-built — column exists at `schema.prisma:560`; this phase adds the evaluator | First time the column has a consumer |
| XState for every multi-step UI | `useReducer` for linear-with-conditional flows; XState only for genuinely graph-shaped state | This phase | Pragmatic — XState v5 (5.30.0) is excellent but not earned by this phase's complexity |
**Deprecated/outdated:**
- Reading PF2e data from `client/public/*.json` at runtime (per CLAUDE.md "nicht direkt aus JSON-Dateien"). Already the rule; the new ClassProgression tables follow it.
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Pathbuilder FA auto-detect heuristic (count of Archetype-typed feats ≥ 2) reliably indicates FA was active | §Pattern 6 | Auto-detect may misfire on characters who legitimately took an Archetype Dedication via their normal class-feat slot. Mitigation: D-09 already specifies "Spieler kann nachträglich ändern" — the toggle is editable in character settings. False-positive cost is low. |
| A2 | The `WizardState` reducer shape proposed in §Pattern 1 maps cleanly to the JSON-state column | §Pattern 1 | If the planner wants finer granularity (e.g. per-step validation timestamps), the shape needs extending. Extensible — Json column accepts any shape. |
| A3 | The hand-curated `SPELL_SLOT_OVERLAY` covers all 16 D-16 classes correctly | §Code Examples #6 | Sourcing from Archives of Nethys per-class tables; one wrong row = wrong slot count for affected class. Mitigation: unit test `recompute-derived-stats.spec.ts` exercises slot increments per class+level; planner should curate against the official Player Core / APG tables. |
| A4 | Foundry pf2e Apache 2.0 + OGL 1.0a + Paizo CUP allows re-shipping a derived `ClassProgression` table for self-hosted use by our group | §Standard Stack, §Code Examples #1 | Self-hosted single-tenant use for our own group is the textbook Paizo CUP scenario. If the project ever shifts to public SaaS, re-evaluate. |
| A5 | The "comma vs semicolon" disambiguation rule for prereq parsing (semicolon = AND, comma inside `Trained in X, Y, or Z` = OR-list, comma elsewhere = AND) is correct for the corpus | §Pattern 4, §Code Examples #2 | Corpus audit suggests these heuristics; some edge cases will resolve differently. Mitigation: log every UNKNOWN result and review the first ~50 in production. |
| A6 | Class-feature subchoice references in prereqs (`enigma muse`, `flame order`) become evaluable once we persist them as `CharacterFeat` rows with the option name | §Code Examples #2 (Sub-classification) | If the class-feature commit path stores these differently (e.g. as a column on `CharacterAlchemyState.researchField`), the evaluator must look in multiple places. The planner should standardize: every class-feature choice produces a `CharacterFeat` row whose `name` is the option name. |
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Jest 30.0.0 + ts-jest (server only — `server/package.json:88-104`) [VERIFIED: `npm view jest version` → 30.3.0; we are on 30.0.0 = compatible] |
| 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` |
**Client test framework:** None today (TESTING.md confirms zero coverage). Phase 1 deliberately does NOT introduce vitest on the client — that is a Phase-2/3 cross-cutting decision. Wizard UI is verified manually + via the integration test that exercises the full commit path through the API.
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| LVL-02 | `applyAttributeBoost(17) = 19` | unit | `npm test -- apply-attribute-boost.spec.ts` | ❌ Wave 1 |
| LVL-02 | `applyAttributeBoost(18) = 19` (cap) | unit | same | ❌ Wave 1 |
| LVL-02 | `applyAttributeBoost(20) = 21` (above cap) | unit | same | ❌ Wave 1 |
| LVL-02 | `isValidBoostSet(['STR','STR','CON','INT']) = false` | unit | same | ❌ Wave 1 |
| LVL-02 | `isValidBoostSet(['STR','DEX','CON','INT']) = true` | unit | same | ❌ Wave 1 |
| LVL-06 | `canIncreaseSkill(currentRank='TRAINED', characterLevel=2) = false` (T→E only at L3+) | unit | `npm test -- skill-increase-cap.spec.ts` | ❌ Wave 1 |
| LVL-06 | `canIncreaseSkill('TRAINED', 3) = true` | unit | same | ❌ Wave 1 |
| LVL-06 | `canIncreaseSkill('EXPERT', 6) = false` (E→M only at L7+) | unit | same | ❌ Wave 1 |
| LVL-06 | `canIncreaseSkill('EXPERT', 7) = true` | unit | same | ❌ Wave 1 |
| LVL-06 | `canIncreaseSkill('MASTER', 14) = false` | unit | same | ❌ Wave 1 |
| LVL-06 | `canIncreaseSkill('MASTER', 15) = true` | unit | same | ❌ Wave 1 |
| LVL-09 | Pure skill-rank prereq evaluates correctly: `evaluatePrereq("Trained in Athletics", { skills: { Athletics: 'TRAINED' }})``{ ok: true }` | unit | `npm test -- prereq-evaluator.spec.ts` | ❌ Wave 1 |
| LVL-09 | Same prereq, untrained: → `{ ok: false, reason: ... }` | unit | same | ❌ Wave 1 |
| LVL-09 | Disjunctive: `"Trained in Arcana, Trained in Nature, or Trained in Religion"` with one match → `{ ok: true }` | unit | same | ❌ Wave 1 |
| LVL-09 | Conjunctive: `"Trained in Deception; Trained in Stealth"` with one missing → `{ ok: false }` | unit | same | ❌ Wave 1 |
| LVL-09 | Bare feat-name: `"Power Attack"` with feat present → `{ ok: true }` | unit | same | ❌ Wave 1 |
| LVL-09 | Heritage ref: `"Unbreakable Goblin heritage"` with matching heritage → `{ ok: true }` | unit | same | ❌ Wave 1 |
| LVL-09 | Class ref: `"Fighter"` with `classId='Fighter'``{ ok: true }` | unit | same | ❌ Wave 1 |
| LVL-09 | Spellcasting ref: `"spellcasting class feature"``{ unknown: true, raw: ... }` | unit | same | ❌ Wave 1 |
| LVL-09 | Deity ref: `"worshipper of Droskar"``{ unknown: true, raw: ... }` | unit | same | ❌ Wave 1 |
| LVL-09 | Empty/null prereq → `{ ok: true }` | unit | same | ❌ Wave 1 |
| LVL-10 | `recomputeDerivedStats(character, choices, progression).hpMax = ancestryHP + (classHP + conMod) × level` | unit | `npm test -- recompute-derived-stats.spec.ts` | ❌ Wave 1 |
| LVL-10 | Recompute respects boost-cap-at-18 | unit | same | ❌ Wave 1 |
| LVL-10 | Recompute applies proficiencyChanges from ClassProgression at the new level | unit | same | ❌ Wave 1 |
| LVL-10 | Recompute does NOT mutate `hpCurrent` | unit | same | ❌ Wave 1 |
| LVL-01 | `computeApplicableSteps(targetLevel=5, class='Fighter', hasFA=false, isCaster=false)` includes boost, skill-increase, feat-class, feat-skill, feat-ancestry | unit | `npm test -- compute-applicable-steps.spec.ts` | ❌ Wave 1 |
| LVL-01 | At level 4, no boost step | unit | same | ❌ Wave 1 |
| LVL-01 | With FA enabled, includes feat-archetype step | unit | same | ❌ Wave 1 |
| LVL-01 | With caster class, includes spellcaster step | unit | same | ❌ Wave 1 |
| LVL-12 | Commit transaction is atomic: simulated mid-transaction throw rolls back ALL writes | integration | `npm test -- leveling.service.spec.ts` | ❌ Wave 3 |
| LVL-12 | Commit creates one `LevelUpHistory` row with snapshot + choices populated | integration | same | ❌ Wave 3 |
| LVL-12 | Commit broadcasts `level_up_committed` exactly once (mock the gateway) | integration | same | ❌ Wave 3 |
| LVL-11 | DELETE `/level-up/:sessionId` removes the DRAFT without touching the character | integration | same | ❌ Wave 3 |
| LVL-11 | Partial unique index allows re-creating a DRAFT after the previous was committed (`committedAt` IS NOT NULL → not in unique scope) | integration | same | ❌ Wave 3 |
| LVL-13 | `freeArchetype: true` on character causes `computeApplicableSteps` to include the FA step | unit | same as LVL-01 tests | ❌ Wave 1 |
| LVL-14 | Commit applies `spellSlotIncrement` from ClassProgression to `CharacterResource` for caster classes | integration | `leveling.service.spec.ts` | ❌ Wave 3 |
| LVL-14 | Commit does NOT add slots for non-casters | integration | same | ❌ Wave 3 |
| LVL-15 | Translation pipeline call for new prereq strings hits the existing `TranslationsService.getTranslationsBatch` | manual + integration | manual smoke; one integration test asserts the service is invoked | ❌ Wave 3 |
| LVL-08 | Seed script populates `ClassProgression` for all 16 classes × 20 levels = 320 rows minimum | manual | `cd server && npm run db:seed:class-progression && psql -c 'SELECT className, COUNT(*) FROM "ClassProgression" GROUP BY className'` | N/A — seed verification |
| LVL-03/04/05/07 | `feat-filter.service.ts` returns only feats whose prereq evaluates `{ ok: true }` OR `{ unknown: true }` (NOT `{ ok: false }`), respecting class/ancestry/skill-feat-source filters | integration | `npm test -- feat-filter.service.spec.ts` | ❌ Wave 3 |
### Sampling Rate
- **Per task commit:** `cd server && npm test -- --testPathPattern=leveling` — runs only the leveling module's tests in <10s.
- **Per wave merge:** `cd server && npm test` — full suite, including the existing placeholder e2e test (still expected to pass or be marked skipped if not relevant).
- **Phase gate:** Full suite green before `/gsd-verify-work`. Integration test for atomic commit MUST pass.
### Wave 0 Gaps
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 section 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.
*(If no gaps: not applicable — gaps exist as listed.)*
## Security Domain
This phase touches authenticated REST endpoints and persists JSON state. Security enforcement applies; no `security_enforcement: false` in config.
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes (existing) | Bearer JWT via `JwtAuthGuard` — same as all existing endpoints; no new auth surface. WebSocket handshake uses existing token check (`characters.gateway.ts:60-92`). |
| V3 Session Management | yes (existing) | JWT-stateless; logout invalidates client-side; new endpoints do not introduce server-side session. |
| V4 Access Control | yes | `LevelingService` calls `CharactersService.checkCharacterAccess(characterId, userId, requireOwnership=true)` for every endpoint per D-13. The `requireOwnership=true` path in `characters.service.ts:73-78` resolves to `isOwner || isGM` — exact semantic match. |
| V5 Input Validation | yes | All four endpoints' DTOs use `class-validator` decorators (existing pattern — see `server/src/modules/characters/dto/*.dto.ts`). The wizard JSON state inside the DRAFT is validated by a hand-written guard function (TypeScript discriminated unions). Foundry-pf2e seed is dev-time and trusted. |
| V6 Cryptography | no | No new crypto in this phase. JWT signing handled by `@nestjs/jwt` (existing). |
| V7 Errors / Logging | yes | Existing NestJS `Logger` patterns used — log every commit (`Level-up committed: characterId=X, fromLevel=N, toLevel=N+1`); log every unknown prereq encountered to help future grammar improvements. Do NOT log full character snapshots (PII via character names + descriptions; even self-hosted, log discipline matters for shared logs). |
| V8 Data Protection | yes | `LevelUpSession.state` and `LevelUpHistory.snapshotBefore` are JSON columns. They contain character-derived data only — no auth tokens, no passwords. PII risk = character names (already throughout the schema). No additional protection needed beyond DB access controls. |
| V12 Files / Resources | partial | The seed script reads from `server/prisma/data/foundry-pf2e/` (dev-time only; gitignored). Path-traversal not a concern — paths are hardcoded relative to `__dirname`. |
### Known Threat Patterns for {NestJS + Prisma + WebSocket}
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Player attempts to commit a level-up for someone else's character (forged characterId) | Spoofing/EoP | `checkCharacterAccess(characterId, userId, requireOwnership=true)` already throws `ForbiddenException`; tested in integration. |
| Player attempts to skip the boost step or set 5 boost targets via crafted PATCH | Tampering | DTO validator on `PatchLevelUpDto` rejects malformed shape; commit-time guard runs `isValidBoostSet()` and throws `BadRequestException` if not exactly 4 distinct. |
| Player attempts to commit a level beyond `currentLevel + 1` | Tampering | Server validates `session.targetLevel === character.level + 1` at commit time; throw `BadRequestException` otherwise. |
| Player attempts to commit a non-applicable step's choices (e.g. boost choice at level 4) | Tampering | The Wave-1 `computeApplicableSteps()` runs server-side at commit; choices for steps not in the applicable list are rejected. |
| Race condition: two browser tabs both POST `/commit` for the same session | Tampering | Partial unique index on `(characterId) WHERE committedAt IS NULL` makes the second commit a no-op (the session it points to has already been transitioned to `committedAt = now()`). Plus: `prisma.$transaction` is serialized at the row level — second tab's transaction sees `committedAt != null` and aborts. |
| WebSocket payload injection (player crafts a fake `level_up_committed` event) | Tampering | Inbound WebSocket events are unaffected — `level_up_committed` is server-emit only. The CharactersGateway has no `@SubscribeMessage('level_up_committed')` handler. Other clients trust the event's source = the room's server. |
| Stored XSS via German feat-prereq translation text rendered in the prereq-confirm dialog | Injection (Tampering) | The text comes from the existing `Translation.germanName` column populated from Claude API; render with React text-binding (no `dangerouslySetInnerHTML`). Existing Dimension47 patterns already do this. |
| SQL injection via `Feat.prerequisites` containing SQL-like text | Tampering | Prisma parameterizes everything; the prereq strings are never interpolated into raw queries. |
## Open Questions
1. **Spell-slot progression overlay sourcing.**
- **What we know:** Foundry pf2e classfeatures (`wizard-spellcasting.json` etc.) encode slot tables in description prose — verified. We need a hand-curated overlay.
- **What's unclear:** Should the overlay live as a TS constant (`server/prisma/data/spell-slot-overlays.ts`) or as a JSON file (e.g. `server/prisma/data/spell-slot-overlays.json`)?
- **Recommendation:** TS constant, exported from the `data/` folder, imported by the seed script. Type-safe, IDE-navigable, version-controlled, and the values are inherently TS-shaped (enums for `SpellTradition`). Planner: include curating this overlay (16 caster classes × ~20 levels) as a Wave-2 sub-task.
2. **Class-feature option mechanics — where does the recompute apply them?**
- **What we know:** `ClassProgression.choiceType` flags that a step exists; `ClassFeatureOption` lists the user-facing options; the user picks one in the wizard.
- **What's unclear:** When the user picks "Destruction Doctrine" (Cleric L1), the doctrine grants specific abilities/feats/proficiencies. Do those grants live as additional `ClassFeatureOption` columns (`grants: String[]`, `proficiencyChanges: Json`), or in a parallel hand-coded mapping?
- **Recommendation:** **Add the same `grants` + `proficiencyChanges` columns to `ClassFeatureOption`** so the recompute pipeline is uniform: at commit, walk both `ClassProgression` AND any chosen `ClassFeatureOption` rows, apply both sets of grants. This keeps the data model symmetric and the recompute code branch-free. Planner should extend the schema in §Code Examples #3 accordingly.
3. **Banner-resolution UI for D-06 import violations.**
- **What we know:** Phase 1 ships listing-only (per `01-UI-SPEC.md` §"Pathbuilder-Import-Violations Banner": "Phase 1 ships read-only listing only; resolution UI is out of scope").
- **What's unclear:** When a user retrains a feat manually outside the wizard (today possible via direct DB?), should the banner update? **Out of scope this phase** — confirmed by deferral. Planner: do NOT add banner-clearing logic.
4. **Pathbuilder FA auto-detect false-positive recovery (A1 tied).**
- **What we know:** Heuristic might misfire.
- **What's unclear:** Should the auto-detection's result be visible somewhere besides the toggle (e.g. logged on the character)?
- **Recommendation:** Log the detection result as a NestJS Logger info line during import (`PathbuilderImport: detected FA=true for character ${name} based on ${reason}`). The toggle is editable via character-settings. No UI banner needed.
5. **Initial level-up from L1 to L2 — does the wizard support it?**
- **What we know:** ROADMAP/REQUIREMENTS describe the wizard for "Stufe steigen" generally.
- **What's unclear:** Is L1→L2 the same flow as L4→L5? PF2e: L2 is just feats + skill-feat (no boost set). The dynamic-step logic in `computeApplicableSteps` handles this — boost step appears only for L5/10/15/20. So yes, same flow. **Confirmed: no special handling needed.**
## Sources
### Primary (HIGH confidence)
- `server/prisma/data/featlevels.json` — 5,625 feats, audited locally for prereq pattern distribution (3,393 with prereqs, 60.3%).
- `server/prisma/schema.prisma` — current schema, lines 171-220 (Character), 222-231 (CharacterAbility), 233-244 (CharacterFeat), 246-255 (CharacterSkill), 257-269 (CharacterSpell), 544-582 (Feat with prerequisites: String? at line 560).
- `server/src/modules/characters/characters.service.ts:41-86``checkCampaignAccess`/`checkCharacterAccess` patterns; the `requireOwnership=true` semantic confirmed at lines 73-78 means "isOwner OR isGM".
- `server/src/modules/characters/characters.gateway.ts:22-26``CharacterUpdatePayload['type']` union to extend.
- `server/src/modules/characters/pathbuilder-import.service.ts:243-272` — feat import structure (where the PrereqEvaluator hooks in for D-05).
- `server/package.json:88-104` — Jest 30.0.0 inline config, `testRegex: ".*\\.spec\\.ts$"`.
- WebFetch: `https://raw.githubusercontent.com/foundryvtt/pf2e/master/packs/classes/fighter.json` — fighter class JSON shape (top-level, system fields, items keyed-object structure, classFeatLevels arrays).
- WebFetch: `https://raw.githubusercontent.com/foundryvtt/pf2e/master/packs/classes/sorcerer.json` — sorcerer class shape; **confirmed** spell-slot/repertoire NOT in class JSON.
- WebFetch: `https://github.com/foundryvtt/pf2e/blob/master/README.md` — license: Apache 2.0 + OGL 1.0a + Paizo CUP (NOT ORC — D-17 is corrected on this point; the practical effect is identical for self-hosted single-group use).
- WebFetch: `https://raw.githubusercontent.com/foundryvtt/pf2e/master/packs/classfeatures/wizard-spellcasting.json` — confirmed `rules` array does NOT contain slot increments; mechanical progressions live in description prose.
- `npm view jest version` → 30.3.0; `npm view xstate version` → 5.30.0; `npm view @xstate/react version` → 6.1.0; `npm view zod version` → 4.3.6 (verified 2026-04-27).
- `.planning/research/PITFALLS.md` §Pitfall 8 (boost cap), §Pitfall 9 (recompute), §Pitfall 10 (feat retrain orphans), §Pitfall 11 (skill-increase history).
- `.planning/research/SUMMARY.md` §Phase A.
- `.planning/codebase/ARCHITECTURE.md` — NestJS module-per-feature pattern.
- `.planning/codebase/CONVENTIONS.md` — kebab-case files, camelCase functions, PascalCase types, DTOs end in `Dto`, Props in `Props`.
- `.planning/codebase/TESTING.md` — confirms zero current unit tests; first ones to land per ROADMAP First-Phase Note.
### Secondary (MEDIUM confidence)
- WebFetch: `https://www.prisma.io/docs/orm/prisma-schema/data-model/database-mapping` — confirmed Prisma does not express partial unique indexes; raw SQL migration is the documented workaround.
- WebFetch: `https://github.com/foundryvtt/pf2e/tree/master/packs/classes` — confirmed list of 28 class JSON files in master branch (we use the 16 from D-16).
- WebFetch: `https://stately.ai/docs/xstate` — XState v5 capabilities; insufficient to prove serialization story BUT the v5 `createMachine` actor model does support `getPersistedSnapshot()` for resume; ultimately not adopted (see §Standard Stack rejected).
- WebFetch: Archives of Nethys Leveling Up rule page `https://2e.aonprd.com/Rules.aspx?ID=2065` — confirms general structure but the specific cap-at-18 quote is on a different page; cited from PITFALLS.md / common knowledge of the rule.
### Tertiary (LOW confidence — needs validation)
- A1: Pathbuilder FA auto-detect heuristic — based on common Pathbuilder export shape inferred from `pathbuilder-import.service.ts`; should be verified against an actual FA-enabled character JSON.
- A5: prereq parser disambiguation (semicolon=AND, comma-in-OR-list-pattern=OR, comma-otherwise=AND) — heuristic based on corpus inspection; expect ~5% misclassification on edge cases. Mitigation built in: any uncertainty → `{ unknown: true }` → user confirms.
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — all versions verified locally and via npm registry; no new dependencies introduced.
- Architecture: HIGH — every recommendation grounded in existing module/service/gateway patterns and explicit live-code touchpoints.
- Pitfalls: HIGH — directly mapped to PITFALLS.md #8/#9/#10/#11 plus three new phase-specific pitfalls (Foundry shape drift, spell-slot prose, cascading delete decision).
- Foundry pf2e data shape: HIGH for class JSON top-level (verified); MEDIUM for class-feature compendiums (verified for wizard-spellcasting only — assumed similar shape for other classes); LOW for the spell-slot extraction pathway (resolved by hand-curated overlay, A3).
- Prereq evaluator coverage: HIGH for the local corpus audit (5,625 feats inspected); MEDIUM for the projected ~70-80% evaluable rate (depends on grammar implementation quality).
- License: HIGH — Apache 2.0 + OGL 1.0a + Paizo Community Use Policy (confirmed from Foundry pf2e README); CONTEXT.md D-17 mentions ORC but the practical impact for self-hosted private use is identical.
- Validation architecture: HIGH — full test map per requirement, executable commands provided.
**Research date:** 2026-04-27
**Valid until:** 2026-05-27 (30 days for stable libraries; sooner if Foundry pf2e major version ships).