From 5a62ad8caee31ef318cf1ddc23a33e02c848be9e Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 12:11:25 +0200 Subject: [PATCH] docs(01): create phase 1 plans (5 waves, level-up wizard) --- .planning/ROADMAP.md | 12 +- .../01-01-PLAN.md | 548 ++++++ .../01-02-PLAN.md | 1283 +++++++++++++ .../01-03-PLAN.md | 1032 +++++++++++ .../01-04-PLAN.md | 1625 +++++++++++++++++ .../01-05-PLAN.md | 1556 ++++++++++++++++ 6 files changed, 6054 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/01-level-up-pf2e-regelkonform/01-01-PLAN.md create mode 100644 .planning/phases/01-level-up-pf2e-regelkonform/01-02-PLAN.md create mode 100644 .planning/phases/01-level-up-pf2e-regelkonform/01-03-PLAN.md create mode 100644 .planning/phases/01-level-up-pf2e-regelkonform/01-04-PLAN.md create mode 100644 .planning/phases/01-level-up-pf2e-regelkonform/01-05-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 867dfaa..c8d8574 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -32,7 +32,15 @@ Decimal phases appear between their surrounding integers in numeric order. 3. Nach Bestätigung sind HP-Max, Save-Boni, AC, Klassen-DC und Wahrnehmung gemäß PF2e neu berechnet (Boost-Cap-bei-18 wird respektiert: bestehender Wert ≥18 → +1, sonst +2), und alle anderen Mitspieler sehen den neuen Level + neuen Stat-Block in Echtzeit über WebSocket. 4. Spellcaster-Charaktere sehen nach dem Aufstieg korrekte Slot- und Cantrip-Progression gemäß ihrer Klasse/Tradition; spontane Caster sehen zusätzlich erhöhtes Repertoire-Limit. 5. Bestätigtes Level-Up erzeugt einen nachvollziehbaren Historieneintrag (Snapshot-Vorher in JSON) und ist atomar persistiert — kein halbes Level-Up bei Fehler mitten im Commit. -**Plans**: TBD +**Plans:** 5 plans + +Plans: +- [ ] 01-01-PLAN.md — Wave 0: Prisma schema + migration + partial unique index + Jest infrastructure proof +- [ ] 01-02-PLAN.md — Wave 1: 5 pure-function lib modules (boost, skill-cap, prereq, recompute, applicable-steps) with full TDD +- [ ] 01-03-PLAN.md — Wave 2: ClassProgression + ClassFeatureOption seed pipeline (Foundry pf2e + hand-curated overlays, 16 classes × 20 levels) +- [ ] 01-04-PLAN.md — Wave 3: LevelingModule (REST + atomic commit transaction + WebSocket broadcast) + pathbuilder-import integration + integration tests +- [ ] 01-05-PLAN.md — Wave 4: React wizard against UI-SPEC + character-sheet integration + human verification checkpoint + **UI hint**: yes **Spike Required**: Voraussetzungs-DSL-Scope (LVL-09) und Free-Archetype-Slot-Restriktionen müssen vor der Implementierung geklärt werden. Die Discuss-Phase soll fragen: (a) Welche Voraussetzungs-Muster sind mechanisch evaluierbar (Skill-Rang, Talent-Besitz, Level, Klasse) versus Escape-Hatch-Warnung? Empfehlung: Warnung statt Hard-Block für nicht-evaluierbare Prereqs. (b) Welche Archetyp-Talent-Quellen sind nach der Dedication zulässig — strikt nur der gewählte Archetyp oder beliebige Archetyp-Talente (Pathbuilder-Verhalten)? @@ -132,7 +140,7 @@ Phasen werden in numerischer Reihenfolge ausgeführt: 1 → 2 → 3 → 4 → 5 | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| -| 1. Level-Up (PF2e regelkonform) | 0/TBD | Not started | - | +| 1. Level-Up (PF2e regelkonform) | 0/5 | Planned | - | | 2. PWA Foundation | 0/TBD | Not started | - | | 3. Web Push | 0/TBD | Not started | - | | 4. Würfeln & Chat | 0/TBD | Not started | - | diff --git a/.planning/phases/01-level-up-pf2e-regelkonform/01-01-PLAN.md b/.planning/phases/01-level-up-pf2e-regelkonform/01-01-PLAN.md new file mode 100644 index 0000000..7e7ef2e --- /dev/null +++ b/.planning/phases/01-level-up-pf2e-regelkonform/01-01-PLAN.md @@ -0,0 +1,548 @@ +--- +phase: 01-level-up-pf2e-regelkonform +plan: 01 +type: execute +wave: 0 +depends_on: [] +files_modified: + - server/prisma/schema.prisma + - server/prisma/migrations/YYYYMMDDHHMMSS_add_level_up_sessions_and_class_progression/migration.sql + - server/package.json + - .gitignore + - server/src/modules/leveling/lib/apply-attribute-boost.ts + - server/src/modules/leveling/lib/apply-attribute-boost.spec.ts +autonomous: true +requirements: [LVL-08, LVL-11, LVL-12, LVL-13] +tags: [prisma, schema, migration, jest-infrastructure, level-up] +must_haves: + truths: + - "Three new Prisma tables exist in the live PostgreSQL database (LevelUpSession, LevelUpHistory, ClassProgression, ClassFeatureOption)." + - "Character row has two new columns: freeArchetype (Boolean, default false) and prereqViolations (Json, nullable)." + - "A partial unique index enforces at most one open DRAFT (committedAt IS NULL) per character at the database level." + - "Running 'cd server && npx prisma migrate status' reports 'Database schema is up to date'." + - "Jest discovers and runs *.spec.ts files in server/src/ — proven by a passing apply-attribute-boost.spec.ts." + - "npm script db:seed:class-progression exists in server/package.json and points at tsx prisma/seed-class-progression.ts." + - ".gitignore excludes server/prisma/data/foundry-pf2e/ from version control." + artifacts: + - path: "server/prisma/schema.prisma" + provides: "Schema models LevelUpSession, LevelUpHistory, ClassProgression, ClassFeatureOption + extended Character" + contains: "model LevelUpSession" + - path: "server/prisma/migrations/*_add_level_up_sessions_and_class_progression/migration.sql" + provides: "Migration SQL with hand-added partial unique index" + contains: "CREATE UNIQUE INDEX \"LevelUpSession_characterId_open_unique\"" + - path: "server/src/modules/leveling/lib/apply-attribute-boost.ts" + provides: "First pure-function module — applyAttributeBoost + isValidBoostSet" + exports: ["applyAttributeBoost", "isValidBoostSet"] + - path: "server/src/modules/leveling/lib/apply-attribute-boost.spec.ts" + provides: "Proves Jest infrastructure works on *.spec.ts inside src/modules/leveling/lib/" + contains: "describe('applyAttributeBoost'" + - path: "server/package.json" + provides: "db:seed:class-progression npm script" + contains: "db:seed:class-progression" + - path: ".gitignore" + provides: "Excludes Foundry pf2e dev clone" + contains: "server/prisma/data/foundry-pf2e/" + key_links: + - from: "server/prisma/schema.prisma" + to: "PostgreSQL database" + via: "prisma migrate dev" + pattern: "migrate dev .*add_level_up_sessions_and_class_progression" + - from: "Character model" + to: "LevelUpSession[]" + via: "Prisma relation field 'levelUpSessions'" + pattern: "levelUpSessions\\s+LevelUpSession\\[\\]" + - from: "Character model" + to: "LevelUpHistory[]" + via: "Prisma relation field 'levelUpHistories'" + pattern: "levelUpHistories\\s+LevelUpHistory\\[\\]" +--- + + +Lay the foundation for Phase 1: extend the Prisma schema with the four new tables (LevelUpSession, LevelUpHistory, ClassProgression, ClassFeatureOption) and two new Character columns (freeArchetype, prereqViolations); generate the migration; hand-edit the migration SQL to add the partial unique index that Prisma 7 cannot emit from the schema; regenerate the Prisma Client; prove Jest infrastructure works on a real spec file; and add the npm script + .gitignore entry needed by the Wave 2 seed pipeline. + +Purpose: Without this plan, no later wave can run — Wave 1 needs Jest proven, Wave 2 needs the seed npm script wired, Wave 3 needs the schema/tables to exist in the database before the LevelingService can be written. + +Output: Schema file, migration SQL with partial unique index, regenerated Prisma client, npm script entry, .gitignore entry, first proven Jest spec. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/REQUIREMENTS.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md +@server/prisma/schema.prisma +@server/prisma/migrations/20260120080237_add_alchemy_and_rest_system/migration.sql +@server/package.json + + + + + +```prisma +model Character { + id String @id @default(uuid()) + // … existing fields (level, hpMax, ancestryId, classId, …) unchanged … + // NEW (this plan adds these two columns + two reverse relations): + freeArchetype Boolean @default(false) + prereqViolations Json? + levelUpSessions LevelUpSession[] + levelUpHistories LevelUpHistory[] +} +``` + + + + + +```json +"jest": { + "moduleFileExtensions": ["js","json","ts"], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "collectCoverageFrom": ["**/*.(t|j)s"], + "coverageDirectory": "../coverage", + "testEnvironment": "node" +} +``` + + + + + + + Task 1: Extend Prisma schema with four new models + Character columns + server/prisma/schema.prisma + + - server/prisma/schema.prisma (entire file — must understand existing Character model at lines 171-220 and existing CharacterAlchemyState at line 167+ as the analog pattern) + - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 436-496 — exact schema additions specified) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 750-824 — Code Examples §3 — full schema + ClassFeatureOption model) + - server/prisma/migrations/20260120080237_add_alchemy_and_rest_system/migration.sql (analog migration to mirror style) + + + Edit `server/prisma/schema.prisma` to APPEND the four new models AFTER the last existing model and EXTEND the existing Character model. + + **Step 1 — Append four new models at the bottom of the file (verbatim):** + + ```prisma + model LevelUpSession { + id String @id @default(uuid()) + characterId String + targetLevel Int + state Json @default("{}") + committedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + character Character @relation(fields: [characterId], references: [id], onDelete: Cascade) + + @@index([characterId]) + // Partial unique index "one open DRAFT per character" added in raw migration SQL — + // Prisma 7 cannot emit partial indexes from schema (per RESEARCH.md §Don't Hand-Roll). + } + + model LevelUpHistory { + id String @id @default(uuid()) + characterId String + levelFrom Int + levelTo Int + snapshotBefore Json + choices Json + committedAt DateTime @default(now()) + + character Character @relation(fields: [characterId], references: [id], onDelete: Cascade) + + @@index([characterId, committedAt(sort: Desc)]) + } + + model ClassProgression { + id String @id @default(uuid()) + className String + level Int + grants String[] + proficiencyChanges Json + spellSlotIncrement Json? + cantripIncrement Int? + repertoireIncrement Int? + choiceType String? + choiceOptionsRef String? + + @@unique([className, level]) + @@index([className]) + } + + model ClassFeatureOption { + id String @id @default(uuid()) + optionsRef String + optionKey String + name String + nameGerman String? + description String + grants String[] + proficiencyChanges Json? + + @@unique([optionsRef, optionKey]) + @@index([optionsRef]) + } + ``` + + Note: `ClassFeatureOption` carries `grants: String[]` and `proficiencyChanges: Json?` per RESEARCH.md §Open Questions Q2 recommendation — keeps the recompute pipeline uniform. + + **Step 2 — Extend the existing `model Character` block (find it around line 171). Add EXACTLY these two columns and two reverse relations inside the existing model block:** + + ```prisma + // … existing fields stay unchanged … + // === Phase 1 additions === + freeArchetype Boolean @default(false) // D-08 + prereqViolations Json? // D-06 + levelUpSessions LevelUpSession[] + levelUpHistories LevelUpHistory[] + ``` + + Place these inside the Character block AFTER the last existing scalar field but BEFORE the existing relation block (or at the end inside the model body — Prisma is order-tolerant). Do NOT touch any existing column. + + **Constraints (per CLAUDE.md and RESEARCH.md):** + - No `any` types (this is a schema; rule applies to following TS work). + - Do NOT run `prisma db push` — Task 2 runs `prisma migrate dev`. + - Cascade choice for both LevelUpSession and LevelUpHistory is `onDelete: Cascade` per RESEARCH.md §Pitfall 8 (acceptable in self-hosted single-tenant; documented in this plan). + + + cd server && npx prisma format && npx prisma validate + + + - `server/prisma/schema.prisma` contains the literal string `model LevelUpSession {` + - `server/prisma/schema.prisma` contains the literal string `model LevelUpHistory {` + - `server/prisma/schema.prisma` contains the literal string `model ClassProgression {` + - `server/prisma/schema.prisma` contains the literal string `model ClassFeatureOption {` + - `server/prisma/schema.prisma` contains the literal string `freeArchetype Boolean @default(false)` (within the Character model block) + - `server/prisma/schema.prisma` contains the literal string `prereqViolations Json?` + - `server/prisma/schema.prisma` contains the literal string `levelUpSessions LevelUpSession[]` + - `server/prisma/schema.prisma` contains the literal string `levelUpHistories LevelUpHistory[]` + - `server/prisma/schema.prisma` contains `@@unique([className, level])` + - `server/prisma/schema.prisma` contains `@@unique([optionsRef, optionKey])` + - `cd server && npx prisma format` exits 0 and rewrites the file in canonical formatting + - `cd server && npx prisma validate` exits 0 with output containing `The schema is valid` + + + Schema file contains exactly four new models, two new Character columns, two reverse relations on Character. `prisma validate` passes. No existing column on Character was renamed or removed. + + + + + Task 2 [BLOCKING]: Run Prisma migration, hand-edit migration SQL for partial unique index, regenerate Prisma Client + + server/prisma/migrations/YYYYMMDDHHMMSS_add_level_up_sessions_and_class_progression/migration.sql, + server/src/generated/prisma/* (regenerated) + + + - server/prisma/schema.prisma (post-Task-1 — must reflect the four new models) + - server/prisma/migrations/20260120080237_add_alchemy_and_rest_system/migration.sql (analog SQL style — CREATE TABLE, CREATE INDEX, ALTER TABLE FK) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 525-533 — exact partial-index SQL) + - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 500-534 — migration pattern + partial index) + - CLAUDE.md (Prisma `migrate dev`, NEVER `db push`) + + + **Step 1 — Generate the migration (creates the timestamped folder + migration.sql):** + + Run from the project root: + ```bash + cd server && npm run db:migrate:dev -- --name add_level_up_sessions_and_class_progression + ``` + + This generates `server/prisma/migrations/{timestamp}_add_level_up_sessions_and_class_progression/migration.sql` AND applies it to the local PostgreSQL database AND regenerates the Prisma Client into `server/src/generated/prisma/`. + + If the database is not reachable: STOP, report the error, do NOT proceed. The migration must apply cleanly. + + **Step 2 — Hand-edit the freshly generated `migration.sql`** to append the partial unique index that Prisma 7 cannot emit from the schema. Open the file in the new migration folder and APPEND these lines at the very end (after the auto-generated `ALTER TABLE … ADD CONSTRAINT` lines): + + ```sql + -- Partial unique index: enforce at most one open DRAFT per character. + -- Prisma 7 cannot express partial indexes via @@unique; this raw SQL is required. + -- See: .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md §Don't Hand-Roll. + CREATE UNIQUE INDEX "LevelUpSession_characterId_open_unique" + ON "LevelUpSession"("characterId") + WHERE "committedAt" IS NULL; + ``` + + **Step 3 — Apply the appended SQL** by re-running migrate-dev (it detects the migration is already applied but the SQL was edited; if it complains, use the resolve flow): + + ```bash + cd server && npx prisma migrate resolve --applied "{full_migration_name}" + ``` + + OR — preferred — connect to the database and run the partial index DDL directly via psql once, then mark the migration as applied. Use whichever path keeps the schema state consistent with the migration history. Document the chosen path in the plan SUMMARY. + + Concrete preferred sequence (least error-prone): + 1. Run `npm run db:migrate:dev -- --name add_level_up_sessions_and_class_progression` (auto-generates and applies). + 2. Append the partial-index SQL to the generated migration.sql file. + 3. Connect to the dev database (`psql $DATABASE_URL`) and execute the partial-index `CREATE UNIQUE INDEX ... WHERE ...` statement once manually. + 4. Verify with `npm run db:migrate:status` — should report `Database schema is up to date`. + 5. Verify the partial index exists: `psql $DATABASE_URL -c "\d \"LevelUpSession\""` should list `LevelUpSession_characterId_open_unique`. + + **Step 4 — Regenerate the Prisma Client** (this normally runs automatically as part of `migrate dev`, but run explicitly to confirm): + + ```bash + cd server && npm run db:generate + ``` + + This regenerates `server/src/generated/prisma/` so TS types for `LevelUpSession`, `LevelUpHistory`, `ClassProgression`, `ClassFeatureOption`, and the new Character columns are available to subsequent waves. + + **Constraints (per CLAUDE.md):** + - NEVER use `db push`. The migration file must exist on disk for production deploys. + - The hand-edited SQL must be committed to git (it is the source of truth for prod deployment). + + + cd server && npx prisma migrate status + + + - A new directory matching glob `server/prisma/migrations/*_add_level_up_sessions_and_class_progression/` exists + - The directory contains `migration.sql` + - The migration.sql file contains the literal string `CREATE TABLE "LevelUpSession"` + - The migration.sql file contains the literal string `CREATE TABLE "LevelUpHistory"` + - The migration.sql file contains the literal string `CREATE TABLE "ClassProgression"` + - The migration.sql file contains the literal string `CREATE TABLE "ClassFeatureOption"` + - The migration.sql file contains the literal string `CREATE UNIQUE INDEX "LevelUpSession_characterId_open_unique"` (the partial unique index) + - The migration.sql file contains the literal string `WHERE "committedAt" IS NULL` + - `cd server && npx prisma migrate status` exits 0 with output containing `Database schema is up to date` + - `psql $DATABASE_URL -c "\\d \"LevelUpSession\""` lists the partial index `LevelUpSession_characterId_open_unique` + - `cd server && node -e "const {PrismaClient}=require('./src/generated/prisma/client.js'); const p=new PrismaClient(); console.log(typeof p.levelUpSession.findMany);"` outputs `function` + - The Character model in the regenerated client exposes `freeArchetype` and `prereqViolations` fields (verify with: `cd server && grep -E "freeArchetype|prereqViolations" src/generated/prisma/index.d.ts | head -5`) + + + Migration file exists with auto-generated SQL + hand-appended partial unique index. Migration is applied to the live dev database. Prisma Client regenerated. `migrate status` reports up-to-date. + + + + + Task 3: Prove Jest infrastructure with first real spec — apply-attribute-boost + isValidBoostSet + + server/src/modules/leveling/lib/apply-attribute-boost.ts, + server/src/modules/leveling/lib/apply-attribute-boost.spec.ts, + server/package.json, + .gitignore + + + - server/package.json (lines 88-104 — inline Jest config; testRegex is `.*\.spec\.ts$`, rootDir is `src`) + - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 315-374 — pure-function lib pattern + first spec example + naming conventions) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 478-512 — Pattern 3 example + canonical shape; lines 596-601 — anti-patterns) + - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (lines 43-48 — exact behaviors to assert) + - .gitignore (current contents — must understand existing exclusions before appending) + + + - `applyAttributeBoost(10)` returns 12 (PF2e: +2 below 18) + - `applyAttributeBoost(17)` returns 19 (still +2 — 17 is below 18) + - `applyAttributeBoost(18)` returns 19 (PF2e cap rule: +1 at or above 18) — **the load-bearing test, Pitfall #8 fixture** + - `applyAttributeBoost(20)` returns 21 (+1 above cap) + - `isValidBoostSet(['STR','DEX','CON','INT'])` returns true (4 distinct) + - `isValidBoostSet(['STR','STR','CON','INT'])` returns false (duplicate) + - `isValidBoostSet(['STR','DEX','CON'])` returns false (only 3) + - `isValidBoostSet(['STR','DEX','CON','INT','WIS'])` returns false (5 — too many) + + + **Step 1 — Create the production module `server/src/modules/leveling/lib/apply-attribute-boost.ts`** with this exact content: + + ```typescript + /** + * Pure function module — no NestJS, no Prisma, no I/O. + * PF2e Boost Cap rule: +2 if score < 18, +1 if score >= 18 (Pitfall #8). + * See: .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md §Pattern 3 + */ + + export type AbilityScore = number; + + export type AbilityAbbreviation = 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA'; + + /** + * Applies a single PF2e attribute boost to a score. + * - Score below 18 → +2 + * - Score at or above 18 → +1 (cap rule, prevents Pitfall #8) + */ + export function applyAttributeBoost(currentScore: AbilityScore): AbilityScore { + return currentScore >= 18 ? currentScore + 1 : currentScore + 2; + } + + /** + * Validates a level-up boost set: must be exactly 4 distinct abilities. + * PF2e: at boost levels (5/10/15/20), the player picks 4 different attributes. + */ + export function isValidBoostSet(targets: readonly string[]): boolean { + return targets.length === 4 && new Set(targets).size === 4; + } + ``` + + **Step 2 — Create the spec file `server/src/modules/leveling/lib/apply-attribute-boost.spec.ts`** with this exact content (Pitfall #8 fixture is in here): + + ```typescript + import { applyAttributeBoost, isValidBoostSet } from './apply-attribute-boost'; + + describe('applyAttributeBoost', () => { + it('adds +2 when score is 10 (below 18)', () => { + expect(applyAttributeBoost(10)).toBe(12); + }); + + it('adds +2 when score is 17 (still below 18)', () => { + expect(applyAttributeBoost(17)).toBe(19); + }); + + it('adds +1 when score is exactly 18 (PF2e cap rule — Pitfall #8)', () => { + expect(applyAttributeBoost(18)).toBe(19); + }); + + it('adds +1 when score is 20 (above cap)', () => { + expect(applyAttributeBoost(20)).toBe(21); + }); + + it('handles edge case: very high score (24)', () => { + expect(applyAttributeBoost(24)).toBe(25); + }); + }); + + describe('isValidBoostSet', () => { + it('accepts exactly 4 distinct abilities', () => { + expect(isValidBoostSet(['STR', 'DEX', 'CON', 'INT'])).toBe(true); + }); + + it('rejects duplicates (3 distinct + 1 repeat)', () => { + expect(isValidBoostSet(['STR', 'STR', 'CON', 'INT'])).toBe(false); + }); + + it('rejects too few (3 abilities)', () => { + expect(isValidBoostSet(['STR', 'DEX', 'CON'])).toBe(false); + }); + + it('rejects too many (5 abilities)', () => { + expect(isValidBoostSet(['STR', 'DEX', 'CON', 'INT', 'WIS'])).toBe(false); + }); + }); + ``` + + **Step 3 — Run Jest to confirm both files are picked up by the existing inline config:** + + ```bash + cd server && npm test -- apply-attribute-boost.spec.ts + ``` + + Expected: 9 tests pass, 0 fail. If Jest does NOT discover the file, the testRegex or rootDir is misaligned — fix the inline config in `server/package.json`. Do NOT add `server/jest.config.cjs` unless the inline config genuinely fails (per VALIDATION.md Wave 0 default: trust the inline config). + + **Step 4 — Add the `db:seed:class-progression` npm script** to `server/package.json` in the `scripts` block. Insert this entry alongside the existing `db:seed:equipment` script: + + ```json + "db:seed:class-progression": "tsx prisma/seed-class-progression.ts", + ``` + + Place it in alphabetical order with the other `db:seed:*` scripts. + + **Step 5 — Append `.gitignore` exclusion for the Foundry pf2e dev clone:** + + Append (or add if not present) the following lines at the end of `.gitignore`: + + ``` + # Phase 1 Wave 2 — Foundry pf2e dev clone (cloned manually by dev for seed script) + server/prisma/data/foundry-pf2e/ + ``` + + **Constraints:** + - Pure-function module: NO `@Injectable()`, NO Prisma imports, NO NestJS imports. Strict TS — no `any`. Side-effect free. + - Test file MUST sit alongside the source file (per `testRegex: ".*\.spec\.ts$"` and `rootDir: "src"`). + - Use named exports only (no `export default`). + + + cd server && npm test -- apply-attribute-boost.spec.ts + + + - File `server/src/modules/leveling/lib/apply-attribute-boost.ts` exists + - File contains the literal string `export function applyAttributeBoost` + - File contains the literal string `export function isValidBoostSet` + - File contains NO occurrence of the string `@Injectable` (verified with grep) + - File contains NO occurrence of the string `from '@nestjs/` (verified with grep) + - File contains NO occurrence of the string `: any` outside comments + - File `server/src/modules/leveling/lib/apply-attribute-boost.spec.ts` exists + - Spec file contains 9 `it(` invocations + - `cd server && npm test -- apply-attribute-boost.spec.ts` exits 0 with output containing `9 passed` (or `Tests: 9 passed`) + - `server/package.json` `scripts` section contains the literal key `"db:seed:class-progression": "tsx prisma/seed-class-progression.ts"` + - `.gitignore` contains the literal line `server/prisma/data/foundry-pf2e/` + + + Pure-function module exists with both exports. Spec file exists with 9 passing tests. Jest infrastructure proven on a real spec inside `server/src/modules/`. npm script registered. .gitignore extended. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| dev-shell → migration | Developer's shell executes `prisma migrate dev` against the dev database. Trust assumed within self-hosted single-tenant model. | +| schema → live database | DDL crosses from schema source-of-truth to the live PostgreSQL instance. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-1-W0-01 | Tampering | Migration drift between schema.prisma and live DB | mitigate | The hand-edited partial unique index is committed to git inside the migration.sql file; `prisma migrate status` is the verification gate; production deploy uses `migrate deploy` which applies the same SQL atomically. | +| T-1-W0-02 | Repudiation | Lost migration history if dev runs `db push` instead | mitigate | CLAUDE.md hard-rule documented in plan action; `npx prisma migrate dev` only — no `db push` allowed. | +| T-1-W0-03 | DoS | Cascade delete of Character wipes LevelUpHistory permanently | accept | Per RESEARCH.md §Pitfall 8: self-hosted single-tenant, no soft-delete pattern for Character today; cascade is acceptable and documented. v2 may revisit. | +| T-1-W0-04 | Information Disclosure | Foundry pf2e clone committed to repo by accident | mitigate | `.gitignore` excludes `server/prisma/data/foundry-pf2e/` (Task 3 Step 5). | + + + +Run end-to-end checks before declaring this plan done: + +```bash +# Schema valid +cd server && npx prisma validate + +# Migration applied +cd server && npx prisma migrate status # must say "Database schema is up to date" + +# Partial index exists +psql $DATABASE_URL -c "\d \"LevelUpSession\"" | grep "LevelUpSession_characterId_open_unique" + +# Generated client knows the new models +cd server && grep -c "freeArchetype" src/generated/prisma/index.d.ts # must be ≥ 1 +cd server && grep -c "prereqViolations" src/generated/prisma/index.d.ts # must be ≥ 1 +cd server && grep -c "model LevelUpSession" src/generated/prisma/index.d.ts || true # types should exist + +# Jest works on the new spec +cd server && npm test -- apply-attribute-boost.spec.ts # 9 tests pass + +# Npm script wired +cat server/package.json | grep "db:seed:class-progression" + +# .gitignore extended +cat .gitignore | grep "server/prisma/data/foundry-pf2e/" +``` + +If any check fails, the plan is NOT done. Re-run the failing task. + + + +- Schema file extended with 4 new models + 2 new Character columns + 2 reverse relations +- Migration file generated, partial unique index hand-appended, applied to dev DB +- Prisma Client regenerated and includes types for the new models +- `prisma validate` and `prisma migrate status` both green +- First-ever Jest spec passes (apply-attribute-boost: 9 tests) +- npm script `db:seed:class-progression` registered in `server/package.json` +- `.gitignore` excludes `server/prisma/data/foundry-pf2e/` +- Wave 1, 2, 3, 4 are unblocked: schema is applied, Jest works, seed script entry exists, foundry source is gitignored + + + +After completion, create `.planning/phases/01-level-up-pf2e-regelkonform/01-01-SUMMARY.md` documenting: +- Migration timestamp + folder name (so downstream plans know the exact filename) +- Whether the partial-index SQL was applied via raw psql or via re-running migrate-dev +- Confirmation that the Prisma Client exposes `prisma.levelUpSession`, `prisma.levelUpHistory`, `prisma.classProgression`, `prisma.classFeatureOption` and that Character has `freeArchetype` + `prereqViolations` +- Test result: 9/9 passing for apply-attribute-boost.spec.ts +- Any deviations from the plan + diff --git a/.planning/phases/01-level-up-pf2e-regelkonform/01-02-PLAN.md b/.planning/phases/01-level-up-pf2e-regelkonform/01-02-PLAN.md new file mode 100644 index 0000000..bd4eec7 --- /dev/null +++ b/.planning/phases/01-level-up-pf2e-regelkonform/01-02-PLAN.md @@ -0,0 +1,1283 @@ +--- +phase: 01-level-up-pf2e-regelkonform +plan: 02 +type: tdd +wave: 1 +depends_on: ["01-01"] +files_modified: + - server/src/modules/leveling/lib/skill-increase-cap.ts + - server/src/modules/leveling/lib/skill-increase-cap.spec.ts + - server/src/modules/leveling/lib/prereq-evaluator.ts + - server/src/modules/leveling/lib/prereq-evaluator.spec.ts + - server/src/modules/leveling/lib/recompute-derived-stats.ts + - server/src/modules/leveling/lib/recompute-derived-stats.spec.ts + - server/src/modules/leveling/lib/compute-applicable-steps.ts + - server/src/modules/leveling/lib/compute-applicable-steps.spec.ts + - server/src/modules/leveling/lib/types.ts +autonomous: true +requirements: [LVL-02, LVL-06, LVL-09, LVL-10, LVL-01, LVL-13, LVL-14] +tags: [pure-functions, jest, tdd, level-up, prereq-evaluator, recompute] +must_haves: + truths: + - "skill-increase-cap correctly enforces PF2e rule: TRAINED→EXPERT only at L3+, EXPERT→MASTER only at L7+, MASTER→LEGENDARY only at L15+" + - "prereq-evaluator returns {ok:true} for met prereqs, {ok:false, reason} for evaluable+failed, {unknown:true, raw} for non-evaluable patterns (Deity, Spellcasting-Tradition, etc. — D-02)" + - "prereq-evaluator handles all D-01 patterns: skill-rank, feat-possession, level, class, ancestry, heritage" + - "recompute-derived-stats produces correct hpMax, AC, classDC, perception, fortitude, reflex, will using boost-cap-at-18 (Pitfall #8) and never mutates hpCurrent (Pitfall #9)" + - "compute-applicable-steps returns the right step list per (targetLevel, class, hasFA, isCaster) — boost only at L5/10/15/20, FA-step only when hasFA, spellcaster-step only for casters, etc." + - "All four pure-function modules have NO NestJS decorators, NO Prisma imports, NO `any` types" + - "Every behavior in 01-VALIDATION.md rows 1-W1-06 through 1-W1-29 has a passing test" + artifacts: + - path: "server/src/modules/leveling/lib/types.ts" + provides: "Shared types — Proficiency, EvalResult, CharacterContext, DerivedStats, StepKind, AbilityAbbreviation" + exports: ["Proficiency", "EvalResult", "CharacterContext", "DerivedStats", "StepKind", "AbilityAbbreviation"] + - path: "server/src/modules/leveling/lib/skill-increase-cap.ts" + provides: "Pure function canIncreaseSkill(currentRank, characterLevel)" + exports: ["canIncreaseSkill", "SKILL_INCREASE_LEVELS"] + - path: "server/src/modules/leveling/lib/prereq-evaluator.ts" + provides: "Pure function evaluatePrereq(prereqString, ctx) returning discriminated union" + exports: ["evaluatePrereq", "parsePrereq"] + - path: "server/src/modules/leveling/lib/recompute-derived-stats.ts" + provides: "Pure function recomputeDerivedStats(character, choices, progression) returning DerivedStats" + exports: ["recomputeDerivedStats"] + - path: "server/src/modules/leveling/lib/compute-applicable-steps.ts" + provides: "Pure function computeApplicableSteps(targetLevel, className, hasFreeArchetype, isCaster) returning StepKind[]" + exports: ["computeApplicableSteps"] + key_links: + - from: "prereq-evaluator.ts" + to: "CharacterContext type" + via: "import from ./types" + pattern: "from ['\"]\\./types['\"]" + - from: "recompute-derived-stats.ts" + to: "applyAttributeBoost (Plan 01)" + via: "import" + pattern: "from ['\"]\\./apply-attribute-boost['\"]" + - from: "All four modules" + to: "Their .spec.ts siblings" + via: "Jest testRegex" + pattern: ".*\\.spec\\.ts$" +--- + + +Build the five pure-function modules that hold all the math for the Level-Up system: `skill-increase-cap`, `prereq-evaluator`, `recompute-derived-stats`, `compute-applicable-steps`, plus a shared `types.ts`. Every module is fully unit-tested using strict TDD (write failing test → write minimal implementation → green). These modules are the source-of-truth for PF2e rules math; they have NO NestJS imports, NO Prisma, NO I/O, and they will be called from the LevelingService (Plan 04) and from the React wizard's preview path. + +Purpose: This phase establishes the test discipline (per ROADMAP First-Phase Note) and isolates the bug-prone math (Pitfall #8 boost-cap, Pitfall #9 hp-current, prereq edge cases) behind a fully-tested boundary. Plan 04's integration tests can then trust this math and only test orchestration (transactions, broadcast, access control). + +Output: 5 production modules + 5 spec files, all tests passing under `cd server && npm test -- --testPathPattern=leveling`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/REQUIREMENTS.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-01-SUMMARY.md +@server/src/modules/leveling/lib/apply-attribute-boost.ts +@server/src/modules/characters/pathbuilder-import.service.ts +@client/src/features/characters/components/add-feat-modal.tsx + + + +```typescript +// From server/src/modules/leveling/lib/apply-attribute-boost.ts +export type AbilityScore = number; +export type AbilityAbbreviation = 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA'; +export function applyAttributeBoost(currentScore: AbilityScore): AbilityScore; +export function isValidBoostSet(targets: readonly string[]): boolean; +``` + + + +```typescript +// Existing client-side pattern (partial analog only — server module is fuller): +function checkSkillPrerequisites( + prerequisites: string | undefined, + skills: CharacterSkill[], +): { met: boolean; unmetReason?: string } { /* skill rank regex matching */ } +``` + + +```typescript +function proficiencyFromValue(value: number): Proficiency { + switch (value) { + case 8: return Proficiency.LEGENDARY; + case 6: return Proficiency.MASTER; + case 4: return Proficiency.EXPERT; + case 2: return Proficiency.TRAINED; + default: return Proficiency.UNTRAINED; + } +} +``` + + + + + + + + skill-increase-cap — PF2e Skill Increase Rule + server/src/modules/leveling/lib/skill-increase-cap.ts, server/src/modules/leveling/lib/skill-increase-cap.spec.ts + + PF2e rule: skill-increase steps occur at level 3 and every 2 levels thereafter. Cap rule: + - TRAINED → EXPERT requires character level >= 3 + - EXPERT → MASTER requires character level >= 7 + - MASTER → LEGENDARY requires character level >= 15 + - UNTRAINED → TRAINED is always allowed when a skill-increase step occurs + Test cases (from VALIDATION.md rows 1-W1-06 to 1-W1-11): + - canIncreaseSkill('TRAINED', 2) === false + - canIncreaseSkill('TRAINED', 3) === true + - canIncreaseSkill('EXPERT', 6) === false + - canIncreaseSkill('EXPERT', 7) === true + - canIncreaseSkill('MASTER', 14) === false + - canIncreaseSkill('MASTER', 15) === true + - canIncreaseSkill('LEGENDARY', 20) === false (already maxed) + - canIncreaseSkill('UNTRAINED', 1) === true (untrained → trained always allowed at any skill-increase level) + Also export `SKILL_INCREASE_LEVELS: readonly number[] = [3, 5, 7, 9, 11, 13, 15, 17, 19]` (per PF2e CRB). + + + + + prereq-evaluator — DSL parser + evaluator + formatter (D-01..D-04) + server/src/modules/leveling/lib/prereq-evaluator.ts, server/src/modules/leveling/lib/prereq-evaluator.spec.ts + + Three-layer module: parser produces an AST, evaluator walks AST against CharacterContext, formatter produces a German user-facing reason for failures. + Discriminated union return: `{ok:true} | {ok:false, reason:string} | {unknown:true, raw:string}`. + Evaluable patterns (D-01): + - Skill rank: `Trained in Athletics`, `Expert in Acrobatics`, `Master in Stealth`, `Legendary in Religion` — case-insensitive + - Disjunctive: `Trained in Arcana, Trained in Nature, or Trained in Religion` — comma+OR-list (any one match → ok:true) + - Conjunctive: `Trained in Deception; Trained in Stealth` — semicolon = AND (all must match → ok:true) + - Bare feat-name: `Power Attack` — checks ctx.feats Set + - Heritage ref: `Unbreakable Goblin heritage` — checks ctx.heritageId/heritageName + - Ancestry ref: `Human`, `Elf`, etc. (one of D-16 16 ancestries) — checks ctx.ancestryName + - Class ref: `Fighter`, `Wizard`, etc. — checks ctx.className + - Level ref: `level 5` — checks ctx.level + Non-evaluable (D-02 — return `{unknown:true, raw}`): + - Deity refs: `worshipper of Droskar`, `worship of`, `follower of` + - Spellcasting-tradition refs: `spellcasting class feature`, `divine spells`, `spellcaster` + - Age/ethnicity: `at least 100 years old`, `Tian-Dan ethnicity` + - Vision/sense traits: `low-light vision`, `darkvision` + - Free-text: anything that doesn't match a known pattern + Test cases (from VALIDATION.md rows 1-W1-12 to 1-W1-21): + - evaluatePrereq("Trained in Athletics", { skills: { Athletics: 'TRAINED' }, ... }) → {ok:true} + - Same prereq with skills.Athletics = 'UNTRAINED' → {ok:false, reason: "Du benötigst mindestens 'Trained' in Acrobatics" or German equivalent — wording at planner discretion but must be German} + - "Trained in Arcana, Trained in Nature, or Trained in Religion" with skills.Arcana = 'TRAINED' → {ok:true} + - "Trained in Deception; Trained in Stealth" with skills.Deception = 'TRAINED', skills.Stealth = 'UNTRAINED' → {ok:false} + - "Power Attack" with feats Set containing 'Power Attack' → {ok:true} + - "Unbreakable Goblin heritage" with ctx.heritageName = 'Unbreakable Goblin' → {ok:true} + - "Fighter" with ctx.className = 'Fighter' → {ok:true} + - "spellcasting class feature" → {unknown:true, raw: "spellcasting class feature"} + - "worshipper of Droskar" → {unknown:true, raw: "worshipper of Droskar"} + - evaluatePrereq(null, ctx) → {ok:true} (no prereq is always met) + - evaluatePrereq('', ctx) → {ok:true} + Implementation: Use regex-based parser per RESEARCH.md §Pattern 4 grammar (lines 519-531). UNKNOWN-aggressive: any non-classifiable atom marks the whole clause unknown. + + + + + recompute-derived-stats — pure recompute pipeline (Pitfall #9) + server/src/modules/leveling/lib/recompute-derived-stats.ts, server/src/modules/leveling/lib/recompute-derived-stats.spec.ts + + Pure function `recomputeDerivedStats(character, choices, progression) → DerivedStats`. No DB writes; no side effects. + Inputs: + - `character`: Character snapshot with current ancestryHP, classHP, abilities (STR/DEX/CON/INT/WIS/CHA), skills (Map), feats (Set), level, hpCurrent + - `choices`: WizardState.choices subset (boostTargets, skillIncrease, classFeatureChoices) + - `progression`: ClassProgression row for (className, targetLevel) — proficiencyChanges Json, spellSlotIncrement, etc. + Outputs (DerivedStats type): + - level: number (the new targetLevel) + - hpMax: number (= ancestryHP + (classHP + conMod) × newLevel + bonusPerLevel × newLevel) + - ac: number (= 10 + clamp(dexMod, dexCap) + armor.ac + proficiencyBonus(armor)) + - classDc: number (= 10 + keyAbility-mod + proficiencyBonus(class)) + - perception: number (= wisMod + proficiencyBonus(perception)) + - fortitude: number (= conMod + proficiencyBonus(fort)) + - reflex: number (= dexMod + proficiencyBonus(ref)) + - will: number (= wisMod + proficiencyBonus(will)) + Test cases (from VALIDATION.md rows 1-W1-22 to 1-W1-25): + - Given character L4 → L5 with CON 16 (mod +3), classHP 8, ancestryHP 8: new hpMax = 8 + (8+3)×5 = 63 + - Boost-cap-at-18 honored: if boostTargets includes CON and current CON = 18, new CON = 19 (not 20) → conMod 4 → hpMax uses 4 + - proficiencyChanges from ClassProgression are applied: if progression.proficiencyChanges = {fortitude: "EXPERT"} and old fort proficiency was TRAINED, new will use EXPERT bonus + - Output object MUST NOT contain `hpCurrent` field (Pitfall #9 — verify with `expect(result).not.toHaveProperty('hpCurrent')`) + - Output object MUST NOT contain `hpTemp` field + Helper functions inside this module (private or exported as needed): + - `proficiencyBonus(rank: Proficiency, level: number): number` — PF2e: untrained 0, trained 2+L, expert 4+L, master 6+L, legendary 8+L + - `abilityModifier(score: number): number` — Math.floor((score - 10) / 2) + + + + + compute-applicable-steps — Wizard step list per character/level (LVL-01, LVL-13, LVL-14) + server/src/modules/leveling/lib/compute-applicable-steps.ts, server/src/modules/leveling/lib/compute-applicable-steps.spec.ts + + Pure function `computeApplicableSteps(targetLevel, className, hasFreeArchetype, isCaster, isSpontaneousCaster, classProgressionHasChoiceType): StepKind[]` returning the ordered list of wizard steps for this level-up. + Step ordering (per D-10): class-features → class-feature-choice (if choiceType present) → boost (if L5/10/15/20) → skill-increase (if L3+) → feat-class (if even level) → feat-skill (if even level) → feat-general (if L3/7/11/15/19) → feat-ancestry (if L5/9/13/17) → feat-archetype (if hasFA) → spellcaster (if isCaster) → review. + StepKind type (per RESEARCH.md §Pattern 1 line 357): + `'class-features' | 'class-feature-choice' | 'boost' | 'skill-increase' | 'feat-class' | 'feat-skill' | 'feat-general' | 'feat-ancestry' | 'feat-archetype' | 'spellcaster' | 'review'` + Test cases (from VALIDATION.md rows 1-W1-26 to 1-W1-29): + - computeApplicableSteps(5, 'Fighter', false, false, false, false) returns ['class-features', 'boost', 'skill-increase', 'feat-class', 'feat-skill', 'feat-ancestry', 'review'] (no FA, no spellcaster, no choiceType) + - computeApplicableSteps(4, 'Fighter', false, false, false, false) does NOT contain 'boost' (4 is not a boost level) + - computeApplicableSteps(5, 'Fighter', true, false, false, false) contains 'feat-archetype' + - computeApplicableSteps(5, 'Wizard', false, true, false, false) contains 'spellcaster' + - computeApplicableSteps(3, 'Bard', false, true, true, false) contains 'spellcaster' AND 'feat-general' AND 'skill-increase' + - computeApplicableSteps(2, 'Fighter', false, false, false, false) contains 'feat-class' AND 'feat-skill' but NOT 'boost' AND NOT 'skill-increase' (skill-increase starts at L3) + - computeApplicableSteps(1, 'Cleric', false, true, false, true) contains 'class-feature-choice' (Cleric L1 doctrine) + - Result ALWAYS ends with 'review' as the last step + - Result ALWAYS starts with 'class-features' (auto-summary, even if empty grants for that level) + + + + + + + Task 1: Shared types module (types.ts) + server/src/modules/leveling/lib/types.ts + + - server/src/modules/leveling/lib/apply-attribute-boost.ts (existing exports — extend the type vocabulary) + - server/src/generated/prisma/index.d.ts (search for `Proficiency` enum — must align) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 369-401 — WizardState shape; lines 537-548 — EvalResult; lines 562-573 — DerivedStats) + + + No tests for types-only file. Types file MUST contain (verifiable via grep): + - `export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY'` + - `export type EvalResult = { ok: true } | { ok: false; reason: string } | { unknown: true; raw: string }` + - `export type StepKind = 'class-features' | 'class-feature-choice' | 'boost' | 'skill-increase' | 'feat-class' | 'feat-skill' | 'feat-general' | 'feat-ancestry' | 'feat-archetype' | 'spellcaster' | 'review'` + - `export interface CharacterContext { level: number; className: string; ancestryName: string; heritageName?: string; abilities: Record; skills: Record; feats: Set }` + - `export interface DerivedStats { level: number; hpMax: number; ac: number; classDc: number; perception: number; fortitude: number; reflex: number; will: number }` + + + Create `server/src/modules/leveling/lib/types.ts` with the following exact content: + + ```typescript + /** + * Shared types for the Level-Up pure-function library. + * No runtime dependencies — types only. + */ + import type { AbilityAbbreviation } from './apply-attribute-boost'; + + export type { AbilityAbbreviation }; + + /** PF2e proficiency ranks (mirrors Prisma `Proficiency` enum). */ + export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY'; + + /** Numeric proficiency bonus per rank, for use in proficiencyBonus(rank, level) calculation. */ + export const PROFICIENCY_BASE_BONUS: Record = { + UNTRAINED: 0, + TRAINED: 2, + EXPERT: 4, + MASTER: 6, + LEGENDARY: 8, + }; + + /** Discriminated union for prereq evaluation result. */ + export type EvalResult = + | { ok: true } + | { ok: false; reason: string } + | { unknown: true; raw: string }; + + /** Ordered union of wizard step kinds (UI-SPEC + RESEARCH §Pattern 1). */ + export type StepKind = + | 'class-features' + | 'class-feature-choice' + | 'boost' + | 'skill-increase' + | 'feat-class' + | 'feat-skill' + | 'feat-general' + | 'feat-ancestry' + | 'feat-archetype' + | 'spellcaster' + | 'review'; + + /** Snapshot a character's mechanical state for prereq evaluation and recompute. */ + export interface CharacterContext { + level: number; + className: string; + ancestryName: string; + heritageName?: string; + abilities: Record; + skills: Record; + feats: Set; + } + + /** Output of recomputeDerivedStats — never includes hpCurrent (Pitfall #9). */ + export interface DerivedStats { + level: number; + hpMax: number; + ac: number; + classDc: number; + perception: number; + fortitude: number; + reflex: number; + will: number; + } + + /** ClassProgression row shape — read-only input to recompute pipeline. */ + export interface ClassProgressionRow { + className: string; + level: number; + grants: string[]; + proficiencyChanges: Partial>; + spellSlotIncrement?: { tradition: string; spellLevel: number; count: number } | null; + cantripIncrement?: number | null; + repertoireIncrement?: number | null; + choiceType?: string | null; + choiceOptionsRef?: string | null; + } + + /** Wizard choices subset — what the user picked across the wizard. */ + export interface WizardChoices { + boostTargets?: AbilityAbbreviation[]; + skillIncrease?: { skillName: string; toRank: Proficiency }; + featClassId?: string; + featSkillId?: string; + featGeneralId?: string; + featAncestryId?: string; + featArchetypeId?: string; + classFeatureChoices?: Record; + spellcasterRepertoirePicks?: string[]; + } + ``` + + + cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -v "^$" | head -20 || echo "tsc clean" + + + - File `server/src/modules/leveling/lib/types.ts` exists + - File contains `export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY'` + - File contains `export type EvalResult =` + - File contains `export type StepKind =` + - File contains `export interface CharacterContext` + - File contains `export interface DerivedStats` + - File contains `export interface ClassProgressionRow` + - File contains `export interface WizardChoices` + - File contains NO `: any` outside comments + - File contains NO `@nestjs/` imports + - File contains NO Prisma client import (only `import type` from sibling for AbilityAbbreviation) + - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 (no type errors) + + + types.ts exports the full vocabulary used by the next four modules. No production code yet — no test needed because the file has no behavior beyond type aliases. + + + + + Task 2: skill-increase-cap module (RED → GREEN → REFACTOR) + server/src/modules/leveling/lib/skill-increase-cap.ts, server/src/modules/leveling/lib/skill-increase-cap.spec.ts + + - server/src/modules/leveling/lib/types.ts (Proficiency type) + - server/src/modules/leveling/lib/apply-attribute-boost.ts (style template — strict TS, named exports, JSDoc) + - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W1-06 to 1-W1-11) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 967-979 — Phase Requirements → Test Map for LVL-06) + - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (LVL-06: T→E ab L3, E→M ab L7, M→L ab L15) + + + See `` block above. RED: write spec first with all 8 test cases. GREEN: implement minimal `canIncreaseSkill` to pass all tests. REFACTOR: extract magic numbers to a SKILL_RANK_LEVEL_REQUIREMENTS constant. + + + **RED phase — write the failing spec FIRST:** + + Create `server/src/modules/leveling/lib/skill-increase-cap.spec.ts`: + + ```typescript + import { canIncreaseSkill, SKILL_INCREASE_LEVELS } from './skill-increase-cap'; + + describe('canIncreaseSkill', () => { + it('rejects TRAINED → EXPERT at level 2 (T→E requires L3+)', () => { + expect(canIncreaseSkill('TRAINED', 2)).toBe(false); + }); + + it('allows TRAINED → EXPERT at level 3', () => { + expect(canIncreaseSkill('TRAINED', 3)).toBe(true); + }); + + it('rejects EXPERT → MASTER at level 6 (E→M requires L7+)', () => { + expect(canIncreaseSkill('EXPERT', 6)).toBe(false); + }); + + it('allows EXPERT → MASTER at level 7', () => { + expect(canIncreaseSkill('EXPERT', 7)).toBe(true); + }); + + it('rejects MASTER → LEGENDARY at level 14 (M→L requires L15+)', () => { + expect(canIncreaseSkill('MASTER', 14)).toBe(false); + }); + + it('allows MASTER → LEGENDARY at level 15', () => { + expect(canIncreaseSkill('MASTER', 15)).toBe(true); + }); + + it('rejects LEGENDARY at any level (already maxed)', () => { + expect(canIncreaseSkill('LEGENDARY', 20)).toBe(false); + }); + + it('allows UNTRAINED → TRAINED at any level >= 1 (no cap on first training)', () => { + expect(canIncreaseSkill('UNTRAINED', 1)).toBe(true); + expect(canIncreaseSkill('UNTRAINED', 20)).toBe(true); + }); + }); + + describe('SKILL_INCREASE_LEVELS', () => { + it('exposes the PF2e skill-increase level list', () => { + expect(SKILL_INCREASE_LEVELS).toEqual([3, 5, 7, 9, 11, 13, 15, 17, 19]); + }); + }); + ``` + + Run `cd server && npm test -- skill-increase-cap.spec.ts` — must FAIL (module doesn't exist yet). Commit: `test(01): RED — skill-increase-cap spec` + + **GREEN phase — write minimal implementation:** + + Create `server/src/modules/leveling/lib/skill-increase-cap.ts`: + + ```typescript + import type { Proficiency } from './types'; + + /** Levels at which a skill-increase step occurs (PF2e CRB). */ + export const SKILL_INCREASE_LEVELS: readonly number[] = [3, 5, 7, 9, 11, 13, 15, 17, 19]; + + /** + * Minimum character level required to advance a skill from `currentRank`. + * UNTRAINED → TRAINED is unrestricted (any level >= 1 with a skill-increase step). + */ + const SKILL_RANK_LEVEL_REQUIREMENTS: Record = { + UNTRAINED: 1, // → TRAINED + TRAINED: 3, // → EXPERT (PF2e CRB) + EXPERT: 7, // → MASTER + MASTER: 15, // → LEGENDARY + LEGENDARY: null, // already maxed + }; + + /** + * PF2e skill-increase cap rule (LVL-06). + * Returns true if the character at `characterLevel` may advance a skill that is + * currently at `currentRank` to the next rank. + */ + export function canIncreaseSkill( + currentRank: Proficiency, + characterLevel: number, + ): boolean { + const required = SKILL_RANK_LEVEL_REQUIREMENTS[currentRank]; + if (required === null) return false; + return characterLevel >= required; + } + ``` + + Run `cd server && npm test -- skill-increase-cap.spec.ts` — must PASS (8 + 1 = 9 tests). Commit: `feat(01): implement skill-increase-cap` + + + cd server && npm test -- skill-increase-cap.spec.ts + + + - File `server/src/modules/leveling/lib/skill-increase-cap.ts` exists + - File exports `canIncreaseSkill` and `SKILL_INCREASE_LEVELS` + - File contains NO `@nestjs/` imports + - File contains NO `: any` + - File `server/src/modules/leveling/lib/skill-increase-cap.spec.ts` exists with at least 9 `it(` invocations + - `cd server && npm test -- skill-increase-cap.spec.ts` exits 0 with `9 passed` (or `Tests: 9 passed`) + - All test cases from VALIDATION.md rows 1-W1-06 to 1-W1-11 are present in the spec + + + Spec written first and observed failing; minimal implementation green; all 9 tests pass. + + + + + Task 3: prereq-evaluator module (RED → GREEN → REFACTOR) + server/src/modules/leveling/lib/prereq-evaluator.ts, server/src/modules/leveling/lib/prereq-evaluator.spec.ts + + - server/src/modules/leveling/lib/types.ts (CharacterContext, EvalResult, Proficiency) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 514-548 — Pattern 4 grammar; lines 727-748 — Code Examples §2 real prereq strings) + - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W1-12 to 1-W1-21 — exact assertions) + - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-01..D-04 — evaluable patterns and warning behavior) + - client/src/features/characters/components/add-feat-modal.tsx (lines 27-74 — partial client-side analog `checkSkillPrerequisites`) + + + See `` block above for full behavior. RED: spec all 14+ test cases. GREEN: implement parser + evaluator + formatter using regex patterns from grammar in RESEARCH.md. + + + **RED phase — write the failing spec FIRST:** + + Create `server/src/modules/leveling/lib/prereq-evaluator.spec.ts`: + + ```typescript + import { evaluatePrereq } from './prereq-evaluator'; + import type { CharacterContext } from './types'; + + function makeCtx(overrides: Partial = {}): CharacterContext { + return { + level: 5, + className: 'Fighter', + ancestryName: 'Human', + heritageName: undefined, + abilities: { STR: 16, DEX: 14, CON: 14, INT: 10, WIS: 12, CHA: 10 }, + skills: {}, + feats: new Set(), + ...overrides, + }; + } + + describe('evaluatePrereq — empty/null', () => { + it('returns ok for null prereq', () => { + expect(evaluatePrereq(null, makeCtx())).toEqual({ ok: true }); + }); + + it('returns ok for empty string', () => { + expect(evaluatePrereq('', makeCtx())).toEqual({ ok: true }); + }); + }); + + describe('evaluatePrereq — skill rank', () => { + it('returns ok when skill rank meets requirement', () => { + const ctx = makeCtx({ skills: { Athletics: 'TRAINED' } }); + expect(evaluatePrereq('Trained in Athletics', ctx)).toEqual({ ok: true }); + }); + + it('returns ok when skill rank exceeds requirement', () => { + const ctx = makeCtx({ skills: { Athletics: 'EXPERT' } }); + expect(evaluatePrereq('Trained in Athletics', ctx)).toEqual({ ok: true }); + }); + + it('returns ok:false with German reason when skill rank below requirement', () => { + const ctx = makeCtx({ skills: { Athletics: 'UNTRAINED' } }); + const result = evaluatePrereq('Trained in Athletics', ctx); + expect(result.ok).toBe(false); + if (result.ok === false) { + expect(result.reason).toMatch(/Athletics/i); + } + }); + + it('returns ok:false when skill is missing entirely', () => { + const ctx = makeCtx({ skills: {} }); + const result = evaluatePrereq('Trained in Athletics', ctx); + expect(result.ok).toBe(false); + }); + }); + + describe('evaluatePrereq — disjunctive (OR-list)', () => { + it('returns ok when any of the listed skills matches', () => { + const ctx = makeCtx({ skills: { Arcana: 'TRAINED' } }); + expect(evaluatePrereq('Trained in Arcana, Trained in Nature, or Trained in Religion', ctx)).toEqual({ ok: true }); + }); + + it('returns ok:false when no listed skill matches', () => { + const ctx = makeCtx({ skills: { Athletics: 'TRAINED' } }); + const result = evaluatePrereq('Trained in Arcana, Trained in Nature, or Trained in Religion', ctx); + expect(result.ok).toBe(false); + }); + }); + + describe('evaluatePrereq — conjunctive (semicolon AND)', () => { + it('returns ok when both clauses match', () => { + const ctx = makeCtx({ skills: { Deception: 'TRAINED', Stealth: 'TRAINED' } }); + expect(evaluatePrereq('Trained in Deception; Trained in Stealth', ctx)).toEqual({ ok: true }); + }); + + it('returns ok:false when one clause is missing', () => { + const ctx = makeCtx({ skills: { Deception: 'TRAINED' } }); + const result = evaluatePrereq('Trained in Deception; Trained in Stealth', ctx); + expect(result.ok).toBe(false); + }); + }); + + describe('evaluatePrereq — bare feat name', () => { + it('returns ok when the feat is held', () => { + const ctx = makeCtx({ feats: new Set(['Power Attack']) }); + expect(evaluatePrereq('Power Attack', ctx)).toEqual({ ok: true }); + }); + + it('returns ok:false when the feat is missing', () => { + const ctx = makeCtx({ feats: new Set() }); + const result = evaluatePrereq('Power Attack', ctx); + expect(result.ok).toBe(false); + }); + }); + + describe('evaluatePrereq — heritage', () => { + it('returns ok when heritage matches', () => { + const ctx = makeCtx({ heritageName: 'Unbreakable Goblin' }); + expect(evaluatePrereq('Unbreakable Goblin heritage', ctx)).toEqual({ ok: true }); + }); + }); + + describe('evaluatePrereq — class ref', () => { + it('returns ok when class matches', () => { + const ctx = makeCtx({ className: 'Fighter' }); + expect(evaluatePrereq('Fighter', ctx)).toEqual({ ok: true }); + }); + }); + + describe('evaluatePrereq — non-evaluable patterns (D-02 → unknown)', () => { + it('returns unknown for spellcasting refs', () => { + const result = evaluatePrereq('spellcasting class feature', makeCtx()); + expect(result).toEqual({ unknown: true, raw: 'spellcasting class feature' }); + }); + + it('returns unknown for deity refs', () => { + const result = evaluatePrereq('worshipper of Droskar', makeCtx()); + expect('unknown' in result && result.unknown).toBe(true); + }); + + it('returns unknown for age refs', () => { + const result = evaluatePrereq('at least 100 years old', makeCtx()); + expect('unknown' in result && result.unknown).toBe(true); + }); + + it('returns unknown for vision-trait refs', () => { + const result = evaluatePrereq('low-light vision', makeCtx()); + expect('unknown' in result && result.unknown).toBe(true); + }); + + it('returns unknown for free-text patterns the parser cannot classify', () => { + const result = evaluatePrereq('You worship a god of fire and destruction', makeCtx()); + expect('unknown' in result && result.unknown).toBe(true); + }); + }); + ``` + + Run `cd server && npm test -- prereq-evaluator.spec.ts` — must FAIL. Commit: `test(01): RED — prereq-evaluator spec` + + **GREEN phase — implement the parser + evaluator** in `server/src/modules/leveling/lib/prereq-evaluator.ts`. Implementation must: + + 1. Define internal AST node types: `SkillRankAtom`, `FeatAtom`, `HeritageAtom`, `ClassAtom`, `AncestryAtom`, `LevelAtom`, `UnknownAtom`, `AndNode`, `OrNode`. + 2. Export `evaluatePrereq(prereqString: string | null, ctx: CharacterContext): EvalResult`. + 3. Internal regex patterns based on RESEARCH.md grammar (lines 519-531): + - `/^(trained|expert|master|legendary)\s+in\s+(.+?)$/i` → SkillRankAtom + - `/(.+?)\s+heritage$/i` → HeritageAtom + - `/^level\s+(\d+)$/i` → LevelAtom + - Class names: lookup against the 16 D-16 class names hardcoded in the module + - Spellcasting/deity/age/vision: regex blacklist → UnknownAtom + - Bare capitalized phrase as last-resort feat lookup → FeatAtom + 4. Splitter: split on `;` for AND clauses, then within a clause split on `,` and ` or ` for OR-lists (see RESEARCH.md A5 — heuristic "comma inside Trained in X, Y, or Z = OR-list"). + 5. UNKNOWN-aggressive: if ANY atom resolves to UnknownAtom, the whole prereq returns `{ unknown: true, raw: }` per RESEARCH.md line 550. + 6. Failure reasons in German (D-15 — UI is German throughout). Examples: + - `Du benötigst mindestens 'Trained' in Athletics` (skill rank fail) + - `Dir fehlt das Talent: Power Attack` (feat fail) + - `Voraussetzung nicht erfüllt: ` (generic fallback) + + Sketched implementation skeleton (planner outlines key shapes; executor fills in regex correctness and German strings): + + ```typescript + import type { CharacterContext, EvalResult, Proficiency } from './types'; + + const PROFICIENCY_RANK_ORDER: readonly Proficiency[] = ['UNTRAINED', 'TRAINED', 'EXPERT', 'MASTER', 'LEGENDARY']; + + const KNOWN_CLASS_NAMES = new Set([ + 'Alchemist', 'Barbarian', 'Bard', 'Champion', 'Cleric', 'Druid', + 'Fighter', 'Investigator', 'Monk', 'Oracle', 'Ranger', 'Rogue', + 'Sorcerer', 'Swashbuckler', 'Witch', 'Wizard', + ]); + + const NON_EVALUABLE_PATTERNS: readonly RegExp[] = [ + /spellcasting\s+class\s+feature/i, + /\bspellcaster\b/i, + /(divine|arcane|primal|occult)\s+spells?/i, + /\bcantrip\b/i, + /worship(?:per|s|ing)?\s+of/i, + /follower\s+of/i, + /\bdeity\b/i, + /at\s+least\s+\d+\s+years?\s+old/i, + /\bethnicity\b/i, + /low-light\s+vision/i, + /\bdarkvision\b/i, + /\bscent\b/i, + ]; + + function rankAtLeast(have: Proficiency, need: Proficiency): boolean { + return PROFICIENCY_RANK_ORDER.indexOf(have) >= PROFICIENCY_RANK_ORDER.indexOf(need); + } + + type Atom = + | { kind: 'skill'; skill: string; required: Proficiency } + | { kind: 'heritage'; name: string } + | { kind: 'class'; name: string } + | { kind: 'level'; min: number } + | { kind: 'feat'; name: string } + | { kind: 'unknown'; raw: string }; + + type Node = + | { kind: 'atom'; atom: Atom } + | { kind: 'and'; children: Node[] } + | { kind: 'or'; children: Node[] }; + + // parse(): tokenize on ';' then ',' then ' or '; classify each token + // evaluate(): walk tree, short-circuit OR, propagate UNKNOWN + // formatReason(): German strings + + export function evaluatePrereq(prereqString: string | null, ctx: CharacterContext): EvalResult { + if (!prereqString || prereqString.trim() === '') return { ok: true }; + // 1. Quick UNKNOWN check + if (NON_EVALUABLE_PATTERNS.some(rx => rx.test(prereqString))) { + return { unknown: true, raw: prereqString }; + } + // 2. Parse + evaluate + // ... (executor implements; tests are the contract) + } + ``` + + Run `cd server && npm test -- prereq-evaluator.spec.ts` — must PASS (all tests). Commit: `feat(01): implement prereq-evaluator (D-01..D-04)` + + **REFACTOR phase (if needed):** extract regex constants, extract German string formatter, ensure no `any` types. + + + cd server && npm test -- prereq-evaluator.spec.ts + + + - File `server/src/modules/leveling/lib/prereq-evaluator.ts` exists + - File exports `evaluatePrereq` + - File contains NO `@nestjs/` imports + - File contains NO Prisma client import + - File contains NO `: any` outside comments + - File `server/src/modules/leveling/lib/prereq-evaluator.spec.ts` exists with at least 17 `it(` invocations + - `cd server && npm test -- prereq-evaluator.spec.ts` exits 0 — all tests pass + - The spec covers EVERY VALIDATION.md row from 1-W1-12 to 1-W1-21 + - Failure reasons returned by evaluator are in German (verifiable: spec asserts a German-language regex match for at least one failure case) + + + Spec written first; all 17+ tests pass; UNKNOWN-aggressive behavior verified for D-02 patterns; German reason strings on failures. + + + + + Task 4: recompute-derived-stats module (RED → GREEN, Pitfall #9 enforced) + server/src/modules/leveling/lib/recompute-derived-stats.ts, server/src/modules/leveling/lib/recompute-derived-stats.spec.ts + + - server/src/modules/leveling/lib/types.ts (CharacterContext, DerivedStats, ClassProgressionRow, WizardChoices, PROFICIENCY_BASE_BONUS) + - server/src/modules/leveling/lib/apply-attribute-boost.ts (applyAttributeBoost — must use this, not re-implement) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 552-575 — Pattern 5 inputs/outputs; lines 624-633 — Pitfall 2 hpCurrent rule) + - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W1-22 to 1-W1-25) + - server/src/modules/characters/pathbuilder-import.service.ts (lines 42-62 — proficiencyFromValue helper for reference; do not import — re-implement here) + + + See `` block above. Critical Pitfall #9 invariant: output object MUST NOT contain `hpCurrent` or `hpTemp`. Spec has dedicated assertion for this. + + + **RED phase — write spec FIRST:** + + Create `server/src/modules/leveling/lib/recompute-derived-stats.spec.ts`: + + ```typescript + import { recomputeDerivedStats } from './recompute-derived-stats'; + import type { CharacterContext, ClassProgressionRow, WizardChoices } from './types'; + + function baseCharacter(overrides: Partial = {}): CharacterContext & { ancestryHp: number; classHp: number; armorAc: number; armorProficiency: 'UNTRAINED'|'TRAINED'|'EXPERT'|'MASTER'|'LEGENDARY'; dexCap: number } { + return { + level: 4, + className: 'Fighter', + ancestryName: 'Human', + heritageName: undefined, + abilities: { STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 12, CHA: 10 }, + skills: {}, + feats: new Set(), + ancestryHp: 8, + classHp: 10, + armorAc: 4, + armorProficiency: 'EXPERT', + dexCap: 2, + ...overrides, + } as never; + } + + function emptyProgression(level: number): ClassProgressionRow { + return { + className: 'Fighter', + level, + grants: [], + proficiencyChanges: {}, + }; + } + + describe('recomputeDerivedStats — hpMax', () => { + it('computes hpMax = ancestryHP + (classHP + conMod) × newLevel for L4 → L5 Fighter, CON 16', () => { + // CON 16 → mod +3; classHP 10 + 3 = 13 per level; ancestryHP 8; L5: 8 + 13×5 = 73 + const ch = baseCharacter(); + const choices: WizardChoices = {}; + const result = recomputeDerivedStats(ch, choices, emptyProgression(5)); + expect(result.hpMax).toBe(73); + }); + + it('respects boost-cap-at-18 when CON is boosted from 18', () => { + // CON starts at 18, boost target includes CON → new CON = 19 (not 20). conMod = +4. + // L5: 8 + (10+4)×5 = 78 + const ch = baseCharacter({ abilities: { STR: 16, DEX: 14, CON: 18, INT: 10, WIS: 12, CHA: 10 } }); + const choices: WizardChoices = { boostTargets: ['CON', 'STR', 'DEX', 'INT'] }; + const result = recomputeDerivedStats(ch, choices, emptyProgression(5)); + expect(result.hpMax).toBe(78); + }); + }); + + describe('recomputeDerivedStats — proficiencyChanges from ClassProgression', () => { + it('applies proficiencyChanges from ClassProgression at the new level (fortitude EXPERT)', () => { + // Fighter L4: fort proficiency was TRAINED (assume base). Progression bumps to EXPERT. + // CON mod +3, level 5; fort = conMod + (level + 4) = 3 + 9 = 12 + const ch = baseCharacter(); + const progression: ClassProgressionRow = { + ...emptyProgression(5), + proficiencyChanges: { fortitude: 'EXPERT' }, + }; + const result = recomputeDerivedStats(ch, {}, progression); + // fort = 3 + (5 + 4) = 12 (proficiencyBase EXPERT = 4; PF2e prof bonus = base + level when trained+) + expect(result.fortitude).toBe(12); + }); + }); + + describe('recomputeDerivedStats — Pitfall #9 (hpCurrent must not be in output)', () => { + it('does NOT include hpCurrent in the result object', () => { + const ch = baseCharacter(); + const result = recomputeDerivedStats(ch, {}, emptyProgression(5)); + expect(result).not.toHaveProperty('hpCurrent'); + expect(result).not.toHaveProperty('hpTemp'); + }); + }); + + describe('recomputeDerivedStats — level passthrough', () => { + it('returns the new level in the output', () => { + const ch = baseCharacter(); + const result = recomputeDerivedStats(ch, {}, emptyProgression(5)); + expect(result.level).toBe(5); + }); + }); + ``` + + Run — must FAIL. Commit: `test(01): RED — recompute-derived-stats spec` + + **GREEN phase — implement** in `server/src/modules/leveling/lib/recompute-derived-stats.ts`: + + The function signature MUST be: + ```typescript + export function recomputeDerivedStats( + character: CharacterContext & { + ancestryHp: number; + classHp: number; + armorAc: number; + armorProficiency: Proficiency; + dexCap: number; + }, + choices: WizardChoices, + progression: ClassProgressionRow, + ): DerivedStats; + ``` + + Implementation requirements: + 1. Apply `applyAttributeBoost` to each ability listed in `choices.boostTargets`. Compute new abilities map. + 2. `abilityModifier(score) = Math.floor((score - 10) / 2)`. + 3. `proficiencyBonus(rank, level) = rank === 'UNTRAINED' ? 0 : (PROFICIENCY_BASE_BONUS[rank] + level)`. + 4. `hpMax = ancestryHp + (classHp + abilityModifier(newAbilities.CON)) * progression.level`. + 5. `ac = 10 + Math.min(abilityModifier(newAbilities.DEX), character.dexCap) + character.armorAc + proficiencyBonus(character.armorProficiency, progression.level)` — note `armorProficiency` may have been bumped by `progression.proficiencyChanges.ac`; if so, use the new rank. + 6. `classDc = 10 + abilityModifier(newAbilities[keyAbility]) + proficiencyBonus(classDcRank, progression.level)`. For tests above, assume keyAbility is STR and classDcRank is TRAINED (Fighter L1). The spec only asserts hpMax + fortitude + Pitfall #9; remaining fields tested in later integration. + 7. `perception = abilityModifier(newAbilities.WIS) + proficiencyBonus(perceptionRank, progression.level)`. + 8. `fortitude = abilityModifier(newAbilities.CON) + proficiencyBonus(fortRank, progression.level)`. Use `progression.proficiencyChanges.fortitude ?? `. + 9. Same for reflex (DEX) and will (WIS). + 10. Return ONLY the keys defined in DerivedStats. Do NOT spread the input character. Do NOT include hpCurrent. + + Run `cd server && npm test -- recompute-derived-stats.spec.ts` — must PASS. Commit: `feat(01): implement recompute-derived-stats (Pitfall #8/#9 safe)` + + + cd server && npm test -- recompute-derived-stats.spec.ts + + + - File `server/src/modules/leveling/lib/recompute-derived-stats.ts` exists + - File exports `recomputeDerivedStats` + - File contains the literal string `applyAttributeBoost` (proves it imports from the existing module — no math duplication) + - File contains NO `@nestjs/` imports + - File contains NO Prisma client import + - File contains NO `: any` outside comments + - File contains NO `hpCurrent` or `hpTemp` literal strings (defensive: cannot accidentally output them) + - File `server/src/modules/leveling/lib/recompute-derived-stats.spec.ts` exists with at least 5 `it(` invocations + - Spec contains an `expect(result).not.toHaveProperty('hpCurrent')` assertion (Pitfall #9 enforcement) + - `cd server && npm test -- recompute-derived-stats.spec.ts` exits 0 — all tests pass + + + Spec written first; recompute is pure; Pitfall #8 (boost cap) and Pitfall #9 (no hpCurrent mutation) both enforced by tests. + + + + + Task 5: compute-applicable-steps module (RED → GREEN) + server/src/modules/leveling/lib/compute-applicable-steps.ts, server/src/modules/leveling/lib/compute-applicable-steps.spec.ts + + - server/src/modules/leveling/lib/types.ts (StepKind type) + - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W1-26 to 1-W1-29) + - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-10 — step ordering; D-13 — FA toggle, D-18 — spellcaster) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 696-722 — Boost step / spellcaster / FA-archetype analysis) + + + See `` block above. Step ordering is fixed per D-10. Function output is deterministic given inputs. + + + **RED phase — write spec FIRST:** + + Create `server/src/modules/leveling/lib/compute-applicable-steps.spec.ts`: + + ```typescript + import { computeApplicableSteps } from './compute-applicable-steps'; + import type { StepKind } from './types'; + + describe('computeApplicableSteps — Fighter (martial, no FA, no caster)', () => { + it('at L5 returns [class-features, boost, skill-increase, feat-class, feat-skill, feat-ancestry, review]', () => { + const steps = computeApplicableSteps({ + targetLevel: 5, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).toEqual([ + 'class-features', + 'boost', + 'skill-increase', + 'feat-class', + 'feat-skill', + 'feat-ancestry', + 'review', + ]); + }); + + it('at L4 (not a boost level) does NOT contain boost', () => { + const steps = computeApplicableSteps({ + targetLevel: 4, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).not.toContain('boost'); + }); + + it('at L4 (even level) contains feat-class AND feat-skill', () => { + const steps = computeApplicableSteps({ + targetLevel: 4, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).toContain('feat-class'); + expect(steps).toContain('feat-skill'); + }); + + it('at L3 contains skill-increase AND feat-general but NOT feat-class (odd level)', () => { + const steps = computeApplicableSteps({ + targetLevel: 3, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).toContain('skill-increase'); + expect(steps).toContain('feat-general'); + expect(steps).not.toContain('feat-class'); + }); + + it('at L2 (even but no skill-increase yet) contains feat-class+feat-skill but NOT skill-increase', () => { + const steps = computeApplicableSteps({ + targetLevel: 2, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).toContain('feat-class'); + expect(steps).toContain('feat-skill'); + expect(steps).not.toContain('skill-increase'); + }); + }); + + describe('computeApplicableSteps — Free Archetype', () => { + it('with FA enabled at L5 includes feat-archetype', () => { + const steps = computeApplicableSteps({ + targetLevel: 5, + className: 'Fighter', + hasFreeArchetype: true, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).toContain('feat-archetype'); + }); + + it('without FA never includes feat-archetype', () => { + const steps = computeApplicableSteps({ + targetLevel: 5, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).not.toContain('feat-archetype'); + }); + }); + + describe('computeApplicableSteps — Spellcaster', () => { + it('with isCaster=true includes spellcaster step', () => { + const steps = computeApplicableSteps({ + targetLevel: 5, + className: 'Wizard', + hasFreeArchetype: false, + isCaster: true, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).toContain('spellcaster'); + }); + + it('with isCaster=false never includes spellcaster step', () => { + const steps = computeApplicableSteps({ + targetLevel: 5, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).not.toContain('spellcaster'); + }); + }); + + describe('computeApplicableSteps — class-feature-choice (D-19)', () => { + it('includes class-feature-choice when ClassProgression carries choiceType', () => { + const steps = computeApplicableSteps({ + targetLevel: 1, + className: 'Cleric', + hasFreeArchetype: false, + isCaster: true, + isSpontaneousCaster: false, + classProgressionHasChoiceType: true, + }); + expect(steps).toContain('class-feature-choice'); + }); + + it('does NOT include class-feature-choice when no choiceType', () => { + const steps = computeApplicableSteps({ + targetLevel: 5, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps).not.toContain('class-feature-choice'); + }); + }); + + describe('computeApplicableSteps — invariants', () => { + it('always starts with class-features', () => { + for (const level of [1, 2, 3, 5, 10, 15, 20]) { + const steps = computeApplicableSteps({ + targetLevel: level, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps[0]).toBe('class-features'); + } + }); + + it('always ends with review', () => { + for (const level of [1, 2, 3, 5, 10, 15, 20]) { + const steps = computeApplicableSteps({ + targetLevel: level, + className: 'Fighter', + hasFreeArchetype: false, + isCaster: false, + isSpontaneousCaster: false, + classProgressionHasChoiceType: false, + }); + expect(steps[steps.length - 1]).toBe('review'); + } + }); + }); + ``` + + Run — must FAIL. Commit: `test(01): RED — compute-applicable-steps spec` + + **GREEN phase — implement** in `server/src/modules/leveling/lib/compute-applicable-steps.ts`: + + ```typescript + import type { StepKind } from './types'; + + export interface ComputeStepsInput { + targetLevel: number; + className: string; + hasFreeArchetype: boolean; + isCaster: boolean; + isSpontaneousCaster: boolean; + classProgressionHasChoiceType: boolean; + } + + const BOOST_LEVELS: ReadonlySet = new Set([5, 10, 15, 20]); + const SKILL_INCREASE_LEVELS: ReadonlySet = new Set([3, 5, 7, 9, 11, 13, 15, 17, 19]); + const GENERAL_FEAT_LEVELS: ReadonlySet = new Set([3, 7, 11, 15, 19]); + const ANCESTRY_FEAT_LEVELS: ReadonlySet = new Set([1, 5, 9, 13, 17]); + + /** + * Returns the ordered list of wizard step kinds for a level-up. + * Per D-10 ordering, conditional on the inputs. Pure function. + */ + export function computeApplicableSteps(input: ComputeStepsInput): StepKind[] { + const { targetLevel, hasFreeArchetype, isCaster, classProgressionHasChoiceType } = input; + const steps: StepKind[] = ['class-features']; + + if (classProgressionHasChoiceType) steps.push('class-feature-choice'); + if (BOOST_LEVELS.has(targetLevel)) steps.push('boost'); + if (SKILL_INCREASE_LEVELS.has(targetLevel)) steps.push('skill-increase'); + if (targetLevel % 2 === 0) { + steps.push('feat-class'); + steps.push('feat-skill'); + } + if (GENERAL_FEAT_LEVELS.has(targetLevel)) steps.push('feat-general'); + if (ANCESTRY_FEAT_LEVELS.has(targetLevel) && targetLevel !== 1) steps.push('feat-ancestry'); + if (hasFreeArchetype) steps.push('feat-archetype'); + if (isCaster) steps.push('spellcaster'); + + steps.push('review'); + return steps; + } + ``` + + Note on ancestry: PF2e ancestry feats are at levels 1 (start) and 5/9/13/17 (level-ups). Since this function is for level-ups (not character creation), L1 is excluded. + + Run `cd server && npm test -- compute-applicable-steps.spec.ts` — must PASS. Commit: `feat(01): implement compute-applicable-steps` + + + cd server && npm test -- compute-applicable-steps.spec.ts + + + - File `server/src/modules/leveling/lib/compute-applicable-steps.ts` exists + - File exports `computeApplicableSteps` + - File contains NO `@nestjs/` imports + - File contains NO Prisma client import + - File contains NO `: any` outside comments + - File `server/src/modules/leveling/lib/compute-applicable-steps.spec.ts` exists with at least 11 `it(` invocations + - `cd server && npm test -- compute-applicable-steps.spec.ts` exits 0 — all tests pass + - All four VALIDATION.md rows 1-W1-26 through 1-W1-29 are covered + + + Spec written first; all 11+ tests pass; step ordering invariants enforced; function is pure. + + + + + Task 6: Run full leveling test suite — gate before Wave 2 + (no file writes — verification only) + + - server/src/modules/leveling/lib/ (verify all 5 .ts + 5 .spec.ts files present) + + + Run the full leveling test suite to confirm no test-file cross-contamination: + + ```bash + cd server && npm test -- --testPathPattern=leveling + ``` + + Expected: at least 5 spec files run, total tests ≥ 50 (9 boost + 9 skill + 17 prereq + 5 recompute + 11 steps), 0 fail. + + If any test fails: STOP. Fix in the originating task — do NOT mark this plan complete. + + Then run a quick TS check across the lib folder: + + ```bash + cd server && npx tsc --noEmit -p tsconfig.json + ``` + + Expected: exits 0 (no type errors anywhere in the project after these additions). + + + cd server && npm test -- --testPathPattern=leveling + + + - `cd server && npm test -- --testPathPattern=leveling` exits 0 + - Test summary shows at least 5 test suites passing + - Test summary shows at least 50 tests passing + - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 (no type errors) + - All five files exist: apply-attribute-boost.ts, skill-increase-cap.ts, prereq-evaluator.ts, recompute-derived-stats.ts, compute-applicable-steps.ts (and types.ts) + - All five spec files exist: apply-attribute-boost.spec.ts, skill-increase-cap.spec.ts, prereq-evaluator.spec.ts, recompute-derived-stats.spec.ts, compute-applicable-steps.spec.ts + + + Whole leveling lib suite green; TS clean; ready to be consumed by Plan 03 (seed) and Plan 04 (LevelingService). + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| (none — pure functions) | This plan introduces only pure-function modules with no I/O. No trust boundary is crossed at runtime by any code in this plan. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-1-W1-01 | Tampering | prereq-evaluator returns `{ok: true}` for a malformed prereq string when running on the server, allowing a client to bypass intent | mitigate | UNKNOWN-aggressive design (RESEARCH.md line 550): when ANY atom is non-classifiable, the entire prereq returns `{unknown: true, raw}`. The server consumer (Plan 04 feat-filter) treats `{unknown:true}` as "show with warning" — never as "show as if met". Spec covers spellcasting/deity/age/vision/free-text returning unknown. | +| T-1-W1-02 | Tampering | recompute-derived-stats accidentally mutates the input `character` object via shared object references | mitigate | Pure function contract: spec asserts the result object does NOT contain hpCurrent (Pitfall #9). Implementation uses property reads only on input; output is a fresh object literal. Future regression caught by the not.toHaveProperty test. | +| T-1-W1-03 | Information Disclosure | Prereq evaluator includes raw character data in failure reason strings, leaking PII into logs | accept | Failure reasons name skills/feats/heritages by their canonical PF2e name, not character-specific data (no character name, no user ID). Self-hosted single-tenant; no PII risk surface. | + + + +After all tasks complete, run: + +```bash +# Full lib suite +cd server && npm test -- --testPathPattern=leveling + +# Should report ≥5 suites, ≥50 tests passing, 0 failing. + +# TS strict +cd server && npx tsc --noEmit -p tsconfig.json + +# Should exit 0. + +# Verify file presence +ls server/src/modules/leveling/lib/ +# Should list: 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, compute-applicable-steps.spec.ts, +# types.ts + +# Verify no `any` types crept in +grep -rE ": any\b" server/src/modules/leveling/lib/ --include="*.ts" | grep -v "spec.ts" | grep -v "//.*any" +# Should produce no output +``` + +If any check fails, the plan is NOT done. + + + +- 5 production modules + types.ts created in server/src/modules/leveling/lib/ +- 5 spec files created alongside; all tests passing +- TDD discipline observed: each module had spec written first (RED commit), then implementation (GREEN commit) +- Pitfall #8 (boost-cap-at-18) covered by apply-attribute-boost (Plan 01) AND recompute-derived-stats specs +- Pitfall #9 (no hpCurrent mutation) enforced by recompute-derived-stats spec via `not.toHaveProperty` +- Prereq evaluator handles all D-01 evaluable patterns and returns `{unknown:true}` for D-02 non-evaluable patterns +- Step ordering matches D-10 +- Zero `: any` types in production code +- Zero NestJS or Prisma imports in any lib file +- `cd server && npm test -- --testPathPattern=leveling` exits 0 with ≥50 tests +- `cd server && npx tsc --noEmit` exits 0 + + + +After completion, create `.planning/phases/01-level-up-pf2e-regelkonform/01-02-SUMMARY.md` documenting: +- Total test count (per module + suite total) +- Any deviations from the planned regex patterns / German strings / function shapes +- Notes on prereq-evaluator parsing edge cases discovered during TDD (for future grammar tuning) +- Confirmation that types.ts exports are imported by all four downstream modules (no duplication) +- Test run output (pass/fail summary) for the audit trail + diff --git a/.planning/phases/01-level-up-pf2e-regelkonform/01-03-PLAN.md b/.planning/phases/01-level-up-pf2e-regelkonform/01-03-PLAN.md new file mode 100644 index 0000000..06fa760 --- /dev/null +++ b/.planning/phases/01-level-up-pf2e-regelkonform/01-03-PLAN.md @@ -0,0 +1,1032 @@ +--- +phase: 01-level-up-pf2e-regelkonform +plan: 03 +type: execute +wave: 2 +depends_on: ["01-01", "01-02"] +files_modified: + - server/prisma/seed-class-progression.ts + - server/prisma/data/spell-slot-overlays.ts + - server/prisma/data/class-feature-options.ts + - server/prisma/data/foundry-pf2e/.keep + - .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md +autonomous: true +requirements: [LVL-08, LVL-14, LVL-19] +tags: [seeding, prisma, foundry-pf2e, class-progression, spellcaster, level-up] +must_haves: + truths: + - "After running `npm run db:seed:class-progression`, the ClassProgression table contains at least 320 rows (16 classes × 20 levels)." + - "After seed, every caster class (Wizard, Sorcerer, Bard, Cleric, Druid, Oracle, Witch) has spellSlotIncrement and/or cantripIncrement entries at appropriate levels." + - "After seed, classes with L1 doctrine/school/etc. choices (Cleric L1, Wizard L1, Champion L1) have rows with `choiceType` and `choiceOptionsRef` populated, and the matching ClassFeatureOption rows exist." + - "Seed script is idempotent — running it twice does not duplicate rows." + - "Seed script reads from Foundry pf2e clone at `server/prisma/data/foundry-pf2e/` (gitignored, dev-time clone) and merges in the hand-curated `spell-slot-overlays.ts` constant." + - "If the Foundry clone is missing, the script fails loudly with a documented error message pointing the dev to the seed README." + artifacts: + - path: "server/prisma/seed-class-progression.ts" + provides: "Idempotent seed transforming Foundry pf2e class JSONs + spell-slot overlay → ClassProgression + ClassFeatureOption rows" + exports: ["main (executed when run via tsx)"] + - path: "server/prisma/data/spell-slot-overlays.ts" + provides: "Hand-curated spell-slot/cantrip/repertoire progression for the 16 D-16 classes that cast" + contains: "SPELL_SLOT_OVERLAY" + - path: "server/prisma/data/class-feature-options.ts" + provides: "Hand-curated ClassFeatureOption seed data — Cleric Doctrines, Wizard Schools, Champion Causes, etc." + contains: "CLASS_FEATURE_OPTIONS" + - path: "server/prisma/data/foundry-pf2e/.keep" + provides: "Anchor file so the gitignored data dir exists in fresh clones for the README to reference" + - path: ".planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md" + provides: "Developer instructions: how to clone Foundry pf2e at the pinned tag and run the seed" + contains: "git clone" + key_links: + - from: "seed-class-progression.ts" + to: "ClassProgression table" + via: "prisma.classProgression.create / update" + pattern: "prisma\\.classProgression\\." + - from: "seed-class-progression.ts" + to: "ClassFeatureOption table" + via: "prisma.classFeatureOption.create / update" + pattern: "prisma\\.classFeatureOption\\." + - from: "seed-class-progression.ts" + to: "spell-slot-overlays.ts" + via: "import SPELL_SLOT_OVERLAY" + pattern: "from ['\"].*spell-slot-overlays['\"]" + - from: "seed-class-progression.ts" + to: "Foundry pf2e clone" + via: "fs.readFileSync of packs/classes/*.json" + pattern: "foundry-pf2e.*packs/classes" +--- + + +Build the Foundry-pf2e-driven seed pipeline that populates the `ClassProgression` and `ClassFeatureOption` tables for the 16 in-scope Core+APG classes (D-16) across levels 1..20. The seed transforms Foundry pf2e class JSON files (manually cloned by the dev to `server/prisma/data/foundry-pf2e/`) into our schema and merges in hand-curated overlays for spell-slot progressions (which Foundry encodes as prose, not machine-readable rules — Pitfall #6) and class-feature options (Cleric Doctrines, Wizard Schools, etc.). + +Purpose: Plan 04's atomic commit transaction needs ClassProgression rows to know what each (className, level) grants and what proficiency/spell-slot/repertoire changes apply. Without this seed, the LevelingService cannot recompute correctly. The seed is **idempotent** so devs and CI can re-run safely. + +Output: Seed script + two hand-curated data modules + .keep anchor + dev README with cloning instructions. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/REQUIREMENTS.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-01-SUMMARY.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-02-SUMMARY.md +@server/prisma/seed-equipment.ts +@server/prisma/seed.ts + + + + +```jsonc +{ + "_id": "...", + "name": "Fighter", + "type": "class", + "system": { + "hp": 10, + "perception": 2, + "keyAbility": ["dex", "str"], + "spellcasting": 0, + "attacks": { "simple": 2, "martial": 2, "advanced": 1, "unarmed": 2 }, + "defenses": { "light": 1, "medium": 1, "heavy": 1, "unarmored": 1 }, + "savingThrows": { "fortitude": 2, "reflex": 2, "will": 1 }, + "trainedSkills": { "value": [], "additional": 3 }, + "classFeatLevels": { "value": [1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20] }, + "ancestryFeatLevels":{ "value": [1, 5, 9, 13, 17] }, + "skillFeatLevels": { "value": [2, 4, 6, 8, 10, 12, 14, 16, 18, 20] }, + "generalFeatLevels": { "value": [3, 7, 11, 15, 19] }, + "items": { + "u8k07": { "img": "...", "level": 5, "name": "Fighter Weapon Mastery", "uuid": "Compendium.pf2e.classfeatures.Item.Fighter Weapon Mastery" } + // … + } + } +} +``` + + +```typescript +import 'dotenv/config'; +import * as fs from 'fs'; +import * as path from 'path'; +import { PrismaClient } from '../src/generated/prisma/client.js'; +import { PrismaPg } from '@prisma/adapter-pg'; + +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); +const prisma = new PrismaClient({ adapter }); +``` + + +```typescript +for (const item of data) { + const existing = await prisma.x.findUnique({ where: { unique_combo } }); + if (existing) { + await prisma.x.update({ where: { id: existing.id }, data: { ... } }); + updated++; + } else { + await prisma.x.create({ data: { ... } }); + created++; + } +} +``` + + +```typescript +export const SPELL_SLOT_OVERLAY: Record> = { Wizard: [...], Sorcerer: [...], ... }; +``` + + + + + + + + + + Task 1: Create the dev README + .keep anchor for the Foundry pf2e clone path + + server/prisma/data/foundry-pf2e/.keep, + .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md + + + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 648-654 — Pitfall 5: Foundry data shape; lines 285-289 — Project Structure) + - .gitignore (must already contain `server/prisma/data/foundry-pf2e/` from Plan 01 Task 3 Step 5) + + + **Step 1 — Create `server/prisma/data/foundry-pf2e/.keep`** as an empty file (so the directory itself can be tracked even though its contents are gitignored — this lets the README reference an existing path). + + Note: `.gitignore` already excludes `server/prisma/data/foundry-pf2e/` per Plan 01. To track the `.keep` file, append an exception in `.gitignore` BEFORE this task — actually, the cleaner approach: do NOT track the directory at all. Instead, the seed script's missing-clone error message tells the dev to create it. Skip the .keep file entirely. + + REVISED Step 1: Skip the .keep file. The directory will be created by the dev when they run `git clone`. This avoids the .gitignore exception entirely. + + **Step 2 — Create `.planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md`** with the following content: + + ```markdown + # Phase 1 — ClassProgression Seed README + + The seed script `server/prisma/seed-class-progression.ts` populates the `ClassProgression` + and `ClassFeatureOption` tables from the Foundry pf2e system repository (license: Apache 2.0 + + OGL 1.0a + Paizo Community Use Policy — verified 2026-04-27). + + ## One-time dev setup + + Clone the pinned Foundry pf2e tag into the gitignored data folder: + + ```bash + cd server/prisma/data + git clone --depth 1 --branch https://github.com/foundryvtt/pf2e.git foundry-pf2e + ``` + + **Pinned tag:** `` (record the chosen tag here when this task runs; + the executor selects a stable release branch from `https://github.com/foundryvtt/pf2e/tags` + that includes Core Rulebook + APG content for all 16 D-16 classes). + + ## Run the seed + + ```bash + cd server + npm run db:seed:class-progression + ``` + + Idempotent: running twice does not duplicate rows. Console output reports `created` and + `updated` counts plus any errors. + + ## Failure modes + + - **"Foundry pf2e clone not found at server/prisma/data/foundry-pf2e/packs/classes/"** — + run the `git clone` step above. + - **"Class JSON does not match expected schema"** — Foundry pf2e changed the JSON shape + between major versions. Update the seed parser or pin to an older tag. + + ## What gets seeded + + - **ClassProgression** rows: 16 classes × 20 levels = 320 rows, with `grants[]`, + `proficiencyChanges`, `spellSlotIncrement`, `cantripIncrement`, `repertoireIncrement`, + `choiceType`, `choiceOptionsRef`. + - **ClassFeatureOption** rows: hand-curated Cleric Doctrines, Wizard Schools, Champion + Causes, Sorcerer Bloodlines (where L1-set), Druid Orders, etc. + - Spell-slot/cantrip/repertoire progressions come from the hand-curated overlay + `server/prisma/data/spell-slot-overlays.ts` because Foundry encodes them in prose + (Pitfall #6). + ``` + + Note: The `` placeholder is filled in during execution when the dev/executor selects a tag. + + **Step 3 — Verify:** `ls server/prisma/data/` should not yet contain `foundry-pf2e/` (the dev creates it later via the README instructions). The README itself lives under `.planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md`. + + + test -f .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md && grep -c "git clone" .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md + + + - File `.planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md` exists + - File contains the literal string `git clone` + - File contains the literal string `npm run db:seed:class-progression` + - File contains the literal string `Apache 2.0` (license declaration) + - File contains a "Pinned tag" reference (placeholder OK if dev hasn't picked one yet) + + + Dev README documents the clone step and the run step. No tracked files inside the gitignored data dir. + + + + + Task 2: Hand-curated spell-slot overlay (16 classes, all levels) + server/prisma/data/spell-slot-overlays.ts + + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 656-661 — Pitfall #6 + lines 897-924 — Code Examples #6 shape) + - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-16 — class scope; D-17 — Foundry as source; D-18 — spontaneous casters get repertoire) + - server/src/modules/leveling/lib/types.ts (Proficiency, AbilityAbbreviation type vocabulary; SpellTradition can be defined here in the overlay file) + - Archives of Nethys spell tables (cited as authoritative source for hand-curation): https://2e.aonprd.com — for each caster class, the canonical PF2e Player Core / APG spell-slot table + + + Create `server/prisma/data/spell-slot-overlays.ts` containing hand-curated spell-slot/cantrip/repertoire data for all 16 D-16 classes. Casters get rows; non-casters (Fighter, Barbarian, Rogue, Monk, Swashbuckler, Investigator, Ranger, Alchemist) get an empty array. + + **File header:** + + ```typescript + /** + * Hand-curated spell-slot / cantrip / repertoire progression overlay. + * + * WHY HAND-CURATED: Foundry pf2e encodes slot tables in description prose, not machine- + * readable rules (Pitfall #6 / verified 2026-04-27 against `wizard-spellcasting.json`). + * NLP-parsing prose is fragile; the canonical PF2e tables fit in ~300 lines and are stable + * across reprints. + * + * SOURCE: Archives of Nethys — Pathfinder 2e Player Core + Advanced Player's Guide + * (https://2e.aonprd.com). Per-class spell-slot tables, cantrip counts, and repertoire + * sizes (spontaneous casters only). + * + * SCOPE: 16 D-16 classes (Core + APG). Casters: Bard, Cleric, Druid, Oracle, Sorcerer, + * Witch, Wizard, Champion (focus only — minimal). Non-casters: empty array. + * + * SPONTANEOUS vs PREPARED: Spontaneous casters (Bard, Sorcerer, Oracle) get + * repertoireIncrement entries on level-up. Prepared casters (Cleric, Druid, Witch, Wizard) + * get spellSlotIncrement only. Both get cantripIncrement at L1. + */ + + export type SpellTradition = 'ARCANE' | 'DIVINE' | 'OCCULT' | 'PRIMAL'; + + export interface SpellSlotOverlayEntry { + level: number; + spellSlotIncrement?: { tradition: SpellTradition; spellLevel: number; count: number }; + cantripIncrement?: number; + repertoireIncrement?: number; + } + + /** + * Each class maps to an array of overlay entries. Multiple entries per level are allowed + * (e.g. L1 Wizard gets 5 cantrips AND 2 grade-1 slots — two separate entries). + * Order within a level does not matter — the seed script merges them per (class, level). + */ + export const SPELL_SLOT_OVERLAY: Record = { + // === PREPARED CASTERS === + + Wizard: [ + { level: 1, cantripIncrement: 5 }, + { level: 1, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 2 } }, + { level: 2, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 1 } }, + { level: 3, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 2, count: 2 } }, + { level: 4, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 2, count: 1 } }, + { level: 5, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 3, count: 2 } }, + { level: 6, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 3, count: 1 } }, + { level: 7, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 4, count: 2 } }, + { level: 8, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 4, count: 1 } }, + { level: 9, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 5, count: 2 } }, + { level: 10, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 5, count: 1 } }, + { level: 11, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 6, count: 2 } }, + { level: 12, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 6, count: 1 } }, + { level: 13, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 7, count: 2 } }, + { level: 14, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 7, count: 1 } }, + { level: 15, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 8, count: 2 } }, + { level: 16, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 8, count: 1 } }, + { level: 17, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 9, count: 2 } }, + { level: 18, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 9, count: 1 } }, + // L19 Magnum Opus = 1 grade-10 slot per day + { level: 19, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 10, count: 1 } }, + // L20: no slot increment (capstone is qualitative) + ], + + Cleric: [ + { level: 1, cantripIncrement: 5 }, + { level: 1, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 1, count: 2 } }, + // … L2..L20 mirroring Wizard cadence with DIVINE tradition (executor curates from + // Archives of Nethys Cleric class entry). + ], + + Druid: [ + { level: 1, cantripIncrement: 5 }, + { level: 1, spellSlotIncrement: { tradition: 'PRIMAL', spellLevel: 1, count: 2 } }, + // … executor curates from AoN Druid entry. + ], + + Witch: [ + { level: 1, cantripIncrement: 5 }, + // Witch tradition depends on patron — DEFAULT to ARCANE for the overlay; recompute + // may need the actual chosen patron for accurate filtering. Document in the + // seed README's "Caveats" section. + { level: 1, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 2 } }, + // … executor curates from AoN Witch entry. + ], + + // === SPONTANEOUS CASTERS (D-18 — get repertoireIncrement) === + + Bard: [ + { level: 1, cantripIncrement: 5 }, + { level: 1, spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 1, count: 2 } }, + // Repertoire size at L1 = 4 (Player Core); growth = +1 per even level for spell-level + // slots gained. Executor curates exact deltas. + { level: 2, repertoireIncrement: 1 }, + // … executor curates from AoN Bard entry, full L1..L20. + ], + + Sorcerer: [ + { level: 1, cantripIncrement: 5 }, + // Tradition depends on bloodline — DEFAULT to ARCANE; document caveat. + { level: 1, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 3 } }, + { level: 2, repertoireIncrement: 1 }, + // … executor curates from AoN Sorcerer entry, full L1..L20. + ], + + Oracle: [ + { level: 1, cantripIncrement: 5 }, + { level: 1, spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 1, count: 3 } }, + { level: 2, repertoireIncrement: 1 }, + // … executor curates from AoN Oracle entry, full L1..L20. + ], + + // === HALF-CASTERS / FOCUS-ONLY (minimal entries) === + + Champion: [ + // Champion gets focus spells (Devotion Spells) — for Phase 1 we record cantrip-like + // L1 entry only; focus-spell mechanics handled outside the slot table. + { level: 1, cantripIncrement: 0 }, + ], + + // === NON-CASTERS — explicitly empty so the seed knows "no overlay needed" === + + Alchemist: [], + Barbarian: [], + Fighter: [], + Investigator: [], + Monk: [], + Ranger: [], + Rogue: [], + Swashbuckler: [], + }; + ``` + + **Executor's curation responsibility:** + + For Wizard, the table above is fully populated as a worked example. For the other 6 casters (Cleric, Druid, Witch, Bard, Sorcerer, Oracle), the executor must curate the full L1..L20 entries from Archives of Nethys before this task is `done`. Use the Wizard cadence as the template; tradition and counts vary per class. + + For non-casters (Alchemist, Barbarian, Fighter, Investigator, Monk, Ranger, Rogue, Swashbuckler) the empty array is correct. + + Champion has minimal entries (focus-spell mechanics are outside the slot table). + + **Constraint:** No `: any` types. Every entry strictly typed against `SpellSlotOverlayEntry`. + + **Constraint:** This file is hand-edited human knowledge — no programmatic generation. It is small (≤500 lines) and stable across PF2e printings. + + + cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "spell-slot-overlays" || echo "tsc clean" + + + - File `server/prisma/data/spell-slot-overlays.ts` exists + - File exports `SPELL_SLOT_OVERLAY` + - File exports `SpellTradition` type and `SpellSlotOverlayEntry` interface + - All 16 D-16 class names appear as keys in the SPELL_SLOT_OVERLAY object (Alchemist, Barbarian, Bard, Champion, Cleric, Druid, Fighter, Investigator, Monk, Oracle, Ranger, Rogue, Sorcerer, Swashbuckler, Witch, Wizard) + - Wizard array has at least 19 entries spanning L1..L19 (verifiable: count entries with `level:` keys) + - Bard, Sorcerer, Oracle arrays each contain at least one `repertoireIncrement` entry (D-18) + - File contains NO `: any` outside comments + - `cd server && npx tsc --noEmit` exits 0 (no type errors involving this file) + + + Spell-slot overlay populated for all 7 caster classes (Wizard fully, others curated by executor) and explicitly empty for non-casters. Type-safe. Imported by Task 4. + + + + + Task 3: Hand-curated class-feature-options data (D-19 choices) + server/prisma/data/class-feature-options.ts + + - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-19 — Wahl-Klassenmerkmale; D-15 — choiceOptionsRef; D-16 — class scope) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 802-813 — ClassFeatureOption schema with grants + proficiencyChanges; lines 1064-1067 — Open Question Q2 recommendation) + - server/prisma/schema.prisma (post-Plan-01 — ClassFeatureOption model) + - Archives of Nethys class entries for: Cleric (Doctrines), Wizard (Schools), Champion (Causes), Druid (Orders), Sorcerer (Bloodlines, abbreviated), Bard (Muses), Barbarian (Instincts), Monk (no L1 choice but L8 Mountain Stance etc. — defer to v2) + + + Create `server/prisma/data/class-feature-options.ts` containing hand-curated ClassFeatureOption seed data for the L1 (and any other) choice points across the 16 D-16 classes. + + **Scope for Phase 1 (executor curates from AoN):** + - **Cleric Doctrines** (L1) — Cloistered Cleric, Warpriest (and any others available in Player Core) + - **Wizard Schools** (L1) — Arcane School: Battle Magic, Civic Wizardry, Mentalism, Protean Form, Unified Magical Theory, Universalist (Player Core arcane schools) + - **Champion Causes** (L1) — Liberator (Good), Paladin (Good), Redeemer (Good), Antipaladin (Evil), Tyrant (Evil), Desecrator (Evil) — and any neutral or remaster-equivalent + - **Druid Orders** (L1) — Animal, Leaf, Storm, Wild, plus any in APG + - **Sorcerer Bloodlines** (L1) — Aberrant, Angelic, Demonic, Diabolic, Draconic, Elemental, Fey, Genie, Hag, Imperial, Nymph, Phoenix, Psychopomp, Shadow, Undead, plus APG additions + - **Bard Muses** (L1) — Enigma, Maestro, Polymath, plus APG (Warrior etc.) + - **Barbarian Instincts** (L1) — Animal, Dragon, Fury, Giant, Spirit, plus APG (Superstition etc.) + - **Witch Patrons** (L1) — Faith, Fervor, Mosquito Witch, Resentment, Silence, Spinner of Threads, Starless Shadow, Wilding, plus Curse-of-the-Hag-Eye etc. + - **Oracle Mysteries** (L1) — Battle, Bones, Cosmos, Flames, Life, Lore, Tempest, plus APG + - **Investigator Methodologies** (APG) — Empiricism, Forensic Medicine, Interrogation, Sensate, Stratagem (or whatever exists) + - **Monk Stances** etc. — DEFER to v2 (Monk's L1 choice is minimal; complex stances are L2+ feats handled via the Klassentalent step) + - **Ranger Edges** (APG) — Flurry, Outwit, Precision + - **Rogue Rackets** — Eldritch Trickster, Mastermind, Ruffian, Scoundrel, Thief + - **Swashbuckler Styles** — Battledancer, Braggart, Fencer, Gymnast, Wit + - **Alchemist Research Fields** — Bomber, Chirurgeon, Mutagenist, Toxicologist + - **Fighter** — no L1 choice (handled at L5 Weapon Mastery via the L5 ClassProgression `choiceType`) + + **File structure:** + + ```typescript + /** + * Hand-curated ClassFeatureOption seed data for D-19 wizard sub-steps. + * + * Each entry's `optionsRef` matches a `ClassProgression.choiceOptionsRef` so the wizard + * can resolve choice → option list at runtime. + * + * `grants` and `proficiencyChanges` are option-level mechanical effects applied at commit + * (per RESEARCH.md §Open Question Q2 — symmetric with ClassProgression so the recompute + * pipeline is uniform). + * + * SOURCE: Archives of Nethys — PF2e Player Core + APG class entries. + * SCOPE: 16 D-16 classes' L1 (and other) choice points. + */ + + import type { Proficiency } from '../../src/modules/leveling/lib/types'; + + export interface ClassFeatureOptionEntry { + optionsRef: string; // matches ClassProgression.choiceOptionsRef + optionKey: string; // unique within optionsRef + name: string; // English (German via TranslationsService at runtime) + nameGerman?: string; // optional pre-translated German name (Phase 1 may leave this undefined and let TranslationsService handle on-demand) + description: string; // English + grants: string[]; // class-feature names this option awards (e.g. ["Cloistered Cleric", "Domain Spell"]) + proficiencyChanges?: Partial>; + } + + export const CLASS_FEATURE_OPTIONS: ClassFeatureOptionEntry[] = [ + // === CLERIC DOCTRINE (optionsRef: 'cleric-doctrine') === + { + optionsRef: 'cleric-doctrine', + optionKey: 'cloistered-cleric', + name: 'Cloistered Cleric', + description: 'You have devoted your life to the study of religion and the casting of divine spells…', + grants: ['Cloistered Cleric Doctrine'], + // Doctrine bumps spell DC trained → expert at varying levels; encoded via ClassProgression at L1 + }, + { + optionsRef: 'cleric-doctrine', + optionKey: 'warpriest', + name: 'Warpriest', + description: 'You are a martial defender of your faith…', + grants: ['Warpriest Doctrine'], + // Warpriest at L1 grants martial weapon proficiency = trained + }, + + // === WIZARD SCHOOL (optionsRef: 'wizard-school') === + { + optionsRef: 'wizard-school', + optionKey: 'battle-magic', + name: 'School of Battle Magic', + description: 'You focus on offensive evocations and battlefield control…', + grants: ['Battle Magic Curriculum'], + }, + // … executor curates remaining 5 schools + + // === CHAMPION CAUSE === + // … executor curates 6 causes + + // === DRUID ORDER === + // … executor curates ~4 orders + + // === SORCERER BLOODLINE === + // … executor curates ~15 bloodlines + + // === BARD MUSE === + // … executor curates 3-4 muses + + // === BARBARIAN INSTINCT === + // … executor curates 5+ instincts + + // === WITCH PATRON === + // … executor curates patrons + + // === ORACLE MYSTERY === + // … executor curates mysteries + + // === INVESTIGATOR METHODOLOGY === + // … executor curates methodologies + + // === RANGER EDGE === + // … executor curates edges + + // === ROGUE RACKET === + // … executor curates 5 rackets + + // === SWASHBUCKLER STYLE === + // … executor curates 5 styles + + // === ALCHEMIST RESEARCH FIELD === + // … executor curates 4 research fields (already in DB as ResearchField enum — cross-reference) + ]; + ``` + + **Executor's curation responsibility:** + + The Cleric Doctrine and Wizard School blocks are seeded above as worked examples. The executor must populate the remaining classes by reading each class's L1 entry on Archives of Nethys and producing a ClassFeatureOptionEntry object per option. + + **Cross-references the executor must maintain:** + - `optionsRef` strings must match the `choiceOptionsRef` values used in `seed-class-progression.ts` (Task 4) when it sets `choiceType` and `choiceOptionsRef` on the L1 ClassProgression rows. + - `optionKey` values are stable identifiers — once chosen, do not rename (would break commits). + - `nameGerman` may be left undefined in this seed — the TranslationsService picks up English `name` and translates on demand (D-15). + + **Constraint:** No `any` types. Type-checked. + + **Constraint:** Alchemist research fields cross-reference the existing `ResearchField` enum in `server/prisma/schema.prisma` (BOMBER, CHIRURGEON, etc.) — the `optionKey` values for Alchemist must match the enum values lowercase-kebab so the existing `CharacterAlchemyState.researchField` column can be set from the option pick. + + + cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "class-feature-options" || echo "tsc clean" + + + - File `server/prisma/data/class-feature-options.ts` exists + - File exports `CLASS_FEATURE_OPTIONS` array + - File exports `ClassFeatureOptionEntry` interface + - Array contains at least 50 entries (cumulative across all classes — Cleric 2-4, Wizard 6, Champion 6, Druid 4, Sorcerer ≥10, Bard 3, Barbarian 5, Witch ≥5, Oracle ≥7, Investigator 3-5, Ranger 3, Rogue 5, Swashbuckler 5, Alchemist 4) + - Every entry has a non-empty `optionsRef`, `optionKey`, `name`, `description`, `grants` field + - At least 5 distinct `optionsRef` values appear (verifying all class choice points are represented) + - File contains NO `: any` outside comments + - `cd server && npx tsc --noEmit` exits 0 + + + Class-feature options seeded for all D-16 classes that have L1 choice points. optionsRef strings established as the contract between this file and Task 4's seed script. + + + + + Task 4: Seed script — Foundry pf2e + overlays → ClassProgression + ClassFeatureOption + server/prisma/seed-class-progression.ts + + - server/prisma/seed-equipment.ts (canonical analog — imports, idempotent pattern, console output style) + - server/prisma/data/spell-slot-overlays.ts (Task 2 output) + - server/prisma/data/class-feature-options.ts (Task 3 output) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 686-725 — Foundry shape + mapping; lines 538-575 — seed pattern from PATTERNS.md analog) + - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 537-587 — exact seed pattern) + - server/package.json (db:seed:class-progression script wired in Plan 01 Task 3) + - server/prisma/schema.prisma (ClassProgression and ClassFeatureOption models) + + + Create `server/prisma/seed-class-progression.ts` with the following structure (executor implements bodies): + + ```typescript + /** + * Seed: ClassProgression + ClassFeatureOption from Foundry pf2e + hand-curated overlays. + * + * Sources: + * - Foundry pf2e class JSONs at `server/prisma/data/foundry-pf2e/packs/classes/*.json` + * (cloned manually by dev; see SEED-README.md). + * - SPELL_SLOT_OVERLAY from `data/spell-slot-overlays.ts` (Pitfall #6 mitigation). + * - CLASS_FEATURE_OPTIONS from `data/class-feature-options.ts` (D-19 choices). + * + * Idempotent — safe to re-run. + */ + + import 'dotenv/config'; + import * as fs from 'fs'; + import * as path from 'path'; + import { PrismaClient } from '../src/generated/prisma/client.js'; + import { PrismaPg } from '@prisma/adapter-pg'; + import { SPELL_SLOT_OVERLAY, type SpellSlotOverlayEntry } from './data/spell-slot-overlays'; + import { CLASS_FEATURE_OPTIONS } from './data/class-feature-options'; + + const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); + const prisma = new PrismaClient({ adapter }); + + const FOUNDRY_CLASSES_DIR = path.join(__dirname, 'data', 'foundry-pf2e', 'packs', 'classes'); + + // The 16 D-16 classes. Filename in Foundry = lowercase classname. + const D16_CLASS_NAMES = [ + 'Alchemist', 'Barbarian', 'Bard', 'Champion', 'Cleric', 'Druid', + 'Fighter', 'Investigator', 'Monk', 'Oracle', 'Ranger', 'Rogue', + 'Sorcerer', 'Swashbuckler', 'Witch', 'Wizard', + ] as const; + + // Map of (className → choiceType, choiceOptionsRef) for the L1 doctrine/school/etc. step. + // Executor populates from the optionsRef values defined in class-feature-options.ts. + const L1_CHOICE_MAP: Record = { + Cleric: { choiceType: 'doctrine', choiceOptionsRef: 'cleric-doctrine' }, + Wizard: { choiceType: 'school', choiceOptionsRef: 'wizard-school' }, + Champion: { choiceType: 'cause', choiceOptionsRef: 'champion-cause' }, + Druid: { choiceType: 'order', choiceOptionsRef: 'druid-order' }, + Sorcerer: { choiceType: 'bloodline', choiceOptionsRef: 'sorcerer-bloodline' }, + Bard: { choiceType: 'muse', choiceOptionsRef: 'bard-muse' }, + Barbarian: { choiceType: 'instinct', choiceOptionsRef: 'barbarian-instinct' }, + Witch: { choiceType: 'patron', choiceOptionsRef: 'witch-patron' }, + Oracle: { choiceType: 'mystery', choiceOptionsRef: 'oracle-mystery' }, + Investigator: { choiceType: 'methodology', choiceOptionsRef: 'investigator-methodology' }, + Ranger: { choiceType: 'edge', choiceOptionsRef: 'ranger-edge' }, + Rogue: { choiceType: 'racket', choiceOptionsRef: 'rogue-racket' }, + Swashbuckler: { choiceType: 'style', choiceOptionsRef: 'swashbuckler-style' }, + Alchemist: { choiceType: 'research-field', choiceOptionsRef: 'alchemist-research-field' }, + Fighter: null, // Fighter has no L1 choice; weapon mastery is at L5 + Monk: null, // Monk's L1 stance is via class feats, not a choiceType + }; + + // Optional: Fighter L5 weapon-mastery group choice + const HIGHER_LEVEL_CHOICES: Array<{ className: string; level: number; choiceType: string; choiceOptionsRef: string }> = [ + { className: 'Fighter', level: 5, choiceType: 'weapon-mastery-group', choiceOptionsRef: 'fighter-weapon-mastery' }, + ]; + + interface FoundryClassJson { + name: string; + type: 'class'; + system: { + hp: number; + keyAbility: string[]; + spellcasting: number; + savingThrows: { fortitude: number; reflex: number; will: number }; + attacks: Record; + defenses: Record; + classFeatLevels?: { value: number[] }; + items: Record; + }; + } + + function readFoundryClass(className: string): FoundryClassJson { + // Foundry filename convention: lowercase, e.g. 'fighter.json' + const filename = `${className.toLowerCase()}.json`; + const fullPath = path.join(FOUNDRY_CLASSES_DIR, filename); + if (!fs.existsSync(fullPath)) { + throw new Error( + `Foundry pf2e clone not found at ${fullPath}. ` + + `See .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md for setup.`, + ); + } + const raw = fs.readFileSync(fullPath, 'utf-8'); + const json = JSON.parse(raw) as FoundryClassJson; + if (json.type !== 'class' || !json.name || !json.system) { + throw new Error(`Class JSON does not match expected schema: ${fullPath}`); + } + return json; + } + + /** Map Foundry numeric proficiency rank (0..8) to our Proficiency string enum. */ + function profFromValue(v: number | undefined): 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY' { + if (v === undefined || v === null) return 'UNTRAINED'; + if (v >= 8) return 'LEGENDARY'; + if (v >= 6) return 'MASTER'; + if (v >= 4) return 'EXPERT'; + if (v >= 2) return 'TRAINED'; + return 'UNTRAINED'; + } + + /** + * Aggregate all Foundry items at this level and the spell-slot overlay entries at this level + * into a single ClassProgression upsert payload. + */ + function buildProgressionRow(className: string, level: number, foundry: FoundryClassJson) { + // 1. Grants from Foundry items + const grants: string[] = Object.values(foundry.system.items) + .filter(item => item.level === level) + .map(item => item.name); + + // 2. Proficiency changes — for L1 only, take the class's base proficiency from the JSON. + // For L2+, executor adds hand-curated proficiency bumps as needed (e.g. Fighter L5 + // martial → master). The Foundry data does NOT carry per-level proficiency bump + // info on the class JSON; that lives in classfeatures compendium files (Pitfall #6). + // For Phase 1 minimum: seed only L1 base proficiencies; populate higher-level bumps + // via a hand-curated overlay if time permits, otherwise leave proficiencyChanges + // empty and rely on the recompute pipeline's class-feature-driven lookups in v2. + let proficiencyChanges: Record = {}; + if (level === 1) { + proficiencyChanges = { + fortitude: profFromValue(foundry.system.savingThrows.fortitude), + reflex: profFromValue(foundry.system.savingThrows.reflex), + will: profFromValue(foundry.system.savingThrows.will), + }; + } + + // 3. Spell-slot overlay merge + const overlayEntries: SpellSlotOverlayEntry[] = (SPELL_SLOT_OVERLAY[className] || []) + .filter(e => e.level === level); + const slotEntry = overlayEntries.find(e => e.spellSlotIncrement); + const cantripEntry = overlayEntries.find(e => e.cantripIncrement !== undefined); + const repertoireEntry = overlayEntries.find(e => e.repertoireIncrement !== undefined); + + // 4. Choice type for this (class, level) + let choiceType: string | null = null; + let choiceOptionsRef: string | null = null; + if (level === 1 && L1_CHOICE_MAP[className]) { + const choice = L1_CHOICE_MAP[className]!; + choiceType = choice.choiceType; + choiceOptionsRef = choice.choiceOptionsRef; + } + const higherChoice = HIGHER_LEVEL_CHOICES.find(c => c.className === className && c.level === level); + if (higherChoice) { + choiceType = higherChoice.choiceType; + choiceOptionsRef = higherChoice.choiceOptionsRef; + } + + return { + className, + level, + grants, + proficiencyChanges, + spellSlotIncrement: slotEntry?.spellSlotIncrement ?? null, + cantripIncrement: cantripEntry?.cantripIncrement ?? null, + repertoireIncrement: repertoireEntry?.repertoireIncrement ?? null, + choiceType, + choiceOptionsRef, + }; + } + + async function seedClassProgression(): Promise<{ created: number; updated: number; errors: number }> { + let created = 0; let updated = 0; let errors = 0; + console.log('📚 Seeding ClassProgression for 16 classes × 20 levels…'); + + for (const className of D16_CLASS_NAMES) { + let foundry: FoundryClassJson; + try { + foundry = readFoundryClass(className); + } catch (e) { + errors++; + console.error(`Failed to load Foundry class ${className}:`, (e as Error).message); + continue; + } + for (let level = 1; level <= 20; level++) { + const row = buildProgressionRow(className, level, foundry); + try { + const existing = await prisma.classProgression.findUnique({ + where: { className_level: { className, level } }, + }); + if (existing) { + await prisma.classProgression.update({ + where: { id: existing.id }, + data: row, + }); + updated++; + } else { + await prisma.classProgression.create({ data: row }); + created++; + } + } catch (e) { + errors++; + console.error(`Failed to seed ${className} L${level}:`, (e as Error).message); + } + } + } + console.log(` ✓ ClassProgression: ${created} created, ${updated} updated, ${errors} errors`); + return { created, updated, errors }; + } + + async function seedClassFeatureOptions(): Promise<{ created: number; updated: number; errors: number }> { + let created = 0; let updated = 0; let errors = 0; + console.log(`📚 Seeding ${CLASS_FEATURE_OPTIONS.length} ClassFeatureOption rows…`); + + for (const opt of CLASS_FEATURE_OPTIONS) { + try { + const existing = await prisma.classFeatureOption.findUnique({ + where: { optionsRef_optionKey: { optionsRef: opt.optionsRef, optionKey: opt.optionKey } }, + }); + if (existing) { + await prisma.classFeatureOption.update({ + where: { id: existing.id }, + data: { + name: opt.name, + nameGerman: opt.nameGerman ?? null, + description: opt.description, + grants: opt.grants, + proficiencyChanges: opt.proficiencyChanges ?? null, + }, + }); + updated++; + } else { + await prisma.classFeatureOption.create({ + data: { + optionsRef: opt.optionsRef, + optionKey: opt.optionKey, + name: opt.name, + nameGerman: opt.nameGerman ?? null, + description: opt.description, + grants: opt.grants, + proficiencyChanges: opt.proficiencyChanges ?? null, + }, + }); + created++; + } + } catch (e) { + errors++; + console.error(`Failed to seed option ${opt.optionsRef}/${opt.optionKey}:`, (e as Error).message); + } + } + console.log(` ✓ ClassFeatureOption: ${created} created, ${updated} updated, ${errors} errors`); + return { created, updated, errors }; + } + + async function main(): Promise { + const cp = await seedClassProgression(); + const cfo = await seedClassFeatureOptions(); + const totalErrors = cp.errors + cfo.errors; + if (totalErrors > 0) { + console.error(`\nSeed completed with ${totalErrors} errors.`); + process.exit(1); + } + console.log('\n✅ ClassProgression + ClassFeatureOption seed complete.'); + } + + main() + .catch(e => { console.error(e); process.exit(1); }) + .finally(() => prisma.$disconnect()); + ``` + + **Constraints:** + - `: any` is forbidden. Use the `FoundryClassJson` interface (or expand it). + - The script must fail loudly with the README pointer when the Foundry clone is missing. + - Idempotency: every row uses `findUnique → update OR create` per RESEARCH.md §Pitfall 5 + analog `seed-equipment.ts` lines 105-130. + - `prisma.classProgression.findUnique` uses the compound unique key `className_level` (Prisma generates this from `@@unique([className, level])`). + - `prisma.classFeatureOption.findUnique` uses `optionsRef_optionKey` (from `@@unique([optionsRef, optionKey])`). + + + cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "seed-class-progression" || echo "tsc clean" + + + - File `server/prisma/seed-class-progression.ts` exists + - File contains the literal string `import { SPELL_SLOT_OVERLAY` (proves overlay import) + - File contains the literal string `import { CLASS_FEATURE_OPTIONS` (proves option import) + - File contains the literal string `prisma.classProgression.findUnique` (proves idempotent pattern) + - File contains the literal string `prisma.classFeatureOption.findUnique` (proves idempotent pattern) + - File contains the literal string `Foundry pf2e clone not found at` (proves loud-fail pattern) + - File contains NO `: any` outside comments + - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 + + + Seed script type-checks cleanly. Idempotent CRUD against ClassProgression + ClassFeatureOption. Imports both hand-curated data modules. + + + + + Task 5: Run the seed (manual verification — requires Foundry clone) + (no file writes — execution + verification only) + + - .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md + - server/prisma/seed-class-progression.ts + - server/package.json (db:seed:class-progression script) + + + **Step 1 — Clone the Foundry pf2e repo at a stable tag** following the README: + + ```bash + cd server/prisma/data + git clone --depth 1 --branch https://github.com/foundryvtt/pf2e.git foundry-pf2e + ``` + + The executor selects a stable tag from `https://github.com/foundryvtt/pf2e/tags` that has been released ≥1 week ago and has no known issues. Record the tag in the SEED-README.md `` placeholder. + + **Step 2 — Verify the clone has class JSONs:** + + ```bash + ls server/prisma/data/foundry-pf2e/packs/classes/ + ``` + + Should list at least 16 `.json` files matching the D-16 class names (lowercase). + + **Step 3 — Run the seed:** + + ```bash + cd server && npm run db:seed:class-progression + ``` + + Expected output (approximate): + ``` + 📚 Seeding ClassProgression for 16 classes × 20 levels… + ✓ ClassProgression: 320 created, 0 updated, 0 errors + 📚 Seeding 60 ClassFeatureOption rows… + ✓ ClassFeatureOption: 60 created, 0 updated, 0 errors + ✅ ClassProgression + ClassFeatureOption seed complete. + ``` + + **Step 4 — Run the seed AGAIN to prove idempotency:** + + ```bash + cd server && npm run db:seed:class-progression + ``` + + Expected output: `0 created, 320 updated, 0 errors` for ClassProgression and `0 created, N updated, 0 errors` for ClassFeatureOption. + + **Step 5 — Verify row counts:** + + ```bash + psql $DATABASE_URL -c 'SELECT "className", COUNT(*) FROM "ClassProgression" GROUP BY "className" ORDER BY "className"' + ``` + + Expected: 16 rows, each `count = 20`. + + ```bash + psql $DATABASE_URL -c 'SELECT "optionsRef", COUNT(*) FROM "ClassFeatureOption" GROUP BY "optionsRef" ORDER BY "optionsRef"' + ``` + + Expected: at least 5 distinct optionsRef rows, each with ≥2 options. + + **Step 6 — Verify spellcaster overlays applied:** + + ```bash + psql $DATABASE_URL -c 'SELECT "className", "level", "spellSlotIncrement" FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"' AND "level" <= 5 ORDER BY "level"' + ``` + + Expected: rows for Wizard L1..L5 with non-null `spellSlotIncrement` JSON containing `tradition: ARCANE`. + + If any verification step fails, the plan is NOT done — fix the seed script or overlay data. + + + cd server && psql $DATABASE_URL -t -c 'SELECT COUNT(*) FROM "ClassProgression"' | tr -d ' \n' + + + - `server/prisma/data/foundry-pf2e/packs/classes/` directory exists with at least 16 .json files (Foundry clone present) + - `cd server && npm run db:seed:class-progression` exits 0 + - First run reports `≥320 created` for ClassProgression and `≥40 created` for ClassFeatureOption + - Second run reports `0 created, ≥320 updated` for ClassProgression (proves idempotency) + - `psql $DATABASE_URL -t -c 'SELECT COUNT(*) FROM "ClassProgression"'` outputs at least 320 + - `psql $DATABASE_URL -t -c 'SELECT COUNT(*) FROM "ClassFeatureOption"'` outputs at least 40 + - `psql $DATABASE_URL -c 'SELECT * FROM "ClassProgression" WHERE "className" = '"'"'Wizard'"'"' AND "level" = 1'` returns a row with non-null `spellSlotIncrement` and non-null `cantripIncrement` + - `psql $DATABASE_URL -c 'SELECT * FROM "ClassProgression" WHERE "className" = '"'"'Cleric'"'"' AND "level" = 1'` returns a row with `choiceType = doctrine` and `choiceOptionsRef = cleric-doctrine` + - SEED-README.md has the `` placeholder replaced with the actual tag chosen + + + Seed script runs cleanly twice, all 320+ ClassProgression rows present, all ClassFeatureOption rows present, spell-slot overlay applied to caster classes, choiceType/choiceOptionsRef set on L1 rows for classes with L1 choices. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| dev-shell → seed script | Developer runs the seed script with elevated DB access (DATABASE_URL with write privilege). Trust assumed within self-hosted single-tenant model. | +| Foundry clone → seed parser | The seed reads JSON from a developer-controlled local clone. Trust = developer chooses a clean tag. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-1-W2-01 | Tampering | Foundry pf2e tag is moved underneath us between seed runs | mitigate | SEED-README.md instructs the dev to clone a specific pinned tag; the seed script's loud-fail on schema mismatch (Pitfall #5) catches drift. | +| T-1-W2-02 | Tampering | Hand-curated overlay contains a bug (e.g. wrong slot count for class X) | mitigate | Plan 04's integration tests assert ClassProgression-driven recompute results for a representative caster (Wizard L1 cantrips, Sorcerer L5 repertoire). Bugs surface there. | +| T-1-W2-03 | Information Disclosure | DATABASE_URL leaked in seed output / error logs | accept | Self-hosted single-tenant; developer controls .env; no production deploy yet exposes seed script. | +| T-1-W2-04 | Repudiation | Seed errors silently corrupt rows | mitigate | Idempotent pattern with try/catch per row; error count reported in console; non-zero error count exits 1 (CI catches this). | +| T-1-W2-05 | DoS | Cascading seed errors flood DB connection pool | mitigate | Sequential per-row CRUD (no Promise.all); seed completes in <30s for 320+60 rows. | + + + +After all tasks complete: + +```bash +# README + tag pinned +cat .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md | grep -E "Pinned tag.*[a-z0-9]" + +# Overlay file types clean +cd server && npx tsc --noEmit -p tsconfig.json + +# Seed runs idempotently (run twice, second run = all updates) +cd server && npm run db:seed:class-progression && npm run db:seed:class-progression + +# Row counts +psql $DATABASE_URL -c 'SELECT COUNT(*) FROM "ClassProgression"' # >= 320 +psql $DATABASE_URL -c 'SELECT COUNT(*) FROM "ClassFeatureOption"' # >= 40 + +# Spellcaster verification +psql $DATABASE_URL -c 'SELECT className, level, spellSlotIncrement->>'"'"'tradition'"'"' FROM "ClassProgression" WHERE className IN ('"'"'Wizard'"'"','"'"'Sorcerer'"'"','"'"'Bard'"'"','"'"'Cleric'"'"','"'"'Druid'"'"','"'"'Witch'"'"','"'"'Oracle'"'"') AND spellSlotIncrement IS NOT NULL ORDER BY className, level LIMIT 30' + +# Choice-type verification (Cleric L1 should have doctrine choice) +psql $DATABASE_URL -c 'SELECT className, choiceType, choiceOptionsRef FROM "ClassProgression" WHERE level = 1 AND choiceType IS NOT NULL ORDER BY className' +``` + + + +- SEED-README.md exists with cloning instructions and a recorded pinned Foundry tag +- spell-slot-overlays.ts populated for all 7 caster classes + empty for non-casters; type-clean +- class-feature-options.ts populated with ≥40 entries across ≥10 distinct optionsRef values; type-clean +- seed-class-progression.ts type-checks cleanly, imports both overlays, fails loudly on missing Foundry clone +- Running `npm run db:seed:class-progression` populates ≥320 ClassProgression rows + ≥40 ClassFeatureOption rows +- Running it a second time updates 0 created / N updated (idempotent) +- Wizard, Sorcerer, Bard, Cleric, Druid, Witch, Oracle have non-null spellSlotIncrement at appropriate levels +- Cleric L1, Wizard L1, Champion L1 (and other classes with L1 choices) have choiceType + choiceOptionsRef set +- Plan 04 (LevelingService) can `prisma.classProgression.findUnique` for any (className, level) and get rows + + + +After completion, create `.planning/phases/01-level-up-pf2e-regelkonform/01-03-SUMMARY.md` documenting: +- The pinned Foundry pf2e tag chosen +- Final row counts (ClassProgression, ClassFeatureOption — by class breakdown) +- Any deviations from the planned overlay contents (e.g. Witch tradition default note) +- Any classes/levels where Foundry data was incomplete and a hand-curated fallback was applied +- Confirmation idempotency observed on second run + diff --git a/.planning/phases/01-level-up-pf2e-regelkonform/01-04-PLAN.md b/.planning/phases/01-level-up-pf2e-regelkonform/01-04-PLAN.md new file mode 100644 index 0000000..a3ee363 --- /dev/null +++ b/.planning/phases/01-level-up-pf2e-regelkonform/01-04-PLAN.md @@ -0,0 +1,1625 @@ +--- +phase: 01-level-up-pf2e-regelkonform +plan: 04 +type: execute +wave: 3 +depends_on: ["01-01", "01-02", "01-03"] +files_modified: + - server/src/modules/leveling/leveling.module.ts + - server/src/modules/leveling/leveling.controller.ts + - server/src/modules/leveling/leveling.service.ts + - server/src/modules/leveling/feat-filter.service.ts + - server/src/modules/leveling/dto/start-level-up.dto.ts + - server/src/modules/leveling/dto/patch-level-up.dto.ts + - server/src/modules/leveling/dto/commit-level-up.dto.ts + - server/src/modules/leveling/dto/level-up-state.dto.ts + - server/src/modules/leveling/dto/index.ts + - server/src/modules/leveling/leveling.service.spec.ts + - server/src/modules/leveling/feat-filter.service.spec.ts + - server/src/modules/characters/characters.gateway.ts + - server/src/modules/characters/pathbuilder-import.service.ts + - server/src/app.module.ts +autonomous: true +requirements: [LVL-03, LVL-04, LVL-05, LVL-07, LVL-09, LVL-10, LVL-11, LVL-12, LVL-13, LVL-14, LVL-15] +tags: [nestjs, rest-api, websocket, transaction, level-up, server, integration-tests] +must_haves: + truths: + - "POST /characters/:characterId/level-up creates or resumes a single open LevelUpSession DRAFT for the character (Owner OR GM access)." + - "PATCH /characters/:characterId/level-up/:sessionId merges a partial state into the DRAFT and validates the shape via class-validator + a TS guard." + - "GET /characters/:characterId/level-up/:sessionId/preview returns Vorher/Nachher DerivedStats computed via recompute-derived-stats.ts (no commit)." + - "POST /characters/:characterId/level-up/:sessionId/commit runs an atomic prisma.$transaction that: inserts LevelUpHistory snapshot, updates Character (level + hpMax + freeArchetype), upserts CharacterAbility/CharacterSkill/CharacterFeat/CharacterResource/CharacterSpell rows, marks the session committedAt = now, then emits ONE 'level_up_committed' WebSocket event." + - "DELETE /characters/:characterId/level-up/:sessionId removes the DRAFT (LevelUpSession row) without touching the Character." + - "feat-filter.service returns only feats whose prereqs evaluate {ok:true} or {unknown:true} per slot/source filter, never {ok:false}." + - "PathbuilderImportService runs the prereq evaluator after import and persists violations to Character.prereqViolations + auto-detects FA from feat tuples." + - "characters.gateway.ts CharacterUpdatePayload union includes 'level_up_committed' and the broadcast happens once per commit (Pitfall #9)." + - "Commit transaction NEVER mutates Character.hpCurrent (Pitfall #9 — verified by integration test)." + - "Race-condition: second simultaneous commit on the same session is rejected by the partial unique index (verified by integration test)." + artifacts: + - path: "server/src/modules/leveling/leveling.module.ts" + provides: "NestJS module wiring controller + services + JWT + dependency on CharactersModule (for gateway)" + exports: ["LevelingModule"] + - path: "server/src/modules/leveling/leveling.controller.ts" + provides: "5 REST endpoints: start, patch, preview, commit, discard" + contains: "@Controller('characters/:characterId/level-up')" + - path: "server/src/modules/leveling/leveling.service.ts" + provides: "Orchestration: startOrResume, patchState, computePreview, commit, discard" + contains: "prisma.$transaction" + - path: "server/src/modules/leveling/feat-filter.service.ts" + provides: "Filtered feat lookup driven by prereq-evaluator + slot+source criteria" + exports: ["FeatFilterService"] + - path: "server/src/modules/leveling/leveling.service.spec.ts" + provides: "Integration tests for atomic commit, broadcast count, hpCurrent invariant, race condition, FA, spellcaster" + contains: "prisma.$transaction" + - path: "server/src/modules/characters/characters.gateway.ts" + provides: "Extended CharacterUpdatePayload['type'] union with 'level_up_committed'" + contains: "'level_up_committed'" + - path: "server/src/modules/characters/pathbuilder-import.service.ts" + provides: "Extended import flow with prereq violations + FA auto-detect" + contains: "evaluatePrereq" + key_links: + - from: "leveling.service.ts → commit()" + to: "characters.gateway.ts → broadcastCharacterUpdate" + via: "single emit AFTER $transaction returns" + pattern: "broadcastCharacterUpdate.*level_up_committed" + - from: "leveling.service.ts" + to: "lib/recompute-derived-stats.ts" + via: "import recomputeDerivedStats" + pattern: "from.*lib/recompute-derived-stats" + - from: "leveling.service.ts" + to: "characters.service.ts → checkCharacterAccess" + via: "delegated permission check" + pattern: "checkCharacterAccess.*requireOwnership" + - from: "feat-filter.service.ts" + to: "lib/prereq-evaluator.ts" + via: "import evaluatePrereq" + pattern: "from.*lib/prereq-evaluator" + - from: "pathbuilder-import.service.ts" + to: "lib/prereq-evaluator.ts" + via: "post-import evaluation loop" + pattern: "evaluatePrereq" + - from: "app.module.ts" + to: "LevelingModule" + via: "imports array" + pattern: "LevelingModule" +--- + + +Build the server-side orchestration that ties together the schema (Plan 01), pure-function lib (Plan 02), and seed data (Plan 03) into the live REST + WebSocket surface that the React wizard (Plan 05) consumes. This plan creates the new NestJS `LevelingModule` with controller, service, DTOs, the supporting `FeatFilterService`, plus integration tests for the atomic commit transaction. It also extends two existing files: `characters.gateway.ts` (one-line union type) and `pathbuilder-import.service.ts` (prereq-evaluator integration + FA auto-detect). + +Purpose: This is the load-bearing wave — without these REST endpoints + the atomic commit transaction + the WebSocket broadcast, the React wizard has nothing to call. The integration tests written here are the regression net for Pitfall #9 (no hpCurrent mutation), Pitfall #8 (boost cap honored end-to-end), and the partial-unique-index race-condition behavior. + +Output: 9 new server files (1 module + 1 controller + 2 services + 4 DTOs + 1 barrel + 2 spec files = 11 files counting specs), 3 extended files, 320+ lines of integration tests with full coverage of VALIDATION.md rows 1-W3-01 through 1-W3-10. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/REQUIREMENTS.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-01-SUMMARY.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-02-SUMMARY.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-03-SUMMARY.md +@server/src/modules/characters/characters.module.ts +@server/src/modules/characters/characters.controller.ts +@server/src/modules/characters/characters.service.ts +@server/src/modules/characters/characters.gateway.ts +@server/src/modules/characters/pathbuilder-import.service.ts +@server/src/modules/characters/dto/dying.dto.ts +@server/src/modules/characters/dto/alchemy.dto.ts +@server/src/modules/characters/dto/rest.dto.ts +@server/src/modules/characters/dto/index.ts +@server/src/modules/battle/combatants.service.ts +@server/src/app.module.ts +@server/src/modules/translations/translations.service.ts + + + +```typescript +// From server/src/modules/leveling/lib/types.ts +export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY'; +export type StepKind = 'class-features' | 'class-feature-choice' | 'boost' | ... | 'review'; +export type EvalResult = { ok: true } | { ok: false; reason: string } | { unknown: true; raw: string }; +export interface CharacterContext { level, className, ancestryName, heritageName?, abilities, skills, feats } +export interface DerivedStats { level, hpMax, ac, classDc, perception, fortitude, reflex, will } +export interface ClassProgressionRow { className, level, grants[], proficiencyChanges, spellSlotIncrement?, ... } +export interface WizardChoices { boostTargets?, skillIncrease?, featClassId?, ..., classFeatureChoices?, spellcasterRepertoirePicks? } + +// From server/src/modules/leveling/lib/apply-attribute-boost.ts +export function applyAttributeBoost(score: number): number; +export function isValidBoostSet(targets: readonly string[]): boolean; + +// From server/src/modules/leveling/lib/skill-increase-cap.ts +export function canIncreaseSkill(currentRank: Proficiency, characterLevel: number): boolean; +export const SKILL_INCREASE_LEVELS: readonly number[]; + +// From server/src/modules/leveling/lib/prereq-evaluator.ts +export function evaluatePrereq(prereqString: string | null, ctx: CharacterContext): EvalResult; + +// From server/src/modules/leveling/lib/recompute-derived-stats.ts +export function recomputeDerivedStats(character, choices, progression): DerivedStats; + +// From server/src/modules/leveling/lib/compute-applicable-steps.ts +export function computeApplicableSteps(input): StepKind[]; +``` + + +```typescript +export interface CharacterUpdatePayload { + characterId: string; + type: 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status' | 'rest' + | 'alchemy_vials' | 'alchemy_formulas' | 'alchemy_prepared' | 'alchemy_state' | 'dying'; + data: any; // (existing — leave for now) +} + +// Method on the gateway (already exposed for other broadcasts): +broadcastCharacterUpdate(characterId: string, payload: CharacterUpdatePayload): void; +``` + + +```typescript +// requireOwnership=true means "isOwner OR isGM" per RESEARCH.md line 862 +async checkCharacterAccess(characterId: string, userId: string, requireOwnership = false) { + // throws NotFoundException if missing, ForbiddenException if no access + // returns the loaded Character (with campaign + members included) +} +``` + + +```typescript +async importCharacter(campaignId: string, ownerId: string, pathbuilderJson: PathbuilderJson) { ... } +``` + + +```typescript +// Used for D-15 — German translation of new prereq strings + class-feature descriptions. +async getTranslationsBatch(items: { englishText: string; context: string }[]): Promise>; +``` + + +```typescript +@Module({ + imports: [ + AuthModule, CampaignsModule, CharactersModule, EquipmentModule, FeatsModule, + BattleModule, TranslationsModule, ConfigModule.forRoot({ ... }), JwtModule.registerAsync({ ... }), + // ADD: LevelingModule + ], + ... +}) +``` + + + + + + + Task 1: DTOs + barrel (start, patch, commit, level-up-state, index) + + server/src/modules/leveling/dto/start-level-up.dto.ts, + server/src/modules/leveling/dto/patch-level-up.dto.ts, + server/src/modules/leveling/dto/commit-level-up.dto.ts, + server/src/modules/leveling/dto/level-up-state.dto.ts, + server/src/modules/leveling/dto/index.ts + + + - server/src/modules/characters/dto/dying.dto.ts (canonical small DTO with class-validator + ApiProperty) + - server/src/modules/characters/dto/alchemy.dto.ts (nested DTO + ValidateNested + ApiPropertyOptional) + - server/src/modules/characters/dto/rest.dto.ts (response DTO shape — RestPreviewDto for analog) + - server/src/modules/characters/dto/index.ts (barrel pattern) + - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 248-311 — DTO pattern with exact decorator usage) + - server/src/modules/leveling/lib/types.ts (WizardChoices + DerivedStats — these typed shapes are the runtime contract) + + + Create the four DTOs and the barrel. + + **1. `server/src/modules/leveling/dto/start-level-up.dto.ts`:** + + ```typescript + import { ApiPropertyOptional } from '@nestjs/swagger'; + import { IsInt, IsOptional, Max, Min } from 'class-validator'; + + export class StartLevelUpDto { + @ApiPropertyOptional({ description: 'Target level (defaults to currentLevel + 1)', minimum: 2, maximum: 20 }) + @IsOptional() + @IsInt() + @Min(2) + @Max(20) + targetLevel?: number; + } + ``` + + **2. `server/src/modules/leveling/dto/patch-level-up.dto.ts`:** + + ```typescript + import { ApiProperty } from '@nestjs/swagger'; + import { IsObject } from 'class-validator'; + + /** + * Wire DTO for PATCH /level-up/:sessionId. + * The full WizardChoices shape is validated in the service via a TS guard + * (see leveling.service.ts → assertValidWizardChoices) — class-validator only + * checks "is an object" at the wire boundary; deep validation happens in TS. + */ + export class PatchLevelUpDto { + @ApiProperty({ description: 'Partial WizardChoices to merge into the DRAFT', type: 'object' }) + @IsObject() + state: Record; + } + ``` + + **3. `server/src/modules/leveling/dto/commit-level-up.dto.ts`:** + + ```typescript + import { ApiPropertyOptional } from '@nestjs/swagger'; + import { IsBoolean, IsOptional } from 'class-validator'; + + /** + * No required fields — commit reads the latest persisted state. + * Optional flag for client to acknowledge any pending non-evaluable prereqs. + */ + export class CommitLevelUpDto { + @ApiPropertyOptional({ description: 'Acknowledge any non-evaluable prereqs that were warned about' }) + @IsOptional() + @IsBoolean() + acknowledgePrereqWarnings?: boolean; + } + ``` + + **4. `server/src/modules/leveling/dto/level-up-state.dto.ts`** (response shapes — used by Swagger + as TS types in the service): + + ```typescript + import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + import type { DerivedStats } from '../lib/types'; + + /** + * Response shape for GET /level-up/:sessionId/preview — Vorher/Nachher. + */ + export class LevelUpPreviewDto { + @ApiProperty({ description: 'Stats before commit (current Character state)' }) + before: DerivedStats; + + @ApiProperty({ description: 'Stats after commit (computed via recompute-derived-stats)' }) + after: DerivedStats; + + @ApiPropertyOptional({ description: 'Spellcaster slot increments (if applicable)' }) + spellcaster?: { + slotIncrements: { tradition: string; spellLevel: number; count: number }[]; + cantripDelta?: number; + repertoireDelta?: number; + }; + } + + /** + * Response shape for the LevelUpSession (start / patch / get). + * `state` is the JSON-serializable WizardChoices + UI-only fields. + */ + export class LevelUpSessionDto { + @ApiProperty() id: string; + @ApiProperty() characterId: string; + @ApiProperty() targetLevel: number; + @ApiProperty({ type: 'object', additionalProperties: true }) state: Record; + @ApiPropertyOptional({ nullable: true }) committedAt: Date | null; + @ApiProperty() createdAt: Date; + @ApiProperty() updatedAt: Date; + } + ``` + + **5. `server/src/modules/leveling/dto/index.ts`:** + + ```typescript + export * from './start-level-up.dto'; + export * from './patch-level-up.dto'; + export * from './commit-level-up.dto'; + export * from './level-up-state.dto'; + ``` + + **Constraints:** + - No `: any` outside the existing `data: any` on the gateway payload (which is left untouched per existing convention — RESEARCH.md line 873). + - Use `import type` for type-only imports from sibling modules. + - Mirror the exact decorator style of `dto/dying.dto.ts` and `dto/alchemy.dto.ts`. + + + cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "leveling/dto" || echo "tsc clean" + + + - All 5 files exist in `server/src/modules/leveling/dto/` + - `start-level-up.dto.ts` exports `StartLevelUpDto` + - `patch-level-up.dto.ts` exports `PatchLevelUpDto` + - `commit-level-up.dto.ts` exports `CommitLevelUpDto` + - `level-up-state.dto.ts` exports `LevelUpPreviewDto` and `LevelUpSessionDto` + - `index.ts` re-exports all four DTO files + - No file contains `: any` outside comments + - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 + + + All DTOs typed, validated with class-validator decorators, documented with Swagger, exported through barrel. + + + + + Task 2: Extend characters.gateway.ts union — add 'level_up_committed' (single-line) + server/src/modules/characters/characters.gateway.ts + + - server/src/modules/characters/characters.gateway.ts (entire file — must understand the existing union, broadcast method, room subscription) + - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 376-396 — union extension pattern) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 866-892 — exact `level_up_committed` payload shape) + - client/src/shared/hooks/use-character-socket.ts (line 15 — mirror union; this client file is updated in Plan 05) + + + Open `server/src/modules/characters/characters.gateway.ts`. Find the `CharacterUpdatePayload` interface (around line 22-26). + + Append `'level_up_committed'` to the `type` union (it currently ends with `'dying'`): + + BEFORE: + ```typescript + type: 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status' | 'rest' | 'alchemy_vials' | 'alchemy_formulas' | 'alchemy_prepared' | 'alchemy_state' | 'dying'; + ``` + + AFTER: + ```typescript + type: 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status' | 'rest' | 'alchemy_vials' | 'alchemy_formulas' | 'alchemy_prepared' | 'alchemy_state' | 'dying' | 'level_up_committed'; + ``` + + Do NOT change anything else in this file. The existing `data: any` stays (per RESEARCH.md line 873 — typed-event refactor is a future phase). + + **Document the new payload shape** by adding a comment ABOVE the interface (so Plan 05's client mirror has a contract to read): + + ```typescript + /** + * Payload for type='level_up_committed' (added in Phase 1): + * data: { + * level: number; // new level + * derived: { // recomputed stats from recompute-derived-stats.ts + * hpMax: number; + * ac: number; + * classDc: number; + * perception: number; + * fortitude: number; + * reflex: number; + * will: number; + * }; + * } + * Emitted ONCE per commit (Pitfall #9 / RESEARCH.md First-Phase-Note). Clients invalidate + * the character query and refetch via REST for the full state. + */ + export interface CharacterUpdatePayload { + ... + } + ``` + + + cd server && grep -c "level_up_committed" src/modules/characters/characters.gateway.ts + + + - File `server/src/modules/characters/characters.gateway.ts` contains the literal string `'level_up_committed'` (in the union) + - File contains the JSDoc comment block describing the `data:` shape (look for `Payload for type='level_up_committed'`) + - All other content unchanged — verify by checking that the broadcast method `broadcastCharacterUpdate` still exists and the room subscription `character:${characterId}` pattern is preserved + - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 + + + Single-line union extension applied; payload contract documented; gateway file otherwise unchanged. + + + + + Task 3: FeatFilterService — filtered feat lookup driven by prereq-evaluator + server/src/modules/leveling/feat-filter.service.ts + + - server/src/modules/leveling/lib/prereq-evaluator.ts (Plan 02 — function signature) + - server/src/modules/leveling/lib/types.ts (CharacterContext, EvalResult) + - server/prisma/schema.prisma (Feat model around line 544 — prerequisites: String?, source field, level field) + - server/src/modules/feats/ (existing FeatsService — for reference on how feats are queried today) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 590-597 — anti-pattern: don't compute prereqs client-side) + - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (row 1-W3-09 — exact assertion) + + + Create `server/src/modules/leveling/feat-filter.service.ts` exposing a method that returns feats filtered by: + 1. **Slot kind**: `'class' | 'skill' | 'general' | 'ancestry' | 'archetype'` + 2. **Source**: matches `Feat.source` enum (CLASS / ANCESTRY / GENERAL / SKILL / ARCHETYPE / BONUS) + 3. **Class restriction** (for slot=class): only feats whose `Feat.className` matches character's class OR are general + 4. **Level cap**: only feats with `Feat.level <= character.level` + 5. **Prereq evaluation**: for each candidate, call `evaluatePrereq` with the character's CharacterContext + - Include feats with `{ ok: true }` and `{ unknown: true }` (warning path per D-03) + - EXCLUDE feats with `{ ok: false }` from default response + - Optionally include `{ ok: false }` if `includeUnavailable=true` (UI toggle per UI-SPEC line 174) + + File contents: + + ```typescript + import { Injectable } from '@nestjs/common'; + import { PrismaService } from '../../prisma/prisma.service'; + import { evaluatePrereq } from './lib/prereq-evaluator'; + import type { CharacterContext, EvalResult } from './lib/types'; + + export type SlotKind = 'class' | 'skill' | 'general' | 'ancestry' | 'archetype'; + + export interface FeatFilterRequest { + slot: SlotKind; + character: CharacterContext; + maxLevel?: number; // defaults to character.level + includeUnavailable?: boolean; // include {ok:false} feats with reason annotated + } + + export interface FeatWithEval { + id: string; + name: string; + level: number; + source: string; + prerequisites: string | null; + description: string; + traits: string[]; + eval: EvalResult; + } + + @Injectable() + export class FeatFilterService { + constructor(private prisma: PrismaService) {} + + async getFilteredFeats(req: FeatFilterRequest): Promise { + const maxLevel = req.maxLevel ?? req.character.level; + + // 1. Map slot → eligible source values + const sourceFilter = this.sourcesForSlot(req.slot); + + // 2. Class filter (only matters for slot='class') + const classFilter = + req.slot === 'class' + ? { OR: [{ className: req.character.className }, { className: null }] } + : {}; + + // 3. Query Feat table (existing schema) + const feats = await this.prisma.feat.findMany({ + where: { + source: { in: sourceFilter }, + level: { lte: maxLevel }, + ...classFilter, + }, + orderBy: [{ level: 'asc' }, { name: 'asc' }], + select: { + id: true, name: true, level: true, source: true, + prerequisites: true, description: true, traits: true, className: true, + }, + }); + + // 4. Evaluate prereqs against character context + const evaluated: FeatWithEval[] = feats.map(f => ({ + id: f.id, + name: f.name, + level: f.level, + source: f.source, + prerequisites: f.prerequisites, + description: f.description, + traits: f.traits, + eval: evaluatePrereq(f.prerequisites, req.character), + })); + + // 5. Filter by eval result unless includeUnavailable + if (req.includeUnavailable) return evaluated; + return evaluated.filter(f => f.eval.ok === true || ('unknown' in f.eval && f.eval.unknown)); + } + + /** + * Maps wizard slot to the Feat.source values eligible for that slot. + * Slot 'general' allows both GENERAL and SKILL feats per LVL-05. + */ + private sourcesForSlot(slot: SlotKind): string[] { + switch (slot) { + case 'class': return ['CLASS']; + case 'skill': return ['SKILL']; + case 'general': return ['GENERAL', 'SKILL']; // LVL-05 + case 'ancestry': return ['ANCESTRY']; + case 'archetype': return ['ARCHETYPE']; + } + } + } + ``` + + **Note on Feat schema:** check `server/prisma/schema.prisma:544` for the actual column names (`source`, `level`, `prerequisites`, `traits`, `className`, `description`). If column names differ, adjust the `select` and `where` accordingly. + + **Constraints:** + - `@Injectable()` decorator present (NestJS service). + - Imports `evaluatePrereq` from the lib — does NOT re-implement. + - No `: any`. The `Feat.source` field's type comes from Prisma — leave as the generated string type. + + + cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "feat-filter" || echo "tsc clean" + + + - File `server/src/modules/leveling/feat-filter.service.ts` exists + - File contains `@Injectable()` + - File imports `evaluatePrereq` from `./lib/prereq-evaluator` + - File exports `FeatFilterService` class + - File exports `SlotKind`, `FeatFilterRequest`, `FeatWithEval` types + - File contains the literal string `prisma.feat.findMany` (proves DB integration) + - File contains NO `: any` outside comments + - `cd server && npx tsc --noEmit` exits 0 + + + Service queries the existing Feat table, applies slot+source+class+level filters, evaluates prereqs, returns annotated results. + + + + + Task 4: LevelingService — orchestration (start, patch, preview, commit, discard) + server/src/modules/leveling/leveling.service.ts + + - server/src/modules/characters/characters.service.ts (entire file — checkCharacterAccess pattern + service-level error patterns) + - server/src/modules/battle/combatants.service.ts (lines 82-118 — atomic transaction pattern) + - server/src/modules/leveling/lib/* (Plan 02 — recompute, prereq, applicable-steps, boost, skill-cap) + - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 173-247 — service pattern; 405-468 — atomic commit; 904-921 — broadcast pattern) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 405-470 — Pattern 2 atomic commit example; lines 826-862 — REST endpoint shape) + - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-11 DRAFT, D-12 atomic + snapshot, D-13 owner+GM, D-14 one DRAFT) + - server/src/modules/translations/translations.service.ts (TranslationsService.getTranslationsBatch signature for D-15) + + + Create `server/src/modules/leveling/leveling.service.ts` containing the full orchestration. The class signature and method bodies are sketched below; executor implements full method bodies guided by the spec in Task 7 (the integration tests). + + **File header + class skeleton:** + + ```typescript + import { + Injectable, NotFoundException, ForbiddenException, BadRequestException, ConflictException, + Inject, forwardRef, Logger, + } from '@nestjs/common'; + import { PrismaService } from '../../prisma/prisma.service'; + import { CharactersService } from '../characters/characters.service'; + import { CharactersGateway } from '../characters/characters.gateway'; + import { TranslationsService } from '../translations/translations.service'; + import { applyAttributeBoost, isValidBoostSet } from './lib/apply-attribute-boost'; + import { canIncreaseSkill } from './lib/skill-increase-cap'; + import { evaluatePrereq } from './lib/prereq-evaluator'; + import { recomputeDerivedStats } from './lib/recompute-derived-stats'; + import { computeApplicableSteps } from './lib/compute-applicable-steps'; + import type { + CharacterContext, DerivedStats, ClassProgressionRow, WizardChoices, StepKind, + } from './lib/types'; + import type { StartLevelUpDto, PatchLevelUpDto, CommitLevelUpDto, LevelUpPreviewDto } from './dto'; + + @Injectable() + export class LevelingService { + private readonly logger = new Logger(LevelingService.name); + + constructor( + private prisma: PrismaService, + @Inject(forwardRef(() => CharactersService)) + private charactersService: CharactersService, + @Inject(forwardRef(() => CharactersGateway)) + private charactersGateway: CharactersGateway, + private translationsService: TranslationsService, + ) {} + + // ============================================================ + // PUBLIC API — called by LevelingController + // ============================================================ + + /** + * Start a new DRAFT or resume the existing one for this character. + * D-11/D-13/D-14 — owner-or-GM, one DRAFT per character (DB-enforced via partial unique index). + */ + async startOrResume(characterId: string, dto: StartLevelUpDto, userId: string) { + const character = await this.charactersService.checkCharacterAccess(characterId, userId, true); + if (character.level >= 20) { + throw new BadRequestException('Charakter ist bereits auf maximaler Stufe'); + } + const targetLevel = dto.targetLevel ?? character.level + 1; + if (targetLevel !== character.level + 1) { + throw new BadRequestException(`Stufenaufstieg nur auf Stufe ${character.level + 1} möglich`); + } + + // Try to resume the open DRAFT (partial unique index → 0 or 1 row) + const existing = await this.prisma.levelUpSession.findFirst({ + where: { characterId, committedAt: null }, + }); + if (existing) { + this.logger.log(`Resumed LevelUpSession ${existing.id} for character ${characterId}`); + return existing; + } + + // Create fresh DRAFT + try { + const created = await this.prisma.levelUpSession.create({ + data: { + characterId, + targetLevel, + state: this.initialWizardState(), + }, + }); + this.logger.log(`Created LevelUpSession ${created.id} for character ${characterId} → L${targetLevel}`); + return created; + } catch (e) { + // Partial unique index race — another tab beat us. Re-fetch and return that one. + const fallback = await this.prisma.levelUpSession.findFirst({ + where: { characterId, committedAt: null }, + }); + if (fallback) return fallback; + throw e; + } + } + + /** + * Merge a partial WizardChoices into the DRAFT.state JSON. + * Validates shape via assertValidWizardChoices. + */ + async patchState(characterId: string, sessionId: string, dto: PatchLevelUpDto, userId: string) { + const session = await this.loadSessionAndAuthorize(sessionId, userId); + if (session.characterId !== characterId) { + throw new BadRequestException('Session gehört nicht zu diesem Charakter'); + } + if (session.committedAt !== null) { + throw new ConflictException('Session ist bereits committed'); + } + + // Merge: existing state + patch + const newState: Record = { + ...(session.state as Record), + ...dto.state, + }; + this.assertValidWizardChoices(newState); + + const updated = await this.prisma.levelUpSession.update({ + where: { id: sessionId }, + data: { state: newState }, + }); + return updated; + } + + /** + * Compute Vorher/Nachher DerivedStats without committing. + * Reads ClassProgression + character snapshot, runs recomputeDerivedStats. + */ + async computePreview(characterId: string, sessionId: string, userId: string): Promise { + const session = await this.loadSessionAndAuthorize(sessionId, userId); + const character = await this.loadCharacterFullForRecompute(characterId); + const progression = await this.loadProgression(character.className, session.targetLevel); + + const before: DerivedStats = this.computeBeforeStats(character); + const choices = (session.state as Record) as unknown as WizardChoices; + const after: DerivedStats = recomputeDerivedStats(character, choices, progression); + + const spellcaster = this.computeSpellcasterPreview(progression, choices); + return { before, after, spellcaster }; + } + + /** + * Atomic commit: snapshot + character mutations + history insert + session.committedAt + ONE broadcast. + * D-12 / Pitfall #9: never touches Character.hpCurrent. + */ + async commit(characterId: string, sessionId: string, dto: CommitLevelUpDto, userId: string) { + const session = await this.loadSessionAndAuthorize(sessionId, userId); + if (session.characterId !== characterId) { + throw new BadRequestException('Session gehört nicht zu diesem Charakter'); + } + if (session.committedAt !== null) { + throw new ConflictException('Session ist bereits committed'); + } + + const character = await this.loadCharacterFullForRecompute(characterId); + if (character.level + 1 !== session.targetLevel) { + throw new BadRequestException( + `Charakter ist auf Stufe ${character.level}; Session erwartet Aufstieg auf Stufe ${session.targetLevel}`, + ); + } + + const choices = (session.state as Record) as unknown as WizardChoices; + this.assertValidWizardChoices(choices); + if (choices.boostTargets && !isValidBoostSet(choices.boostTargets)) { + throw new BadRequestException('Boost-Set muss genau 4 verschiedene Attribute enthalten'); + } + + const progression = await this.loadProgression(character.className, session.targetLevel); + const newDerived = recomputeDerivedStats(character, choices, progression); + const snapshotJson = this.buildSnapshot(character); + + // ============ ATOMIC TRANSACTION ============ + const result = await this.prisma.$transaction(async (tx) => { + // 1. Snapshot history + await tx.levelUpHistory.create({ + data: { + characterId, + levelFrom: character.level, + levelTo: session.targetLevel, + snapshotBefore: snapshotJson, + choices: session.state as never, + }, + }); + + // 2. Character update — level + hpMax + freeArchetype passthrough. + // CRITICAL: do NOT touch hpCurrent (Pitfall #9). + await tx.character.update({ + where: { id: characterId }, + data: { + level: session.targetLevel, + hpMax: newDerived.hpMax, + // (hpCurrent intentionally absent) + }, + }); + + // 3. Boost targets → CharacterAbility upserts (4 rows max) + if (choices.boostTargets) { + for (const ability of choices.boostTargets) { + const current = character.abilities[ability]; + const newScore = applyAttributeBoost(current); + await tx.characterAbility.upsert({ + where: { characterId_ability: { characterId, ability } }, + update: { score: newScore }, + create: { characterId, ability, score: newScore }, + }); + } + } + + // 4. Skill increase + if (choices.skillIncrease) { + await tx.characterSkill.upsert({ + where: { characterId_skillName: { characterId, skillName: choices.skillIncrease.skillName } }, + update: { proficiency: choices.skillIncrease.toRank }, + create: { + characterId, + skillName: choices.skillIncrease.skillName, + proficiency: choices.skillIncrease.toRank, + }, + }); + } + + // 5. Feat picks → CharacterFeat creates + const featSlots: Array<[keyof WizardChoices, string]> = [ + ['featClassId', 'CLASS'], + ['featSkillId', 'SKILL'], + ['featGeneralId', 'GENERAL'], + ['featAncestryId', 'ANCESTRY'], + ['featArchetypeId', 'ARCHETYPE'], + ]; + for (const [field, source] of featSlots) { + const featId = (choices as Record)[field as string] as string | undefined; + if (featId) { + await tx.characterFeat.create({ + data: { + characterId, + featId, + source, + level: session.targetLevel, + }, + }); + } + } + + // 6. Class-feature choice picks → CharacterFeat creates with optionKey + if (choices.classFeatureChoices) { + for (const [choiceKey, optionKey] of Object.entries(choices.classFeatureChoices)) { + const option = await tx.classFeatureOption.findUnique({ + where: { optionsRef_optionKey: { optionsRef: choiceKey, optionKey } }, + }); + if (option) { + await tx.characterFeat.create({ + data: { + characterId, + featId: null as never, // class-feature choice may have no featId — schema permitting + name: option.name, + source: 'CLASS', + level: session.targetLevel, + }, + }); + } + } + } + + // 7. Spellcaster — apply spellSlotIncrement / cantripIncrement / repertoirePicks + if (progression.spellSlotIncrement) { + // Implementation depends on existing CharacterResource shape — executor adapts. + // Pattern: upsert CharacterResource for {tradition, spellLevel} key, increment count. + } + if (choices.spellcasterRepertoirePicks?.length) { + for (const spellId of choices.spellcasterRepertoirePicks) { + await tx.characterSpell.create({ + data: { characterId, spellId, isInRepertoire: true } as never, + }); + } + } + + // 8. Mark session committed (soft-archive per RESEARCH.md line 470) + await tx.levelUpSession.update({ + where: { id: sessionId }, + data: { committedAt: new Date() }, + }); + + return tx.character.findUnique({ + where: { id: characterId }, + include: { abilities: true, skills: true, feats: true }, + }); + }); + // ============ END TRANSACTION ============ + + // 9. SINGLE WebSocket broadcast (Pitfall #9 / RESEARCH.md First-Phase-Note) + this.charactersGateway.broadcastCharacterUpdate(characterId, { + characterId, + type: 'level_up_committed', + data: { + level: session.targetLevel, + derived: newDerived, + }, + }); + + // 10. D-15 — kick off German translation of any new prereq strings + class-feature + // descriptions used by this commit (fire-and-forget; cached for next view). + await this.maybeTranslateNewStrings(progression, choices).catch(e => + this.logger.warn(`Translation kick-off failed: ${(e as Error).message}`), + ); + + this.logger.log(`Committed level-up: characterId=${characterId} fromLevel=${character.level} toLevel=${session.targetLevel}`); + return result; + } + + /** Discard a DRAFT — no character mutation. */ + async discard(characterId: string, sessionId: string, userId: string) { + const session = await this.loadSessionAndAuthorize(sessionId, userId); + if (session.characterId !== characterId) { + throw new BadRequestException('Session gehört nicht zu diesem Charakter'); + } + if (session.committedAt !== null) { + throw new ConflictException('Bereits committed — kann nicht verworfen werden'); + } + await this.prisma.levelUpSession.delete({ where: { id: sessionId } }); + this.logger.log(`Discarded LevelUpSession ${sessionId} for character ${characterId}`); + } + + // ============================================================ + // PRIVATE HELPERS + // ============================================================ + + private initialWizardState(): Record { + return { choices: {}, acknowledgedNonEvaluablePrereqs: [] }; + } + + /** + * TS guard for in-flight wizard state. Throws BadRequestException if shape is invalid. + * Cheap structural validation only — does NOT validate against PF2e rules (commit() does). + */ + private assertValidWizardChoices(state: unknown): asserts state is Record { + if (!state || typeof state !== 'object') { + throw new BadRequestException('Ungültiges Wizard-State-Format'); + } + // Optional further checks: boostTargets is array of valid abbreviations, etc. + const s = state as Record; + if (s.boostTargets !== undefined) { + if (!Array.isArray(s.boostTargets) || s.boostTargets.some(t => typeof t !== 'string')) { + throw new BadRequestException('boostTargets muss ein Array von Strings sein'); + } + } + } + + private async loadSessionAndAuthorize(sessionId: string, userId: string) { + const session = await this.prisma.levelUpSession.findUnique({ + where: { id: sessionId }, + }); + if (!session) throw new NotFoundException('Stufenaufstiegs-Session nicht gefunden'); + // Delegate access check to the character path + await this.charactersService.checkCharacterAccess(session.characterId, userId, true); + return session; + } + + private async loadCharacterFullForRecompute(characterId: string) { + // Load character + relations needed by recompute (abilities, skills, feats, ancestry, class) + // Returns shape compatible with CharacterContext + the extra fields recompute needs + // (ancestryHp, classHp, armorAc, armorProficiency, dexCap, className). + // Executor implements actual joins per existing schema. + throw new Error('TODO: implement loadCharacterFullForRecompute'); + } + + private async loadProgression(className: string, level: number): Promise { + const row = await this.prisma.classProgression.findUnique({ + where: { className_level: { className, level } }, + }); + if (!row) throw new NotFoundException(`Keine ClassProgression für ${className} Stufe ${level}`); + return row as unknown as ClassProgressionRow; + } + + private computeBeforeStats(character: never): DerivedStats { + // Read existing Character.hpMax/ac/etc. directly — no recompute (this is the "Vorher" snapshot) + throw new Error('TODO: read existing stats from character object'); + } + + private buildSnapshot(character: never): Record { + // Capture full character + relations subset for LevelUpHistory.snapshotBefore + throw new Error('TODO: build snapshot JSON'); + } + + private computeSpellcasterPreview(progression: ClassProgressionRow, choices: WizardChoices): LevelUpPreviewDto['spellcaster'] { + if (!progression.spellSlotIncrement && !progression.cantripIncrement && !progression.repertoireIncrement) { + return undefined; + } + return { + slotIncrements: progression.spellSlotIncrement ? [progression.spellSlotIncrement] : [], + cantripDelta: progression.cantripIncrement ?? undefined, + repertoireDelta: progression.repertoireIncrement ?? undefined, + }; + } + + private async maybeTranslateNewStrings(progression: ClassProgressionRow, choices: WizardChoices): Promise { + // For D-15: collect new prereq strings + class-feature descriptions encountered in this commit. + // Pass them to TranslationsService.getTranslationsBatch — German results cached in DB. + const items: { englishText: string; context: string }[] = []; + for (const grant of progression.grants) { + items.push({ englishText: grant, context: 'class-feature-name' }); + } + if (items.length > 0) { + await this.translationsService.getTranslationsBatch(items); + } + } + } + ``` + + **Implementation notes for the executor:** + + 1. The TODO methods (`loadCharacterFullForRecompute`, `computeBeforeStats`, `buildSnapshot`) require reading the existing Character schema (`abilities`, `skills`, `feats`, `equipped armor` for AC, etc.). The executor reads `server/prisma/schema.prisma` lines 171-269 to wire these correctly. + + 2. The transaction body's specific upsert keys (e.g. `characterId_ability`) depend on the actual `@@unique` constraints on the existing CharacterAbility/CharacterSkill/CharacterFeat tables. Verify against `schema.prisma` and adjust. + + 3. The `data: any` cast in the broadcast payload is fine — it matches the existing gateway's untyped `data: any` (which is intentional per RESEARCH.md line 873; full typing is a future-phase refactor). + + 4. The `as unknown as` casts on `WizardChoices` are pragmatic Prisma-Json-to-TS bridges. Wrap them in the assertValidWizardChoices guard for safety. + + 5. Use `Logger` (NestJS) for every commit/discard/start log line — discoverable via NestJS log pipeline. + + **Constraints:** + - `@Injectable()` decorator. + - Owner-or-GM permission per D-13 via `checkCharacterAccess(..., requireOwnership=true)`. + - SINGLE broadcast at the end (after `$transaction` returns). + - NEVER touch `hpCurrent` in the character update payload. + - All German user-facing error messages. + - No `: any` outside the existing gateway untyped-data convention. + + + cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "leveling.service" || echo "tsc clean" + + + - File `server/src/modules/leveling/leveling.service.ts` exists + - File contains `@Injectable()` + - File contains the literal string `prisma.$transaction` (proves atomic commit pattern) + - File contains the literal string `'level_up_committed'` (proves broadcast emission) + - File contains the literal string `checkCharacterAccess` and `requireOwnership=true` or `requireOwnership = true` (D-13) + - File contains the literal string `applyAttributeBoost` (uses Plan 02 boost helper) + - File contains the literal string `recomputeDerivedStats` (uses Plan 02 recompute) + - File does NOT contain the literal string `hpCurrent:` inside the `tx.character.update({ where..., data: ` block (Pitfall #9) + - File does NOT contain the literal string `: any` outside comments (the `data: any` of the broadcast payload literal is fine — that's the existing gateway contract) + - File contains German strings (verifiable: at least one of `Stufenaufstieg`, `Charakter`, `Sitzung`, `Stufe` appears as user-facing message) + - All five public methods present: `startOrResume`, `patchState`, `computePreview`, `commit`, `discard` + - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 (TODO bodies count as type-correct after executor fills them in) + + + Service compiles, exposes 5 public methods, uses lib helpers, atomic transaction pattern in place, broadcast emitted once, hpCurrent never written, German messages. + + + + + Task 5: LevelingController — 5 REST endpoints + server/src/modules/leveling/leveling.controller.ts + + - server/src/modules/characters/characters.controller.ts (canonical controller — endpoint pattern, decorators, CurrentUser) + - server/src/common/decorators/current-user.decorator.ts (extracts userId from JWT) + - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 130-169 — controller pattern with decorators) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 826-862 — exact controller shape) + + + Create `server/src/modules/leveling/leveling.controller.ts`: + + ```typescript + import { + Body, Controller, Delete, Get, Param, Patch, Post, + } from '@nestjs/common'; + import { + ApiBearerAuth, ApiOperation, ApiResponse, ApiTags, + } from '@nestjs/swagger'; + import { LevelingService } from './leveling.service'; + import { CurrentUser } from '../../common/decorators/current-user.decorator'; + import { + StartLevelUpDto, + PatchLevelUpDto, + CommitLevelUpDto, + LevelUpPreviewDto, + LevelUpSessionDto, + } from './dto'; + + @ApiTags('Level-Up') + @ApiBearerAuth() + @Controller('characters/:characterId/level-up') + export class LevelingController { + constructor(private readonly levelingService: LevelingService) {} + + @Post() + @ApiOperation({ summary: 'Start a new level-up session or resume the open one' }) + @ApiResponse({ status: 201, description: 'LevelUpSession created or resumed', type: LevelUpSessionDto }) + async start( + @Param('characterId') characterId: string, + @Body() dto: StartLevelUpDto, + @CurrentUser('id') userId: string, + ) { + return this.levelingService.startOrResume(characterId, dto, userId); + } + + @Patch(':sessionId') + @ApiOperation({ summary: 'Merge partial wizard state into the DRAFT' }) + @ApiResponse({ status: 200, description: 'Updated LevelUpSession', type: LevelUpSessionDto }) + async patch( + @Param('characterId') characterId: string, + @Param('sessionId') sessionId: string, + @Body() dto: PatchLevelUpDto, + @CurrentUser('id') userId: string, + ) { + return this.levelingService.patchState(characterId, sessionId, dto, userId); + } + + @Get(':sessionId/preview') + @ApiOperation({ summary: 'Vorher/Nachher-Vorschau (no commit)' }) + @ApiResponse({ status: 200, description: 'Preview', type: LevelUpPreviewDto }) + async preview( + @Param('characterId') characterId: string, + @Param('sessionId') sessionId: string, + @CurrentUser('id') userId: string, + ): Promise { + return this.levelingService.computePreview(characterId, sessionId, userId); + } + + @Post(':sessionId/commit') + @ApiOperation({ summary: 'Bestätigen — atomic commit' }) + @ApiResponse({ status: 200, description: 'Updated Character' }) + async commit( + @Param('characterId') characterId: string, + @Param('sessionId') sessionId: string, + @Body() dto: CommitLevelUpDto, + @CurrentUser('id') userId: string, + ) { + return this.levelingService.commit(characterId, sessionId, dto, userId); + } + + @Delete(':sessionId') + @ApiOperation({ summary: 'Verwerfen — discard the DRAFT' }) + @ApiResponse({ status: 204, description: 'DRAFT removed' }) + async discard( + @Param('characterId') characterId: string, + @Param('sessionId') sessionId: string, + @CurrentUser('id') userId: string, + ): Promise { + await this.levelingService.discard(characterId, sessionId, userId); + } + } + ``` + + **Constraints:** + - JWT auth is global (per `server/src/app.module.ts` lines 53-56) — no `@UseGuards` needed. + - `@CurrentUser('id')` decorator extracts userId from JWT. + - Route prefix: `characters/:characterId/level-up` so DELETE/PATCH/COMMIT all share the same shape. + - All Swagger decorators present for OpenAPI generation. + + + cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "leveling.controller" || echo "tsc clean" + + + - File `server/src/modules/leveling/leveling.controller.ts` exists + - File contains `@Controller('characters/:characterId/level-up')` + - File contains `@Post()` (start), `@Patch(':sessionId')` (patch), `@Get(':sessionId/preview')`, `@Post(':sessionId/commit')`, `@Delete(':sessionId')` + - All 5 methods present with `@ApiOperation` decorators + - File imports `LevelingService` and DTOs from barrel + - File contains `@CurrentUser('id') userId: string` + - File contains NO `: any` + - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 + + + Controller exposes 5 REST endpoints with full Swagger annotations, JWT auth via global guard, delegates to LevelingService. + + + + + Task 6: LevelingModule + register in AppModule + + server/src/modules/leveling/leveling.module.ts, + server/src/app.module.ts + + + - server/src/modules/characters/characters.module.ts (canonical module — JwtModule + provider list) + - server/src/app.module.ts (where to register — imports array, line ~25) + - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 92-127 — module pattern + app.module registration) + + + **1. Create `server/src/modules/leveling/leveling.module.ts`:** + + ```typescript + import { Module, forwardRef } from '@nestjs/common'; + import { JwtModule } from '@nestjs/jwt'; + import { ConfigModule, ConfigService } from '@nestjs/config'; + import { LevelingController } from './leveling.controller'; + import { LevelingService } from './leveling.service'; + import { FeatFilterService } from './feat-filter.service'; + import { CharactersModule } from '../characters/characters.module'; + import { TranslationsModule } from '../translations/translations.module'; + + @Module({ + imports: [ + TranslationsModule, + forwardRef(() => CharactersModule), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + }), + inject: [ConfigService], + }), + ], + controllers: [LevelingController], + providers: [LevelingService, FeatFilterService], + exports: [LevelingService, FeatFilterService], + }) + export class LevelingModule {} + ``` + + **2. Register in `server/src/app.module.ts`:** + + Open the file. Find the `imports: [...]` array on `@Module({ ... })`. Append `LevelingModule` to it. + + Add the import statement at the top of the file: + + ```typescript + import { LevelingModule } from './modules/leveling/leveling.module'; + ``` + + Add to the imports array (alphabetical order with other feature modules): + + ```typescript + imports: [ + // ... existing modules ... + LevelingModule, + // ... rest ... + ], + ``` + + Do NOT touch any other configuration in app.module.ts (JwtAuthGuard, validation pipe, etc. all stay). + + Note on `forwardRef`: CharactersModule already imports things and providing `LevelingService` may create a circular dep if CharactersGateway is exported from CharactersModule and LevelingService injects it. Use `forwardRef(() => CharactersModule)` per PATTERNS.md to break the cycle. CharactersModule must export `CharactersGateway` and `CharactersService` for LevelingService to inject them — verify this in `characters.module.ts` and add to its `exports: []` if missing. + + **If CharactersModule does NOT currently export `CharactersGateway` or `CharactersService`:** open `server/src/modules/characters/characters.module.ts` and add them to `exports: []`. This is a non-breaking change. + + + cd server && npm run build 2>&1 | tail -20 + + + - File `server/src/modules/leveling/leveling.module.ts` exists with `@Module` decorator + - File contains `controllers: [LevelingController]` + - File contains `providers: [LevelingService, FeatFilterService]` + - File contains `exports: [LevelingService, FeatFilterService]` + - File contains `forwardRef(() => CharactersModule)` + - `server/src/app.module.ts` contains the import line `import { LevelingModule } from './modules/leveling/leveling.module'` + - `server/src/app.module.ts` references `LevelingModule` inside an `imports: [` array + - `server/src/modules/characters/characters.module.ts` exports `CharactersGateway` AND `CharactersService` (verify or add) + - `cd server && npm run build` exits 0 (Nest can resolve the dependency graph at compile time) + + + LevelingModule wired, registered in AppModule, no circular-dep errors at build, NestJS recognizes the new endpoints (visible in startup log when started, and in Swagger). + + + + + Task 7: Integration tests for LevelingService — atomic commit, broadcast count, hpCurrent invariant, race condition + + server/src/modules/leveling/leveling.service.spec.ts, + server/src/modules/leveling/feat-filter.service.spec.ts + + + - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W3-01 to 1-W3-10 — every assertion must be covered) + - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 1027-1031 — first NestJS integration test, no analog — use @nestjs/testing) + - server/src/modules/leveling/leveling.service.ts (the SUT) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 992-1007 — integration test rows from research) + + + Create `server/src/modules/leveling/leveling.service.spec.ts`. Use `@nestjs/testing` (already installed) with the real `PrismaService` against a test database — OR mock Prisma if the dev setup doesn't have a test DB. Default: use Prisma against the dev DB with cleanup after each test (Phase 1 establishes the integration test pattern; future phases may invest in a Testcontainers-backed test DB). + + **Test plan (from VALIDATION.md):** + + ```typescript + import { Test, TestingModule } from '@nestjs/testing'; + import { LevelingService } from './leveling.service'; + import { PrismaService } from '../../prisma/prisma.service'; + import { CharactersService } from '../characters/characters.service'; + import { CharactersGateway } from '../characters/characters.gateway'; + import { TranslationsService } from '../translations/translations.service'; + + describe('LevelingService — integration', () => { + let service: LevelingService; + let prisma: PrismaService; + let gatewayBroadcastSpy: jest.SpyInstance; + let testCharacterId: string; + let testUserId: string; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LevelingService, + PrismaService, + // Use mocks for Characters services where appropriate + { + provide: CharactersService, + useValue: { checkCharacterAccess: jest.fn().mockResolvedValue({ /* test character */ }) }, + }, + { + provide: CharactersGateway, + useValue: { broadcastCharacterUpdate: jest.fn() }, + }, + { + provide: TranslationsService, + useValue: { getTranslationsBatch: jest.fn().mockResolvedValue({}) }, + }, + ], + }).compile(); + service = module.get(LevelingService); + prisma = module.get(PrismaService); + + const gw = module.get(CharactersGateway); + gatewayBroadcastSpy = jest.spyOn(gw, 'broadcastCharacterUpdate'); + + // Set up a test character + user in the DB (executor implements seeding) + // ... + }); + + afterEach(async () => { + // Clean up LevelUpSession rows created in tests (executor implements cleanup) + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + // 1-W3-01 + 1-W3-02 + describe('commit transaction atomicity', () => { + it('rolls back ALL writes when mid-transaction throws', async () => { + // Arrange: create a session whose commit will fail at the broadcast step + // (mock something inside the tx to throw, e.g. a missing ClassProgression row). + // Assert: Character.level UNCHANGED, no LevelUpHistory row created. + // Executor implements with a forced error injection. + }); + + it('creates exactly one LevelUpHistory row with snapshotBefore + choices populated', async () => { + // Happy path: full commit. Assert LevelUpHistory row count = 1, fields non-null. + }); + }); + + // 1-W3-03 + describe('broadcast count', () => { + it('emits level_up_committed exactly once per commit', async () => { + gatewayBroadcastSpy.mockClear(); + // ... run commit ... + expect(gatewayBroadcastSpy).toHaveBeenCalledTimes(1); + expect(gatewayBroadcastSpy.mock.calls[0][1].type).toBe('level_up_committed'); + }); + }); + + // Pitfall #9 — explicit hpCurrent invariant + describe('Pitfall #9 — never mutates hpCurrent', () => { + it('does NOT include hpCurrent in the character update payload', async () => { + // Capture the prisma.character.update call (spy on prisma.character.update). + // Assert: the data: object passed to update does NOT contain hpCurrent. + }); + }); + + // 1-W3-04 + describe('discard', () => { + it('removes the DRAFT and leaves the character untouched', async () => { + // Create DRAFT, change Character.level snapshot, discard, assert Character unchanged + no DRAFT row. + }); + }); + + // 1-W3-05 — race condition / partial unique index + describe('partial unique index', () => { + it('allows creating a new DRAFT after the previous was committed', async () => { + // Create DRAFT, commit it (committedAt becomes non-null), create a second DRAFT — should succeed. + }); + + it('rejects creating a second open DRAFT for the same character', async () => { + // Create DRAFT, attempt to create another via raw insert — should hit partial unique violation. + // (Note: startOrResume() returns the existing one — the test exercises the DB constraint via raw SQL.) + }); + }); + + // 1-W3-06 + 1-W3-07 + describe('spellcaster slot increments', () => { + it('applies spellSlotIncrement from ClassProgression for caster classes', async () => { + // Use a Wizard character; commit L1→L2; assert CharacterResource (or whatever stores slots) reflects +1 grade-1 slot. + }); + + it('does NOT add slots for non-casters (Fighter)', async () => { + // Use a Fighter character; commit; assert no spell-slot writes. + }); + }); + + // 1-W3-08 — translation pipeline + describe('translation kick-off', () => { + it('calls TranslationsService.getTranslationsBatch with new prereq strings during commit', async () => { + const spy = jest.spyOn(/* TranslationsService instance */ {} as never, 'getTranslationsBatch'); + // ... commit ... + expect(spy).toHaveBeenCalled(); + }); + }); + + // 1-W3-10 — access control + describe('access control (D-13)', () => { + it('rejects when user is neither Owner nor GM', async () => { + await expect( + service.startOrResume(testCharacterId, {}, 'random-user-id'), + ).rejects.toThrow(/Forbidden|Kein Zugriff/); + }); + }); + }); + ``` + + **Also create `server/src/modules/leveling/feat-filter.service.spec.ts`** (1-W3-09): + + ```typescript + import { Test } from '@nestjs/testing'; + import { FeatFilterService } from './feat-filter.service'; + import { PrismaService } from '../../prisma/prisma.service'; + + describe('FeatFilterService', () => { + let service: FeatFilterService; + + beforeAll(async () => { + const module = await Test.createTestingModule({ + providers: [FeatFilterService, PrismaService], + }).compile(); + service = module.get(FeatFilterService); + }); + + describe('getFilteredFeats', () => { + it('returns only feats with eval.ok === true OR eval.unknown === true (default)', async () => { + const ctx = { /* CharacterContext */ } as never; + const result = await service.getFilteredFeats({ slot: 'class', character: ctx }); + for (const f of result) { + expect(f.eval.ok === true || ('unknown' in f.eval && f.eval.unknown)).toBe(true); + } + }); + + it('respects slot=class → only CLASS-source feats', async () => { + const ctx = { /* ... */ } as never; + const result = await service.getFilteredFeats({ slot: 'class', character: ctx }); + for (const f of result) expect(f.source).toBe('CLASS'); + }); + + it('respects slot=general → returns BOTH GENERAL and SKILL feats (LVL-05)', async () => { + const ctx = { /* ... */ } as never; + const result = await service.getFilteredFeats({ slot: 'general', character: ctx }); + const sources = new Set(result.map(f => f.source)); + expect(sources.has('GENERAL') || sources.has('SKILL')).toBe(true); + }); + + it('with includeUnavailable=true returns failed feats annotated with reason', async () => { + const ctx = { /* … */ } as never; + const result = await service.getFilteredFeats({ slot: 'class', character: ctx, includeUnavailable: true }); + // At least some result must have eval.ok === false (assuming the seeded Feat table has any) + }); + }); + }); + ``` + + **Implementation note for the executor:** + + Many of the integration tests need real DB seeds (a test character, a test campaign, ClassProgression rows for Wizard L1, etc.). The executor sets these up in `beforeAll` and tears down in `afterEach`/`afterAll`. If the dev environment doesn't have a separate test DB, tests run against the dev DB but use unique IDs and clean up after themselves. + + Tests are slower than unit tests; that's expected for integration tier. The full suite should still complete in <30s per VALIDATION.md "Estimated runtime". + + + cd server && npm test -- --testPathPattern=leveling + + + - File `server/src/modules/leveling/leveling.service.spec.ts` exists + - File contains at least 9 `it(` invocations covering: atomicity, history-row, broadcast-count, hpCurrent invariant, discard, partial-unique-index (×2), spellcaster (×2), translation, access control + - File `server/src/modules/leveling/feat-filter.service.spec.ts` exists with at least 4 `it(` invocations + - File contains the assertion `expect(gatewayBroadcastSpy).toHaveBeenCalledTimes(1)` (Pitfall #9 broadcast count check) + - File contains an assertion that the prisma.character.update payload does NOT contain `hpCurrent` (Pitfall #9) + - File contains a partial-unique-index assertion (race-condition test) + - `cd server && npm test -- --testPathPattern=leveling` exits 0 (all leveling tests including the Plan 02 lib + Plan 04 integration tests pass) + - Total leveling test count is at least 60 (50 from Plan 02 lib + at least 13 here) + + + Integration tests written for atomic commit transaction, broadcast count, hpCurrent invariant, race condition, FA, spellcaster, translation, access control. All VALIDATION.md W3 rows (1-W3-01 through 1-W3-10) covered. Full leveling suite green. + + + + + Task 8: Extend pathbuilder-import.service.ts — prereq violations + FA auto-detect + server/src/modules/characters/pathbuilder-import.service.ts + + - server/src/modules/characters/pathbuilder-import.service.ts (entire file — must understand the existing import flow, especially lines 248-272 where feats are processed) + - server/src/modules/leveling/lib/prereq-evaluator.ts (Plan 02) + - server/src/modules/leveling/lib/types.ts (CharacterContext shape) + - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 400-432 — pathbuilder-import extension pattern) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 577-587 — Pattern 6 FA auto-detect heuristic) + - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-05/D-06 prereq violations; D-09 FA auto-detect) + + + Open `server/src/modules/characters/pathbuilder-import.service.ts`. After the existing `importCharacter` function creates the Character + CharacterFeat rows (~lines 248-272), append two new steps: + + **Step A — Run prereq evaluator over imported feats and persist violations to Character.prereqViolations (D-05/D-06):** + + Add this block at the END of the `importCharacter` method, BEFORE the return statement: + + ```typescript + // === Phase 1: Prereq evaluation across imported feats (D-05, D-06) === + const characterContext: CharacterContext = { + level: character.level, + className: character.class?.name ?? '', + ancestryName: character.ancestry?.name ?? '', + heritageName: character.heritage?.name, + abilities: this.abilitiesToContextMap(character.abilities), + skills: this.skillsToContextMap(character.skills), + feats: new Set(character.feats.map(f => f.feat?.name).filter((n): n is string => !!n)), + }; + + const violations: Array<{ featId: string; featName: string; prereqText: string }> = []; + for (const characterFeat of character.feats) { + const featRecord = await this.prisma.feat.findUnique({ where: { id: characterFeat.featId } }); + if (!featRecord || !featRecord.prerequisites) continue; + const result = evaluatePrereq(featRecord.prerequisites, characterContext); + if (result.ok === false) { + violations.push({ + featId: characterFeat.featId, + featName: featRecord.name, + prereqText: featRecord.prerequisites, + }); + } + } + if (violations.length > 0) { + await this.prisma.character.update({ + where: { id: character.id }, + data: { prereqViolations: { violations } }, + }); + this.logger.log( + `PathbuilderImport: ${violations.length} prereq violations recorded for character ${character.name}`, + ); + } + ``` + + **Step B — Auto-detect Free Archetype based on imported feats (D-09):** + + Add this block immediately after Step A: + + ```typescript + // === Phase 1: Free Archetype auto-detect (D-09) === + // Heuristic per RESEARCH.md §Pattern 6: + // - count of Archetype-typed feats >= 2 → likely FA + // - OR any even level has 2+ Class-or-Archetype feats + const archetypeFeatCount = character.feats.filter(f => f.source === 'ARCHETYPE').length; + const evenLevelClassFeatCounts = new Map(); + for (const f of character.feats) { + if ((f.source === 'CLASS' || f.source === 'ARCHETYPE') && f.level && f.level % 2 === 0) { + evenLevelClassFeatCounts.set(f.level, (evenLevelClassFeatCounts.get(f.level) ?? 0) + 1); + } + } + const anyEvenLevelHasMultiple = Array.from(evenLevelClassFeatCounts.values()).some(c => c >= 2); + const detectedFA = archetypeFeatCount >= 2 || anyEvenLevelHasMultiple; + if (detectedFA) { + await this.prisma.character.update({ + where: { id: character.id }, + data: { freeArchetype: true }, + }); + this.logger.log( + `PathbuilderImport: detected FA=true for character ${character.name} ` + + `based on ${archetypeFeatCount} archetype feats and ${evenLevelClassFeatCounts.size} even levels with multiple class feats`, + ); + } + ``` + + **Add the import statement at the top of the file:** + + ```typescript + import { evaluatePrereq } from '../leveling/lib/prereq-evaluator'; + import type { CharacterContext } from '../leveling/lib/types'; + ``` + + **Helper methods** (add private methods to the service class for `abilitiesToContextMap` and `skillsToContextMap` — they convert from the Prisma row shape to the CharacterContext shape; executor implements per the actual schema fields). + + **Constraints:** + - The new code must be additive: do NOT change any existing import logic. + - If `evaluatePrereq` returns `{unknown: true}`, do NOT add it to violations (only `{ok: false}` are violations per D-06). + - The FA detection is a heuristic — A1 in RESEARCH.md flags this as LOW confidence. Per D-09, the player can flip the toggle in character settings later. + - Use NestJS `Logger` (already injected as `this.logger` if present; if not, add `private readonly logger = new Logger(PathbuilderImportService.name)`). + + + cd server && grep -c "evaluatePrereq" src/modules/characters/pathbuilder-import.service.ts + + + - File `server/src/modules/characters/pathbuilder-import.service.ts` contains the literal string `evaluatePrereq` (proves Plan 02 import) + - File contains the literal string `prereqViolations` (proves D-06 persistence) + - File contains the literal string `freeArchetype: true` (proves D-09 auto-detect) + - File contains the literal string `detected FA=true` (log message proves observability) + - Import statement `import { evaluatePrereq } from '../leveling/lib/prereq-evaluator'` present at top + - Import statement `import type { CharacterContext } from '../leveling/lib/types'` present at top + - File contains NO `: any` outside existing comments + - `cd server && npm run build` exits 0 + + + Pathbuilder import now writes Character.prereqViolations on D-06 violations and auto-sets Character.freeArchetype on D-09 FA detection. Logs both detections via NestJS Logger. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| client → REST endpoints | Untrusted JSON crosses HTTP boundary at all 5 leveling endpoints. JWT auth + class-validator + service-level guards. | +| client → WebSocket | The new `level_up_committed` event is server-emit only — no inbound WebSocket handler. | +| LevelingService → DB | Atomic transaction crosses application/DB trust boundary. Partial unique index enforced at DB level for concurrency safety. | +| PathbuilderImportService → DB | Imports user-uploaded JSON; new evaluator + persist crosses to DB. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-1-W3-01 | Spoofing | Player POSTs commit for someone else's character (forged characterId) | mitigate | All 5 endpoints call `checkCharacterAccess(characterId, userId, true)` — throws `ForbiddenException` if neither owner nor GM. Integration test 1-W3-10 covers. | +| T-1-W3-02 | Tampering | Player crafts a PATCH with 5 boost targets or non-applicable step's choices | mitigate | DTO `PatchLevelUpDto` rejects non-object shape; service `assertValidWizardChoices` does TS guard; commit-time `isValidBoostSet` check throws BadRequestException for invalid sets. | +| T-1-W3-03 | Tampering | Player commits a level beyond `currentLevel + 1` | mitigate | `commit()` validates `character.level + 1 === session.targetLevel`, throws BadRequestException otherwise. | +| T-1-W3-04 | Tampering | Race condition: two browser tabs both POST `/commit` for the same session | mitigate | Partial unique index on `(characterId) WHERE committedAt IS NULL` makes the second commit a no-op (the session has already been transitioned). Plus: `prisma.$transaction` is row-serialized; second tab sees `committedAt != null` and throws ConflictException. Integration test 1-W3-05 covers. | +| T-1-W3-05 | Tampering | WebSocket payload injection — player crafts a fake `level_up_committed` event | mitigate | Inbound WebSocket events are unaffected — `level_up_committed` is server-emit only. CharactersGateway has NO `@SubscribeMessage('level_up_committed')` handler. Other clients trust the event source = the room's server. | +| T-1-W3-06 | Information Disclosure | LevelUpHistory.snapshotBefore JSON contains character data; logs leak character names | mitigate | Per VALIDATION.md §Security, NestJS Logger output uses `characterId` not `name`; no full snapshot in logs. Snapshot lives only in DB. | +| T-1-W3-07 | Tampering | Stored XSS via German feat-prereq translation text rendered in the prereq-confirm dialog | mitigate | Translation text comes from existing `Translation.germanName` column populated from Claude API; React text-binding renders without `dangerouslySetInnerHTML`. Plan 05 (client) honors this contract. | +| T-1-W3-08 | EoP | Pathbuilder import auto-sets freeArchetype = true based on heuristic; could be exploited to gain extra slots | accept | Heuristic + LOW-confidence flag (A1 in RESEARCH.md); D-09 says player can flip the toggle in character settings. Self-hosted single-tenant — no adversarial player model. | +| T-1-W3-09 | Repudiation | Commit fails partway and Character is left half-mutated | mitigate | `prisma.$transaction` rolls back ALL writes on any failure (Pitfall #9). Integration test 1-W3-01 explicitly verifies rollback by injecting mid-tx error. | + + + +After all tasks complete: + +```bash +# Build clean +cd server && npm run build + +# All leveling tests green (lib from Plan 02 + integration from Plan 04) +cd server && npm test -- --testPathPattern=leveling + +# Type-check clean +cd server && npx tsc --noEmit -p tsconfig.json + +# Smoke test the new endpoints (server must be running) +# Use Swagger UI at http://localhost:5000/api-docs to inspect: +# POST /characters/:characterId/level-up +# PATCH /characters/:characterId/level-up/:sessionId +# GET /characters/:characterId/level-up/:sessionId/preview +# POST /characters/:characterId/level-up/:sessionId/commit +# DELETE /characters/:characterId/level-up/:sessionId + +# Verify gateway extension +grep -c "level_up_committed" server/src/modules/characters/characters.gateway.ts # ≥ 2 + +# Verify pathbuilder integration +grep -c "evaluatePrereq" server/src/modules/characters/pathbuilder-import.service.ts # ≥ 1 +grep -c "freeArchetype: true" server/src/modules/characters/pathbuilder-import.service.ts # ≥ 1 +``` + + + +- LevelingModule exists with Controller, Service, FeatFilterService, 4 DTOs + barrel +- Module registered in app.module.ts; CharactersModule exports gateway+service for cross-module injection +- 5 REST endpoints fully wired with JWT auth, Swagger annotations, German error messages +- Atomic commit via prisma.$transaction with single broadcast at the end +- Character.hpCurrent NEVER appears in the update payload (Pitfall #9 enforced + tested) +- characters.gateway.ts CharacterUpdatePayload union extended with 'level_up_committed' + JSDoc payload contract +- pathbuilder-import.service.ts integrates prereq evaluator (D-06 violations) + FA auto-detect (D-09) +- All VALIDATION.md W3 rows (1-W3-01 through 1-W3-10) covered by integration tests +- Total leveling test count ≥ 60 (50 from Plan 02 lib + ≥10 here) +- `cd server && npm test -- --testPathPattern=leveling` green +- `cd server && npm run build` green +- `cd server && npx tsc --noEmit` green + + + +After completion, create `.planning/phases/01-level-up-pf2e-regelkonform/01-04-SUMMARY.md` documenting: +- Final endpoint list with HTTP methods + paths (5 endpoints) +- Test results: total leveling test count, breakdown by spec file +- Confirmation that hpCurrent invariant test is green (Pitfall #9) +- Confirmation that broadcast-count test asserts exactly 1 (Pitfall #9 / RESEARCH First-Phase-Note) +- Notes on any deviations from the planned method bodies (e.g. spell-slot upsert keys differing from anticipated schema) +- Confirmation that pathbuilder import logs FA detection lines and writes prereqViolations + diff --git a/.planning/phases/01-level-up-pf2e-regelkonform/01-05-PLAN.md b/.planning/phases/01-level-up-pf2e-regelkonform/01-05-PLAN.md new file mode 100644 index 0000000..0c53243 --- /dev/null +++ b/.planning/phases/01-level-up-pf2e-regelkonform/01-05-PLAN.md @@ -0,0 +1,1556 @@ +--- +phase: 01-level-up-pf2e-regelkonform +plan: 05 +type: execute +wave: 4 +depends_on: ["01-01", "01-02", "01-03", "01-04"] +files_modified: + - client/src/features/characters/components/level-up/wizard-state-reducer.ts + - client/src/features/characters/components/level-up/use-level-up-session.ts + - client/src/features/characters/components/level-up/level-up-choice-card.tsx + - client/src/features/characters/components/level-up/level-up-prereq-confirm-dialog.tsx + - client/src/features/characters/components/level-up/level-up-resume-banner.tsx + - client/src/features/characters/components/level-up/level-up-violations-banner.tsx + - client/src/features/characters/components/level-up/level-up-step-class-features.tsx + - client/src/features/characters/components/level-up/level-up-step-class-feature-choice.tsx + - client/src/features/characters/components/level-up/level-up-step-boost.tsx + - client/src/features/characters/components/level-up/level-up-step-skill-increase.tsx + - client/src/features/characters/components/level-up/level-up-step-feat-class.tsx + - client/src/features/characters/components/level-up/level-up-step-feat-skill.tsx + - client/src/features/characters/components/level-up/level-up-step-feat-general.tsx + - client/src/features/characters/components/level-up/level-up-step-feat-ancestry.tsx + - client/src/features/characters/components/level-up/level-up-step-feat-archetype.tsx + - client/src/features/characters/components/level-up/level-up-step-spellcaster.tsx + - client/src/features/characters/components/level-up/level-up-step-review.tsx + - client/src/features/characters/components/level-up/level-up-wizard.tsx + - client/src/features/characters/components/character-sheet-page.tsx + - client/src/shared/lib/api.ts + - client/src/shared/types/index.ts + - client/src/shared/hooks/use-character-socket.ts +autonomous: false +requirements: [LVL-01, LVL-02, LVL-03, LVL-04, LVL-05, LVL-06, LVL-07, LVL-08, LVL-09, LVL-10, LVL-11, LVL-12, LVL-13, LVL-14, LVL-15] +tags: [react, ui, wizard, level-up, character-sheet, mobile-first, websocket-client] +must_haves: + truths: + - "Player on a character sheet can click 'Stufe steigen' (Sparkles icon) and a modal wizard opens, ranging from 4 to 11 steps depending on level/class/FA/caster, ending in a Review step." + - "Wizard chrome (header, stepper with progress label, body, footer) matches `01-UI-SPEC.md` exactly — no redesign." + - "Choice-cards show source-color badges (Klassen/Abstammung/etc. — reuse existing featSourceColors) and a yellow AlertTriangle for non-evaluable prereqs (D-03)." + - "Boost step uses +/- counters at h-11 w-11 (44×44 touch targets), shows 'wird {newScore}' live with cap-bei-18 chip when applicable." + - "Review step shows Vorher/Nachher cards (HP-Max, RK, Klassen-DC, Wahrnehmung, Saves) using server-computed preview, with Ändern links + chain re-validation contract." + - "Bestätigen runs the commit; wizard closes; toast shows; WebSocket level_up_committed event arrives at all other open clients of the character within ~1s." + - "DRAFT-Resume banner appears at the top of the character-sheet when an open DRAFT exists, with Fortsetzen + Verwerfen actions." + - "Pathbuilder-import-violations banner appears below the avatar header when Character.prereqViolations is non-null." + - "All UI text is in German; no emojis; only Lucide icons; mobile-first (44px touch targets)." + - "Human verification checkpoint passes — user walks through the wizard for a real character on a mobile viewport (375×667) and confirms each step matches UI-SPEC." + artifacts: + - path: "client/src/features/characters/components/level-up/level-up-wizard.tsx" + provides: "Outer modal container — header, stepper, motion orchestration, footer wiring" + exports: ["LevelUpWizard"] + - path: "client/src/features/characters/components/level-up/wizard-state-reducer.ts" + provides: "useReducer + WizardState/WizardEvent discriminated unions" + exports: ["wizardReducer", "WizardState", "WizardEvent"] + - path: "client/src/features/characters/components/level-up/use-level-up-session.ts" + provides: "react-query hooks: start, patch, preview, commit, discard" + exports: ["useStartLevelUpMutation", "usePatchLevelUpMutation", "useCommitLevelUpMutation", "useDiscardLevelUpMutation", "useLevelUpPreviewQuery"] + - path: "client/src/features/characters/components/level-up/level-up-step-*.tsx (12 files)" + provides: "Per-step components — class-features, class-feature-choice, boost, skill-increase, feat-class, feat-skill, feat-general, feat-ancestry, feat-archetype, spellcaster, review (and choice-card primitive)" + - path: "client/src/features/characters/components/level-up/level-up-resume-banner.tsx" + provides: "DRAFT-Resume banner — Fortsetzen / Verwerfen" + exports: ["LevelUpResumeBanner"] + - path: "client/src/features/characters/components/level-up/level-up-violations-banner.tsx" + provides: "Pathbuilder-import-violations banner with collapsible list" + exports: ["LevelUpViolationsBanner"] + - path: "client/src/features/characters/components/character-sheet-page.tsx (extended)" + provides: "Header button + 2 banner mounts + wizard mount" + - path: "client/src/shared/lib/api.ts (extended)" + provides: "5 new methods: startLevelUp, patchLevelUp, getLevelUpPreview, commitLevelUp, discardLevelUp" + - path: "client/src/shared/types/index.ts (extended)" + provides: "LevelUpSession, LevelUpPreview, WizardChoices types + extended Character with freeArchetype, prereqViolations" + - path: "client/src/shared/hooks/use-character-socket.ts (extended)" + provides: "Adds 'level_up_committed' to CharacterUpdateType union + onLevelUpCommitted callback" + key_links: + - from: "level-up-wizard.tsx" + to: "LevelingService REST API" + via: "use-level-up-session hooks → api methods → axios" + pattern: "/characters/.*level-up" + - from: "character-sheet-page.tsx" + to: "LevelUpWizard mount" + via: "showLevelUpWizard state + conditional render" + pattern: " +Build the React wizard UI exactly as specified in `01-UI-SPEC.md` — no redesign. The wizard is the user-facing surface that drives the LevelingService REST endpoints (Plan 04). It is mobile-first (Bottom-Sheet on small screens, centered modal on `sm:` and up), German throughout, and reuses existing project primitives (`Button`, `Card`, `Spinner`, `ActionIcon`, `featSourceColors`). The plan also extends the character-sheet page with the "Stufe steigen" button, the DRAFT-Resume banner, and the Pathbuilder-Import-Violations banner. + +Purpose: Without this plan, the entire backend (Plans 01-04) has no consumer. The wizard is the product surface — it converts the regelkonform engine into a usable feature for the player and GM at the table. + +Output: 18 new client files (1 reducer + 1 hooks file + 1 choice-card + 1 prereq dialog + 2 banners + 12 step components) + 1 modal container + 4 extended files (character-sheet-page, api, shared types, use-character-socket). One human-verification checkpoint at the end. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/REQUIREMENTS.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-01-SUMMARY.md +@.planning/phases/01-level-up-pf2e-regelkonform/01-04-SUMMARY.md +@client/src/features/characters/components/character-sheet-page.tsx +@client/src/features/characters/components/add-feat-modal.tsx +@client/src/features/characters/components/add-condition-modal.tsx +@client/src/features/characters/components/rest-modal.tsx +@client/src/features/characters/components/hp-control.tsx +@client/src/features/characters/components/feat-detail-modal.tsx +@client/src/shared/lib/api.ts +@client/src/shared/hooks/use-character-socket.ts +@client/src/shared/components/ui/index.ts +@client/src/index.css + + + + + + + + + + +```typescript +// Server → client (server/src/modules/characters/characters.gateway.ts) +// type='level_up_committed' data: { level: number; derived: { hpMax, ac, classDc, perception, fortitude, reflex, will } } +``` + + +```typescript +async getRestPreview(campaignId: string, characterId: string) { + const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}/rest/preview`); + return response.data; +} +``` + + +```typescript +export type CharacterUpdateType = 'hp' | 'conditions' | ... | 'dying'; +// Add: | 'level_up_committed' +``` + + +```typescript +const featSourceColors = { + Class: 'bg-red-500/20 text-red-400', + Ancestry: 'bg-blue-500/20 text-blue-400', + General: 'bg-yellow-500/20 text-yellow-400', + Skill: 'bg-green-500/20 text-green-400', + Archetype: 'bg-purple-500/20 text-purple-400', + Bonus: 'bg-cyan-500/20 text-cyan-400', +}; +``` + + +```tsx +
+
+
+ {/* Header / Body / Footer */} +
+
+``` + + + + + +```typescript +export type StepKind = 'class-features' | 'class-feature-choice' | 'boost' | 'skill-increase' + | 'feat-class' | 'feat-skill' | 'feat-general' | 'feat-ancestry' | 'feat-archetype' + | 'spellcaster' | 'review'; +``` + + + + + + + Task 1: Extend shared types + client API client + use-character-socket hook + + client/src/shared/types/index.ts, + client/src/shared/lib/api.ts, + client/src/shared/hooks/use-character-socket.ts + + + - client/src/shared/types/index.ts (entire file — must understand existing Character + Campaign types) + - client/src/shared/lib/api.ts (lines 381-388 — getRestPreview/performRest analog; entire file for structure) + - client/src/shared/hooks/use-character-socket.ts (entire file — must understand CharacterUpdateType union, existing onXxxUpdate callbacks) + - server/src/modules/leveling/dto/level-up-state.dto.ts (Plan 04 — LevelUpSessionDto + LevelUpPreviewDto field shapes) + - server/src/modules/characters/characters.gateway.ts (Plan 04 — extended payload doc) + - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 769-808 — api.ts extension pattern) + + + **Step 1 — Add types to `client/src/shared/types/index.ts`:** + + Append to the existing types file: + + ```typescript + // ============ Phase 1 Level-Up types ============ + + export type AbilityAbbreviation = 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA'; + export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY'; + + export type StepKind = + | 'class-features' + | 'class-feature-choice' + | 'boost' + | 'skill-increase' + | 'feat-class' + | 'feat-skill' + | 'feat-general' + | 'feat-ancestry' + | 'feat-archetype' + | 'spellcaster' + | 'review'; + + export interface WizardChoices { + boostTargets?: AbilityAbbreviation[]; + skillIncrease?: { skillName: string; toRank: Proficiency }; + featClassId?: string; + featSkillId?: string; + featGeneralId?: string; + featAncestryId?: string; + featArchetypeId?: string; + classFeatureChoices?: Record; // optionsRef → optionKey + spellcasterRepertoirePicks?: string[]; // spell IDs + } + + export interface LevelUpSession { + id: string; + characterId: string; + targetLevel: number; + state: { choices: WizardChoices; acknowledgedNonEvaluablePrereqs?: string[] }; + committedAt: string | null; + createdAt: string; + updatedAt: string; + } + + export interface DerivedStats { + level: number; + hpMax: number; + ac: number; + classDc: number; + perception: number; + fortitude: number; + reflex: number; + will: number; + } + + export interface LevelUpPreview { + before: DerivedStats; + after: DerivedStats; + spellcaster?: { + slotIncrements: { tradition: string; spellLevel: number; count: number }[]; + cantripDelta?: number; + repertoireDelta?: number; + }; + } + + export interface PrereqViolation { + featId: string; + featName: string; + prereqText: string; + } + ``` + + **Step 2 — Extend the existing `Character` interface** in the same file. Find the `interface Character {` block and append two fields: + + ```typescript + export interface Character { + // ... existing fields ... + freeArchetype?: boolean; + prereqViolations?: { violations: PrereqViolation[] } | null; + } + ``` + + **Step 3 — Add 5 methods to `client/src/shared/lib/api.ts`** alongside the existing methods (analog: `getRestPreview` at lines 381-388): + + ```typescript + // ============ Phase 1 Level-Up methods ============ + + async startLevelUp(characterId: string, targetLevel?: number): Promise { + const response = await this.client.post( + `/characters/${characterId}/level-up`, + { targetLevel }, + ); + return response.data; + } + + async patchLevelUp( + characterId: string, + sessionId: string, + state: Partial<{ choices: WizardChoices; acknowledgedNonEvaluablePrereqs?: string[] }>, + ): Promise { + const response = await this.client.patch( + `/characters/${characterId}/level-up/${sessionId}`, + { state }, + ); + return response.data; + } + + async getLevelUpPreview(characterId: string, sessionId: string): Promise { + const response = await this.client.get( + `/characters/${characterId}/level-up/${sessionId}/preview`, + ); + return response.data; + } + + async commitLevelUp( + characterId: string, + sessionId: string, + acknowledgePrereqWarnings?: boolean, + ): Promise { + const response = await this.client.post( + `/characters/${characterId}/level-up/${sessionId}/commit`, + { acknowledgePrereqWarnings }, + ); + return response.data; + } + + async discardLevelUp(characterId: string, sessionId: string): Promise { + await this.client.delete( + `/characters/${characterId}/level-up/${sessionId}`, + ); + } + ``` + + Add the imports at the top of api.ts: + ```typescript + import type { Character, LevelUpSession, LevelUpPreview, WizardChoices } from '@/shared/types'; + ``` + + **Step 4 — Extend `client/src/shared/hooks/use-character-socket.ts`:** + + Find the `CharacterUpdateType` union (line 15) and append `'level_up_committed'`: + + ```typescript + export type CharacterUpdateType = + | 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status' + | 'rest' | 'alchemy_vials' | 'alchemy_formulas' | 'alchemy_prepared' | 'alchemy_state' + | 'dying' + | 'level_up_committed'; + ``` + + Find the `UseCharacterSocketOptions` interface and add a new optional callback field (analog: `onRestUpdate` ~line 53): + + ```typescript + export interface UseCharacterSocketOptions { + // ... existing fields ... + onLevelUpCommitted?: (data: { + level: number; + derived: import('@/shared/types').DerivedStats; + }) => void; + } + ``` + + Find the place in the hook body where `onAlchemyUpdate` and similar callbacks are dispatched (look for the switch on `payload.type` / similar). Add the new branch: + + ```typescript + if (payload.type === 'level_up_committed') { + options.onLevelUpCommitted?.(payload.data as { level: number; derived: DerivedStats }); + } + ``` + + Import `DerivedStats` if not already. + + **Constraints (per CLAUDE.md):** + - TypeScript strict — no `any` outside the existing untyped places. + - Use `import type` for type-only imports. + - All file naming kebab-case (these files already exist and are kebab-case). + - All UI text added later is German. + + + cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "level-up|LevelUp|level_up_committed" || echo "tsc clean" + + + - `client/src/shared/types/index.ts` exports `LevelUpSession`, `LevelUpPreview`, `DerivedStats`, `WizardChoices`, `StepKind`, `Proficiency`, `AbilityAbbreviation`, `PrereqViolation` + - `client/src/shared/types/index.ts` Character interface has `freeArchetype?: boolean` and `prereqViolations?` fields + - `client/src/shared/lib/api.ts` contains 5 new method names: `startLevelUp`, `patchLevelUp`, `getLevelUpPreview`, `commitLevelUp`, `discardLevelUp` + - `client/src/shared/hooks/use-character-socket.ts` CharacterUpdateType union contains `'level_up_committed'` + - `client/src/shared/hooks/use-character-socket.ts` UseCharacterSocketOptions interface contains `onLevelUpCommitted?:` + - `cd client && npx tsc --noEmit -p tsconfig.app.json` exits 0 + + + Shared client surface (types + api + socket) extended to talk to the Plan 04 server endpoints + receive the new WebSocket event. + + + + + Task 2: wizard-state-reducer.ts + use-level-up-session.ts (state machine + react-query hooks) + + client/src/features/characters/components/level-up/wizard-state-reducer.ts, + client/src/features/characters/components/level-up/use-level-up-session.ts + + + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 354-401 — exact reducer shape; lines 812-816 — react-query hook recommendation) + - .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md (entire — reducer must support every interaction the UI needs, especially the Ändern revision contract) + - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 819-823 — wizard-state-reducer; no analog — first useReducer in codebase) + - server/src/modules/leveling/lib/types.ts (Plan 02 — StepKind, WizardChoices types — mirror in client) + - client/src/shared/types/index.ts (Task 1 added the wire types — reuse) + + + **Step 1 — Create `wizard-state-reducer.ts`:** + + ```typescript + import type { StepKind, WizardChoices } from '@/shared/types'; + + /** + * The full wizard state — JSON-serializable so it survives PATCH to the DRAFT. + * Mirror of server/src/modules/leveling/lib/types.ts WizardChoices, plus client-only + * navigation/UI fields. + */ + export interface WizardState { + sessionId: string; + targetLevel: number; + steps: StepKind[]; + currentIdx: number; + choices: WizardChoices; + acknowledgedNonEvaluablePrereqs: string[]; + 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 } + | { type: 'GO_TO_STEP_FROM_REVIEW'; idx: number } // sets revisionMode + | { type: 'RETURN_TO_REVIEW' } // clears revisionMode + chain re-validation + | { type: 'SET_BOOST_TARGETS'; targets: WizardChoices['boostTargets'] } + | { type: 'SET_SKILL_INCREASE'; pick: WizardChoices['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 }; + + export function wizardReducer(state: WizardState, ev: WizardEvent): WizardState { + switch (ev.type) { + case 'GO_NEXT': + return state.currentIdx < state.steps.length - 1 + ? { ...state, currentIdx: state.currentIdx + 1 } + : state; + case 'GO_PREV': + return state.currentIdx > 0 + ? { ...state, currentIdx: state.currentIdx - 1 } + : state; + case 'GO_TO_STEP': + if (ev.idx < 0 || ev.idx > state.currentIdx) return state; // can't jump forward to incomplete + return { ...state, currentIdx: ev.idx }; + case 'GO_TO_STEP_FROM_REVIEW': { + const reviewIdx = state.steps.findIndex(s => s === 'review'); + return { ...state, currentIdx: ev.idx, revisionMode: { fromStep: reviewIdx } }; + } + case 'RETURN_TO_REVIEW': { + const reviewIdx = state.steps.findIndex(s => s === 'review'); + // CHAIN RE-VALIDATION: clear any later-step choice that depends on revised state. + // Phase 1 implementation: clear all feat slots + spellcaster picks if a boost was changed + // after the user revised (conservative). Production refinement + // can be more granular per UI-SPEC §Ändern revision contract. + // For now: clear nothing; let Plan 04's commit-time validation surface invalid combos. + return { ...state, currentIdx: reviewIdx, revisionMode: null }; + } + case 'SET_BOOST_TARGETS': + return { ...state, choices: { ...state.choices, boostTargets: ev.targets } }; + case 'SET_SKILL_INCREASE': + return { ...state, choices: { ...state.choices, skillIncrease: ev.pick } }; + case 'SET_FEAT': { + const fieldName = `feat${ev.slot.charAt(0).toUpperCase() + ev.slot.slice(1)}Id` as keyof WizardChoices; + return { ...state, choices: { ...state.choices, [fieldName]: ev.featId } }; + } + case 'SET_CLASS_FEATURE_CHOICE': + return { + ...state, + choices: { + ...state.choices, + classFeatureChoices: { + ...(state.choices.classFeatureChoices ?? {}), + [ev.key]: ev.optionId, + }, + }, + }; + case 'SET_REPERTOIRE_PICKS': + return { ...state, choices: { ...state.choices, spellcasterRepertoirePicks: ev.picks } }; + case 'ACKNOWLEDGE_PREREQ_WARNING': + return state.acknowledgedNonEvaluablePrereqs.includes(ev.featId) + ? state + : { ...state, acknowledgedNonEvaluablePrereqs: [...state.acknowledgedNonEvaluablePrereqs, ev.featId] }; + } + } + + /** Initialize the wizard state from a freshly-fetched LevelUpSession. */ + export function initWizardState(session: { + id: string; + targetLevel: number; + state: { choices: WizardChoices; acknowledgedNonEvaluablePrereqs?: string[] }; + }, steps: StepKind[]): WizardState { + return { + sessionId: session.id, + targetLevel: session.targetLevel, + steps, + currentIdx: 0, + choices: session.state.choices ?? {}, + acknowledgedNonEvaluablePrereqs: session.state.acknowledgedNonEvaluablePrereqs ?? [], + revisionMode: null, + }; + } + ``` + + **Step 2 — Create `use-level-up-session.ts`** (react-query hooks): + + ```typescript + import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + import { api } from '@/shared/lib/api'; + import type { LevelUpSession, LevelUpPreview, WizardChoices } from '@/shared/types'; + + /** + * Start or resume a LevelUpSession for the given character. + */ + export function useStartLevelUpMutation(characterId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (targetLevel?: number) => api.startLevelUp(characterId, targetLevel), + onSuccess: (session) => { + qc.setQueryData(['levelUpSession', characterId], session); + }, + }); + } + + export function usePatchLevelUpMutation(characterId: string, sessionId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (state: Partial<{ choices: WizardChoices; acknowledgedNonEvaluablePrereqs?: string[] }>) => + api.patchLevelUp(characterId, sessionId, state), + onSuccess: (session) => { + qc.setQueryData(['levelUpSession', characterId], session); + }, + }); + } + + export function useLevelUpPreviewQuery(characterId: string, sessionId: string, enabled: boolean) { + return useQuery({ + queryKey: ['levelUpPreview', characterId, sessionId], + queryFn: () => api.getLevelUpPreview(characterId, sessionId), + enabled, + staleTime: 0, // always re-fetch when the user reaches Review + }); + } + + export function useCommitLevelUpMutation(characterId: string, sessionId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (acknowledgePrereqWarnings?: boolean) => + api.commitLevelUp(characterId, sessionId, acknowledgePrereqWarnings), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['character', characterId] }); + qc.removeQueries({ queryKey: ['levelUpSession', characterId] }); + }, + }); + } + + export function useDiscardLevelUpMutation(characterId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (sessionId: string) => api.discardLevelUp(characterId, sessionId), + onSuccess: () => { + qc.removeQueries({ queryKey: ['levelUpSession', characterId] }); + }, + }); + } + ``` + + **Constraint:** No `any` types. The mutation payload types must match the Plan 04 DTOs exactly. + + + cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "wizard-state-reducer|use-level-up-session" || echo "tsc clean" + + + - File `client/src/features/characters/components/level-up/wizard-state-reducer.ts` exists + - File exports `wizardReducer`, `WizardState`, `WizardEvent`, `initWizardState` + - File contains a `case 'RETURN_TO_REVIEW':` (proves revision-mode contract) + - File contains NO `: any` outside comments + - File `client/src/features/characters/components/level-up/use-level-up-session.ts` exists + - File exports `useStartLevelUpMutation`, `usePatchLevelUpMutation`, `useLevelUpPreviewQuery`, `useCommitLevelUpMutation`, `useDiscardLevelUpMutation` + - File imports `useMutation`, `useQuery`, `useQueryClient` from `@tanstack/react-query` + - `cd client && npx tsc --noEmit -p tsconfig.app.json` exits 0 + + + Reducer + hooks compile cleanly; ready to be consumed by the wizard container. + + + + + Task 3: Choice-Card primitive + Prereq-Confirm dialog + 2 banners (the shared building blocks) + + client/src/features/characters/components/level-up/level-up-choice-card.tsx, + client/src/features/characters/components/level-up/level-up-prereq-confirm-dialog.tsx, + client/src/features/characters/components/level-up/level-up-resume-banner.tsx, + client/src/features/characters/components/level-up/level-up-violations-banner.tsx + + + - .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md (entire — these four components are fully specified): + - Choice-Card: lines 308-359 (canonical layout, locked / selected / warning states) + - Prereq-Confirm Dialog: lines 570-579 (z-60 layered modal, AlertTriangle + raw prereq quote) + - DRAFT-Resume Banner: lines 583-613 (full JSX provided in UI-SPEC) + - Violations Banner: lines 617-649 (full JSX provided in UI-SPEC) + - client/src/features/characters/components/feat-detail-modal.tsx (lines 22-29 — featSourceColors map to REUSE) + - client/src/features/characters/components/add-feat-modal.tsx (lines 272-330 — feat card pattern + add-condition-modal.tsx:91-103 — modal chrome) + - client/src/shared/components/ui/index.ts (Button, Card, etc. exports) + - client/src/index.css (design tokens — bg-bg-tertiary, text-warning-500, etc.) + + + Implement each component EXACTLY per `01-UI-SPEC.md`. Do NOT redesign — UI-SPEC is the contract. + + **1. `level-up-choice-card.tsx`** — UI-SPEC §"Component Contract — Choice-Card" (lines 308-359): + + ```tsx + import { Lock, AlertTriangle, Check } from 'lucide-react'; + import { cn } from '@/shared/lib/utils'; + + export interface ChoiceCardOption { + id: string; + title: string; // German display name + englishName?: string; // sub-line if German != English + description?: string; // truncated to 2 lines via line-clamp-2 + sourceBadge?: { label: string; colorClass: string }; // colorClass from featSourceColors map + levelChip?: number; + actionCost?: 'A' | 'AA' | 'AAA' | 'R' | 'F' | null; // ActionIcon + rarityChip?: 'Uncommon' | 'Rare' | 'Unique'; + isSelected: boolean; + isLocked: boolean; // shows Lock icon, opacity 60%, disabled + lockReason?: string; // tooltip + hasNonEvaluablePrereq: boolean; // shows yellow AlertTriangle + rawPrereqText?: string; // for AlertTriangle tooltip + onSelect: (id: string) => void; + onMore?: () => void; // opens FeatDetailModal at z-60 + } + + export function ChoiceCard(props: ChoiceCardOption) { + // Implement per UI-SPEC §Choice-Card layout. + // - Container: + )} + + ); + } + ``` + + **2. `level-up-prereq-confirm-dialog.tsx`** — UI-SPEC §"Component Contract — Prereq-Confirm Dialog" (lines 570-579): + + ```tsx + import { AlertTriangle } from 'lucide-react'; + import { Button } from '@/shared/components/ui'; + + export interface PrereqConfirmDialogProps { + featName: string; + rawPrereqText: string; + onCancel: () => void; + onConfirm: () => void; + } + + /** Layered modal at z-60 (sits over the wizard z-50). Used for non-evaluable prereqs (D-03). */ + export function PrereqConfirmDialog({ rawPrereqText, onCancel, onConfirm }: PrereqConfirmDialogProps) { + return ( +
+
+
+
+ +

Voraussetzung nicht prüfbar

+
+

Voraussetzung:

+

+ „{rawPrereqText}" +

+

+ Dimension47 kann diese Bedingung nicht automatisch prüfen. Erfüllst du sie? +

+
+ + +
+
+
+ ); + } + ``` + + **3. `level-up-resume-banner.tsx`** — UI-SPEC §"Component Contract — DRAFT-Resume Banner" (lines 583-613). Use the exact JSX from UI-SPEC lines 591-613 with these tweaks: + + ```tsx + import { RotateCcw, Trash2 } from 'lucide-react'; + import { Button } from '@/shared/components/ui'; + + export interface LevelUpResumeBannerProps { + targetLevel: number; + lastEditedRelative: string; // e.g. "vor 3 Tagen" via Intl.RelativeTimeFormat + onResume: () => void; + onDiscard: () => void; + } + + export function LevelUpResumeBanner({ targetLevel, lastEditedRelative, onResume, onDiscard }: LevelUpResumeBannerProps) { + return ( +
+
+ +
+

+ Du hast eine offene Stufenaufstiegs-Session — Stufe {targetLevel}. +

+

+ Zuletzt bearbeitet: {lastEditedRelative}. +

+
+
+
+ + +
+
+ ); + } + ``` + + **4. `level-up-violations-banner.tsx`** — UI-SPEC §"Component Contract — Pathbuilder-Import-Violations Banner" (lines 617-649). Use the exact JSX from UI-SPEC with these tweaks: + + ```tsx + import { useState } from 'react'; + import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react'; + import { Button } from '@/shared/components/ui'; + import type { PrereqViolation } from '@/shared/types'; + + export interface LevelUpViolationsBannerProps { + violations: PrereqViolation[]; + } + + export function LevelUpViolationsBanner({ violations }: LevelUpViolationsBannerProps) { + const [expanded, setExpanded] = useState(false); + if (violations.length === 0) return null; + return ( +
+
+ +
+

