From a8c4944bb6ccba81ddf698dac1de87031526d410 Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 15:13:50 +0200 Subject: [PATCH] =?UTF-8?q?chore(phase-01):=20checkpoint=20after=20Wave=20?= =?UTF-8?q?3=20Task=201=20=E2=80=94=20pause=20for=20resume=20on=20another?= =?UTF-8?q?=20machine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 execution state at pause: - Wave 1 ✓ (01-01 schema/migration, 01-02 pure-function lib) - Wave 2 ✓ (01-03 seed pipeline + Wizard worked example) - Wave 3 partial (01-03b Task 1 of 3 done — 6 caster overlays salvaged from c5d40cd) - Wave 4 pending (01-04 LevelingModule) - Wave 5 pending (01-05 React wizard UI) To resume: \`/gsd-execute-phase 1\` discovers SUMMARY files for completed plans and picks up at 01-03b Task 2 (class-feature-options bulk curation). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/settings.local.json | 19 +- .planning/ROADMAP.md | 13 +- .planning/STATE.md | 26 +- .planning/config.json | 3 +- .../01-03b-PLAN.md | 502 ++++++++ .../01-04-PLAN.md | 774 ++++++++++-- .../01-PATTERNS.md | 1091 +++++++++++++++++ .../01-RESEARCH.md | 18 +- 8 files changed, 2322 insertions(+), 124 deletions(-) create mode 100644 .planning/phases/01-level-up-pf2e-regelkonform/01-03b-PLAN.md create mode 100644 .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2acf542..6ad4501 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,24 @@ "permissions": { "allow": [ "Bash(npx tsc:*)", - "Bash(npm install:*)" + "Bash(npm install:*)", + "Bash(npm run db:migrate:dev:*)", + "Bash(npm run db:seed:*)", + "Bash(npx tsx:*)", + "Bash(npm run db:generate:*)", + "WebFetch(domain:docs.anthropic.com)", + "Bash(npm run build:*)", + "Bash(npm run db:seed:equipment:*)", + "Bash(find:*)", + "Bash(npx prisma migrate dev:*)", + "WebFetch(domain:2e.aonprd.com)", + "WebFetch(domain:rpgbot.net)", + "Bash(node -e:*)", + "Bash(npx ts-node:*)", + "Bash(ssh:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git push)" ] } } diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index c8d8574..2934bd0 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -32,14 +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:** 5 plans +**Plans:** 6 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 +- [x] 01-01-PLAN.md — Wave 0: Prisma schema + migration + partial unique index + Jest infrastructure proof +- [x] 01-02-PLAN.md — Wave 1: 5 pure-function lib modules (boost, skill-cap, prereq, recompute, applicable-steps) with full TDD +- [x] 01-03-PLAN.md — Wave 2: ClassProgression + ClassFeatureOption seed pipeline (Foundry pf2e + Wizard worked example) +- [ ] 01-03b-PLAN.md — Wave 3: bulk curation of caster spell-slot overlays + class-feature-options for the other 15 D-16 classes (sequential after 01-03 — same data files) +- [ ] 01-04-PLAN.md — Wave 4: LevelingModule (REST + atomic commit transaction + WebSocket broadcast) + pathbuilder-import integration + integration tests +- [ ] 01-05-PLAN.md — Wave 5: React wizard against UI-SPEC + character-sheet integration + human verification checkpoint **UI hint**: yes diff --git a/.planning/STATE.md b/.planning/STATE.md index b686078..29b7b66 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,14 +2,14 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: milestone -status: planning -stopped_at: Phase 1 context gathered -last_updated: "2026-04-27T08:50:25.053Z" -last_activity: 2026-04-27 — Roadmap created from research-recommended 7-phase build order +status: executing +stopped_at: Phase 1 UI-SPEC approved +last_updated: "2026-04-27T11:58:04.508Z" +last_activity: 2026-04-27 -- Phase 01 execution started progress: total_phases: 7 completed_phases: 0 - total_plans: 0 + total_plans: 6 completed_plans: 0 percent: 0 --- @@ -21,14 +21,14 @@ progress: See: .planning/PROJECT.md (updated 2026-04-27) **Core value:** Am Tisch funktioniert alles in Echtzeit und regelkonform — Charakterbogen am Handy, Battle-Steuerung am Laptop, Tisch-Display als read-only Spielsicht, ohne Reibung, ohne falsche Werte, ohne Reload. -**Current focus:** Phase 1 — Level-Up (PF2e regelkonform) +**Current focus:** Phase 01 — level-up-pf2e-regelkonform ## Current Position -Phase: 1 of 7 (Level-Up (PF2e regelkonform)) -Plan: 0 of TBD in current phase -Status: Ready to plan -Last activity: 2026-04-27 — Roadmap created from research-recommended 7-phase build order +Phase: 01 (level-up-pf2e-regelkonform) — EXECUTING +Plan: 1 of 6 +Status: Executing Phase 01 +Last activity: 2026-04-27 -- Phase 01 execution started Progress: [░░░░░░░░░░] 0% @@ -95,6 +95,6 @@ Items acknowledged and carried forward from previous milestone close: ## Session Continuity -Last session: 2026-04-27T08:50:25.043Z -Stopped at: Phase 1 context gathered -Resume file: .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md +Last session: 2026-04-27T09:17:39.142Z +Stopped at: Phase 1 UI-SPEC approved +Resume file: .planning/phases/01-level-up-pf2e-regelkonform/01-UI-SPEC.md diff --git a/.planning/config.json b/.planning/config.json index a33f606..b0c9411 100644 --- a/.planning/config.json +++ b/.planning/config.json @@ -35,7 +35,8 @@ "plan_bounce": false, "plan_bounce_script": null, "plan_bounce_passes": 2, - "auto_prune_state": false + "auto_prune_state": false, + "_auto_chain_active": false }, "hooks": { "context_warnings": true diff --git a/.planning/phases/01-level-up-pf2e-regelkonform/01-03b-PLAN.md b/.planning/phases/01-level-up-pf2e-regelkonform/01-03b-PLAN.md new file mode 100644 index 0000000..27b11c1 --- /dev/null +++ b/.planning/phases/01-level-up-pf2e-regelkonform/01-03b-PLAN.md @@ -0,0 +1,502 @@ +--- +phase: 01-level-up-pf2e-regelkonform +plan: 03b +type: execute +wave: 3 +depends_on: ["01-01", "01-02", "01-03"] +files_modified: + - server/prisma/data/spell-slot-overlays.ts + - server/prisma/data/class-feature-options.ts +autonomous: true +requirements: [LVL-08, LVL-14] +tags: [seeding, curation, spellcaster, class-features, level-up, data-only] +must_haves: + truths: + - "After Plan 03b appends data and the seed re-runs, spell-slot-overlays.ts has L1..L20 entries for the 6 remaining caster classes (Cleric, Druid, Witch, Bard, Sorcerer, Oracle) and minimal Champion entries — total ≥120 spell-slot/cantrip/repertoire entries across all casters." + - "After Plan 03b appends data and the seed re-runs, class-feature-options.ts contains the joint-goal of ≥50 ClassFeatureOption entries across all classes that have L1 (or other) choice points (Plan 03 ships ≥1; Plan 03b ships ≥49 more)." + - "Spontaneous casters (Bard, Sorcerer, Oracle) have at least one repertoireIncrement entry per the D-18 contract." + - "Re-running `npm run db:seed:class-progression` after the data appends results in `0 created, ≥320 updated` for ClassProgression and ≥49 newly created ClassFeatureOption rows on the first re-run." + - "No code changes to seed-class-progression.ts — the script's iteration over D16_CLASS_NAMES + array iteration over CLASS_FEATURE_OPTIONS already handles the new entries." + - "Cross-references are intact: every choiceOptionsRef set in seed-class-progression.ts L1_CHOICE_MAP has at least one matching ClassFeatureOption row after this plan." + artifacts: + - path: "server/prisma/data/spell-slot-overlays.ts" + provides: "Entries for Cleric/Druid/Witch/Bard/Sorcerer/Oracle/Champion appended (replacing the [] stubs from Plan 03). Wizard remains untouched." + contains: "SPELL_SLOT_OVERLAY" + - path: "server/prisma/data/class-feature-options.ts" + provides: "≥49 additional ClassFeatureOption entries appended (Cleric Doctrines, Champion Causes, Druid Orders, Sorcerer Bloodlines, Bard Muses, Barbarian Instincts, Witch Patrons, Oracle Mysteries, Investigator Methodologies, Ranger Edges, Rogue Rackets, Swashbuckler Styles, Alchemist Research Fields, plus remaining Wizard Schools)." + contains: "CLASS_FEATURE_OPTIONS" + key_links: + - from: "spell-slot-overlays.ts" + to: "ClassProgression rows (via seed-class-progression.ts)" + via: "seed re-run picks up the appended entries automatically" + pattern: "SPELL_SLOT_OVERLAY" + - from: "class-feature-options.ts" + to: "ClassFeatureOption table" + via: "seed re-run iterates over CLASS_FEATURE_OPTIONS array" + pattern: "CLASS_FEATURE_OPTIONS" + - from: "class-feature-options.ts optionsRef" + to: "seed-class-progression.ts L1_CHOICE_MAP / HIGHER_LEVEL_CHOICES" + via: "stable string match (e.g. 'cleric-doctrine')" + pattern: "optionsRef.*[a-z-]+" +--- + + +Bulk curation pass for Phase 1 ClassProgression seeding. Plan 03 owns the pipeline + Wizard worked example; Plan 03b owns the data entry for the remaining 15 classes — populating `spell-slot-overlays.ts` for the 6 other caster classes and `class-feature-options.ts` for all classes with L1 (or other) choice points. + +Why split: this is curation work transcribed from Archives of Nethys per-class entries. Independent rows, no shared logic, no schema changes. Splitting it out gives the curation a separate review surface (a reviewer can audit L1 Cleric Doctrines without paging through pipeline code) and isolates the data-entry pass into its own wave. Plan 03b runs in Wave 3 directly after Plan 03 (Wave 2) because both modify the same data files (`spell-slot-overlays.ts`, `class-feature-options.ts`), so file-ownership rules require sequential execution. + +Purpose: After Plan 03b, the seed produces a complete dataset that satisfies LVL-08 + LVL-14 across all 16 D-16 classes — Plan 04's atomic commit transaction can recompute correctly for any class/level combination. + +Output: Two data files extended with appended entries. No code changes. Re-running the seed bulk-updates the database. + + + +@$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-03-SUMMARY.md +@server/prisma/data/spell-slot-overlays.ts +@server/prisma/data/class-feature-options.ts +@server/prisma/seed-class-progression.ts + + + + + +```typescript +export type SpellTradition = 'ARCANE' | 'DIVINE' | 'OCCULT' | 'PRIMAL'; +export interface SpellSlotOverlayEntry { + level: number; + spellSlotIncrement?: { tradition: SpellTradition; spellLevel: number; count: number }; + cantripIncrement?: number; + repertoireIncrement?: number; +} +export const SPELL_SLOT_OVERLAY: Record = { + Wizard: [ ... 19 entries ... ], // <-- DO NOT TOUCH (Plan 03 owns Wizard) + Cleric: [], // <-- Plan 03b populates this + Druid: [], // <-- Plan 03b populates this + Witch: [], // <-- Plan 03b populates this + Bard: [], // <-- Plan 03b populates this (with repertoireIncrement entries — D-18) + Sorcerer: [], // <-- Plan 03b populates this (with repertoireIncrement entries — D-18) + Oracle: [], // <-- Plan 03b populates this (with repertoireIncrement entries — D-18) + Champion: [], // <-- Plan 03b populates this (focus-only, minimal) + Alchemist: [], // <-- Plan 03b confirms empty + Barbarian: [], // <-- Plan 03b confirms empty + Fighter: [], // <-- Plan 03b confirms empty + Investigator: [], // <-- Plan 03b confirms empty + Monk: [], // <-- Plan 03b confirms empty + Ranger: [], // <-- Plan 03b confirms empty + Rogue: [], // <-- Plan 03b confirms empty + Swashbuckler: [], // <-- Plan 03b confirms empty +}; +``` + + +```typescript +export interface ClassFeatureOptionEntry { + optionsRef: string; + optionKey: string; + name: string; + nameGerman?: string; + description: string; + grants: string[]; + proficiencyChanges?: Partial>; +} +export const CLASS_FEATURE_OPTIONS: ClassFeatureOptionEntry[] = [ + // 1 Wizard School entry from Plan 03 (battle-magic) — DO NOT TOUCH + // Plan 03b appends ≥49 more entries below +]; +``` + + + + + + + + + + + + + + + Task 1: Append spell-slot overlay entries for the 6 remaining caster classes (Cleric, Druid, Witch, Bard, Sorcerer, Oracle) + minimal Champion + server/prisma/data/spell-slot-overlays.ts + + - server/prisma/data/spell-slot-overlays.ts (the Plan 03 file with stubs in place — append values into the existing keys) + - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-18 — spontaneous casters get repertoire) + - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 656-661 — Pitfall #6 hand-curation rationale) + - Archives of Nethys class entries (cited as authoritative source for hand-curation): + - Cleric: https://2e.aonprd.com/Classes.aspx?ID=4 + - Druid: https://2e.aonprd.com/Classes.aspx?ID=6 + - Witch: https://2e.aonprd.com/Classes.aspx?ID=27 + - Bard: https://2e.aonprd.com/Classes.aspx?ID=2 + - Sorcerer: https://2e.aonprd.com/Classes.aspx?ID=15 + - Oracle: https://2e.aonprd.com/Classes.aspx?ID=12 + - Champion: https://2e.aonprd.com/Classes.aspx?ID=3 + (URL IDs are illustrative — verify current canonical URLs at execution time.) + + + Open `server/prisma/data/spell-slot-overlays.ts`. The file (from Plan 03) already declares all 16 D-16 keys; the 6 caster classes + Champion currently hold `[]`. **Replace each `[]` with the curated array** for that class. + + **Reference template (Wizard, fully populated by Plan 03 — DO NOT modify Wizard):** + + ```typescript + Wizard: [ + { level: 1, cantripIncrement: 5 }, + { level: 1, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 2 } }, + { level: 2, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 1 } }, + { level: 3, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 2, count: 2 } }, + // ... continues to L19 with the +2/+1 cadence + ], + ``` + + **Per-class curation requirements:** + + **Cleric** (PREPARED, DIVINE) — same +2/+1 cadence as Wizard, replacing tradition with `'DIVINE'`. L1: `cantripIncrement: 5` + `spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 1, count: 2 }`. Continue per AoN Cleric spell-slot table through L19. Expected ~19-20 entries. + + **Druid** (PREPARED, PRIMAL) — same cadence, tradition `'PRIMAL'`. L1: 5 cantrips + 2 grade-1 slots. Continue per AoN Druid table through L19. Expected ~19-20 entries. + + **Witch** (PREPARED) — Witch tradition depends on patron (which the player picks at L1). For the overlay, default to `'ARCANE'` and document the caveat in a comment above the Witch array: + ```typescript + // CAVEAT: Witch tradition depends on patron (Plan 03b — picked in wizard at L1). + // The overlay defaults to ARCANE for slot bookkeeping; the recompute pipeline (Plan 04) + // may need to remap this to the actual patron's tradition at commit time. For Phase 1 + // we accept the default — the slot count is the same regardless of tradition. + ``` + Same +2/+1 cadence per AoN Witch table. Expected ~19-20 entries. + + **Bard** (SPONTANEOUS, OCCULT) — D-18 requires `repertoireIncrement` entries on each level-up. L1: `cantripIncrement: 5` + `spellSlotIncrement: { tradition: 'OCCULT', spellLevel: 1, count: 2 }`. From L2 onward, add `repertoireIncrement: 1` per level the spontaneous caster table grants a new spell-known. Per AoN Bard repertoire growth: typically +1 spell per spell-level per level after L2. Expected ~25-30 entries (slot rows + repertoire rows). Use multiple entries per level when both apply (e.g. L3 Bard gets a grade-2 slot AND a repertoire-pick — two separate entries). + + **Sorcerer** (SPONTANEOUS) — Tradition depends on bloodline (similar caveat as Witch). Default to `'ARCANE'`. Per AoN Sorcerer table: + - L1: `cantripIncrement: 5` + `spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 3 }` (Sorcerer L1 = 3 grade-1 slots) + - Add `repertoireIncrement` entries per level per AoN. + Document the bloodline-tradition caveat in a comment. + Expected ~25-30 entries. + + **Oracle** (SPONTANEOUS, DIVINE) — Tradition is fixed (DIVINE — Mystery doesn't change it). L1: `cantripIncrement: 5` + `spellSlotIncrement: { tradition: 'DIVINE', spellLevel: 1, count: 3 }` (3 grade-1 slots like Sorcerer). Add `repertoireIncrement` entries per level per AoN. Expected ~25-30 entries. + + **Champion** — focus-only caster (Devotion Spells via focus pool, NOT slot-based). Phase 1 records minimal entries: + ```typescript + Champion: [ + // Focus-only caster -- focus-spell mechanics handled outside slot table (Plan 04 Phase 2 may + // add focus-pool tracking). Phase 1 treats Champion as effectively non-caster for slots. + // No cantrip/slot entries here. + ], + ``` + Champion remains `[]` (or with the explanatory comment) — this is correct. + + **Total expected entries across the 7 added classes:** 19 (Cleric) + 19 (Druid) + 19 (Witch) + 28 (Bard) + 28 (Sorcerer) + 28 (Oracle) + 0 (Champion) ≈ **141 new entries**, comfortably above the ≥120 must_haves goal. + + **Constraints:** + - Do NOT modify the Wizard array (Plan 03 owns it). + - Do NOT remove any of the 16 keys (the seed depends on key presence, even if the value is `[]`). + - Every entry strictly typed against `SpellSlotOverlayEntry` — no `: any`. + - Cite the AoN source (URL or page) in a comment block above each newly-populated class. + + + 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` still contains all 16 D-16 class keys (no key removed) + - Wizard array still has at least 19 entries (Plan 03 unchanged) + - Cleric, Druid, Witch arrays each have at least 18 entries + - Bard, Sorcerer, Oracle arrays each have at least 1 `repertoireIncrement` entry (D-18) AND at least 18 entries total + - Total entries across all caster classes is at least 120 (verifiable via grep counting `level:` keys outside the Wizard block) + - File contains NO `: any` outside comments + - `cd server && npx tsc --noEmit` exits 0 + + + Spell-slot overlay file extended with curated data for the 6 other casters + Champion. Type-clean. Wizard unchanged. + + + + + Task 2: Append ClassFeatureOption entries for the 13 remaining classes (Cleric, Champion, Druid, Sorcerer, Bard, Barbarian, Witch, Oracle, Investigator, Ranger, Rogue, Swashbuckler, Alchemist) + remaining Wizard Schools + server/prisma/data/class-feature-options.ts + + - server/prisma/data/class-feature-options.ts (the Plan 03 file — append into CLASS_FEATURE_OPTIONS array using the section anchors) + - server/prisma/seed-class-progression.ts (Plan 03 — verify L1_CHOICE_MAP optionsRef strings match exactly) + - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-15 — choiceOptionsRef; D-19 — Wahl-Klassenmerkmale) + - server/prisma/schema.prisma (verify ResearchField enum for Alchemist optionKey alignment) + - Archives of Nethys class entries — see Task 1's URL list, plus: + - Investigator: https://2e.aonprd.com/Classes.aspx?ID=8 + - Ranger: https://2e.aonprd.com/Classes.aspx?ID=13 + - Rogue: https://2e.aonprd.com/Classes.aspx?ID=14 + - Swashbuckler: https://2e.aonprd.com/Classes.aspx?ID=16 + - Alchemist: https://2e.aonprd.com/Classes.aspx?ID=1 + - Barbarian: https://2e.aonprd.com/Classes.aspx?ID=2 (verify ID — this clashes with Bard above; use the canonical AoN landing page) + + + Open `server/prisma/data/class-feature-options.ts`. The file (from Plan 03) already has the type definitions and 1 Wizard School entry. **Append additional entries to the `CLASS_FEATURE_OPTIONS` array**, organized into the section blocks the Plan 03 file already comments out. + + **Per-class curation requirements (joint goal: ≥50 entries total across this file; Plan 03 ships 1 → Plan 03b ships ≥49 more):** + + 1. **Wizard Schools** (optionsRef: `'wizard-school'`) — Plan 03 has `battle-magic`. Add: `civic-wizardry`, `mentalism`, `protean-form`, `unified-magical-theory`, `universalist`. Expected: 5 more. + + 2. **Cleric Doctrines** (optionsRef: `'cleric-doctrine'`) — Player Core: `cloistered-cleric`, `warpriest`. Add any APG additions. Expected: 2-4. + + 3. **Champion Causes** (optionsRef: `'champion-cause'`) — `liberator`, `paladin`, `redeemer`, plus Evil-aligned `antipaladin`, `tyrant`, `desecrator`, plus any neutral or remaster-equivalent. Expected: 6. + + 4. **Druid Orders** (optionsRef: `'druid-order'`) — `animal`, `leaf`, `storm`, `wild`, plus APG additions. Expected: 4-5. + + 5. **Sorcerer Bloodlines** (optionsRef: `'sorcerer-bloodline'`) — `aberrant`, `angelic`, `demonic`, `diabolic`, `draconic`, `elemental`, `fey`, `genie`, `hag`, `imperial`, `nymph`, `phoenix`, `psychopomp`, `shadow`, `undead`, plus APG additions. Expected: ≥15. + + 6. **Bard Muses** (optionsRef: `'bard-muse'`) — `enigma`, `maestro`, `polymath`, plus APG (`warrior` etc.). Expected: 3-4. + + 7. **Barbarian Instincts** (optionsRef: `'barbarian-instinct'`) — `animal`, `dragon`, `fury`, `giant`, `spirit`, plus APG (`superstition` etc.). Expected: 5-6. + + 8. **Witch Patrons** (optionsRef: `'witch-patron'`) — `faith`, `fervor`, `mosquito-witch`, `resentment`, `silence`, `spinner-of-threads`, `starless-shadow`, `wilding`, plus any Curse-of-the-Hag-Eye etc. Expected: ≥5. + + 9. **Oracle Mysteries** (optionsRef: `'oracle-mystery'`) — `battle`, `bones`, `cosmos`, `flames`, `life`, `lore`, `tempest`, plus APG. Expected: ≥7. + + 10. **Investigator Methodologies** (optionsRef: `'investigator-methodology'`) — `empiricism`, `forensic-medicine`, `interrogation`, `sensate`, `stratagem` (or whatever exists in APG). Expected: 3-5. + + 11. **Ranger Edges** (optionsRef: `'ranger-edge'`) — `flurry`, `outwit`, `precision`. Expected: 3. + + 12. **Rogue Rackets** (optionsRef: `'rogue-racket'`) — `eldritch-trickster`, `mastermind`, `ruffian`, `scoundrel`, `thief`. Expected: 5. + + 13. **Swashbuckler Styles** (optionsRef: `'swashbuckler-style'`) — `battledancer`, `braggart`, `fencer`, `gymnast`, `wit`. Expected: 5. + + 14. **Alchemist Research Fields** (optionsRef: `'alchemist-research-field'`) — `bomber`, `chirurgeon`, `mutagenist`, `toxicologist`. **CRITICAL:** these `optionKey` values must match the existing `ResearchField` enum in `server/prisma/schema.prisma` (lowercase-kebab transformation of `BOMBER`, `CHIRURGEON`, etc.) so the existing `CharacterAlchemyState.researchField` column can be set from the option pick. Verify the enum values against the schema before appending. Expected: 4. + + **Total entry count check:** 5 + 4 + 6 + 5 + 15 + 4 + 6 + 5 + 7 + 5 + 3 + 5 + 5 + 4 = **79 entries** added. Plus Plan 03's 1 Wizard School entry = **80 total** — comfortably above the ≥50 joint goal. + + **Per-entry shape:** + + ```typescript + { + optionsRef: 'cleric-doctrine', + optionKey: 'cloistered-cleric', + name: 'Cloistered Cleric', + // nameGerman omitted — TranslationsService handles on demand (D-15) + description: 'You have devoted your life to the study of religion and the casting of divine spells…', + grants: ['Cloistered Cleric Doctrine'], + // proficiencyChanges optional — only set if the option modifies a proficiency level at L1 (e.g. Warpriest grants martial weapon trained) + }, + ``` + + For options with proficiency effects (e.g. **Warpriest** grants martial trained at L1): + ```typescript + { + optionsRef: 'cleric-doctrine', + optionKey: 'warpriest', + name: 'Warpriest', + description: 'You are a martial defender of your faith…', + grants: ['Warpriest Doctrine'], + proficiencyChanges: { /* if a proficiency bump applies at L1, encode it here */ }, + }, + ``` + + **Constraints:** + - Append only — do NOT modify the existing Wizard School `battle-magic` entry from Plan 03. + - `optionsRef` strings MUST match the L1_CHOICE_MAP / HIGHER_LEVEL_CHOICES values in `seed-class-progression.ts` (Plan 03 Task 4) — copy them verbatim. + - `optionKey` values: lowercase-kebab of the option's English name. Once chosen, do not rename — these are stable identifiers persisted in CharacterFeat rows after commit. + - Cite the AoN source URL in a comment block above each class section. + - No `: any` outside comments. + - Every entry must have non-empty `optionsRef`, `optionKey`, `name`, `description`, `grants` fields. + + + 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 and has been extended + - Plan 03's `battle-magic` entry is still present (unchanged) + - Total `CLASS_FEATURE_OPTIONS.length` is at least 50 (joint goal across Plans 03 and 03b) + - At least 13 distinct `optionsRef` values appear: `wizard-school`, `cleric-doctrine`, `champion-cause`, `druid-order`, `sorcerer-bloodline`, `bard-muse`, `barbarian-instinct`, `witch-patron`, `oracle-mystery`, `investigator-methodology`, `ranger-edge`, `rogue-racket`, `swashbuckler-style`, `alchemist-research-field` + - For Alchemist: the four optionKey values are exactly `bomber`, `chirurgeon`, `mutagenist`, `toxicologist` (matches `ResearchField` enum lowercase-kebab — verify against `server/prisma/schema.prisma`) + - Every entry has non-empty `optionsRef`, `optionKey`, `name`, `description`, `grants` fields (verify with a quick scan) + - File contains NO `: any` outside comments + - `cd server && npx tsc --noEmit` exits 0 + + + Class-feature options file extended with ≥49 new entries across all 13 remaining classes (plus the rest of Wizard Schools). All optionsRef strings match seed-class-progression.ts L1_CHOICE_MAP. Alchemist optionKeys cross-validate against ResearchField enum. + + + + + Task 3: Re-run the seed and verify cumulative row counts + (no file writes — execution + verification only) + + - .planning/phases/01-level-up-pf2e-regelkonform/SEED-README.md (verify Foundry clone is in place from Plan 03 Task 5) + - server/prisma/seed-class-progression.ts (Plan 03 — generic over D16_CLASS_NAMES + CLASS_FEATURE_OPTIONS, no changes needed) + + + **Step 1 — Verify the Foundry clone is still present:** + + ```bash + ls server/prisma/data/foundry-pf2e/packs/classes/ + ``` + + Should still list ≥16 .json files from Plan 03's Task 5 step 1. + + **Step 2 — Run the seed:** + + ```bash + cd server && npm run db:seed:class-progression + ``` + + Expected output (Plan 03 already populated 320 ClassProgression rows + 1 ClassFeatureOption row): + + ``` + Seeding ClassProgression for 16 classes x 20 levels... + ClassProgression: 0 created, 320 updated, 0 errors + Seeding ~80 ClassFeatureOption rows... + ClassFeatureOption: ~79 created, 1 updated, 0 errors + ClassProgression + ClassFeatureOption seed complete. + ``` + + The 320 ClassProgression rows are UPDATED in place — the new spell-slot/cantrip/repertoire data from Task 1's overlay appends gets merged into the existing rows. + + **Step 3 — Run the seed AGAIN to confirm idempotency:** + + ```bash + cd server && npm run db:seed:class-progression + ``` + + Expected: `0 created, 320 updated, 0 errors` for both tables. + + **Step 4 — Verify ClassProgression row counts and overlay data:** + + ```bash + psql $DATABASE_URL -c 'SELECT "className", COUNT(*) FROM "ClassProgression" GROUP BY "className" ORDER BY "className"' + ``` + + Expected: 16 rows, each `count = 20`. + + ```bash + psql $DATABASE_URL -c 'SELECT "className", COUNT(*) FROM "ClassProgression" WHERE "spellSlotIncrement" IS NOT NULL GROUP BY "className" ORDER BY "className"' + ``` + + Expected: rows for `Bard`, `Cleric`, `Druid`, `Oracle`, `Sorcerer`, `Witch`, `Wizard` (no `Champion` since it's focus-only; no non-casters). Each caster should have ≥18 rows. + + ```bash + psql $DATABASE_URL -c 'SELECT "className", COUNT(*) FROM "ClassProgression" WHERE "repertoireIncrement" IS NOT NULL GROUP BY "className" ORDER BY "className"' + ``` + + Expected: rows for `Bard`, `Sorcerer`, `Oracle` (the three spontaneous casters per D-18). + + **Step 5 — Verify ClassFeatureOption row counts:** + + ```bash + psql $DATABASE_URL -c 'SELECT "optionsRef", COUNT(*) FROM "ClassFeatureOption" GROUP BY "optionsRef" ORDER BY "optionsRef"' + ``` + + Expected: at least 13 distinct `optionsRef` values (one per class with an L1 choice point), each with ≥2 options. Total row count ≥50. + + **Step 6 — Cross-reference verification (optional but recommended):** + + ```bash + # Every choiceOptionsRef in ClassProgression should have at least one matching ClassFeatureOption + psql $DATABASE_URL -c ' + SELECT cp."choiceOptionsRef", COUNT(cfo.id) AS option_count + FROM "ClassProgression" cp + LEFT JOIN "ClassFeatureOption" cfo ON cfo."optionsRef" = cp."choiceOptionsRef" + WHERE cp."choiceOptionsRef" IS NOT NULL + GROUP BY cp."choiceOptionsRef" + ORDER BY cp."choiceOptionsRef" + ' + ``` + + Expected: every `choiceOptionsRef` row has `option_count >= 1`. If any has 0, a class chose a choiceType but no options exist — append the missing options to class-feature-options.ts and re-run the seed. + + If any verification step fails, fix the curation data and re-run. + + + cd server && psql $DATABASE_URL -t -c 'SELECT COUNT(*) FROM "ClassFeatureOption"' | tr -d ' \n' + + + - `cd server && npm run db:seed:class-progression` exits 0 + - First Plan 03b run reports `0 created, 320 updated` for ClassProgression (Plan 03 already created the rows; Plan 03b updates them with new overlay data) + - First Plan 03b run reports `≥49 created` for ClassFeatureOption (joint with Plan 03's 1 entry → ≥50 total) + - Second run reports `0 created, ≥320 updated` for ClassProgression and `0 created, ≥50 updated` for ClassFeatureOption (idempotency) + - `psql $DATABASE_URL -t -c 'SELECT COUNT(*) FROM "ClassFeatureOption"'` outputs at least 50 + - `psql $DATABASE_URL -t -c 'SELECT COUNT(DISTINCT "optionsRef") FROM "ClassFeatureOption"'` outputs at least 13 + - For all 7 caster classes (Bard, Cleric, Druid, Oracle, Sorcerer, Witch, Wizard): the count of ClassProgression rows with non-null spellSlotIncrement is ≥18 + - For Bard, Sorcerer, Oracle: count of ClassProgression rows with non-null repertoireIncrement is ≥1 (D-18) + - Every `choiceOptionsRef` declared on a ClassProgression row has at least one matching ClassFeatureOption row (cross-reference query passes) + + + Seed re-run succeeds; full dataset of ≥320 ClassProgression rows (with overlay data merged for all casters) and ≥50 ClassFeatureOption rows is present in the DB; idempotency holds; cross-references intact. Plan 04 can rely on the complete dataset for any (className, level) recompute. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| dev-shell → seed script | Same boundary as Plan 03 — developer runs the seed with DB write privilege. | +| Hand-curated data → DB | Curation can have transcription errors (wrong slot count, typo'd optionKey). | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-1-W2b-01 | Tampering | Hand-curation transcription error (e.g. wrong slot count for Bard L5) | mitigate | Plan 04's integration tests assert ClassProgression-driven recompute results for Wizard L1 + at least one spontaneous caster. Bugs surface there. Curator double-checks against AoN before committing. | +| T-1-W2b-02 | Tampering | Stable optionKey is renamed after persist (orphaning CharacterFeat rows) | mitigate | This task's acceptance criteria + the comment in Plan 03's class-feature-options.ts both flag optionKey as stable; once committed in production, optionKey changes require a migration. Plan 04 LVL-V2-02 (reverse-level-up) will exercise the read path. | +| T-1-W2b-03 | Information Disclosure | (No new exposure beyond Plan 03's accept disposition.) | accept | Same trust model as Plan 03 — self-hosted single-tenant. | +| T-1-W2b-04 | Repudiation | Curated data quality drift over time (e.g. Witch tradition changes in a remaster) | mitigate | The Witch / Sorcerer "tradition depends on patron/bloodline" caveats are documented inline; recompute pipeline (Plan 04) treats the overlay as a starting point and may remap at commit. Phase 1 accepts the default. | + + + +After Tasks 1-3 complete: + +```bash +# Type-check clean (no shape changes; just data appends) +cd server && npx tsc --noEmit -p tsconfig.json + +# Idempotency check +cd server && npm run db:seed:class-progression && npm run db:seed:class-progression + +# Joint row counts after both Plan 03 and Plan 03b +psql $DATABASE_URL -c 'SELECT COUNT(*) FROM "ClassProgression"' # >= 320 +psql $DATABASE_URL -c 'SELECT COUNT(*) FROM "ClassFeatureOption"' # >= 50 + +# Caster verification +psql $DATABASE_URL -c 'SELECT className, COUNT(*) FROM "ClassProgression" WHERE "spellSlotIncrement" IS NOT NULL GROUP BY className' +# Expected: 7 rows (Bard, Cleric, Druid, Oracle, Sorcerer, Witch, Wizard), each count >= 18 + +# Spontaneous caster repertoire (D-18) +psql $DATABASE_URL -c 'SELECT className, COUNT(*) FROM "ClassProgression" WHERE "repertoireIncrement" IS NOT NULL GROUP BY className' +# Expected: 3 rows (Bard, Sorcerer, Oracle), each count >= 1 + +# Cross-reference integrity +psql $DATABASE_URL -c ' + SELECT cp."choiceOptionsRef", COUNT(cfo.id) + FROM "ClassProgression" cp + LEFT JOIN "ClassFeatureOption" cfo ON cfo."optionsRef" = cp."choiceOptionsRef" + WHERE cp."choiceOptionsRef" IS NOT NULL + GROUP BY cp."choiceOptionsRef" + HAVING COUNT(cfo.id) = 0 +' +# Expected: empty result (no orphaned choiceOptionsRef) +``` + + + +- spell-slot-overlays.ts has curated entries for Cleric, Druid, Witch, Bard, Sorcerer, Oracle (≥18 entries each, ≥120 total beyond Wizard); Champion remains minimal/empty per design; non-casters confirmed empty +- class-feature-options.ts has ≥50 total entries (Plan 03's 1 + Plan 03b's ≥49) across ≥13 distinct optionsRef values +- Re-running the seed bulk-updates the database without touching the seed script +- Idempotency holds — second run reports 0 created +- All cross-references intact: every choiceOptionsRef in ClassProgression has at least one matching ClassFeatureOption +- Plan 04 LevelingService can recompute correctly for any of the 16 D-16 classes at any level 1..20 + + + +After completion, create `.planning/phases/01-level-up-pf2e-regelkonform/01-03b-SUMMARY.md` documenting: +- Final cumulative row counts (ClassProgression by className + total; ClassFeatureOption by optionsRef + total) +- Any deviations from the planned overlay contents (e.g. Witch tradition default, Sorcerer bloodline default) +- Any classes/levels where AoN data was ambiguous and a documented choice was made +- Confirmation idempotency observed on second run +- Confirmation cross-reference integrity check passed + 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 index a3ee363..7126e1e 100644 --- a/.planning/phases/01-level-up-pf2e-regelkonform/01-04-PLAN.md +++ b/.planning/phases/01-level-up-pf2e-regelkonform/01-04-PLAN.md @@ -2,8 +2,8 @@ phase: 01-level-up-pf2e-regelkonform plan: 04 type: execute -wave: 3 -depends_on: ["01-01", "01-02", "01-03"] +wave: 4 +depends_on: ["01-01", "01-02", "01-03", "01-03b"] files_modified: - server/src/modules/leveling/leveling.module.ts - server/src/modules/leveling/leveling.controller.ts @@ -34,6 +34,10 @@ must_haves: - "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)." + - "GET /characters/:characterId/level-up returns the open DRAFT for resume-banner detection, or 404 when none exists." + - "GET /characters/:characterId/level-up/:sessionId/feats?slot=class|skill|general|ancestry|archetype returns FeatWithEval[] from FeatFilterService for the wizard's feat steps." + - "GET /characters/:characterId/level-up/:sessionId/class-feature-options/:optionsRef returns the ClassFeatureOption rows for that optionsRef so the class-feature-choice step can render them." + - "LevelUpSessionDto includes a steps: StepKind[] field computed via computeApplicableSteps so the wizard renders the correct step list without extra round-trips." artifacts: - path: "server/src/modules/leveling/leveling.module.ts" provides: "NestJS module wiring controller + services + JWT + dependency on CharactersModule (for gateway)" @@ -298,12 +302,21 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P /** * Response shape for the LevelUpSession (start / patch / get). * `state` is the JSON-serializable WizardChoices + UI-only fields. + * `steps` is computed server-side via computeApplicableSteps so the React wizard + * (Plan 05) can render the correct ordered step list without a second round-trip. */ export class LevelUpSessionDto { @ApiProperty() id: string; @ApiProperty() characterId: string; @ApiProperty() targetLevel: number; @ApiProperty({ type: 'object', additionalProperties: true }) state: Record; + @ApiProperty({ + description: 'Ordered list of wizard steps applicable to this session (computed via computeApplicableSteps from Plan 02 lib).', + isArray: true, + type: String, + example: ['class-features', 'boost', 'skill-increase', 'feat-class', 'feat-skill', 'feat-ancestry', 'review'], + }) + steps: string[]; // StepKind[] — see lib/types.ts for the exact union @ApiPropertyOptional({ nullable: true }) committedAt: Date | null; @ApiProperty() createdAt: Date; @ApiProperty() updatedAt: Date; @@ -567,6 +580,7 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P Injectable, NotFoundException, ForbiddenException, BadRequestException, ConflictException, Inject, forwardRef, Logger, } from '@nestjs/common'; + import { Prisma } from '../../generated/prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { CharactersService } from '../characters/characters.service'; import { CharactersGateway } from '../characters/characters.gateway'; @@ -576,6 +590,7 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P import { evaluatePrereq } from './lib/prereq-evaluator'; import { recomputeDerivedStats } from './lib/recompute-derived-stats'; import { computeApplicableSteps } from './lib/compute-applicable-steps'; + import { FeatFilterService, type SlotKind, type FeatWithEval } from './feat-filter.service'; import type { CharacterContext, DerivedStats, ClassProgressionRow, WizardChoices, StepKind, } from './lib/types'; @@ -592,6 +607,7 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P @Inject(forwardRef(() => CharactersGateway)) private charactersGateway: CharactersGateway, private translationsService: TranslationsService, + private featFilterService: FeatFilterService, ) {} // ============================================================ @@ -609,16 +625,16 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P } const targetLevel = dto.targetLevel ?? character.level + 1; if (targetLevel !== character.level + 1) { - throw new BadRequestException(`Stufenaufstieg nur auf Stufe ${character.level + 1} möglich`); + throw new BadRequestException(`Stufenaufstieg nur auf Stufe ${character.level + 1} moeglich`); } - // Try to resume the open DRAFT (partial unique index → 0 or 1 row) + // 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; + return this.attachSteps(existing, characterId); } // Create fresh DRAFT @@ -630,18 +646,39 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P state: this.initialWizardState(), }, }); - this.logger.log(`Created LevelUpSession ${created.id} for character ${characterId} → L${targetLevel}`); - return created; + this.logger.log(`Created LevelUpSession ${created.id} for character ${characterId} -> L${targetLevel}`); + return this.attachSteps(created, characterId); } catch (e) { - // Partial unique index race — another tab beat us. Re-fetch and return that one. + // 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; + if (fallback) return this.attachSteps(fallback, characterId); throw e; } } + /** + * Decorate the raw LevelUpSession row with `steps: StepKind[]` -- computed via + * computeApplicableSteps from Plan 02 lib. Plan 05 (React wizard) consumes this + * directly so the client doesn't need to re-derive the step list. + */ + private async attachSteps( + session: T, + characterId: string, + ): Promise { + const ctx = await this.loadCharacterFullForRecompute(characterId); + const charContext = this.toCharacterContext(ctx); + const progression = await this.loadProgression(charContext.className, session.targetLevel); + const steps = computeApplicableSteps({ + character: charContext, + targetLevel: session.targetLevel, + progression, + freeArchetype: ctx.character.freeArchetype ?? false, + }); + return { ...session, steps }; + } + /** * Merge a partial WizardChoices into the DRAFT.state JSON. * Validates shape via assertValidWizardChoices. @@ -666,7 +703,7 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P where: { id: sessionId }, data: { state: newState }, }); - return updated; + return this.attachSteps(updated, characterId); } /** @@ -675,12 +712,16 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P */ 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 ctx = await this.loadCharacterFullForRecompute(characterId); + const progression = await this.loadProgression(ctx.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 rawState = session.state as Prisma.JsonObject; + this.assertValidWizardChoices(rawState); + const choices: WizardChoices = this.parseWizardChoices(rawState); + + const charContext = this.toCharacterContext(ctx); + const before: DerivedStats = await this.computeBeforeStats(ctx); + const after: DerivedStats = recomputeDerivedStats(charContext, choices, progression); const spellcaster = this.computeSpellcasterPreview(progression, choices); return { before, after, spellcaster }; @@ -693,28 +734,34 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P 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'); + throw new BadRequestException('Session gehoert 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) { + const ctx = await this.loadCharacterFullForRecompute(characterId); + if (ctx.level + 1 !== session.targetLevel) { throw new BadRequestException( - `Charakter ist auf Stufe ${character.level}; Session erwartet Aufstieg auf Stufe ${session.targetLevel}`, + `Charakter ist auf Stufe ${ctx.level}; Session erwartet Aufstieg auf Stufe ${session.targetLevel}`, ); } - const choices = (session.state as Record) as unknown as WizardChoices; - this.assertValidWizardChoices(choices); + // Parse + validate wizard state into a properly-typed WizardChoices. + // assertValidWizardChoices is the structural guard; parseWizardChoices below builds the + // strongly-typed object to avoid `as unknown as WizardChoices` casts downstream. + const rawState = session.state as Prisma.JsonObject; + this.assertValidWizardChoices(rawState); + const choices: WizardChoices = this.parseWizardChoices(rawState); 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); + const charContext = this.toCharacterContext(ctx); + const progression = await this.loadProgression(ctx.className, session.targetLevel); + const beforeStats = await this.computeBeforeStats(ctx); + const newDerived = recomputeDerivedStats(charContext, choices, progression); + const snapshotJson = this.buildSnapshot(ctx, beforeStats); // ============ ATOMIC TRANSACTION ============ const result = await this.prisma.$transaction(async (tx) => { @@ -722,14 +769,14 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P await tx.levelUpHistory.create({ data: { characterId, - levelFrom: character.level, + levelFrom: ctx.level, levelTo: session.targetLevel, - snapshotBefore: snapshotJson, - choices: session.state as never, + snapshotBefore: snapshotJson as unknown as Prisma.InputJsonValue, + choices: rawState as Prisma.InputJsonValue, }, }); - // 2. Character update — level + hpMax + freeArchetype passthrough. + // 2. Character update -- level + hpMax + freeArchetype passthrough. // CRITICAL: do NOT touch hpCurrent (Pitfall #9). await tx.character.update({ where: { id: characterId }, @@ -740,10 +787,12 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P }, }); - // 3. Boost targets → CharacterAbility upserts (4 rows max) + // 3. Boost targets -> CharacterAbility upserts (4 rows max) if (choices.boostTargets) { + const abilityScores: Record = {}; + for (const a of ctx.abilities) abilityScores[a.ability] = a.score; for (const ability of choices.boostTargets) { - const current = character.abilities[ability]; + const current = abilityScores[ability] ?? 10; const newScore = applyAttributeBoost(current); await tx.characterAbility.upsert({ where: { characterId_ability: { characterId, ability } }, @@ -766,7 +815,7 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P }); } - // 5. Feat picks → CharacterFeat creates + // 5. Feat picks -> CharacterFeat creates const featSlots: Array<[keyof WizardChoices, string]> = [ ['featClassId', 'CLASS'], ['featSkillId', 'SKILL'], @@ -775,7 +824,7 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P ['featArchetypeId', 'ARCHETYPE'], ]; for (const [field, source] of featSlots) { - const featId = (choices as Record)[field as string] as string | undefined; + const featId = choices[field] as string | undefined; if (featId) { await tx.characterFeat.create({ data: { @@ -788,7 +837,11 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P } } - // 6. Class-feature choice picks → CharacterFeat creates with optionKey + // 6. Class-feature choice picks -> CharacterFeat creates with the option name. + // CharacterFeat in the current schema already has `featId String?` (nullable) and + // `name String` (required) -- verified at server/prisma/schema.prisma lines 233-244. + // We pass `featId: null` for class-feature choices that have no underlying Feat row, + // and use the ClassFeatureOption.name as the CharacterFeat.name. No schema change. if (choices.classFeatureChoices) { for (const [choiceKey, optionKey] of Object.entries(choices.classFeatureChoices)) { const option = await tx.classFeatureOption.findUnique({ @@ -798,25 +851,30 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P await tx.characterFeat.create({ data: { characterId, - featId: null as never, // class-feature choice may have no featId — schema permitting - name: option.name, + featId: null, // featId is `String?` in schema (verified) + name: option.name, // required `String` -- pass the option's English name + nameGerman: option.nameGerman ?? null, source: 'CLASS', level: session.targetLevel, - }, + } satisfies Prisma.CharacterFeatUncheckedCreateInput, }); } } } - // 7. Spellcaster — apply spellSlotIncrement / cantripIncrement / repertoirePicks + // 7. Spellcaster -- apply spellSlotIncrement / cantripIncrement / repertoirePicks if (progression.spellSlotIncrement) { - // Implementation depends on existing CharacterResource shape — executor adapts. + // 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, + data: { + characterId, + spellId, + isInRepertoire: true, + } satisfies Prisma.CharacterSpellUncheckedCreateInput, }); } } @@ -844,13 +902,13 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P }, }); - // 10. D-15 — kick off German translation of any new prereq strings + class-feature + // 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}`); + this.logger.log(`Committed level-up: characterId=${characterId} fromLevel=${ctx.level} toLevel=${session.targetLevel}`); return result; } @@ -867,6 +925,75 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P this.logger.log(`Discarded LevelUpSession ${sessionId} for character ${characterId}`); } + // ============================================================ + // WIZARD-SUPPORT QUERIES (used by Plan 05 React wizard) + // ============================================================ + + /** + * Resume-banner detection — returns the open DRAFT for this character or null. + * Used by GET /characters/:characterId/level-up. + */ + async findOpenDraft(characterId: string, userId: string) { + await this.charactersService.checkCharacterAccess(characterId, userId, true); + const draft = await this.prisma.levelUpSession.findFirst({ + where: { characterId, committedAt: null }, + }); + if (!draft) return null; + return this.attachSteps(draft, characterId); + } + + /** + * Filtered feat list for a wizard slot. Delegates to FeatFilterService and uses the + * session's targetLevel + the character's current state to build the CharacterContext. + */ + async getFeatsForSlot( + characterId: string, + sessionId: string, + slot: SlotKind, + includeUnavailable: boolean, + userId: string, + ): Promise { + const session = await this.loadSessionAndAuthorize(sessionId, userId); + if (session.characterId !== characterId) { + throw new BadRequestException('Session gehört nicht zu diesem Charakter'); + } + const character = await this.loadCharacterFullForRecompute(characterId); + const ctx: CharacterContext = this.toCharacterContext(character); + return this.featFilterService.getFilteredFeats({ + slot, + character: ctx, + maxLevel: session.targetLevel, + includeUnavailable, + }); + } + + /** + * Options for a class-feature choice step (e.g. Cleric Doctrine, Wizard School). + * Validates that the optionsRef is allowed by this session's ClassProgression row. + */ + async getClassFeatureOptions( + characterId: string, + sessionId: string, + optionsRef: string, + userId: string, + ) { + const session = await this.loadSessionAndAuthorize(sessionId, userId); + if (session.characterId !== characterId) { + throw new BadRequestException('Session gehört nicht zu diesem Charakter'); + } + const character = await this.loadCharacterFullForRecompute(characterId); + const progression = await this.loadProgression(character.className, session.targetLevel); + if (progression.choiceOptionsRef !== optionsRef) { + throw new BadRequestException( + `optionsRef ${optionsRef} ist für diese Sitzung nicht erlaubt (erwartet: ${progression.choiceOptionsRef ?? 'kein Wahlschritt'})`, + ); + } + return this.prisma.classFeatureOption.findMany({ + where: { optionsRef }, + orderBy: { name: 'asc' }, + }); + } + // ============================================================ // PRIVATE HELPERS // ============================================================ @@ -881,7 +1008,7 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P */ private assertValidWizardChoices(state: unknown): asserts state is Record { if (!state || typeof state !== 'object') { - throw new BadRequestException('Ungültiges Wizard-State-Format'); + throw new BadRequestException('Ungueltiges Wizard-State-Format'); } // Optional further checks: boostTargets is array of valid abbreviations, etc. const s = state as Record; @@ -892,6 +1019,39 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P } } + /** + * Build a strongly-typed WizardChoices from the JSON-blob session.state. + * Replaces `as unknown as WizardChoices` casts with explicit field-by-field reads + * so no `as never` / unsound cast is needed downstream. + * + * The discriminated-union WizardChoices type is defined in lib/types.ts (Plan 02). + * If a field shape doesn\'t match expectations, throws BadRequestException with the + * offending key. + */ + private parseWizardChoices(state: Record): WizardChoices { + const choicesRoot = (state.choices ?? state) as Record; + const out: WizardChoices = {}; + if (Array.isArray(choicesRoot.boostTargets)) { + out.boostTargets = choicesRoot.boostTargets as WizardChoices['boostTargets']; + } + if (choicesRoot.skillIncrease && typeof choicesRoot.skillIncrease === 'object') { + out.skillIncrease = choicesRoot.skillIncrease as WizardChoices['skillIncrease']; + } + if (typeof choicesRoot.featClassId === 'string') out.featClassId = choicesRoot.featClassId; + if (typeof choicesRoot.featSkillId === 'string') out.featSkillId = choicesRoot.featSkillId; + if (typeof choicesRoot.featGeneralId === 'string') out.featGeneralId = choicesRoot.featGeneralId; + if (typeof choicesRoot.featAncestryId === 'string') out.featAncestryId = choicesRoot.featAncestryId; + if (typeof choicesRoot.featArchetypeId === 'string') out.featArchetypeId = choicesRoot.featArchetypeId; + if (choicesRoot.classFeatureChoices && typeof choicesRoot.classFeatureChoices === 'object') { + out.classFeatureChoices = choicesRoot.classFeatureChoices as Record; + } + if (Array.isArray(choicesRoot.spellcasterRepertoirePicks)) { + out.spellcasterRepertoirePicks = (choicesRoot.spellcasterRepertoirePicks as unknown[]) + .filter((s): s is string => typeof s === 'string'); + } + return out; + } + private async loadSessionAndAuthorize(sessionId: string, userId: string) { const session = await this.prisma.levelUpSession.findUnique({ where: { id: sessionId }, @@ -902,30 +1062,177 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P 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'); + /** + * Load Character + every relation the recompute pipeline needs. + * + * Concrete shape (verify field names against schema.prisma): + * prisma.character.findUnique({ + * where: { id: characterId }, + * include: { + * class: true, // Character.classId -> Class + * ancestry: true, // Character.ancestryId -> Ancestry + * heritage: true, // Character.heritageId? -> Heritage + * abilities: true, // CharacterAbility[] + * skills: true, // CharacterSkill[] + * feats: { include: { feat: true } }, // CharacterFeat[] with Feat + * spells: true, // CharacterSpell[] + * resources: true, // CharacterResource[] (spell slots, focus pts, etc.) + * items: true, // CharacterItem[] (for armor -> AC computation) + * }, + * }) + * + * Then ALSO load the ClassProgression rows for class.name from level 1 up to and including + * (character.level + 1) so the recompute lib can apply cumulative grants: + * const progressionRows = await this.prisma.classProgression.findMany({ + * where: { className: character.class.name, level: { lte: character.level + 1 } }, + * orderBy: { level: 'asc' }, + * }); + * + * Return type contract (define near top of file or in lib/types.ts): + * export interface CharacterFullContext { + * character: Character & { class: Class; ancestry: Ancestry; heritage: Heritage | null }; + * abilities: CharacterAbility[]; + * skills: CharacterSkill[]; + * feats: (CharacterFeat & { feat: Feat | null })[]; + * spells: CharacterSpell[]; + * resources: CharacterResource[]; + * items: CharacterItem[]; + * progressionRows: ClassProgression[]; // L1..currentLevel inclusive + * className: string; + * level: number; + * } + */ + private async loadCharacterFullForRecompute(characterId: string): Promise { + const character = await this.prisma.character.findUnique({ + where: { id: characterId }, + include: { + class: true, + ancestry: true, + heritage: true, + abilities: true, + skills: true, + feats: { include: { feat: true } }, + spells: true, + resources: true, + items: true, + }, + }); + if (!character) throw new NotFoundException('Charakter nicht gefunden'); + const className = character.class?.name ?? ''; + const progressionRows = await this.prisma.classProgression.findMany({ + where: { className, level: { lte: character.level } }, + orderBy: { level: 'asc' }, + }); + return { + character, + abilities: character.abilities, + skills: character.skills, + feats: character.feats, + spells: character.spells, + resources: character.resources, + items: character.items, + progressionRows, + className, + level: character.level, + }; } 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}`); + if (!row) throw new NotFoundException(`Keine ClassProgression fuer ${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'); + /** + * Vorher-Snapshot: call recomputeDerivedStats with the character's CURRENT level's + * progression slice and NO new choices. This produces the same DerivedStats shape as + * the after-commit recompute, ensuring the diff in the UI is computed from identical + * units (apples-to-apples). + * + * The lib signature is recomputeDerivedStats(character, choices, progression): + * - character: CharacterContext (mapped via toCharacterContext below) + * - choices: null -> "no new choices, just compute current stats" + * - progression: the ClassProgression row at character.level (NOT targetLevel) + * + * If the character is L1 and there is no L1 progression row yet (legacy data), fall back + * to reading character.hpMax / character.armorClass / etc. directly from the row. + */ + private async computeBeforeStats(ctx: CharacterFullContext): Promise { + const charContext = this.toCharacterContext(ctx); + const currentLevelProgression = await this.prisma.classProgression.findUnique({ + where: { className_level: { className: ctx.className, level: ctx.level } }, + }); + if (!currentLevelProgression) { + // Fallback: read stored stats directly (legacy character or L0 edge case) + return { + level: ctx.level, + hpMax: ctx.character.hpMax ?? 0, + ac: ctx.character.armorClass ?? 10, + classDc: ctx.character.classDc ?? 10, + perception: ctx.character.perception ?? 0, + fortitude: ctx.character.fortitude ?? 0, + reflex: ctx.character.reflex ?? 0, + will: ctx.character.will ?? 0, + }; + } + return recomputeDerivedStats(charContext, null, currentLevelProgression as unknown as ClassProgressionRow); } - private buildSnapshot(character: never): Record { - // Capture full character + relations subset for LevelUpHistory.snapshotBefore - throw new Error('TODO: build snapshot JSON'); + /** + * LevelUpHistory.snapshotBefore JSON contract (versioned for forward-compat). + * + * Schema (define this explicit type next to the service file or in lib/types.ts): + * export interface CharacterSnapshot { + * version: '1.0'; + * character: Character; // full Character row (no relations expanded) + * abilities: CharacterAbility[]; + * skills: CharacterSkill[]; + * feats: CharacterFeat[]; // CharacterFeat rows (NOT the joined Feat objects -- IDs only) + * spells: CharacterSpell[]; + * resources: CharacterResource[]; + * items: CharacterItem[]; // include for full restore in v2 + * derived: DerivedStats; // the "Vorher" stats so reverse-level-up has both + * } + * + * Contract: this is the source-of-truth payload that LVL-V2-02 (reverse-level-up, planned + * in REQUIREMENTS.md line 114) will read to restore the pre-commit state. Versioning the + * shape (version: '1.0') lets v2 detect schema drift cleanly. + */ + private buildSnapshot(ctx: CharacterFullContext, derivedBefore: DerivedStats): CharacterSnapshot { + return { + version: '1.0', + character: ctx.character, + abilities: ctx.abilities, + skills: ctx.skills, + feats: ctx.feats.map(f => ({ ...f, feat: undefined })), // strip joined Feat to keep snapshot small + spells: ctx.spells, + resources: ctx.resources, + items: ctx.items, + derived: derivedBefore, + }; + } + + /** Map our CharacterFullContext into the lib's pure CharacterContext shape. */ + private toCharacterContext(ctx: CharacterFullContext): CharacterContext { + const abilitiesMap: Record = {}; + for (const a of ctx.abilities) { + abilitiesMap[a.ability] = a.score; + } + const skillsMap: Record = {}; + for (const s of ctx.skills) { + skillsMap[s.skillName] = s.proficiency; + } + return { + level: ctx.level, + className: ctx.className, + ancestryName: ctx.character.ancestry?.name ?? '', + heritageName: ctx.character.heritage?.name, + abilities: abilitiesMap as CharacterContext['abilities'], + skills: skillsMap as CharacterContext['skills'], + feats: new Set(ctx.feats.map(f => f.feat?.name).filter((n): n is string => !!n)), + }; } private computeSpellcasterPreview(progression: ClassProgressionRow, choices: WizardChoices): LevelUpPreviewDto['spellcaster'] { @@ -996,7 +1303,7 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P - Task 5: LevelingController — 5 REST endpoints + Task 5: LevelingController — 8 REST endpoints (5 core + 3 wizard-support) server/src/modules/leveling/leveling.controller.ts - server/src/modules/characters/characters.controller.ts (canonical controller — endpoint pattern, decorators, CurrentUser) @@ -1086,9 +1393,68 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P ): Promise { await this.levelingService.discard(characterId, sessionId, userId); } + + // === Wizard-support endpoints (added so the React wizard in Plan 05 doesn't need extra round-trips) === + + @Get() + @ApiOperation({ summary: 'Get the open DRAFT for this character (resume-banner detection)' }) + @ApiResponse({ status: 200, description: 'Open DRAFT or null', type: LevelUpSessionDto }) + async getOpenDraft( + @Param('characterId') characterId: string, + @CurrentUser('id') userId: string, + ) { + const draft = await this.levelingService.findOpenDraft(characterId, userId); + if (!draft) { + throw new NotFoundException('Keine offene Stufenaufstiegs-Session'); + } + return draft; + } + + @Get(':sessionId/feats') + @ApiOperation({ summary: 'Filtered feat list for a wizard slot (driven by FeatFilterService)' }) + @ApiResponse({ status: 200, description: 'Feats with prereq evaluation results' }) + async getFeats( + @Param('characterId') characterId: string, + @Param('sessionId') sessionId: string, + @Query('slot') slot: SlotKind, + @Query('includeUnavailable') includeUnavailable: string | undefined, + @CurrentUser('id') userId: string, + ) { + return this.levelingService.getFeatsForSlot( + characterId, + sessionId, + slot, + includeUnavailable === 'true', + userId, + ); + } + + @Get(':sessionId/class-feature-options/:optionsRef') + @ApiOperation({ summary: 'Options for a class-feature choice step (e.g. Cleric Doctrine)' }) + @ApiResponse({ status: 200, description: 'ClassFeatureOption rows for the optionsRef' }) + async getClassFeatureOptions( + @Param('characterId') characterId: string, + @Param('sessionId') sessionId: string, + @Param('optionsRef') optionsRef: string, + @CurrentUser('id') userId: string, + ) { + return this.levelingService.getClassFeatureOptions( + characterId, + sessionId, + optionsRef, + userId, + ); + } } ``` + Add the imports at the top of the file: + + ```typescript + import { Body, Controller, Delete, Get, NotFoundException, Param, Patch, Post, Query } from '@nestjs/common'; + import { SlotKind } from './feat-filter.service'; + ``` + **Constraints:** - JWT auth is global (per `server/src/app.module.ts` lines 53-56) — no `@UseGuards` needed. - `@CurrentUser('id')` decorator extracts userId from JWT. @@ -1102,14 +1468,15 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P - 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 contains `@Get()` (open-DRAFT detect), `@Get(':sessionId/feats')` (filtered feat list), `@Get(':sessionId/class-feature-options/:optionsRef')` (option list) + - All 8 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. + Controller exposes 8 REST endpoints (5 core: start/patch/preview/commit/discard + 3 wizard-support: getOpenDraft/getFeats/getClassFeatureOptions) with full Swagger annotations, JWT auth via global guard, delegates to LevelingService. @@ -1271,94 +1638,312 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P await prisma.$disconnect(); }); + /** + * Helper: seed a Fighter character at level 4 with full relations needed for commit. + * Returns { characterId, ownerId, sessionId } so each test starts from a clean known state. + */ + async function seedFighterAtL4(): Promise<{ characterId: string; ownerId: string }> { + const owner = await prisma.user.create({ + data: { email: `test-${Date.now()}@example.com`, password: 'x', role: 'PLAYER' }, + }); + const campaign = await prisma.campaign.create({ + data: { name: 'Test', gmId: owner.id, members: { create: { userId: owner.id, role: 'PLAYER' } } }, + }); + const fighterClass = await prisma.class.upsert({ + where: { name: 'Fighter' }, + update: {}, + create: { name: 'Fighter', hp: 10, keyAbility: 'STR' }, + }); + const ancestry = await prisma.ancestry.upsert({ + where: { name: 'Human' }, update: {}, create: { name: 'Human', hp: 8 }, + }); + const character = await prisma.character.create({ + data: { + name: 'Test Fighter', campaignId: campaign.id, ownerId: owner.id, + classId: fighterClass.id, ancestryId: ancestry.id, + level: 4, hpMax: 50, hpCurrent: 12, // hpCurrent set deliberately != hpMax + armorClass: 21, classDc: 21, perception: 10, + fortitude: 11, reflex: 9, will: 7, + abilities: { create: [ + { ability: 'STR', score: 18 }, { ability: 'DEX', score: 14 }, { ability: 'CON', score: 16 }, + { ability: 'INT', score: 10 }, { ability: 'WIS', score: 12 }, { ability: 'CHA', score: 10 }, + ]}, + }, + }); + // Seed ClassProgression rows L1..L5 for Fighter so loadProgression has data + for (let lv = 1; lv <= 5; lv++) { + await prisma.classProgression.upsert({ + where: { className_level: { className: 'Fighter', level: lv } }, + update: {}, + create: { + className: 'Fighter', level: lv, + grants: lv === 5 ? ['Fighter Weapon Mastery'] : [], + proficiencyChanges: {}, + }, + }); + } + return { characterId: character.id, ownerId: owner.id }; + } + + /** Helper: build a fresh DRAFT session with valid choices for an L4 -> L5 Fighter. */ + async function makeReadyDraft(characterId: string, choices: WizardChoices): Promise { + const session = await prisma.levelUpSession.create({ + data: { characterId, targetLevel: 5, state: { choices } as Prisma.InputJsonValue }, + }); + return session.id; + } + // 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. + const { characterId, ownerId } = await seedFighterAtL4(); + const sessionId = await makeReadyDraft(characterId, { + boostTargets: ['STR', 'DEX', 'CON', 'INT'], + featClassId: 'fake-feat-id-that-does-not-exist', + }); + // Force LevelUpHistory.create to throw — simulates DB hiccup mid-transaction + const historyCreateSpy = jest + .spyOn(prisma.levelUpHistory, 'create') + .mockRejectedValueOnce(new Error('boom')); + await expect( + service.commit(characterId, sessionId, {}, ownerId), + ).rejects.toThrow(/boom/); + // Verify rollback: character.level unchanged, no history row, session still DRAFT + const after = await prisma.character.findUnique({ where: { id: characterId } }); + expect(after?.level).toBe(4); + expect(after?.hpMax).toBe(50); + const history = await prisma.levelUpHistory.findMany({ where: { characterId } }); + expect(history).toHaveLength(0); + const session = await prisma.levelUpSession.findUnique({ where: { id: sessionId } }); + expect(session?.committedAt).toBeNull(); + historyCreateSpy.mockRestore(); }); it('creates exactly one LevelUpHistory row with snapshotBefore + choices populated', async () => { - // Happy path: full commit. Assert LevelUpHistory row count = 1, fields non-null. + const { characterId, ownerId } = await seedFighterAtL4(); + const sessionId = await makeReadyDraft(characterId, { + boostTargets: ['STR', 'DEX', 'CON', 'INT'], + }); + await service.commit(characterId, sessionId, {}, ownerId); + const history = await prisma.levelUpHistory.findMany({ where: { characterId } }); + expect(history).toHaveLength(1); + expect(history[0].levelFrom).toBe(4); + expect(history[0].levelTo).toBe(5); + expect(history[0].snapshotBefore).not.toBeNull(); + expect(history[0].choices).not.toBeNull(); + // Snapshot version contract + expect((history[0].snapshotBefore as Record).version).toBe('1.0'); }); }); // 1-W3-03 describe('broadcast count', () => { it('emits level_up_committed exactly once per commit', async () => { + const { characterId, ownerId } = await seedFighterAtL4(); + const sessionId = await makeReadyDraft(characterId, { + boostTargets: ['STR', 'DEX', 'CON', 'INT'], + }); gatewayBroadcastSpy.mockClear(); - // ... run commit ... + await service.commit(characterId, sessionId, {}, ownerId); expect(gatewayBroadcastSpy).toHaveBeenCalledTimes(1); expect(gatewayBroadcastSpy.mock.calls[0][1].type).toBe('level_up_committed'); + expect(gatewayBroadcastSpy.mock.calls[0][1].data.level).toBe(5); + expect(gatewayBroadcastSpy.mock.calls[0][1].data.derived).toBeDefined(); }); }); - // Pitfall #9 — explicit hpCurrent invariant - describe('Pitfall #9 — never mutates hpCurrent', () => { + // 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. + const { characterId, ownerId } = await seedFighterAtL4(); + const sessionId = await makeReadyDraft(characterId, { + boostTargets: ['STR', 'DEX', 'CON', 'INT'], + }); + // Spy on the (transactional) character.update inside $transaction. Capture every call. + const updateCalls: Array<{ data: Record }> = []; + const origTx = prisma.$transaction.bind(prisma); + jest.spyOn(prisma, '$transaction').mockImplementation(async (fn) => { + return origTx(async (tx) => { + const wrappedTx = { + ...tx, + character: { + ...tx.character, + update: (args: { data: Record }) => { + updateCalls.push({ data: args.data }); + return tx.character.update(args); + }, + }, + }; + return (fn as Function)(wrappedTx); + }); + }); + await service.commit(characterId, sessionId, {}, ownerId); + // Verify hpCurrent NOT included in any character.update inside the tx + for (const call of updateCalls) { + expect(call.data).not.toHaveProperty('hpCurrent'); + } + // And verify hpCurrent in DB is unchanged from seed value (12) + const after = await prisma.character.findUnique({ where: { id: characterId } }); + expect(after?.hpCurrent).toBe(12); }); }); // 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. + const { characterId, ownerId } = await seedFighterAtL4(); + const sessionId = await makeReadyDraft(characterId, { boostTargets: ['STR', 'DEX', 'CON', 'INT'] }); + const beforeChar = await prisma.character.findUnique({ where: { id: characterId } }); + await service.discard(characterId, sessionId, ownerId); + const afterChar = await prisma.character.findUnique({ where: { id: characterId } }); + expect(afterChar?.level).toBe(beforeChar?.level); + expect(afterChar?.hpMax).toBe(beforeChar?.hpMax); + const session = await prisma.levelUpSession.findUnique({ where: { id: sessionId } }); + expect(session).toBeNull(); }); }); - // 1-W3-05 — race condition / partial unique index + // 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. + const { characterId, ownerId } = await seedFighterAtL4(); + const sessionId = await makeReadyDraft(characterId, { boostTargets: ['STR', 'DEX', 'CON', 'INT'] }); + await service.commit(characterId, sessionId, {}, ownerId); + // Now character is L5; seed L6 progression + await prisma.classProgression.upsert({ + where: { className_level: { className: 'Fighter', level: 6 } }, + update: {}, create: { className: 'Fighter', level: 6, grants: [], proficiencyChanges: {} }, + }); + // Second DRAFT for L5 -> L6 should succeed + const newSession = await service.startOrResume(characterId, { targetLevel: 6 }, ownerId); + expect(newSession.committedAt).toBeNull(); + expect(newSession.targetLevel).toBe(6); }); - 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.) + it('rejects creating a second open DRAFT for the same character via raw insert', async () => { + const { characterId } = await seedFighterAtL4(); + await prisma.levelUpSession.create({ + data: { characterId, targetLevel: 5, state: {} }, + }); + // Raw INSERT bypasses Prisma findFirst-then-create guard and hits the partial index + await expect( + prisma.$executeRawUnsafe( + `INSERT INTO "LevelUpSession" ("id", "characterId", "targetLevel", "state", "createdAt", "updatedAt") VALUES ('${'00000000-0000-0000-0000-000000000099'}', '${characterId}', 5, '{}', NOW(), NOW())`, + ), + ).rejects.toThrow(/LevelUpSession_characterId_open_unique|unique constraint/i); }); }); // 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. + // Seed a Wizard character at L1, with L2 ClassProgression carrying spellSlotIncrement + const owner = await prisma.user.create({ data: { email: `wiz-${Date.now()}@x.x`, password: 'x', role: 'PLAYER' } }); + const campaign = await prisma.campaign.create({ data: { name: 'C', gmId: owner.id, members: { create: { userId: owner.id, role: 'PLAYER' } } } }); + const wizardClass = await prisma.class.upsert({ where: { name: 'Wizard' }, update: {}, create: { name: 'Wizard', hp: 6, keyAbility: 'INT' } }); + const ancestry = await prisma.ancestry.upsert({ where: { name: 'Elf' }, update: {}, create: { name: 'Elf', hp: 6 } }); + const wiz = await prisma.character.create({ + data: { name: 'W', campaignId: campaign.id, ownerId: owner.id, classId: wizardClass.id, ancestryId: ancestry.id, level: 1, hpMax: 14, hpCurrent: 14 }, + }); + await prisma.classProgression.upsert({ + where: { className_level: { className: 'Wizard', level: 2 } }, + update: { spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 1 } }, + create: { className: 'Wizard', level: 2, grants: [], proficiencyChanges: {}, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 1 } }, + }); + const sessionId = await makeReadyDraft(wiz.id, {}); + await service.commit(wiz.id, sessionId, {}, owner.id); + // Assert: a CharacterResource row reflects the new slot (executor adapts to actual schema) + const resources = await prisma.characterResource.findMany({ where: { characterId: wiz.id } }); + // Either: a "spell-slot-arcane-1" entry exists with count >= 1, or maxValue increased + expect(resources.length).toBeGreaterThan(0); }); it('does NOT add slots for non-casters (Fighter)', async () => { - // Use a Fighter character; commit; assert no spell-slot writes. + const { characterId, ownerId } = await seedFighterAtL4(); + const sessionId = await makeReadyDraft(characterId, { boostTargets: ['STR', 'DEX', 'CON', 'INT'] }); + await service.commit(characterId, sessionId, {}, ownerId); + const resources = await prisma.characterResource.findMany({ + where: { characterId, name: { contains: 'spell-slot' } }, + }); + expect(resources).toHaveLength(0); }); }); - // 1-W3-08 — translation pipeline + // 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(); + const { characterId, ownerId } = await seedFighterAtL4(); + // Seed L5 progression with grants to trigger the maybeTranslateNewStrings call + await prisma.classProgression.update({ + where: { className_level: { className: 'Fighter', level: 5 } }, + data: { grants: ['Fighter Weapon Mastery'] }, + }); + const sessionId = await makeReadyDraft(characterId, { boostTargets: ['STR', 'DEX', 'CON', 'INT'] }); + // Get the mocked TranslationsService from the test module + const trans = module.get(TranslationsService); + const transSpy = jest.spyOn(trans, 'getTranslationsBatch'); + transSpy.mockClear(); + await service.commit(characterId, sessionId, {}, ownerId); + expect(transSpy).toHaveBeenCalled(); + const items = transSpy.mock.calls[0][0]; + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThan(0); + expect(items[0]).toHaveProperty('englishText'); }); }); - // 1-W3-10 — access control + // 1-W3-10 -- access control describe('access control (D-13)', () => { it('rejects when user is neither Owner nor GM', async () => { + // Make the CharactersService mock throw ForbiddenException for this user + const charService = module.get(CharactersService); + jest.spyOn(charService, 'checkCharacterAccess').mockRejectedValueOnce( + new ForbiddenException('Kein Zugriff auf diesen Charakter'), + ); + const { characterId } = await seedFighterAtL4(); await expect( - service.startOrResume(testCharacterId, {}, 'random-user-id'), - ).rejects.toThrow(/Forbidden|Kein Zugriff/); + service.startOrResume(characterId, {}, 'random-user-id'), + ).rejects.toThrow(/Kein Zugriff|Forbidden/); }); }); }); ``` + **NOTE FOR EXECUTOR:** the test module returned by Test.createTestingModule must be kept in + a module-level variable so individual tests can `module.get(TranslationsService)` etc. The + skeleton above stores it in `let module: TestingModule` declared at the describe scope; the + `beforeAll` initializes it. Add the missing `let module: TestingModule;` declaration next to + the other `let` declarations at the top of the describe block. + **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'; + import type { CharacterContext } from './lib/types'; + + /** Test fixture: a Fighter L4 with no relevant prereq-satisfying feats. */ + const fighterL4Ctx: CharacterContext = { + level: 4, + className: 'Fighter', + ancestryName: 'Human', + heritageName: undefined, + abilities: { STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 12, CHA: 10 }, + skills: { Athletics: 'EXPERT', Acrobatics: 'TRAINED' }, + feats: new Set(), + }; + + /** Test fixture: a Wizard L4 with INT-based skill ranks for prereq matching. */ + const wizardL4Ctx: CharacterContext = { + level: 4, + className: 'Wizard', + ancestryName: 'Elf', + heritageName: undefined, + abilities: { STR: 10, DEX: 14, CON: 12, INT: 18, WIS: 12, CHA: 10 }, + skills: { Arcana: 'EXPERT' }, + feats: new Set(), + }; describe('FeatFilterService', () => { let service: FeatFilterService; @@ -1372,30 +1957,31 @@ async getTranslationsBatch(items: { englishText: string; context: string }[]): P 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 }); + const result = await service.getFilteredFeats({ slot: 'class', character: fighterL4Ctx }); 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 }); + it('respects slot=class -> only CLASS-source feats', async () => { + const result = await service.getFilteredFeats({ slot: 'class', character: fighterL4Ctx }); 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 }); + it('respects slot=general -> returns BOTH GENERAL and SKILL feats (LVL-05)', async () => { + const result = await service.getFilteredFeats({ slot: 'general', character: fighterL4Ctx }); 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) + const result = await service.getFilteredFeats({ + slot: 'class', character: wizardL4Ctx, includeUnavailable: true, + }); + // The seeded Feat table contains feats with ability/skill prereqs Wizard does not satisfy + // (e.g. STR-gated class feats). At least some entry must have eval.ok === false. + const hasFailing = result.some(f => f.eval.ok === false); + expect(hasFailing).toBe(true); }); }); }); diff --git a/.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md b/.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md new file mode 100644 index 0000000..09dd1e2 --- /dev/null +++ b/.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md @@ -0,0 +1,1091 @@ +# Phase 1: Level-Up (PF2e regelkonform) - Pattern Map + +**Mapped:** 2026-04-27 +**Files analyzed:** 35 (29 new, 6 extended) +**Analogs found:** 33 / 35 (94%) + +--- + +## File Classification + +### Server — NestJS Module (`server/src/modules/leveling/`) + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| `server/src/modules/leveling/leveling.module.ts` | NestJS module | n/a | `server/src/modules/characters/characters.module.ts` | exact | +| `server/src/modules/leveling/leveling.controller.ts` | REST controller | request-response (CRUD) | `server/src/modules/characters/characters.controller.ts` | exact | +| `server/src/modules/leveling/leveling.service.ts` | NestJS service | CRUD + transactional | `server/src/modules/characters/characters.service.ts` | exact | +| `server/src/modules/leveling/leveling.service.spec.ts` | integration test | n/a | (no existing analog — first integration test) | none | +| `server/src/modules/leveling/feat-filter.service.ts` | NestJS service | request-response | `server/src/modules/characters/alchemy.service.ts` | role-match | +| `server/src/modules/leveling/dto/start-level-up.dto.ts` | DTO | n/a | `server/src/modules/characters/dto/dying.dto.ts` | exact | +| `server/src/modules/leveling/dto/patch-level-up.dto.ts` | DTO | n/a | `server/src/modules/characters/dto/alchemy.dto.ts` (`UpdateVialsDto`) | exact | +| `server/src/modules/leveling/dto/commit-level-up.dto.ts` | DTO | n/a | `server/src/modules/characters/dto/dying.dto.ts` (`RecoveryCheckDto`) | exact | +| `server/src/modules/leveling/dto/level-up-state.dto.ts` | DTO + TS shape | n/a | `server/src/modules/characters/dto/rest.dto.ts` (`RestPreviewDto`) | exact | + +### Server — Pure-Function Lib (`server/src/modules/leveling/lib/`) + +| New File | Role | Data Flow | Closest Analog | Match Quality | +|----------|------|-----------|----------------|---------------| +| `server/src/modules/leveling/lib/apply-attribute-boost.ts` | pure-function lib | transform | (no existing pure-function lib — first of its kind) | none | +| `server/src/modules/leveling/lib/apply-attribute-boost.spec.ts` | Jest unit test | n/a | (no existing `*.spec.ts` files in `server/src/`) | none | +| `server/src/modules/leveling/lib/skill-increase-cap.ts` | pure-function lib | transform | (same — first) | none | +| `server/src/modules/leveling/lib/skill-increase-cap.spec.ts` | Jest unit test | n/a | (same — first) | none | +| `server/src/modules/leveling/lib/prereq-evaluator.ts` | pure-function lib | transform | client-side `checkSkillPrerequisites` in `client/src/features/characters/components/add-feat-modal.tsx:27-74` | partial (server-side first) | +| `server/src/modules/leveling/lib/prereq-evaluator.spec.ts` | Jest unit test | n/a | (first) | none | +| `server/src/modules/leveling/lib/recompute-derived-stats.ts` | pure-function lib | transform | helper functions in `server/src/modules/characters/pathbuilder-import.service.ts:42-62` (`proficiencyFromValue`, `featSourceFromType`) | partial | +| `server/src/modules/leveling/lib/recompute-derived-stats.spec.ts` | Jest unit test | n/a | (first) | none | +| `server/src/modules/leveling/lib/compute-applicable-steps.ts` | pure-function lib | transform | (first) | none | +| `server/src/modules/leveling/lib/compute-applicable-steps.spec.ts` | Jest unit test | n/a | (first) | none | + +### Server — Extended Files + +| Modified File | Modification | Closest Analog (for the new code) | Match Quality | +|---------------|--------------|------------------------------------|---------------| +| `server/src/modules/characters/characters.gateway.ts` | Add `'level_up_committed'` to `CharacterUpdatePayload['type']` union (line 24) | already-present pattern in same file | exact (single-line union add) | +| `server/src/modules/characters/pathbuilder-import.service.ts` | Call `PrereqEvaluator` after import, persist violation list | service-orchestration pattern in same file | exact | +| `server/src/app.module.ts` | Register `LevelingModule` | existing `CharactersModule` registration on line 14 | exact | +| `client/src/shared/hooks/use-character-socket.ts` | Add `'level_up_committed'` to `CharacterUpdateType` union (line 15) + new `onLevelUpCommitted` callback | identical mirror of `characters.gateway.ts` | exact | + +### Server — Prisma (Migration + Seed) + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|-------------------|------|-----------|----------------|---------------| +| `server/prisma/schema.prisma` | Schema models (add `LevelUpSession`, `ClassProgression`, `LevelUpHistory`; extend `Character` with `freeArchetype: Boolean`, `prereqViolations: Json?`) | n/a | existing `CharacterAlchemyState` model (line 167+) | exact | +| `server/prisma/migrations/YYYYMMDDHHMMSS_add_level_up_sessions_and_class_progression/migration.sql` | Prisma migration SQL | n/a | `server/prisma/migrations/20260120080237_add_alchemy_and_rest_system/migration.sql` | exact | +| `server/prisma/seed-class-progression.ts` | Seed script | batch / file-I/O | `server/prisma/seed-equipment.ts` | exact | + +### Client — React (`client/src/features/characters/components/level-up/`) + +| New File | Role | Data Flow | Closest Analog | Match Quality | +|----------|------|-----------|----------------|---------------| +| `client/src/features/characters/components/level-up/level-up-wizard.tsx` | React modal container | event-driven (useReducer) + REST | `client/src/features/characters/components/add-feat-modal.tsx` (modal chrome) + `client/src/features/characters/components/rest-modal.tsx` (REST + state) | exact | +| `client/src/features/characters/components/level-up/level-up-step-class-features.tsx` | React step component | display-only | `client/src/features/characters/components/actions-tab.tsx` (read-only list) | role-match | +| `client/src/features/characters/components/level-up/level-up-step-class-feature-choice.tsx` | React step (single-pick) | event-driven | `client/src/features/characters/components/add-condition-modal.tsx` (single-pick + value) | role-match | +| `client/src/features/characters/components/level-up/level-up-step-boost.tsx` | React step (multi-counter) | event-driven | `client/src/features/characters/components/hp-control.tsx` (`+/-` controls) | role-match | +| `client/src/features/characters/components/level-up/level-up-step-skill-increase.tsx` | React step (single-pick from list) | event-driven | `client/src/features/characters/components/add-condition-modal.tsx` | role-match | +| `client/src/features/characters/components/level-up/level-up-step-feat-class.tsx` | React step (filtered feat pick) | request-response + event | `client/src/features/characters/components/add-feat-modal.tsx` | exact | +| `client/src/features/characters/components/level-up/level-up-step-feat-skill.tsx` | React step | request-response + event | same | exact | +| `client/src/features/characters/components/level-up/level-up-step-feat-general.tsx` | React step | request-response + event | same | exact | +| `client/src/features/characters/components/level-up/level-up-step-feat-ancestry.tsx` | React step | request-response + event | same | exact | +| `client/src/features/characters/components/level-up/level-up-step-feat-archetype.tsx` | React step | request-response + event | same | exact | +| `client/src/features/characters/components/level-up/level-up-step-spellcaster.tsx` | React step | request-response + event | `client/src/features/characters/components/alchemy-tab.tsx` (formula/repertoire pick) | role-match | +| `client/src/features/characters/components/level-up/level-up-step-review.tsx` | React step (diff display) | display + commit-CTA | `client/src/features/characters/components/rest-modal.tsx` (preview/commit) | exact | +| `client/src/features/characters/components/level-up/level-up-choice-card.tsx` | React primitive | display | feat card in `add-feat-modal.tsx:272-330` | exact | +| `client/src/features/characters/components/level-up/level-up-prereq-confirm-dialog.tsx` | React layered modal | event-driven | modal chrome in `add-condition-modal.tsx:91-103` | exact | +| `client/src/features/characters/components/level-up/level-up-resume-banner.tsx` | React banner | display + CTA | (no existing banner pattern) | none | +| `client/src/features/characters/components/level-up/level-up-violations-banner.tsx` | React banner | display | (no existing banner pattern) | none | +| `client/src/features/characters/components/level-up/use-level-up-session.ts` | React-query hook | request-response | `client/src/shared/hooks/use-character-socket.ts` (hook structure) | partial | +| `client/src/features/characters/components/level-up/wizard-state-reducer.ts` | TS reducer + types | transform | (no existing useReducer in codebase) | none | + +### Client — Extended Files + +| Modified File | Modification | Closest Analog | Match Quality | +|---------------|--------------|----------------|---------------| +| `client/src/features/characters/components/character-sheet-page.tsx` | Add `Stufe steigen` button (header, lines 1607-1626), mount ``, mount ``, mount `` | existing header buttons (Download/Edit/Delete) at lines 1607-1626 + existing modal mounts at lines 1652-1666 | exact | +| `client/src/shared/lib/api.ts` | Add `startLevelUp`, `patchLevelUp`, `commitLevelUp`, `discardLevelUp`, `getLevelUpPreview` methods | `getRestPreview` / `performRest` at lines 381-388 | exact | +| `client/src/shared/types/index.ts` (or wherever the `Character`/`CharacterUpdate` types live) | Add `LevelUpSession`, `LevelUpPreview`, extend `Character` with `freeArchetype` + `prereqViolations` | existing type definitions | exact | + +--- + +## Pattern Assignments + +### `server/src/modules/leveling/leveling.module.ts` (NestJS module) + +**Analog:** `server/src/modules/characters/characters.module.ts:1-27` + +**Module pattern (full file — copy this shape):** +```typescript +import { Module } 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'; // for CharactersGateway +import { TranslationsModule } from '../translations/translations.module'; + +@Module({ + imports: [ + TranslationsModule, + CharactersModule, // forwardRef if circular + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + }), + inject: [ConfigService], + }), + ], + controllers: [LevelingController], + providers: [LevelingService, FeatFilterService], + exports: [LevelingService], +}) +export class LevelingModule {} +``` + +**Registration in `app.module.ts`:** add the import next to `CharactersModule` (line 14) and include in `imports: []` (line 25). Mirror exact pattern. + +--- + +### `server/src/modules/leveling/leveling.controller.ts` (REST controller, request-response) + +**Analog:** `server/src/modules/characters/characters.controller.ts:1-110` + +**Imports + decorators pattern (lines 1-37):** +```typescript +import { + Controller, Get, Post, Patch, Delete, Body, Param, +} from '@nestjs/common'; +import { + ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, +} from '@nestjs/swagger'; +import { LevelingService } from './leveling.service'; +import { + StartLevelUpDto, PatchLevelUpDto, CommitLevelUpDto, +} from './dto'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; + +@ApiTags('Level-Up') +@ApiBearerAuth() +@Controller('characters/:characterId/level-up') +export class LevelingController { + constructor(private readonly levelingService: LevelingService) {} +``` + +**Endpoint pattern (lines 43-67) — apply to every endpoint:** +```typescript +@Post() +@ApiOperation({ summary: 'Start or resume a level-up session' }) +@ApiResponse({ status: 201, description: 'Session created or resumed' }) +async start( + @Param('characterId') characterId: string, + @Body() dto: StartLevelUpDto, + @CurrentUser('id') userId: string, +) { + return this.levelingService.startOrResume(characterId, dto, userId); +} +``` + +**Note:** auth is global via `JwtAuthGuard` in `app.module.ts:53-56` — no `@UseGuards()` on individual endpoints. `@CurrentUser('id')` decorator extracts `userId` from the JWT payload. + +--- + +### `server/src/modules/leveling/leveling.service.ts` (NestJS service, transactional CRUD) + +**Analog:** `server/src/modules/characters/characters.service.ts:1-86, 261-300` + +**Imports + constructor pattern (lines 1-38):** +```typescript +import { + Injectable, NotFoundException, ForbiddenException, Inject, forwardRef, +} from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CharactersGateway } from '../characters/characters.gateway'; +import { TranslationsService } from '../translations/translations.service'; + +@Injectable() +export class LevelingService { + constructor( + private prisma: PrismaService, + private translationsService: TranslationsService, + @Inject(forwardRef(() => CharactersGateway)) + private charactersGateway: CharactersGateway, + ) {} +``` + +**Permission helper pattern (lines 63-86) — copy `checkCharacterAccess` verbatim or import it from CharactersService (preferred — keep one source of truth). Owner-or-GM gate, `requireOwnership=true` for mutations:** +```typescript +private async checkCharacterAccess(characterId: string, userId: string, requireOwnership = false) { + const character = await this.prisma.character.findUnique({ + where: { id: characterId }, + include: { campaign: { include: { members: true } } }, + }); + if (!character) throw new NotFoundException('Character not found'); + + const isGM = character.campaign.gmId === userId; + const isOwner = character.ownerId === userId; + if (requireOwnership && !isOwner && !isGM) { + throw new ForbiddenException('Only the owner or GM can modify this character'); + } + // ... member check ... + return character; +} +``` + +**Atomic transaction pattern — analog:** `server/src/modules/battle/combatants.service.ts:82-118` +```typescript +return this.prisma.$transaction(async (tx) => { + // 1. INSERT LevelUpHistory snapshot + await tx.levelUpHistory.create({ data: { characterId, snapshot: snapshotJson } }); + // 2. UPDATE Character (level, hpMax) + await tx.character.update({ where: { id: characterId }, data: { level, hpMax } }); + // 3. UPSERT CharacterAbility / Skill / Feat rows + // ... etc ... + // 4. Mark LevelUpSession committed (or DELETE) + await tx.levelUpSession.update({ where: { id: sessionId }, data: { committedAt: new Date() } }); + return tx.character.findUnique({ where: { id: characterId }, include: { ... } }); +}); +``` + +**Broadcast-after-commit pattern (lines 294-299) — single emit AFTER `$transaction` returns:** +```typescript +this.charactersGateway.broadcastCharacterUpdate(characterId, { + characterId, + type: 'level_up_committed', + data: { level: newLevel, derivedStats: { hpMax, ac, fortitude, reflex, will, perception, classDC } }, +}); +``` + +**Error pattern — German user messages, NestJS exceptions:** +```typescript +throw new NotFoundException('Stufenaufstiegs-Session nicht gefunden'); +throw new ForbiddenException('Nur Besitzer oder GM dürfen Stufenaufstiege starten'); +throw new BadRequestException('Charakter ist bereits auf maximaler Stufe'); +``` + +--- + +### `server/src/modules/leveling/dto/*.dto.ts` (DTOs, class-validator) + +**Analog:** `server/src/modules/characters/dto/dying.dto.ts:1-22` (small, single-purpose), `server/src/modules/characters/dto/alchemy.dto.ts:13-56` (request DTO + nested DTOs). + +**Validation pattern — exact decorators (from `dying.dto.ts`):** +```typescript +import { IsString, IsInt, Min, Max, IsBoolean, IsOptional } from 'class-validator'; + +export class RecoveryCheckDto { + @IsString() + characterId: string; + + @IsInt() + @Min(1) + @Max(20) + dieRoll: number; +} +``` + +**With Swagger annotations (from `alchemy.dto.ts:13-37`):** +```typescript +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsInt, Min, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class StartLevelUpDto { + @ApiPropertyOptional({ description: 'Target level (defaults to current level + 1)' }) + @IsOptional() + @IsInt() + @Min(2) + @Max(20) + targetLevel?: number; +} + +export class PatchLevelUpDto { + @ApiProperty({ description: 'Partial wizard state to merge', type: 'object' }) + @ValidateNested() + @Type(() => Object) + state: Record; // shape validated in service via TS guard +} +``` + +**Barrel export (analog: `server/src/modules/characters/dto/index.ts`):** +```typescript +// server/src/modules/leveling/dto/index.ts +export * from './start-level-up.dto'; +export * from './patch-level-up.dto'; +export * from './commit-level-up.dto'; +export * from './level-up-state.dto'; +``` + +**Response DTO pattern (analog: `server/src/modules/characters/dto/rest.dto.ts:16-37`) for `LevelUpPreviewDto`:** +```typescript +export class LevelUpPreviewDto { + @ApiProperty({ description: 'Stats before commit' }) + before: { hpMax: number; ac: number; classDC: number; perception: number; saves: { fort: number; ref: number; will: number } }; + + @ApiProperty({ description: 'Stats after commit' }) + after: { hpMax: number; ac: number; classDC: number; perception: number; saves: { fort: number; ref: number; will: number } }; + + @ApiProperty({ description: 'Spellcaster increments (if applicable)' }) + spellcaster?: { slotIncrements: { level: number; delta: number }[]; repertoireDelta?: number }; +} +``` + +--- + +### `server/src/modules/leveling/lib/*.ts` (pure-function modules) + +**Analog:** the closest existing pure helper is `proficiencyFromValue` / `featSourceFromType` in `server/src/modules/characters/pathbuilder-import.service.ts:42-62`: + +```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; + } +} +``` + +**Lib module conventions to apply:** +- **NO** `@Injectable()`, NO Prisma imports, NO NestJS imports. +- Named exports only: `export function applyAttributeBoost(...)`. +- Strict TypeScript types (no `any` — CLAUDE.md hard rule). +- Side-effect free: input → output. +- Sit alongside their `*.spec.ts` (matches `testRegex: ".*\\.spec\\.ts$"` from `server/package.json:88-104`). + +**Prereq-evaluator partial analog — client-side pattern in `client/src/features/characters/components/add-feat-modal.tsx:27-74`:** +```typescript +function checkSkillPrerequisites( + prerequisites: string | undefined, + skills: CharacterSkill[], +): { met: boolean; unmetReason?: string } { + if (!prerequisites) return { met: true }; + const prereqLower = prerequisites.toLowerCase(); + const patterns = [ + { regex: /legendary in (\w+(?:\s+\w+)?)/gi, required: 'LEGENDARY' as Proficiency }, + { regex: /master in (\w+(?:\s+\w+)?)/gi, required: 'MASTER' as Proficiency }, + { regex: /expert in (\w+(?:\s+\w+)?)/gi, required: 'EXPERT' as Proficiency }, + { regex: /trained in (\w+(?:\s+\w+)?)/gi, required: 'TRAINED' as Proficiency }, + ]; + // ... loop matches, look up character skill rank, compare ranks ... +} +``` + +The server-side `prereq-evaluator.ts` extends this to also handle: feat possession, level, class, ancestry, heritage, plus return discriminated union `{ ok: true } | { ok: false, reason: string } | { unknown: true, raw: string }` per Research §Architecture Patterns. + +**Test file pattern — there are NO existing `*.spec.ts` files in `server/src/`** (this phase establishes the test discipline per `01-CONTEXT.md` line 50 and Research §Code Examples). Use the Jest config already present in `server/package.json:88-104`. Minimal first test: +```typescript +// server/src/modules/leveling/lib/apply-attribute-boost.spec.ts +import { applyAttributeBoost } from './apply-attribute-boost'; + +describe('applyAttributeBoost', () => { + it('returns +2 for scores below 18', () => { + expect(applyAttributeBoost(10)).toBe(12); + expect(applyAttributeBoost(16)).toBe(18); + }); + + it('returns +1 for scores at or above 18 (PF2e cap rule)', () => { + expect(applyAttributeBoost(18)).toBe(19); + expect(applyAttributeBoost(20)).toBe(21); + }); +}); +``` + +--- + +### `server/src/modules/characters/characters.gateway.ts` (EXTEND — single-line union add) + +**Current code (line 22-26):** +```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; +} +``` + +**Required change — append `'level_up_committed'`:** +```typescript +type: 'hp' | 'conditions' | ... | 'dying' | 'level_up_committed'; +``` + +**Mirror change in `client/src/shared/hooks/use-character-socket.ts:15`** — identical union add. Then plumb a new `onLevelUpCommitted` callback in the `UseCharacterSocketOptions` interface (analog: `onRestUpdate` at line 53). + +**Broadcast call pattern** (analog: `characters.service.ts:294-299`) — emit ONCE after `$transaction` returns successfully (Pitfall #9). + +--- + +### `server/src/modules/characters/pathbuilder-import.service.ts` (EXTEND) + +**Analog:** the service itself — extend by injecting `PrereqEvaluator` (a pure-function module, so no DI; just `import` and call) at the end of `importCharacter()`. Persist the `{ featId, featName, prereqText }[]` array to the new `Character.prereqViolations: Json?` column. + +**Service-orchestration pattern (lines 64-72):** +```typescript +@Injectable() +export class PathbuilderImportService { + constructor( + private prisma: PrismaService, + private translationsService: TranslationsService, + ) {} + + async importCharacter(campaignId: string, ownerId: string, pathbuilderJson: PathbuilderJson) { + // existing import flow ... + // NEW at the end: + const violations = character.feats + .map(f => ({ featId: f.id, featName: f.name, prereq: featDb[f.featId]?.prerequisites })) + .filter(v => v.prereq) + .map(v => ({ ...v, result: evaluatePrereq(v.prereq, characterContext) })) + .filter(v => v.result.ok === false) + .map(v => ({ featId: v.featId, featName: v.featName, prereqText: v.prereq })); + + if (violations.length > 0) { + await this.prisma.character.update({ + where: { id: character.id }, + data: { prereqViolations: violations }, + }); + } + return character; + } +} +``` + +--- + +### `server/prisma/schema.prisma` (EXTEND — add 3 models + 2 columns on `Character`) + +**Analog model — `CharacterAlchemyState` (lines 167+) shows the full pattern (table + unique index + cascade delete on Character):** + +The new models follow the same shape: +```prisma +model LevelUpSession { + id String @id @default(uuid()) + characterId String + targetLevel Int + state Json // wizard state (the WizardState shape) + committedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + character Character @relation(fields: [characterId], references: [id], onDelete: Cascade) + + // One open DRAFT per character (Decision D-14): partial unique index on characterId WHERE committedAt IS NULL + // PRISMA NOTE: partial index requires raw SQL in the migration (see Migration pattern below). + @@index([characterId]) +} + +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]) +} + +model LevelUpHistory { + id String @id @default(uuid()) + characterId String + fromLevel Int + toLevel Int + snapshot Json // full character snapshot before the level-up + committedAt DateTime @default(now()) + + character Character @relation(fields: [characterId], references: [id], onDelete: Cascade) + + @@index([characterId]) +} + +// EXTEND existing Character model: +model Character { + // ... existing fields ... + freeArchetype Boolean @default(false) // D-08 + prereqViolations Json? // D-06 + + // ... existing relations ... + levelUpSessions LevelUpSession[] + levelUpHistory LevelUpHistory[] +} +``` + +--- + +### `server/prisma/migrations/YYYYMMDDHHMMSS_add_level_up_sessions_and_class_progression/migration.sql` + +**Analog:** `server/prisma/migrations/20260120080237_add_alchemy_and_rest_system/migration.sql` + +**Naming convention:** `YYYYMMDDHHMMSS_snake_case_description/migration.sql` — Prisma generates this via `npm run db:migrate:dev -- --name add_level_up_sessions_and_class_progression`. + +**Pattern from analog (lines 4-71):** +```sql +-- CreateEnum (if applicable) +CREATE TYPE "ResearchField" AS ENUM ('BOMBER', 'CHIRURGEON', ...); + +-- CreateTable +CREATE TABLE "CharacterAlchemyState" ( + "id" TEXT NOT NULL, + "characterId" TEXT NOT NULL, + -- ... fields ... + CONSTRAINT "CharacterAlchemyState_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CharacterAlchemyState_characterId_key" ON "CharacterAlchemyState"("characterId"); + +-- AddForeignKey +ALTER TABLE "CharacterAlchemyState" ADD CONSTRAINT "..._characterId_fkey" + FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE; +``` + +**Phase 1 specific addition — partial unique index for "one open DRAFT per character" (D-14)** must be hand-added because Prisma 7 does not yet emit partial indexes from the schema: +```sql +-- After auto-generated indexes: +CREATE UNIQUE INDEX "LevelUpSession_one_open_per_character" + ON "LevelUpSession"("characterId") + WHERE "committedAt" IS NULL; +``` + +--- + +### `server/prisma/seed-class-progression.ts` (seed script, batch / file-I/O) + +**Analog:** `server/prisma/seed-equipment.ts:1-120` + +**Imports + Prisma client pattern (lines 1-8):** +```typescript +import 'dotenv/config'; +import * as fs from 'fs'; +import * as path from 'path'; +import { PrismaClient } from '../src/generated/prisma/client.js'; +import { PrismaPg } from '@prisma/adapter-pg'; + +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); +const prisma = new PrismaClient({ adapter }); +``` + +**Idempotent seed pattern — find-or-update (lines 105-130):** +```typescript +for (const item of data) { + try { + const existing = await prisma.classProgression.findUnique({ + where: { className_level: { className: item.className, level: item.level } }, + }); + if (existing) { + await prisma.classProgression.update({ + where: { id: existing.id }, + data: { grants: item.grants, proficiencyChanges: item.proficiencyChanges, /* ... */ }, + }); + updated++; + } else { + await prisma.classProgression.create({ data: { /* ... */ } }); + created++; + } + } catch (err) { + errors++; + console.error(`Failed to seed ${item.className} L${item.level}:`, err); + } +} +``` + +**Console logging pattern (lines 99-104):** +```typescript +console.log(`📚 Importing ${data.length} class progressions...`); +// ... at end ... +console.log(` ✓ ${created} created, ${updated} updated, ${errors} errors`); +``` + +**Add NPM script entry to `server/package.json`** mirroring the existing `db:seed:equipment` script: +```json +"db:seed:class-progression": "tsx prisma/seed-class-progression.ts" +``` + +--- + +### `client/src/features/characters/components/level-up/level-up-wizard.tsx` (modal container) + +**Analogs:** `client/src/features/characters/components/add-feat-modal.tsx:246-260` (chrome) + `client/src/features/characters/components/rest-modal.tsx:1-49` (REST + state pattern). + +**Modal chrome pattern (canonical — from add-feat-modal.tsx:246-260):** +```tsx +return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+

Stufenaufstieg — Stufe {N}

+ +
+ {/* Stepper, Body, Footer ... per UI-SPEC */} +
+
+); +``` + +**REST + state lifecycle (analog — rest-modal.tsx:14-49):** +```tsx +export function LevelUpWizard({ campaignId, characterId, onClose, onCommitted }: Props) { + const [session, setSession] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isCommitting, setIsCommitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { loadSession(); }, [characterId]); + + const loadSession = async () => { + try { + setIsLoading(true); + const data = await api.startLevelUp(characterId); + setSession(data); + } catch (err) { + setError('Fehler beim Laden der Sitzung'); + console.error('Failed to start level-up:', err); + } finally { + setIsLoading(false); + } + }; + + const handleCommit = async () => { + try { + setIsCommitting(true); + const result = await api.commitLevelUp(characterId, session!.id); + onCommitted(result); + onClose(); + } catch (err) { + setError('Fehler beim Bestätigen'); + console.error('Failed to commit:', err); + } finally { + setIsCommitting(false); + } + }; + // ... +} +``` + +**Imports pattern — `@/` alias for client (analog: rest-modal.tsx:1-5):** +```tsx +import { useState, useEffect, useReducer } from 'react'; +import { X, ChevronLeft, ChevronRight, Check, Sparkles } from 'lucide-react'; +import { Button, Spinner } from '@/shared/components/ui'; +import { api } from '@/shared/lib/api'; +import type { LevelUpSession, LevelUpPreview } from '@/shared/types'; +``` + +--- + +### `client/src/features/characters/components/level-up/level-up-step-feat-*.tsx` (filtered feat picks) + +**Analog:** `client/src/features/characters/components/add-feat-modal.tsx` — entire file is the canonical filtered-feat-pick pattern. + +**Loading state (analog rest-modal.tsx:72-76):** +```tsx +{isLoading ? ( +
+ +
+) : ...} +``` + +**Feat card pattern — analog `add-feat-modal.tsx:272-330`:** inner container `p-4 rounded-xl bg-bg-tertiary` with title (`font-semibold text-text-primary`), German subtitle (`text-sm text-text-muted`), action icons, type chip, level chip, rarity chip, prerequisite line, traits, summary. UI-SPEC Choice-Card section overrides exact selected-state styling (border-primary-500 + ring + Check icon). + +**Source colors — already-existing map (must reuse):** +```tsx +// From feat-detail-modal.tsx:22-29 (UI-SPEC Color §Inherited Exceptions) +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', +}; +``` + +--- + +### `client/src/features/characters/components/level-up/level-up-step-boost.tsx` (counter step) + +**Analog:** `client/src/features/characters/components/hp-control.tsx` — `+/-` button counter pattern. + +**Counter button pattern (touch-target floor `h-11 w-11`):** +The exact JSX is in `01-UI-SPEC.md` lines 369-414 (Boost-Set Step Component Contract). The role-match for the `+/-` interaction is the existing HpControl component. + +--- + +### `client/src/features/characters/components/level-up/level-up-step-review.tsx` (preview/diff) + +**Analog:** `client/src/features/characters/components/rest-modal.tsx` — entire file is preview-then-commit. + +**Preview-section pattern (rest-modal.tsx:80-92):** +```tsx +
+ +
+

