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

92 KiB
Raw Blame History

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
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:

// 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:

// 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:

// 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;
}
// 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):

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:

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]

// 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)

// 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)

// 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

// 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)

// 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.jsontestRegex: ".*\\.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
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-86checkCampaignAccess/checkCharacterAccess patterns; the requireOwnership=true semantic confirmed at lines 73-78 means "isOwner OR isGM".
  • server/src/modules/characters/characters.gateway.ts:22-26CharacterUpdatePayload['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).