From 588632fbc07afb0202b27fbecf8cc164f92039a5 Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 11:33:51 +0200 Subject: [PATCH] =?UTF-8?q?docs(01):=20research=20phase=20domain=20?= =?UTF-8?q?=E2=80=94=20Level-Up=20(PF2e=20regelkonform)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../01-RESEARCH.md | 1123 +++++++++++++++++ 1 file changed, 1123 insertions(+) create mode 100644 .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md diff --git a/.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md b/.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md new file mode 100644 index 0000000..16a61a1 --- /dev/null +++ b/.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md @@ -0,0 +1,1123 @@ +# 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 (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 + + + +## 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) | + + +## 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/.json`) reference compendium UUIDs (`Compendium.pf2e.classfeatures.Item.`) 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 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: │ + │ 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; // 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, feats: Set }`. 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.` 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> = { + 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).