HP-Heilung

+

+{preview.hpToHeal} HP (auf {preview.hpAfterRest})

+
+
+``` + +For Level-Up Review use the Vorher/Nachher Card layout from `01-UI-SPEC.md` Section B (line 537+) which uses `Card` + `CardHeader` + `CardContent` (already imported pattern in `character-sheet-page.tsx:24-30`). + +--- + +### `client/src/features/characters/components/character-sheet-page.tsx` (EXTEND — header button + banner mounts) + +**Analog:** `character-sheet-page.tsx:1607-1626` (existing header button cluster). + +**Insert position for "Stufe steigen" button (UI-SPEC line 210 — first in the cluster, left of Download):** +```tsx +
+ {/* NEW: Stufe steigen — only when isOwner-or-isGM AND level < 20 AND no foreign DRAFT */} + {(isOwner || isGM) && character.level < 20 && ( + + )} + + {isOwner && (<>...)} +
+``` + +**Modal mount pattern (analog: lines 1652-1666):** +```tsx +{showLevelUpWizard && ( + setShowLevelUpWizard(false)} + onCommitted={() => { setShowLevelUpWizard(false); fetchCharacter(); }} + /> +)} +``` + +**State variable pattern (analog: lines 122-132):** +```tsx +const [showLevelUpWizard, setShowLevelUpWizard] = useState(false); +``` + +**Banner mount position:** above the avatar header (UI-SPEC §"Component Contract — DRAFT-Resume Banner") and above the tab-navigation (UI-SPEC §"Pathbuilder-Import-Violations Banner"). + +--- + +### `client/src/shared/lib/api.ts` (EXTEND — add level-up REST methods) + +**Analog (lines 381-388 — `getRestPreview` / `performRest`):** +```typescript +async getRestPreview(campaignId: string, characterId: string) { + const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}/rest/preview`); + return response.data; +} + +async performRest(campaignId: string, characterId: string) { + const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/rest`); + return response.data; +} +``` + +**New methods to add (use the `/characters/:characterId/level-up` route prefix from the controller):** +```typescript +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): 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): Promise { + const response = await this.client.post(`/characters/${characterId}/level-up/${sessionId}/commit`); + return response.data; +} + +async discardLevelUp(characterId: string, sessionId: string): Promise { + await this.client.delete(`/characters/${characterId}/level-up/${sessionId}`); +} +``` + +--- + +### `client/src/features/characters/components/level-up/use-level-up-session.ts` (react-query hook stack) + +**Partial analog:** no existing react-query hooks in the codebase (`@tanstack/react-query` 5.90.19 is installed but the codebase still uses raw `useState` + `api.x()` per `rest-modal.tsx`). For Phase 1 the planner has discretion: either (a) introduce react-query usage here and stay consistent with the installed package, or (b) follow the existing `useState + api.x()` pattern for codebase consistency. + +**Recommended (following Research §Standard Stack which lists react-query):** add hooks like `useStartLevelUpMutation`, `usePatchLevelUpMutation`, `useCommitLevelUpMutation`, `useLevelUpPreviewQuery`. Naming follows the `useX` hook convention (CONVENTIONS.md). + +--- + +### `client/src/features/characters/components/level-up/wizard-state-reducer.ts` (useReducer + types) + +**No existing analog** — this is the first useReducer in the codebase. Implementation guidance is in `01-RESEARCH.md` lines 354-399 (the full `WizardState` + `WizardEvent` discriminated unions). Apply CONVENTIONS.md: PascalCase types, kebab-case file, named exports only, no `any`. + +--- + +## Shared Patterns + +### Authentication / Permission Gate + +**Source:** `server/src/modules/characters/characters.service.ts:63-86` (`checkCharacterAccess`). + +**Apply to:** All `LevelingService` methods. Owner OR GM-of-campaign passes; pure members get read-only; mutations require `requireOwnership=true` (which actually means owner-or-GM per the helper's logic). + +**Excerpt:** +```typescript +private async checkCharacterAccess(characterId: string, userId: string, requireOwnership = false) { + const character = await this.prisma.character.findUnique({ + where: { id: characterId }, + include: { campaign: { include: { members: true } } }, + }); + if (!character) throw new NotFoundException('Character not found'); + + const isGM = character.campaign.gmId === userId; + const isOwner = character.ownerId === userId; + if (requireOwnership && !isOwner && !isGM) { + throw new ForbiddenException('Only the owner or GM can modify this character'); + } + const isMember = character.campaign.members.some((m) => m.userId === userId); + if (!isGM && !isMember) { + throw new ForbiddenException('No access to this character'); + } + return character; +} +``` + +Global `JwtAuthGuard` (`server/src/app.module.ts:53-56`) authenticates the request; `@CurrentUser('id')` extracts `userId`. No additional `@UseGuards` on level-up endpoints needed. + +--- + +### Error Handling + +**Source:** Across the codebase, e.g. `characters.service.ts:46-58, 76-83`. + +**Apply to:** All service methods. + +**Pattern — NestJS exception classes, German user-facing messages:** +```typescript +throw new NotFoundException('Charakter nicht gefunden'); +throw new ForbiddenException('Kein Zugriff auf diesen Charakter'); +throw new BadRequestException('Charakter ist bereits auf maximaler Stufe'); +throw new ConflictException('Eine offene Stufenaufstiegs-Session existiert bereits'); +``` + +`class-validator` decorators on DTOs auto-throw `BadRequestException` for shape violations (NestJS `ValidationPipe` is global per `app.module.ts`). + +--- + +### Validation + +**Source:** `server/src/modules/characters/dto/create-character.dto.ts`, `dto/dying.dto.ts`, `dto/alchemy.dto.ts`. + +**Apply to:** All DTO classes in `server/src/modules/leveling/dto/`. + +**Pattern — class-validator decorators + Swagger annotations:** +```typescript +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsInt, Min, Max, IsEnum, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class XxxDto { + @ApiProperty({ description: '...' }) + @IsInt() + @Min(2) + @Max(20) + fieldName: number; +} +``` + +For nested objects: `@ValidateNested() @Type(() => NestedDto)`. For enums: `@IsEnum(EnumType)`. + +--- + +### WebSocket Broadcast + +**Source:** `server/src/modules/characters/characters.service.ts:294-299` (`broadcastCharacterUpdate`). + +**Apply to:** `LevelingService.commit()` after `prisma.$transaction` returns. Single emit (Pitfall #9 / ROADMAP First-Phase-Note). + +**Pattern:** +```typescript +this.charactersGateway.broadcastCharacterUpdate(characterId, { + characterId, + type: 'level_up_committed', + data: { + level: newLevel, + derivedStats: { hpMax, ac, fortitude, reflex, will, perception, classDC }, + }, +}); +``` + +The `CharactersGateway.broadcastCharacterUpdate` method (`characters.gateway.ts:232-236`) emits `'character_update'` to the room `character:{characterId}`. Clients with the character open receive it via `useCharacterSocket`. + +--- + +### Mobile-First Modal Chrome + +**Source:** `client/src/features/characters/components/add-feat-modal.tsx:246-260`. + +**Apply to:** `level-up-wizard.tsx`, `level-up-prereq-confirm-dialog.tsx` (use `z-60` per UI-SPEC for the dialog). + +**Pattern:** +```tsx +
+
+
+ {/* Header / Body / Footer */} +
+
+``` + +Bottom-sheet on mobile (`items-end` + `rounded-t-2xl`); centered modal on `sm:` (≥ 640px). + +--- + +### Header Pattern Inside Modals + +**Source:** `add-feat-modal.tsx:253-260`, `add-condition-modal.tsx:98-103`. + +**Apply to:** All wizard step containers and the wizard root. + +**Pattern:** +```tsx +
+