+ {violations.length} Talente mit nicht erfüllter Voraussetzung +

+

+ Beim Import wurde festgestellt, dass folgende Talente Voraussetzungen haben, + die der Charakter nicht erfüllt. Liste prüfen und ggf. nachträglich anpassen. +

+ + {expanded && ( +
    + {violations.map(v => ( +
  • + {v.featName} + — Voraussetzung: {v.prereqText} +
  • + ))} +
+ )} +
+
+
+ ); + } + ``` + + **Constraints:** All copy in German. Touch targets meet 44px floor. Lucide icons only (no emojis). + + + cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "level-up-(choice-card|prereq-confirm|resume-banner|violations-banner)" || echo "tsc clean" + + + - All 4 files exist in `client/src/features/characters/components/level-up/` + - `level-up-choice-card.tsx` exports `ChoiceCard` and `ChoiceCardOption` interface + - `level-up-choice-card.tsx` contains `Lock`, `AlertTriangle`, `Check` icon imports + the conditional-render block for each + - `level-up-prereq-confirm-dialog.tsx` exports `PrereqConfirmDialog` + uses `z-[60]` + - `level-up-prereq-confirm-dialog.tsx` contains the German strings: `Voraussetzung nicht prüfbar`, `Trotzdem wählen`, `Abbrechen` + - `level-up-resume-banner.tsx` exports `LevelUpResumeBanner` + contains German `Du hast eine offene Stufenaufstiegs-Session` + - `level-up-violations-banner.tsx` exports `LevelUpViolationsBanner` + contains the German violations heading + - All 4 files use Lucide icons only (no emojis); verify with `grep -E "[\\u{1F000}-\\u{1FFFF}]"` returning no matches + - All 4 files use `Button` from `@/shared/components/ui` + - `cd client && npx tsc --noEmit -p tsconfig.app.json` exits 0 + + + Four shared UI primitives exist matching UI-SPEC; ready for consumption by step components and the wizard container. + + + + + Task 4: 12 step components (class-features, class-feature-choice, boost, skill-increase, 5×feat, spellcaster, review) + + client/src/features/characters/components/level-up/level-up-step-class-features.tsx, + client/src/features/characters/components/level-up/level-up-step-class-feature-choice.tsx, + client/src/features/characters/components/level-up/level-up-step-boost.tsx, + client/src/features/characters/components/level-up/level-up-step-skill-increase.tsx, + client/src/features/characters/components/level-up/level-up-step-feat-class.tsx, + client/src/features/characters/components/level-up/level-up-step-feat-skill.tsx, + client/src/features/characters/components/level-up/level-up-step-feat-general.tsx, + client/src/features/characters/components/level-up/level-up-step-feat-ancestry.tsx, + client/src/features/characters/components/level-up/level-up-step-feat-archetype.tsx, + client/src/features/characters/components/level-up/level-up-step-spellcaster.tsx, + client/src/features/characters/components/level-up/level-up-step-review.tsx + + + - .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md (per-step contracts): + - Class-Features step: §Wizard-step screen headings (line 156) + - Class-Feature-Choice (D-19): §Component Contract — Choice-Klassenmerkmal Sub-Step (lines 480-489) + - Boost: §Component Contract — Boost-Set Step (lines 362-423) — FULL JSX provided + - Skill-Increase: §Component Contract — Skill-Increase Step (lines 426-450) + - Feat steps: §Component Contract — Choice-Card (lines 308-359) — all 5 feat steps reuse ChoiceCard with different filters + - FA-Slot: §Component Contract — Free-Archetype-Slot Step (lines 454-463) + - Spellcaster: §Component Contract — Spellcaster Step (lines 466-477) + - Review: §Component Contract — Review Step (lines 492-565) — Section A choices summary + Section B Vorher/Nachher cards + Section C spellcaster diff + Ändern revision contract + - client/src/features/characters/components/level-up/level-up-choice-card.tsx (Task 3 — primitive) + - client/src/features/characters/components/level-up/wizard-state-reducer.ts (Task 2 — events to dispatch) + - client/src/features/characters/components/feat-detail-modal.tsx (lines 22-29 — featSourceColors map to REUSE) + - client/src/features/characters/components/hp-control.tsx (analog: +/- counter pattern for Boost step) + + + Implement each of the 11 step components per UI-SPEC. Each component: + - Receives `(state: WizardState, dispatch: (ev: WizardEvent) => void, characterId: string, ...)` as props OR uses a context provider — planner discretion. + - Uses the ChoiceCard primitive from Task 3 for talent picks (5 feat steps + class-feature-choice + spellcaster repertoire picks). + - Uses the existing FeatDetailModal (or a new `level-up-detail-modal.tsx` if needed) for the Mehr / detail-popover, opened at `z-60` per UI-SPEC. + + **Critical implementation notes per step:** + + **A. `level-up-step-class-features.tsx`** — Auto-summary, no input. Renders the `ClassProgression.grants` list for the new level as a read-only list. Header + sub-line per UI-SPEC. Server endpoint to fetch the list: GET ClassProgression for `(className, targetLevel)` — Plan 04 may need a small read endpoint OR the wizard can fetch from a public ClassProgression endpoint. If neither exists, the wizard receives the data via the LevelUpSession's preview endpoint. + + **B. `level-up-step-class-feature-choice.tsx`** — D-19 sub-step (Cleric Doctrine, Wizard School, etc.). Fetches options from `ClassFeatureOption` table where `optionsRef = ClassProgression.choiceOptionsRef`. Renders one ChoiceCard per option (single-select / radio-group semantics). Dispatches `SET_CLASS_FEATURE_CHOICE`. **Server endpoint required for fetching options** — if not in Plan 04, either add a small public GET endpoint OR include the option list in the LevelUpSession.state at start time. Planner: prefer adding `GET /class-feature-options/:optionsRef` to the leveling controller as a small Plan 04 patch, OR include in the start-session response. Document the chosen path in plan SUMMARY. + + **C. `level-up-step-boost.tsx`** — UI-SPEC lines 362-423 give the FULL JSX. Use it verbatim with these wires: + - State source: `state.choices.boostTargets` (current selection — array of 0..4 strings) + - Per attribute, count = 1 if in boostTargets else 0 + - Dec: removes the ability from boostTargets. Inc: adds (only if length < 4 and not already present) + - Dispatches `SET_BOOST_TARGETS` + - Live-preview "wird {newScore}" calls a local `applyBoostLocal(currentScore)` helper (mirror of server's applyAttributeBoost — could import shared lib if a shared package were set up; for now, inline a 5-line client helper) + - Footer-helper "X von 4 Boosts gewählt" + + **D. `level-up-step-skill-increase.tsx`** — UI-SPEC lines 426-450. Renders all skills as compact rows. For each row: current rank → next rank (or "Cap erreicht" chip if `canIncreaseSkill` would return false; mirror Plan 02 logic client-side). Dispatch `SET_SKILL_INCREASE` on click. + + **E. `level-up-step-feat-class.tsx`** through **`level-up-step-feat-archetype.tsx`** (5 files) — All five feat steps share the same shape: + - Fetch filtered feat list from a server endpoint. **Endpoint needed:** `GET /characters/:characterId/level-up/:sessionId/feats?slot={class|skill|general|ancestry|archetype}` returning `FeatWithEval[]` (Plan 04's FeatFilterService output). If Plan 04 didn't expose this endpoint via the LevelingController, ADD it now as a Plan 04 patch — see Task 4 below for the patch action. + - Render each feat as a ChoiceCard. + - For non-evaluable prereqs: show yellow AlertTriangle on the card; clicking the card opens the PrereqConfirmDialog (Task 3) → on confirm, dispatch `ACKNOWLEDGE_PREREQ_WARNING` + `SET_FEAT`. + - For failed prereqs (`{ok:false}`): hidden by default; toggle "Auch nicht erfüllbare anzeigen" reveals them grayed. + - Slot-specific: feat-archetype only renders if `state.steps.includes('feat-archetype')` (FA enabled); shows the "vor/nach Dedication" filter per UI-SPEC line 462. + + **F. `level-up-step-spellcaster.tsx`** — UI-SPEC lines 466-477. Two sub-shapes: + - Prepared caster: info strip "+N Slot auf Grad M (automatisch)" + immediate `Weiter` enable. + - Spontaneous caster: info strip + repertoire-pick sub-section. Renders spell ChoiceCards filtered by tradition. Dispatch `SET_REPERTOIRE_PICKS`. + + **G. `level-up-step-review.tsx`** — UI-SPEC lines 492-565. THIS IS THE LOAD-BEARING STEP: + - **Section A** — Wahlen-Zusammenfassung: Card with one row per choice. Each row has an `Ändern` link that dispatches `GO_TO_STEP_FROM_REVIEW` (entering revision mode). + - **Section B** — Vorher/Nachher cards: Use `useLevelUpPreviewQuery(characterId, sessionId, enabled=true)` from Task 2. Render two `Card`s side-by-side on `sm:`, stacked on mobile. Display `before` / `after` HP-Max, RK, Klassen-DC, Wahrnehmung, Saves. Use `font-mono text-2xl` for stat numbers. Show success-tinted delta chips (`+X`). + - **Section C** — Spellcaster diff (only if `preview.spellcaster` is non-null): table of slot increments + repertoire delta. + - **Bestätigen button** in the wizard footer (handled by the wizard container, NOT this step) calls `useCommitLevelUpMutation(characterId, sessionId).mutateAsync(...)`. + + **Constraint specific to all step files:** + - Each is a function component with named export. + - Props interface ends with `Props`. + - All copy in German (UI-SPEC §Wizard-step screen headings line 156 supplies the exact strings). + - All touch targets ≥ 44px. + - No `: any`. + + **Plan 04 patch (do this BEFORE the feat steps fetch from it):** Add `GET /characters/:characterId/level-up/:sessionId/feats?slot=...` to LevelingController. Implementation: + ```typescript + @Get(':sessionId/feats') + async getFeats( + @Param('characterId') characterId: string, + @Param('sessionId') sessionId: string, + @Query('slot') slot: SlotKind, + @CurrentUser('id') userId: string, + ) { + return this.levelingService.getFeatsForSlot(characterId, sessionId, slot, userId); + } + ``` + The corresponding service method calls `featFilterService.getFilteredFeats({ slot, character: ctx, ... })`. Add it to leveling.service.ts (Plan 04 file already exists; this is a small extension). + + Similarly add `GET /characters/:characterId/level-up/:sessionId/class-feature-options/:optionsRef` for the class-feature-choice step. + + + cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "level-up-step" || echo "tsc clean" + + + - All 11 step files exist in `client/src/features/characters/components/level-up/` + - Each file uses `function` export with PascalCase name (e.g. `LevelUpStepBoost`) + - Each file imports `Button` and other primitives from `@/shared/components/ui` + - Each file uses German strings matching UI-SPEC §Copywriting (line 156) + - `level-up-step-boost.tsx` contains the literal string `wird` (live preview text) + - `level-up-step-boost.tsx` contains `h-11 w-11` (44px +/- buttons) + - `level-up-step-boost.tsx` contains the literal string `Cap bei 18` (warning chip) + - `level-up-step-skill-increase.tsx` contains the literal string `Cap erreicht` + - `level-up-step-review.tsx` uses `useLevelUpPreviewQuery` (Task 2 hook) + - `level-up-step-review.tsx` contains the literal string `Vorher` AND `Nachher` + - All step files contain NO `: any` outside comments + - All step files contain NO emoji literals + - `cd client && npx tsc --noEmit -p tsconfig.app.json` exits 0 + - `cd server && npm run build` exits 0 (the Plan 04 patch for new GET endpoints compiles) + + + All 11 step components implemented per UI-SPEC; all use German copy; all use ChoiceCard for talent picks; Review step wires up the preview query; the supporting Plan 04 patches for feats and class-feature-options GET endpoints land cleanly. + + + + + Task 5: Wizard container (level-up-wizard.tsx) — chrome, stepper, motion, footer wiring + client/src/features/characters/components/level-up/level-up-wizard.tsx + + - .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md (entire — chrome/header/stepper/footer all specified): + - Chrome §Component Contract — Wizard Chrome (lines 221-305) + - Motion §Motion (lines 654-665) + - States §States Inventory (lines 671-687) + - client/src/features/characters/components/level-up/wizard-state-reducer.ts (Task 2) + - client/src/features/characters/components/level-up/use-level-up-session.ts (Task 2) + - client/src/features/characters/components/level-up/level-up-step-*.tsx (Task 4 — all 11) + - client/src/features/characters/components/level-up/level-up-prereq-confirm-dialog.tsx (Task 3) + - client/src/features/characters/components/rest-modal.tsx (analog: REST + commit lifecycle) + - client/src/features/characters/components/add-feat-modal.tsx (analog: modal chrome) + + + Create `level-up-wizard.tsx` — the outer container that: + 1. Renders the modal chrome per UI-SPEC §Component Contract — Wizard Chrome. + 2. Loads the session via `useStartLevelUpMutation` on mount (start-or-resume). + 3. Computes the step list — the wizard receives the StepKind list from the session (server adds it to the start-session response, OR the wizard fetches GET ClassProgression to know `choiceType` and computes locally; planner: prefer server-provided list to keep one source of truth — Plan 04 patch: `LevelUpSessionDto` includes a `steps: StepKind[]` field). + 4. Initializes the reducer via `initWizardState(session, steps)`. + 5. Renders the active step component based on `state.steps[state.currentIdx]`. + 6. PATCHes the DRAFT after each meaningful state change (debounced — every 500ms after a SET_* event). + 7. Wraps the active step in `` + `` for slide transitions per UI-SPEC §Motion. + 8. Renders the stepper, header (with X close button + character name), and footer (Zurück / Weiter / Bestätigen / Zurück zur Übersicht based on state). + 9. Handles the backdrop-click and X-close → opens the "Wizard mid-flow abbrechen" confirm dialog (reuse PrereqConfirmDialog with different copy, OR a separate small dialog component — planner discretion). + 10. On Bestätigen: `useCommitLevelUpMutation.mutateAsync()` → on success, close the wizard, fire `onCommitted` callback (parent invalidates character query + shows toast). + + **Skeleton:** + + ```tsx + import { useEffect, useReducer, useMemo, useState } from 'react'; + import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; + import { X, ChevronLeft, ChevronRight, Check, ArrowLeft } from 'lucide-react'; + import { Button, Spinner } from '@/shared/components/ui'; + import { cn } from '@/shared/lib/utils'; + import { wizardReducer, initWizardState } from './wizard-state-reducer'; + import { + useStartLevelUpMutation, + usePatchLevelUpMutation, + useCommitLevelUpMutation, + } from './use-level-up-session'; + import { LevelUpStepClassFeatures } from './level-up-step-class-features'; + import { LevelUpStepClassFeatureChoice } from './level-up-step-class-feature-choice'; + import { LevelUpStepBoost } from './level-up-step-boost'; + import { LevelUpStepSkillIncrease } from './level-up-step-skill-increase'; + import { LevelUpStepFeatClass } from './level-up-step-feat-class'; + import { LevelUpStepFeatSkill } from './level-up-step-feat-skill'; + import { LevelUpStepFeatGeneral } from './level-up-step-feat-general'; + import { LevelUpStepFeatAncestry } from './level-up-step-feat-ancestry'; + import { LevelUpStepFeatArchetype } from './level-up-step-feat-archetype'; + import { LevelUpStepSpellcaster } from './level-up-step-spellcaster'; + import { LevelUpStepReview } from './level-up-step-review'; + import type { Character, StepKind } from '@/shared/types'; + + export interface LevelUpWizardProps { + character: Character; + onClose: () => void; + onCommitted: () => void; + } + + const STEP_LABELS: Record = { + 'class-features': 'Merkmale', + 'class-feature-choice': 'Wahl', + 'boost': 'Boost', + 'skill-increase': 'Skill', + 'feat-class': 'Klasse', + 'feat-skill': 'Fertigkeit', + 'feat-general': 'Allgemein', + 'feat-ancestry': 'Abstammung', + 'feat-archetype': 'Archetyp', + 'spellcaster': 'Zauber', + 'review': 'Übersicht', + }; + + const STEP_HEADINGS: Record = { + 'class-features': { heading: 'Klassenmerkmale auf Stufe {N}', sub: 'Diese Merkmale werden automatisch übernommen.' }, + // ... all 11 per UI-SPEC line 156 table + }; + + export function LevelUpWizard({ character, onClose, onCommitted }: LevelUpWizardProps) { + const startMut = useStartLevelUpMutation(character.id); + const [state, dispatch] = useReducer(wizardReducer, undefined as never); + const [direction, setDirection] = useState<'forward' | 'back'>('forward'); + const [showAbortDialog, setShowAbortDialog] = useState(false); + const reducedMotion = useReducedMotion(); + + // Start/resume on mount + useEffect(() => { + startMut.mutate(undefined); + }, [character.id]); + + // Once session loads, init the reducer + // (executor wires up: pull steps from session response + initWizardState) + + // PATCH on choice change — debounced 500ms + // (executor wires up: useEffect on state.choices, debounced patchLevelUp) + + const commitMut = useCommitLevelUpMutation(character.id, state?.sessionId ?? ''); + + const handleClose = () => { + if (state && Object.keys(state.choices).length > 0) { + setShowAbortDialog(true); + } else { + onClose(); + } + }; + + const handleCommit = async () => { + await commitMut.mutateAsync(state.acknowledgedNonEvaluablePrereqs.length > 0); + onCommitted(); + onClose(); + }; + + if (startMut.isPending) { + return ( +
+ +
+ ); + } + + if (!state) return null; + + const currentStep = state.steps[state.currentIdx]; + const heading = STEP_HEADINGS[currentStep].heading.replace('{N}', String(state.targetLevel)); + const isLastStep = state.currentIdx === state.steps.length - 1; + const isFirstStep = state.currentIdx === 0; + + return ( +
+
+
+ + {/* Header */} +
+
+

Stufenaufstieg — Stufe {state.targetLevel}

+

{character.name}

+
+ +
+ + {/* Stepper */} + + + {/* Body */} +
+

{heading}

+

{STEP_HEADINGS[currentStep].sub}

+ + + {renderStep(currentStep, state, dispatch, character)} + + +
+ + {/* Footer */} +
+ Schritt {state.currentIdx + 1}/{state.steps.length} +
+ + {state.revisionMode ? ( + + ) : isLastStep ? ( + + ) : ( + + )} +
+
+
+ + {/* Abort confirm dialog */} + {showAbortDialog && ( + // Reuse PrereqConfirmDialog shape with different copy, OR inline a similar dialog. + // Per UI-SPEC §Destructive confirmations: + // Heading: 'Wizard abbrechen?' + // Body: 'Deine bisherigen Wahlen werden als Entwurf gespeichert und du kannst später fortsetzen.' + // Buttons: 'Weiter bearbeiten' (default) + 'Als Entwurf speichern und schließen' (outline) + null + )} +
+ ); + } + + function renderStep(kind: StepKind, state: WizardState, dispatch: (e: WizardEvent) => void, character: Character) { + switch (kind) { + case 'class-features': return ; + case 'class-feature-choice': return ; + case 'boost': return ; + // ... all 11 + } + } + + function isStepValid(kind: StepKind, state: WizardState): boolean { + switch (kind) { + case 'class-features': return true; + case 'boost': return state.choices.boostTargets?.length === 4; + case 'skill-increase': return !!state.choices.skillIncrease; + case 'feat-class': return !!state.choices.featClassId; + // ... per step + default: return true; + } + } + ``` + + **Constraints:** + - All copy German. + - Modal chrome exactly matches UI-SPEC line 222-238. + - Stepper has 44×44 hit-zones with -m-1 negative margin (UI-SPEC line 271-275). + - Motion respects `prefers-reduced-motion` (UI-SPEC line 660). + + + cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "level-up-wizard" || echo "tsc clean" + + + - File `level-up-wizard.tsx` exists with `LevelUpWizard` named export + - File contains `useReducer(wizardReducer` (uses Task 2 reducer) + - File contains `useStartLevelUpMutation` AND `useCommitLevelUpMutation` (uses Task 2 hooks) + - File contains `` (motion per UI-SPEC §Motion) + - File contains `useReducedMotion` (accessibility per UI-SPEC line 660) + - File contains `Stufenaufstieg — Stufe` (header German copy) + - File contains `Schritt {state.currentIdx + 1} von {state.steps.length}` or close (always-visible progress label per UI-SPEC line 257) + - File contains `h-11 w-11` (stepper hit-zone) AND `-m-1` (negative margin to compensate) + - File contains `aria-modal="true"` and `role="dialog"` (a11y) + - File contains `Bestätigen`, `Zurück`, `Weiter`, `Zurück zur Übersicht` German strings + - File contains NO emoji + - File contains NO `: any` outside comments + - `cd client && npx tsc --noEmit -p tsconfig.app.json` exits 0 + + + Wizard container compiles, mounts, manages reducer, drives steps via switch, runs motion, exposes Bestätigen wired to commit mutation, abort flow handled. + + + + + Task 6: character-sheet-page.tsx — Stufe-steigen button + 2 banner mounts + wizard mount + client/src/features/characters/components/character-sheet-page.tsx + + - client/src/features/characters/components/character-sheet-page.tsx (entire file — header cluster lines 1607-1626; modal mount cluster lines 1652-1666; state pattern lines 122-132) + - .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md (lines 208-217 — Stufe-steigen button states; lines 588 — banner positions) + - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 728-765 — exact insert positions) + + + Make four additions to `character-sheet-page.tsx`: + + **Add 1 — State variables** (around lines 122-132 with the other useState's): + ```tsx + const [showLevelUpWizard, setShowLevelUpWizard] = useState(false); + const [hasOpenDraft, setHasOpenDraft] = useState(false); + const [openDraftMeta, setOpenDraftMeta] = useState<{ targetLevel: number; updatedAt: string } | null>(null); + ``` + + **Add 2 — Effect to detect open DRAFT on character load** (after the character query): + ```tsx + useEffect(() => { + if (!character) return; + // GET /characters/:id/level-up should return the open DRAFT or 404 + // Implementation: call api.startLevelUp() with no targetLevel — if a DRAFT exists, it's returned; + // if not, a new DRAFT is created. To avoid spurious DRAFT creation on a fresh load, instead + // add a separate read-only endpoint. Planner: extend Plan 04 with GET /characters/:id/level-up + // (returns DRAFT or 404). For now: lazily check by leaving hasOpenDraft = false until the user + // clicks 'Stufe steigen' and the start endpoint resolves it. The banner then hydrates on commit/discard mutations. + }, [character]); + ``` + + Note for executor: To detect a DRAFT for the **banner**, add `GET /characters/:characterId/level-up` to the controller (Plan 04 patch) returning the open DRAFT or null. Wire that to `useQuery` here. Update plan SUMMARY with the chosen approach. + + **Add 3 — Header button** (in the header cluster around lines 1607-1626 — INSERT AS FIRST in the cluster, left of Download): + + ```tsx + {(isOwner || isGM) && character.level < 20 && ( + + )} + ``` + + Add the icon imports: `import { Sparkles, RotateCcw } from 'lucide-react';` (or extend existing import). + + **Add 4 — Banner mounts** (above the avatar header AND below the avatar header for the violations banner): + + ```tsx + {/* Resume banner — above avatar */} + {hasOpenDraft && openDraftMeta && ( + setShowLevelUpWizard(true)} + onDiscard={async () => { + // Use useDiscardLevelUpMutation + // ... wire it + }} + /> + )} + + {/* Avatar header (existing) */} + + {/* Violations banner — below avatar, above tabs */} + {character.prereqViolations?.violations && character.prereqViolations.violations.length > 0 && ( + + )} + + {/* Tab navigation (existing) */} + ``` + + Add a tiny `formatRelativeDate(iso: string): string` helper using `Intl.RelativeTimeFormat`: + ```tsx + function formatRelativeDate(iso: string): string { + const diffMs = Date.now() - new Date(iso).getTime(); + const days = Math.floor(diffMs / 86400000); + const rtf = new Intl.RelativeTimeFormat('de-DE', { numeric: 'auto' }); + if (days === 0) return rtf.format(0, 'day'); // 'heute' + return rtf.format(-days, 'day'); // 'vor 3 Tagen' + } + ``` + + **Add 5 — Wizard mount** (in the modal mounts area lines 1652-1666): + + ```tsx + {showLevelUpWizard && ( + setShowLevelUpWizard(false)} + onCommitted={() => { + setShowLevelUpWizard(false); + // refetchCharacter or rely on react-query invalidate from the commit mutation + }} + /> + )} + ``` + + **Add 6 — Wire WebSocket callback** to invalidate character query when level_up_committed arrives: + + Find where `useCharacterSocket` is called (probably elsewhere in the page or a parent). Add the new callback: + ```tsx + useCharacterSocket({ + characterId: character.id, + // ... existing callbacks ... + onLevelUpCommitted: () => { + queryClient.invalidateQueries({ queryKey: ['character', character.id] }); + }, + }); + ``` + + **Constraints:** + - Do NOT change any existing rendering logic for HP, conditions, inventory, etc. The additions are strictly additive. + - All visible new copy German. + - Use existing icon imports where possible; extend the `lucide-react` import line. + + + cd client && npx tsc --noEmit -p tsconfig.app.json 2>&1 | grep -E "character-sheet-page" || echo "tsc clean" + + + - File `character-sheet-page.tsx` contains the literal string `Stufe steigen` (or `Stufe fortsetzen`) + - File contains the literal string ` + + Character sheet now has the entry point + the resume banner + the violations banner + the wizard mount + the WebSocket callback wired. + + + + + Task 7: Human verification — walk through the wizard on mobile viewport + + The full Level-Up wizard (Plans 01-05). Player can click "Stufe steigen" on a character sheet, walk through 4-11 steps depending on level/class/FA/caster, see Vorher/Nachher in Review, click Bestätigen, and have the character mutated atomically with a single WebSocket broadcast. + + Specifically: + - Server: 5 REST endpoints + 1 WebSocket event + - Client: 1 modal wizard + 11 step components + 2 banners + character-sheet integration + - DB: 4 new tables, partial unique index, ClassProgression seeded with 320+ rows + - Tests: ≥60 unit + integration tests passing + + Manual verification is required for the UI surface because: + 1. There is no client-side test framework yet (per VALIDATION.md decision — Phase 1 doesn't introduce vitest). + 2. UI-SPEC compliance is visual. + 3. The "feel" of the wizard on mobile (375×667) is a product judgment. + + + 1. Start the dev environment: + ```bash + # Terminal 1 + cd server && npm run start:dev + # Terminal 2 + cd client && npm run dev + ``` + 2. Open Chrome DevTools, set viewport to **iPhone SE (375×667)** (Mobile-First per CLAUDE.md). + 3. Log in as a user who owns at least one character below level 20. + 4. Open that character's character sheet. + 5. **Verify the "Stufe steigen" button appears** at the FIRST position in the header button cluster (left of Download), with `Sparkles` icon and German label. + 6. Click "Stufe steigen". **Verify the wizard opens as a bottom-sheet** (mobile) with: + - Header: `Stufenaufstieg — Stufe N`, character name beneath, X close button on right. + - Stepper: dot row at top, with always-visible `Schritt 1 von M — Merkmale` progress label. + - Body: first step (Klassenmerkmale auf Stufe N) heading + sub-line. + - Footer: `Zurück` (disabled on first step) + `Weiter`. + 7. **Walk through each applicable step.** For a Fighter L4→L5 (boost level), the steps are: Klassenmerkmale → Boost → Skill-Increase → Klassentalent → Fertigkeitstalent → Abstammungstalent → Übersicht. For each step, verify: + - The step heading + sub-line match `01-UI-SPEC.md` §Wizard-step screen headings (line 156). + - Touch targets are ≥44px (use DevTools "Inspect" → check `Computed → height/width`). + - Choice-cards have the correct source-color badges (Klasse=red, Abstammung=blue, etc.) per UI-SPEC §Color §Inherited Exceptions. + - For Boost step: 6 attribute rows with +/- buttons; tapping `+` increments the boost count, shows "wird {newScore}" preview, "+1 (Cap bei 18)" chip appears for STR if STR is at 18. Footer hint "X von 4 Boosts gewählt" updates. + - The Weiter button is disabled when the step is invalid (e.g. fewer than 4 boosts). + 8. **Verify the prereq-confirm dialog (D-03):** if any feat in the Klassentalent / Fertigkeitstalent step has a yellow `AlertTriangle`, click it. A z-60 layered dialog appears with the raw prereq quote. `Trotzdem wählen` selects it; `Abbrechen` cancels. + 9. **Reach the Review step.** Verify: + - Section A: Wahlen-Zusammenfassung lists every choice made, each with `Ändern` link. + - Section B: Vorher / Nachher cards with HP-Max, RK, Klassen-DC, Wahrnehmung, Saves. Numbers are in `font-mono`. `Sparkles` icon next to "Nachher". Positive deltas show as green chips. + - Section C: only appears for caster characters; shows slot/cantrip increment. + 10. **Test the Ändern revision contract:** click `Ändern` on the Boost row. Wizard navigates back. Footer button changes from `Weiter` to `Zurück zur Übersicht` (with `ArrowLeft` icon). Change a boost. Click `Zurück zur Übersicht`. Wizard returns to Review. + 11. **Click Bestätigen.** Verify: + - Spinner appears on the button. + - Wizard closes. + - Toast appears (planner discretion on toast lib): `Stufenaufstieg bestätigt — Stufe N.` + - Character header now shows the new level. + - HP-Max updated. AC, Saves, Klassen-DC updated. + - `hpCurrent` UNCHANGED (Pitfall #9 — verify by checking HP shows e.g. 12/63 NOT 63/63). + 12. **Open a second browser tab** on the same character (different user with access — e.g. GM viewing a player's sheet). Confirm the new level + stat block appears within ~1s without a page reload (WebSocket sync). + 13. **Test DRAFT-Resume:** + - Start a level-up, make some choices, close the wizard via X (the abort dialog appears: confirm with `Als Entwurf speichern und schließen`). + - Verify the DRAFT-Resume Banner now appears at the top of the character sheet with `Du hast eine offene Stufenaufstiegs-Session — Stufe N.`, `Zuletzt bearbeitet: vor wenigen Minuten.` + - Click `Verwerfen`. Confirm the discard dialog. The DRAFT row disappears. + 14. **Test Pathbuilder-Import-Violations Banner (D-06):** + - Re-import a Pathbuilder JSON whose feats violate prereqs. Open the character sheet. The yellow violations banner appears below the avatar header. Click `Liste anzeigen` to expand the list of violating feats. + 15. **Verify accessibility basics:** + - Tab through the wizard with the keyboard. All controls reachable. + - The stepper-dot wrappers are keyboard-focusable. + - The choice-cards announce their selected/locked/warning state via aria-label. + - In DevTools "Rendering" panel, enable `Emulate CSS prefers-reduced-motion: reduce`. Re-walk the wizard. Step transitions should fade only (no horizontal slide). + 16. **Verify TypeScript + build green** (executor confirms before this checkpoint): + ```bash + cd server && npm run build && npm test -- --testPathPattern=leveling + cd client && npx tsc --noEmit -p tsconfig.app.json && npm run build + ``` + 17. If everything checks out, type `approved` to mark the checkpoint passed. + 18. If any item fails, describe the issue and the executor revises. + + Type "approved" or describe issues found + (no file writes — manual UI verification only) + Pause execution and present the <what-built> and <how-to-verify> sections to the developer. Wait for the <resume-signal>. Do not modify any files during this checkpoint. + Developer types "approved" — or describes issues found that block approval. + Developer has walked through the wizard on a 375×667 mobile viewport per the <how-to-verify> steps and replied "approved". + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser → REST | All wizard interactions cross HTTP via Plan 04 endpoints; client trusts server's authoritative responses. | +| browser → WebSocket | level_up_committed event arrives from server; client invalidates query — no inbound user-tampering surface. | +| translated text → React render | German prereq strings + class-feature descriptions from TranslationsService render in the prereq-confirm dialog and ChoiceCard. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-1-W4-01 | Tampering | Player crafts a custom POST commit bypassing the wizard's validation | mitigate | Plan 04 server-side validation (DTO + commit guards) is the source of truth; the wizard is convenience UI. Any client tampering is rejected by the server. | +| T-1-W4-02 | Injection | Stored XSS via German feat-prereq translation text rendered in PrereqConfirmDialog or ChoiceCard | mitigate | All German strings rendered via React text-binding (`{prereqText}` inside `

`), never `dangerouslySetInnerHTML`. Verified by code review — no usage of dangerouslySetInnerHTML in any of the new client files. | +| T-1-W4-03 | Injection | Stored XSS via class-feature description rendered in ChoiceCard.description | mitigate | Same pattern — text-binding only. Description is `line-clamp-2` truncated CSS-style; full text in the FeatDetailModal also uses text-binding. | +| T-1-W4-04 | Information Disclosure | Wizard JSON state PATCHed to server contains user-typed text (e.g. notes) that surfaces to other character viewers | accept | Phase 1 wizard state contains only IDs (featId, optionKey, etc.) and structured choices — no user-typed text. Future steps that add notes need to reconsider. | +| T-1-W4-05 | DoS | Rapid-fire PATCHes from the wizard overload the server | mitigate | The patchLevelUp call is debounced 500ms client-side (Task 5 implementation). Server endpoint is light (single update). | + + + +After Task 6 (before checkpoint): + +```bash +# Client TS clean +cd client && npx tsc --noEmit -p tsconfig.app.json + +# Client production build clean +cd client && npm run build + +# Server still builds (in case Plan 04 patches in Task 4 were needed) +cd server && npm run build + +# Full server test suite still green +cd server && npm test +``` + +All four commands must exit 0 before the human checkpoint. + + + +- 18 new client files exist in `client/src/features/characters/components/level-up/` +- 4 extended files (character-sheet-page, api, shared types, use-character-socket) extended additively +- All UI text in German; no emojis; only Lucide icons +- Touch targets ≥44px throughout +- Wizard chrome matches UI-SPEC §Component Contract — Wizard Chrome exactly (header, stepper, body, footer) +- Choice-Card primitive matches UI-SPEC §Component Contract — Choice-Card (states, source badges, prereq warning) +- Boost step uses h-11 w-11 +/- buttons + live "wird {newScore}" + cap-bei-18 chip +- Review step uses font-mono for stat numbers, two-column Vorher/Nachher cards +- Ändern revision contract implemented (revision-mode flag + Zurück-zur-Übersicht button + chain re-validation) +- DRAFT-Resume banner implemented per UI-SPEC §Component Contract — DRAFT-Resume Banner +- Pathbuilder-Import-Violations banner implemented per UI-SPEC §Component Contract — Pathbuilder-Import-Violations Banner +- WebSocket level_up_committed callback wired to invalidate character query +- Motion respects prefers-reduced-motion +- TypeScript strict — no `: any` +- Client production build clean (`cd client && npm run build` exits 0) +- Server still builds and tests green (no regressions from Plan 04 patches) +- Human verification checkpoint approved + + + +After completion, create `.planning/phases/01-level-up-pf2e-regelkonform/01-05-SUMMARY.md` documenting: +- Final file list (all 18 new client files + 4 extended) +- Whether the GET DRAFT-detect endpoint was added in Plan 04 patches or alternative chosen (e.g. lazy detect on first wizard open) +- Whether the GET feats / GET class-feature-options endpoints were added as Plan 04 patches +- Toast library used (if any introduced) or pattern chosen (browser alert is NOT acceptable; planner discretion: react-hot-toast, sonner, or hand-rolled context — record the decision) +- Any deviations from UI-SPEC noted with rationale +- Result of the human-verification checkpoint +