{title}

+ +
+``` + +--- + +### Loading State + +**Source:** `rest-modal.tsx:72-76`. + +**Apply to:** All wizard step bodies + the wizard root while session loads. + +**Pattern:** +```tsx +{isLoading ? ( +
+ +
+) : ...} +``` + +(or use the existing `` from `@/shared/components/ui` — visible in `add-feat-modal.tsx:3`). + +--- + +### Imports + +**Server pattern (analog: characters.service.ts:1-29):** +- NestJS imports first. +- `PrismaService` from `'../../prisma/prisma.service'`. +- Sibling-module services from `'./xxx.service'` (relative). +- DTOs from `'./dto'` barrel. +- Generated Prisma types from `'../../generated/prisma/client.js'` (note `.js` extension — required for ESM compat). +- Service-level `@Injectable()` decorator. + +**Client pattern (analog: rest-modal.tsx:1-5):** +- React first: `import { useState, useEffect } from 'react';`. +- Lucide icons next: `import { X, Search } from 'lucide-react';`. +- UI components via barrel: `import { Button, Card, CardContent } from '@/shared/components/ui';`. +- API client: `import { api } from '@/shared/lib/api';`. +- Types as type-only import: `import type { Character } from '@/shared/types';`. +- ALWAYS use `@/` alias on client (vite.config.ts), never relative paths. + +--- + +### Naming + +| Surface | Convention | Example | +|---------|------------|---------| +| All file names | kebab-case | `level-up-wizard.tsx`, `apply-attribute-boost.ts`, `seed-class-progression.ts` | +| Folder names | kebab-case | `leveling/`, `level-up/` | +| Component exports | PascalCase + named | `export function LevelUpWizard()` | +| Service classes | PascalCase + `Service` suffix | `LevelingService`, `FeatFilterService` | +| Controller classes | PascalCase + `Controller` suffix | `LevelingController` | +| Module classes | PascalCase + `Module` suffix | `LevelingModule` | +| DTOs | PascalCase + `Dto` suffix | `StartLevelUpDto`, `LevelUpPreviewDto` | +| Hooks | `use` prefix | `useLevelUpSession`, `useCommitLevelUpMutation` | +| Pure functions | camelCase | `applyAttributeBoost`, `evaluatePrereq` | +| Constants | UPPER_SNAKE_CASE | `BOOST_CAP`, `SKILL_INCREASE_CAPS_BY_LEVEL` | +| TypeScript types | PascalCase | `WizardState`, `StepKind`, `LevelUpSession` | +| Props interfaces | `XxxProps` suffix | `LevelUpWizardProps`, `ChoiceCardProps` | +| Event handlers | `handle` prefix | `handleCommit`, `handleStepChange` | +| Booleans | `is`, `has`, `can` prefix | `isCommitting`, `hasDraft`, `canIncrement` | +| Test files | `*.spec.ts` next to source | `apply-attribute-boost.spec.ts` | + +--- + +## No Analog Found + +| File | Role | Data Flow | Reason | Fallback Guidance | +|------|------|-----------|--------|-------------------| +| `server/src/modules/leveling/lib/*.spec.ts` (all four) | Jest unit tests | n/a | No `*.spec.ts` files exist in `server/src/` today. This phase establishes the test-discipline (CONTEXT.md line 50, RESEARCH.md "First Phase Note"). | Use Jest config from `server/package.json:88-104`. Minimal spec example provided in the Pure-Function Lib section above. | +| `server/src/modules/leveling/leveling.service.spec.ts` | NestJS integration test | n/a | No NestJS integration tests exist today (Supertest is installed but unused for `*.spec.ts`). | Use `Test.createTestingModule({ imports: [LevelingModule], providers: [{ provide: PrismaService, useValue: mockPrisma }] })` per `@nestjs/testing` 11.x docs. Reference: Research §Standard Stack lists `@nestjs/testing` and `supertest`. | +| `client/src/features/characters/components/level-up/level-up-resume-banner.tsx` | Banner component | display + CTA | No banner pattern exists in the client (warning chips inline only). | Implement per `01-UI-SPEC.md` §"Component Contract — DRAFT-Resume Banner" (lines 583-613). The exact JSX is fully specified there. | +| `client/src/features/characters/components/level-up/level-up-violations-banner.tsx` | Banner component | display | Same — no analog. | Implement per `01-UI-SPEC.md` §"Component Contract — Pathbuilder-Import-Violations Banner" (lines 617-649). | +| `client/src/features/characters/components/level-up/wizard-state-reducer.ts` | useReducer + discriminated union | transform | No `useReducer` exists in the codebase (`useState` is used everywhere). | Implementation fully specified in `01-RESEARCH.md` lines 354-399 (`WizardState`, `WizardEvent`). Standard React idiom — no project precedent needed. | +| `client/src/features/characters/components/level-up/use-level-up-session.ts` | react-query hook stack | request-response | No react-query hooks exist in the codebase yet (only raw `useState + api.x()`). | Planner discretion (see Pattern Assignment above). Recommend introducing react-query for Phase 1 since `@tanstack/react-query` 5.90.19 is installed and Research §Standard Stack assumes it. | + +--- + +## Key Patterns Identified + +1. **NestJS Service Pattern: `@Injectable() + constructor(private prisma, private translationsService, @Inject(forwardRef()) gateway)`** + Every service follows the same constructor shape with the gateway via `forwardRef` to break the circular dependency. + +2. **`checkCharacterAccess` is the One Permission Helper.** + Owner-or-GM gate. New `LevelingService` should reuse it (import from CharactersService or duplicate). Three-tier check: NotFoundException → owner/GM check (if mutation) → member check. + +3. **REST Endpoints Use `prisma.$transaction(async tx => ...)` for Atomic Multi-Table Writes.** + Already in use in `combatants.service.ts:82-118`. Apply to `LevelingService.commit()` for snapshot + character mutation + history insert + session-mark-committed. + +4. **Single WebSocket Broadcast After Transaction Commit.** + Existing pattern: `this.charactersGateway.broadcastCharacterUpdate(id, { type: 'xxx', data: ... })` called once after `await prisma.x.update()`. New `'level_up_committed'` type added to the union — same call site pattern. + +5. **DTO + class-validator + Swagger.** + `@ApiProperty({ description })` paired with `@IsInt() @Min() @Max()` etc. Barrel `index.ts` re-exports all DTOs. NestJS global `ValidationPipe` auto-rejects bad shapes. + +6. **Prisma Migration Naming: `YYYYMMDDHHMMSS_snake_case_description/migration.sql`** generated via `npm run db:migrate:dev -- --name xxx`. Hand-add raw SQL for partial unique indexes (Prisma 7 limitation). + +7. **Seed Scripts: idempotent find-then-update-or-create with `'dotenv/config'` + `PrismaPg adapter`.** + `seed-equipment.ts` is the canonical example. New `seed-class-progression.ts` mirrors imports and idempotency exactly. + +8. **Mobile-First Modal Chrome: `fixed inset-0 z-50 flex items-end sm:items-center` + `rounded-t-2xl sm:rounded-2xl`.** + Bottom-sheet on mobile, centered on desktop. Backdrop `bg-black/60`. Close button in header `