---
phase: 01-level-up-pf2e-regelkonform
plan: 02
type: tdd
wave: 1
depends_on: ["01-01"]
files_modified:
  - server/src/modules/leveling/lib/skill-increase-cap.ts
  - server/src/modules/leveling/lib/skill-increase-cap.spec.ts
  - server/src/modules/leveling/lib/prereq-evaluator.ts
  - server/src/modules/leveling/lib/prereq-evaluator.spec.ts
  - server/src/modules/leveling/lib/recompute-derived-stats.ts
  - server/src/modules/leveling/lib/recompute-derived-stats.spec.ts
  - server/src/modules/leveling/lib/compute-applicable-steps.ts
  - server/src/modules/leveling/lib/compute-applicable-steps.spec.ts
  - server/src/modules/leveling/lib/types.ts
autonomous: true
requirements: [LVL-02, LVL-06, LVL-09, LVL-10, LVL-01, LVL-13, LVL-14]
tags: [pure-functions, jest, tdd, level-up, prereq-evaluator, recompute]
must_haves:
  truths:
    - "skill-increase-cap correctly enforces PF2e rule: TRAINED→EXPERT only at L3+, EXPERT→MASTER only at L7+, MASTER→LEGENDARY only at L15+"
    - "prereq-evaluator returns {ok:true} for met prereqs, {ok:false, reason} for evaluable+failed, {unknown:true, raw} for non-evaluable patterns (Deity, Spellcasting-Tradition, etc. — D-02)"
    - "prereq-evaluator handles all D-01 patterns: skill-rank, feat-possession, level, class, ancestry, heritage"
    - "recompute-derived-stats produces correct hpMax, AC, classDC, perception, fortitude, reflex, will using boost-cap-at-18 (Pitfall #8) and never mutates hpCurrent (Pitfall #9)"
    - "compute-applicable-steps returns the right step list per (targetLevel, class, hasFA, isCaster) — boost only at L5/10/15/20, FA-step only when hasFA, spellcaster-step only for casters, etc."
    - "All four pure-function modules have NO NestJS decorators, NO Prisma imports, NO `any` types"
    - "Every behavior in 01-VALIDATION.md rows 1-W1-06 through 1-W1-29 has a passing test"
  artifacts:
    - path: "server/src/modules/leveling/lib/types.ts"
      provides: "Shared types — Proficiency, EvalResult, CharacterContext, DerivedStats, StepKind, AbilityAbbreviation"
      exports: ["Proficiency", "EvalResult", "CharacterContext", "DerivedStats", "StepKind", "AbilityAbbreviation"]
    - path: "server/src/modules/leveling/lib/skill-increase-cap.ts"
      provides: "Pure function canIncreaseSkill(currentRank, characterLevel)"
      exports: ["canIncreaseSkill", "SKILL_INCREASE_LEVELS"]
    - path: "server/src/modules/leveling/lib/prereq-evaluator.ts"
      provides: "Pure function evaluatePrereq(prereqString, ctx) returning discriminated union"
      exports: ["evaluatePrereq", "parsePrereq"]
    - path: "server/src/modules/leveling/lib/recompute-derived-stats.ts"
      provides: "Pure function recomputeDerivedStats(character, choices, progression) returning DerivedStats"
      exports: ["recomputeDerivedStats"]
    - path: "server/src/modules/leveling/lib/compute-applicable-steps.ts"
      provides: "Pure function computeApplicableSteps(targetLevel, className, hasFreeArchetype, isCaster) returning StepKind[]"
      exports: ["computeApplicableSteps"]
  key_links:
    - from: "prereq-evaluator.ts"
      to: "CharacterContext type"
      via: "import from ./types"
      pattern: "from ['\"]\\./types['\"]"
    - from: "recompute-derived-stats.ts"
      to: "applyAttributeBoost (Plan 01)"
      via: "import"
      pattern: "from ['\"]\\./apply-attribute-boost['\"]"
    - from: "All four modules"
      to: "Their .spec.ts siblings"
      via: "Jest testRegex"
      pattern: ".*\\.spec\\.ts$"
---

<objective>
Build the five pure-function modules that hold all the math for the Level-Up system: `skill-increase-cap`, `prereq-evaluator`, `recompute-derived-stats`, `compute-applicable-steps`, plus a shared `types.ts`. Every module is fully unit-tested using strict TDD (write failing test → write minimal implementation → green). These modules are the source-of-truth for PF2e rules math; they have NO NestJS imports, NO Prisma, NO I/O, and they will be called from the LevelingService (Plan 04) and from the React wizard's preview path.

Purpose: This phase establishes the test discipline (per ROADMAP First-Phase Note) and isolates the bug-prone math (Pitfall #8 boost-cap, Pitfall #9 hp-current, prereq edge cases) behind a fully-tested boundary. Plan 04's integration tests can then trust this math and only test orchestration (transactions, broadcast, access control).

Output: 5 production modules + 5 spec files, all tests passing under `cd server && npm test -- --testPathPattern=leveling`.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md
@.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md
@.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md
@.planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md
@.planning/phases/01-level-up-pf2e-regelkonform/01-01-SUMMARY.md
@server/src/modules/leveling/lib/apply-attribute-boost.ts
@server/src/modules/characters/pathbuilder-import.service.ts
@client/src/features/characters/components/add-feat-modal.tsx

<interfaces>
<!-- The Plan 01 module that this plan extends -->
```typescript
// From server/src/modules/leveling/lib/apply-attribute-boost.ts
export type AbilityScore = number;
export type AbilityAbbreviation = 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA';
export function applyAttributeBoost(currentScore: AbilityScore): AbilityScore;
export function isValidBoostSet(targets: readonly string[]): boolean;
```

<!-- Existing client-side prereq parser to reference (from add-feat-modal.tsx:27-74) -->
<!-- Server-side evaluator extends this with feat possession, level, class, ancestry, heritage -->
```typescript
// Existing client-side pattern (partial analog only — server module is fuller):
function checkSkillPrerequisites(
  prerequisites: string | undefined,
  skills: CharacterSkill[],
): { met: boolean; unmetReason?: string } { /* skill rank regex matching */ }
```

<!-- Existing helper from pathbuilder-import.service.ts:42-62 (proficiency mapping) -->
```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;
  }
}
```

<!-- The Proficiency enum from generated Prisma client (server/src/generated/prisma/) -->
<!-- enum Proficiency { UNTRAINED TRAINED EXPERT MASTER LEGENDARY } -->
</interfaces>
</context>

<feature>
  <name>skill-increase-cap — PF2e Skill Increase Rule</name>
  <files>server/src/modules/leveling/lib/skill-increase-cap.ts, server/src/modules/leveling/lib/skill-increase-cap.spec.ts</files>
  <behavior>
    PF2e rule: skill-increase steps occur at level 3 and every 2 levels thereafter. Cap rule:
    - TRAINED → EXPERT requires character level >= 3
    - EXPERT → MASTER requires character level >= 7
    - MASTER → LEGENDARY requires character level >= 15
    - UNTRAINED → TRAINED is always allowed when a skill-increase step occurs
    Test cases (from VALIDATION.md rows 1-W1-06 to 1-W1-11):
    - canIncreaseSkill('TRAINED', 2) === false
    - canIncreaseSkill('TRAINED', 3) === true
    - canIncreaseSkill('EXPERT', 6) === false
    - canIncreaseSkill('EXPERT', 7) === true
    - canIncreaseSkill('MASTER', 14) === false
    - canIncreaseSkill('MASTER', 15) === true
    - canIncreaseSkill('LEGENDARY', 20) === false (already maxed)
    - canIncreaseSkill('UNTRAINED', 1) === true (untrained → trained always allowed at any skill-increase level)
    Also export `SKILL_INCREASE_LEVELS: readonly number[] = [3, 5, 7, 9, 11, 13, 15, 17, 19]` (per PF2e CRB).
  </behavior>
</feature>

<feature>
  <name>prereq-evaluator — DSL parser + evaluator + formatter (D-01..D-04)</name>
  <files>server/src/modules/leveling/lib/prereq-evaluator.ts, server/src/modules/leveling/lib/prereq-evaluator.spec.ts</files>
  <behavior>
    Three-layer module: parser produces an AST, evaluator walks AST against CharacterContext, formatter produces a German user-facing reason for failures.
    Discriminated union return: `{ok:true} | {ok:false, reason:string} | {unknown:true, raw:string}`.
    Evaluable patterns (D-01):
    - Skill rank: `Trained in Athletics`, `Expert in Acrobatics`, `Master in Stealth`, `Legendary in Religion` — case-insensitive
    - Disjunctive: `Trained in Arcana, Trained in Nature, or Trained in Religion` — comma+OR-list (any one match → ok:true)
    - Conjunctive: `Trained in Deception; Trained in Stealth` — semicolon = AND (all must match → ok:true)
    - Bare feat-name: `Power Attack` — checks ctx.feats Set
    - Heritage ref: `Unbreakable Goblin heritage` — checks ctx.heritageId/heritageName
    - Ancestry ref: `Human`, `Elf`, etc. (one of D-16 16 ancestries) — checks ctx.ancestryName
    - Class ref: `Fighter`, `Wizard`, etc. — checks ctx.className
    - Level ref: `level 5` — checks ctx.level
    Non-evaluable (D-02 — return `{unknown:true, raw}`):
    - Deity refs: `worshipper of Droskar`, `worship of`, `follower of`
    - Spellcasting-tradition refs: `spellcasting class feature`, `divine spells`, `spellcaster`
    - Age/ethnicity: `at least 100 years old`, `Tian-Dan ethnicity`
    - Vision/sense traits: `low-light vision`, `darkvision`
    - Free-text: anything that doesn't match a known pattern
    Test cases (from VALIDATION.md rows 1-W1-12 to 1-W1-21):
    - evaluatePrereq("Trained in Athletics", { skills: { Athletics: 'TRAINED' }, ... }) → {ok:true}
    - Same prereq with skills.Athletics = 'UNTRAINED' → {ok:false, reason: "Du benötigst mindestens 'Trained' in Acrobatics" or German equivalent — wording at planner discretion but must be German}
    - "Trained in Arcana, Trained in Nature, or Trained in Religion" with skills.Arcana = 'TRAINED' → {ok:true}
    - "Trained in Deception; Trained in Stealth" with skills.Deception = 'TRAINED', skills.Stealth = 'UNTRAINED' → {ok:false}
    - "Power Attack" with feats Set containing 'Power Attack' → {ok:true}
    - "Unbreakable Goblin heritage" with ctx.heritageName = 'Unbreakable Goblin' → {ok:true}
    - "Fighter" with ctx.className = 'Fighter' → {ok:true}
    - "spellcasting class feature" → {unknown:true, raw: "spellcasting class feature"}
    - "worshipper of Droskar" → {unknown:true, raw: "worshipper of Droskar"}
    - evaluatePrereq(null, ctx) → {ok:true} (no prereq is always met)
    - evaluatePrereq('', ctx) → {ok:true}
    Implementation: Use regex-based parser per RESEARCH.md §Pattern 4 grammar (lines 519-531). UNKNOWN-aggressive: any non-classifiable atom marks the whole clause unknown.
  </behavior>
</feature>

<feature>
  <name>recompute-derived-stats — pure recompute pipeline (Pitfall #9)</name>
  <files>server/src/modules/leveling/lib/recompute-derived-stats.ts, server/src/modules/leveling/lib/recompute-derived-stats.spec.ts</files>
  <behavior>
    Pure function `recomputeDerivedStats(character, choices, progression) → DerivedStats`. No DB writes; no side effects.
    Inputs:
    - `character`: Character snapshot with current ancestryHP, classHP, abilities (STR/DEX/CON/INT/WIS/CHA), skills (Map<string, Proficiency>), feats (Set<featId>), level, hpCurrent
    - `choices`: WizardState.choices subset (boostTargets, skillIncrease, classFeatureChoices)
    - `progression`: ClassProgression row for (className, targetLevel) — proficiencyChanges Json, spellSlotIncrement, etc.
    Outputs (DerivedStats type):
    - level: number (the new targetLevel)
    - hpMax: number (= ancestryHP + (classHP + conMod) × newLevel + bonusPerLevel × newLevel)
    - ac: number (= 10 + clamp(dexMod, dexCap) + armor.ac + proficiencyBonus(armor))
    - classDc: number (= 10 + keyAbility-mod + proficiencyBonus(class))
    - perception: number (= wisMod + proficiencyBonus(perception))
    - fortitude: number (= conMod + proficiencyBonus(fort))
    - reflex: number (= dexMod + proficiencyBonus(ref))
    - will: number (= wisMod + proficiencyBonus(will))
    Test cases (from VALIDATION.md rows 1-W1-22 to 1-W1-25):
    - Given character L4 → L5 with CON 16 (mod +3), classHP 8, ancestryHP 8: new hpMax = 8 + (8+3)×5 = 63
    - Boost-cap-at-18 honored: if boostTargets includes CON and current CON = 18, new CON = 19 (not 20) → conMod 4 → hpMax uses 4
    - proficiencyChanges from ClassProgression are applied: if progression.proficiencyChanges = {fortitude: "EXPERT"} and old fort proficiency was TRAINED, new will use EXPERT bonus
    - Output object MUST NOT contain `hpCurrent` field (Pitfall #9 — verify with `expect(result).not.toHaveProperty('hpCurrent')`)
    - Output object MUST NOT contain `hpTemp` field
    Helper functions inside this module (private or exported as needed):
    - `proficiencyBonus(rank: Proficiency, level: number): number` — PF2e: untrained 0, trained 2+L, expert 4+L, master 6+L, legendary 8+L
    - `abilityModifier(score: number): number` — Math.floor((score - 10) / 2)
  </behavior>
</feature>

<feature>
  <name>compute-applicable-steps — Wizard step list per character/level (LVL-01, LVL-13, LVL-14)</name>
  <files>server/src/modules/leveling/lib/compute-applicable-steps.ts, server/src/modules/leveling/lib/compute-applicable-steps.spec.ts</files>
  <behavior>
    Pure function `computeApplicableSteps(targetLevel, className, hasFreeArchetype, isCaster, isSpontaneousCaster, classProgressionHasChoiceType): StepKind[]` returning the ordered list of wizard steps for this level-up.
    Step ordering (per D-10): class-features → class-feature-choice (if choiceType present) → boost (if L5/10/15/20) → skill-increase (if L3+) → feat-class (if even level) → feat-skill (if even level) → feat-general (if L3/7/11/15/19) → feat-ancestry (if L5/9/13/17) → feat-archetype (if hasFA) → spellcaster (if isCaster) → review.
    StepKind type (per RESEARCH.md §Pattern 1 line 357):
    `'class-features' | 'class-feature-choice' | 'boost' | 'skill-increase' | 'feat-class' | 'feat-skill' | 'feat-general' | 'feat-ancestry' | 'feat-archetype' | 'spellcaster' | 'review'`
    Test cases (from VALIDATION.md rows 1-W1-26 to 1-W1-29):
    - computeApplicableSteps(5, 'Fighter', false, false, false, false) returns ['class-features', 'boost', 'skill-increase', 'feat-class', 'feat-skill', 'feat-ancestry', 'review'] (no FA, no spellcaster, no choiceType)
    - computeApplicableSteps(4, 'Fighter', false, false, false, false) does NOT contain 'boost' (4 is not a boost level)
    - computeApplicableSteps(5, 'Fighter', true, false, false, false) contains 'feat-archetype'
    - computeApplicableSteps(5, 'Wizard', false, true, false, false) contains 'spellcaster'
    - computeApplicableSteps(3, 'Bard', false, true, true, false) contains 'spellcaster' AND 'feat-general' AND 'skill-increase'
    - computeApplicableSteps(2, 'Fighter', false, false, false, false) contains 'feat-class' AND 'feat-skill' but NOT 'boost' AND NOT 'skill-increase' (skill-increase starts at L3)
    - computeApplicableSteps(1, 'Cleric', false, true, false, true) contains 'class-feature-choice' (Cleric L1 doctrine)
    - Result ALWAYS ends with 'review' as the last step
    - Result ALWAYS starts with 'class-features' (auto-summary, even if empty grants for that level)
  </behavior>
</feature>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Shared types module (types.ts)</name>
  <files>server/src/modules/leveling/lib/types.ts</files>
  <read_first>
    - server/src/modules/leveling/lib/apply-attribute-boost.ts (existing exports — extend the type vocabulary)
    - server/src/generated/prisma/index.d.ts (search for `Proficiency` enum — must align)
    - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 369-401 — WizardState shape; lines 537-548 — EvalResult; lines 562-573 — DerivedStats)
  </read_first>
  <behavior>
    No tests for types-only file. Types file MUST contain (verifiable via grep):
    - `export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY'`
    - `export type EvalResult = { ok: true } | { ok: false; reason: string } | { unknown: true; raw: string }`
    - `export type StepKind = 'class-features' | 'class-feature-choice' | 'boost' | 'skill-increase' | 'feat-class' | 'feat-skill' | 'feat-general' | 'feat-ancestry' | 'feat-archetype' | 'spellcaster' | 'review'`
    - `export interface CharacterContext { level: number; className: string; ancestryName: string; heritageName?: string; abilities: Record<AbilityAbbreviation, number>; skills: Record<string, Proficiency>; feats: Set<string> }`
    - `export interface DerivedStats { level: number; hpMax: number; ac: number; classDc: number; perception: number; fortitude: number; reflex: number; will: number }`
  </behavior>
  <action>
    Create `server/src/modules/leveling/lib/types.ts` with the following exact content:

    ```typescript
    /**
     * Shared types for the Level-Up pure-function library.
     * No runtime dependencies — types only.
     */
    import type { AbilityAbbreviation } from './apply-attribute-boost';

    export type { AbilityAbbreviation };

    /** PF2e proficiency ranks (mirrors Prisma `Proficiency` enum). */
    export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY';

    /** Numeric proficiency bonus per rank, for use in proficiencyBonus(rank, level) calculation. */
    export const PROFICIENCY_BASE_BONUS: Record<Proficiency, number> = {
      UNTRAINED: 0,
      TRAINED: 2,
      EXPERT: 4,
      MASTER: 6,
      LEGENDARY: 8,
    };

    /** Discriminated union for prereq evaluation result. */
    export type EvalResult =
      | { ok: true }
      | { ok: false; reason: string }
      | { unknown: true; raw: string };

    /** Ordered union of wizard step kinds (UI-SPEC + RESEARCH §Pattern 1). */
    export type StepKind =
      | 'class-features'
      | 'class-feature-choice'
      | 'boost'
      | 'skill-increase'
      | 'feat-class'
      | 'feat-skill'
      | 'feat-general'
      | 'feat-ancestry'
      | 'feat-archetype'
      | 'spellcaster'
      | 'review';

    /** Snapshot a character's mechanical state for prereq evaluation and recompute. */
    export interface CharacterContext {
      level: number;
      className: string;
      ancestryName: string;
      heritageName?: string;
      abilities: Record<AbilityAbbreviation, number>;
      skills: Record<string, Proficiency>;
      feats: Set<string>;
    }

    /** Output of recomputeDerivedStats — never includes hpCurrent (Pitfall #9). */
    export interface DerivedStats {
      level: number;
      hpMax: number;
      ac: number;
      classDc: number;
      perception: number;
      fortitude: number;
      reflex: number;
      will: number;
    }

    /** ClassProgression row shape — read-only input to recompute pipeline. */
    export interface ClassProgressionRow {
      className: string;
      level: number;
      grants: string[];
      proficiencyChanges: Partial<Record<'fortitude' | 'reflex' | 'will' | 'perception' | 'classDc' | 'ac', Proficiency>>;
      spellSlotIncrement?: { tradition: string; spellLevel: number; count: number } | null;
      cantripIncrement?: number | null;
      repertoireIncrement?: number | null;
      choiceType?: string | null;
      choiceOptionsRef?: string | null;
    }

    /** Wizard choices subset — what the user picked across the wizard. */
    export interface WizardChoices {
      boostTargets?: AbilityAbbreviation[];
      skillIncrease?: { skillName: string; toRank: Proficiency };
      featClassId?: string;
      featSkillId?: string;
      featGeneralId?: string;
      featAncestryId?: string;
      featArchetypeId?: string;
      classFeatureChoices?: Record<string, string>;
      spellcasterRepertoirePicks?: string[];
    }
    ```
  </action>
  <verify>
    <automated>cd server &amp;&amp; npx tsc --noEmit -p tsconfig.json 2&gt;&amp;1 | grep -v "^$" | head -20 || echo "tsc clean"</automated>
  </verify>
  <acceptance_criteria>
    - File `server/src/modules/leveling/lib/types.ts` exists
    - File contains `export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY'`
    - File contains `export type EvalResult =`
    - File contains `export type StepKind =`
    - File contains `export interface CharacterContext`
    - File contains `export interface DerivedStats`
    - File contains `export interface ClassProgressionRow`
    - File contains `export interface WizardChoices`
    - File contains NO `: any` outside comments
    - File contains NO `@nestjs/` imports
    - File contains NO Prisma client import (only `import type` from sibling for AbilityAbbreviation)
    - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 (no type errors)
  </acceptance_criteria>
  <done>
    types.ts exports the full vocabulary used by the next four modules. No production code yet — no test needed because the file has no behavior beyond type aliases.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: skill-increase-cap module (RED → GREEN → REFACTOR)</name>
  <files>server/src/modules/leveling/lib/skill-increase-cap.ts, server/src/modules/leveling/lib/skill-increase-cap.spec.ts</files>
  <read_first>
    - server/src/modules/leveling/lib/types.ts (Proficiency type)
    - server/src/modules/leveling/lib/apply-attribute-boost.ts (style template — strict TS, named exports, JSDoc)
    - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W1-06 to 1-W1-11)
    - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 967-979 — Phase Requirements → Test Map for LVL-06)
    - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (LVL-06: T→E ab L3, E→M ab L7, M→L ab L15)
  </read_first>
  <behavior>
    See `<feature>` block above. RED: write spec first with all 8 test cases. GREEN: implement minimal `canIncreaseSkill` to pass all tests. REFACTOR: extract magic numbers to a SKILL_RANK_LEVEL_REQUIREMENTS constant.
  </behavior>
  <action>
    **RED phase — write the failing spec FIRST:**

    Create `server/src/modules/leveling/lib/skill-increase-cap.spec.ts`:

    ```typescript
    import { canIncreaseSkill, SKILL_INCREASE_LEVELS } from './skill-increase-cap';

    describe('canIncreaseSkill', () => {
      it('rejects TRAINED → EXPERT at level 2 (T→E requires L3+)', () => {
        expect(canIncreaseSkill('TRAINED', 2)).toBe(false);
      });

      it('allows TRAINED → EXPERT at level 3', () => {
        expect(canIncreaseSkill('TRAINED', 3)).toBe(true);
      });

      it('rejects EXPERT → MASTER at level 6 (E→M requires L7+)', () => {
        expect(canIncreaseSkill('EXPERT', 6)).toBe(false);
      });

      it('allows EXPERT → MASTER at level 7', () => {
        expect(canIncreaseSkill('EXPERT', 7)).toBe(true);
      });

      it('rejects MASTER → LEGENDARY at level 14 (M→L requires L15+)', () => {
        expect(canIncreaseSkill('MASTER', 14)).toBe(false);
      });

      it('allows MASTER → LEGENDARY at level 15', () => {
        expect(canIncreaseSkill('MASTER', 15)).toBe(true);
      });

      it('rejects LEGENDARY at any level (already maxed)', () => {
        expect(canIncreaseSkill('LEGENDARY', 20)).toBe(false);
      });

      it('allows UNTRAINED → TRAINED at any level >= 1 (no cap on first training)', () => {
        expect(canIncreaseSkill('UNTRAINED', 1)).toBe(true);
        expect(canIncreaseSkill('UNTRAINED', 20)).toBe(true);
      });
    });

    describe('SKILL_INCREASE_LEVELS', () => {
      it('exposes the PF2e skill-increase level list', () => {
        expect(SKILL_INCREASE_LEVELS).toEqual([3, 5, 7, 9, 11, 13, 15, 17, 19]);
      });
    });
    ```

    Run `cd server && npm test -- skill-increase-cap.spec.ts` — must FAIL (module doesn't exist yet). Commit: `test(01): RED — skill-increase-cap spec`

    **GREEN phase — write minimal implementation:**

    Create `server/src/modules/leveling/lib/skill-increase-cap.ts`:

    ```typescript
    import type { Proficiency } from './types';

    /** Levels at which a skill-increase step occurs (PF2e CRB). */
    export const SKILL_INCREASE_LEVELS: readonly number[] = [3, 5, 7, 9, 11, 13, 15, 17, 19];

    /**
     * Minimum character level required to advance a skill from `currentRank`.
     * UNTRAINED → TRAINED is unrestricted (any level >= 1 with a skill-increase step).
     */
    const SKILL_RANK_LEVEL_REQUIREMENTS: Record<Proficiency, number | null> = {
      UNTRAINED: 1,    // → TRAINED
      TRAINED: 3,      // → EXPERT (PF2e CRB)
      EXPERT: 7,       // → MASTER
      MASTER: 15,      // → LEGENDARY
      LEGENDARY: null, // already maxed
    };

    /**
     * PF2e skill-increase cap rule (LVL-06).
     * Returns true if the character at `characterLevel` may advance a skill that is
     * currently at `currentRank` to the next rank.
     */
    export function canIncreaseSkill(
      currentRank: Proficiency,
      characterLevel: number,
    ): boolean {
      const required = SKILL_RANK_LEVEL_REQUIREMENTS[currentRank];
      if (required === null) return false;
      return characterLevel >= required;
    }
    ```

    Run `cd server && npm test -- skill-increase-cap.spec.ts` — must PASS (8 + 1 = 9 tests). Commit: `feat(01): implement skill-increase-cap`
  </action>
  <verify>
    <automated>cd server &amp;&amp; npm test -- skill-increase-cap.spec.ts</automated>
  </verify>
  <acceptance_criteria>
    - File `server/src/modules/leveling/lib/skill-increase-cap.ts` exists
    - File exports `canIncreaseSkill` and `SKILL_INCREASE_LEVELS`
    - File contains NO `@nestjs/` imports
    - File contains NO `: any`
    - File `server/src/modules/leveling/lib/skill-increase-cap.spec.ts` exists with at least 9 `it(` invocations
    - `cd server && npm test -- skill-increase-cap.spec.ts` exits 0 with `9 passed` (or `Tests: 9 passed`)
    - All test cases from VALIDATION.md rows 1-W1-06 to 1-W1-11 are present in the spec
  </acceptance_criteria>
  <done>
    Spec written first and observed failing; minimal implementation green; all 9 tests pass.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: prereq-evaluator module (RED → GREEN → REFACTOR)</name>
  <files>server/src/modules/leveling/lib/prereq-evaluator.ts, server/src/modules/leveling/lib/prereq-evaluator.spec.ts</files>
  <read_first>
    - server/src/modules/leveling/lib/types.ts (CharacterContext, EvalResult, Proficiency)
    - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 514-548 — Pattern 4 grammar; lines 727-748 — Code Examples §2 real prereq strings)
    - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W1-12 to 1-W1-21 — exact assertions)
    - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-01..D-04 — evaluable patterns and warning behavior)
    - client/src/features/characters/components/add-feat-modal.tsx (lines 27-74 — partial client-side analog `checkSkillPrerequisites`)
  </read_first>
  <behavior>
    See `<feature>` block above for full behavior. RED: spec all 14+ test cases. GREEN: implement parser + evaluator + formatter using regex patterns from grammar in RESEARCH.md.
  </behavior>
  <action>
    **RED phase — write the failing spec FIRST:**

    Create `server/src/modules/leveling/lib/prereq-evaluator.spec.ts`:

    ```typescript
    import { evaluatePrereq } from './prereq-evaluator';
    import type { CharacterContext } from './types';

    function makeCtx(overrides: Partial<CharacterContext> = {}): CharacterContext {
      return {
        level: 5,
        className: 'Fighter',
        ancestryName: 'Human',
        heritageName: undefined,
        abilities: { STR: 16, DEX: 14, CON: 14, INT: 10, WIS: 12, CHA: 10 },
        skills: {},
        feats: new Set<string>(),
        ...overrides,
      };
    }

    describe('evaluatePrereq — empty/null', () => {
      it('returns ok for null prereq', () => {
        expect(evaluatePrereq(null, makeCtx())).toEqual({ ok: true });
      });

      it('returns ok for empty string', () => {
        expect(evaluatePrereq('', makeCtx())).toEqual({ ok: true });
      });
    });

    describe('evaluatePrereq — skill rank', () => {
      it('returns ok when skill rank meets requirement', () => {
        const ctx = makeCtx({ skills: { Athletics: 'TRAINED' } });
        expect(evaluatePrereq('Trained in Athletics', ctx)).toEqual({ ok: true });
      });

      it('returns ok when skill rank exceeds requirement', () => {
        const ctx = makeCtx({ skills: { Athletics: 'EXPERT' } });
        expect(evaluatePrereq('Trained in Athletics', ctx)).toEqual({ ok: true });
      });

      it('returns ok:false with German reason when skill rank below requirement', () => {
        const ctx = makeCtx({ skills: { Athletics: 'UNTRAINED' } });
        const result = evaluatePrereq('Trained in Athletics', ctx);
        expect(result.ok).toBe(false);
        if (result.ok === false) {
          expect(result.reason).toMatch(/Athletics/i);
        }
      });

      it('returns ok:false when skill is missing entirely', () => {
        const ctx = makeCtx({ skills: {} });
        const result = evaluatePrereq('Trained in Athletics', ctx);
        expect(result.ok).toBe(false);
      });
    });

    describe('evaluatePrereq — disjunctive (OR-list)', () => {
      it('returns ok when any of the listed skills matches', () => {
        const ctx = makeCtx({ skills: { Arcana: 'TRAINED' } });
        expect(evaluatePrereq('Trained in Arcana, Trained in Nature, or Trained in Religion', ctx)).toEqual({ ok: true });
      });

      it('returns ok:false when no listed skill matches', () => {
        const ctx = makeCtx({ skills: { Athletics: 'TRAINED' } });
        const result = evaluatePrereq('Trained in Arcana, Trained in Nature, or Trained in Religion', ctx);
        expect(result.ok).toBe(false);
      });
    });

    describe('evaluatePrereq — conjunctive (semicolon AND)', () => {
      it('returns ok when both clauses match', () => {
        const ctx = makeCtx({ skills: { Deception: 'TRAINED', Stealth: 'TRAINED' } });
        expect(evaluatePrereq('Trained in Deception; Trained in Stealth', ctx)).toEqual({ ok: true });
      });

      it('returns ok:false when one clause is missing', () => {
        const ctx = makeCtx({ skills: { Deception: 'TRAINED' } });
        const result = evaluatePrereq('Trained in Deception; Trained in Stealth', ctx);
        expect(result.ok).toBe(false);
      });
    });

    describe('evaluatePrereq — bare feat name', () => {
      it('returns ok when the feat is held', () => {
        const ctx = makeCtx({ feats: new Set(['Power Attack']) });
        expect(evaluatePrereq('Power Attack', ctx)).toEqual({ ok: true });
      });

      it('returns ok:false when the feat is missing', () => {
        const ctx = makeCtx({ feats: new Set() });
        const result = evaluatePrereq('Power Attack', ctx);
        expect(result.ok).toBe(false);
      });
    });

    describe('evaluatePrereq — heritage', () => {
      it('returns ok when heritage matches', () => {
        const ctx = makeCtx({ heritageName: 'Unbreakable Goblin' });
        expect(evaluatePrereq('Unbreakable Goblin heritage', ctx)).toEqual({ ok: true });
      });
    });

    describe('evaluatePrereq — class ref', () => {
      it('returns ok when class matches', () => {
        const ctx = makeCtx({ className: 'Fighter' });
        expect(evaluatePrereq('Fighter', ctx)).toEqual({ ok: true });
      });
    });

    describe('evaluatePrereq — non-evaluable patterns (D-02 → unknown)', () => {
      it('returns unknown for spellcasting refs', () => {
        const result = evaluatePrereq('spellcasting class feature', makeCtx());
        expect(result).toEqual({ unknown: true, raw: 'spellcasting class feature' });
      });

      it('returns unknown for deity refs', () => {
        const result = evaluatePrereq('worshipper of Droskar', makeCtx());
        expect('unknown' in result && result.unknown).toBe(true);
      });

      it('returns unknown for age refs', () => {
        const result = evaluatePrereq('at least 100 years old', makeCtx());
        expect('unknown' in result && result.unknown).toBe(true);
      });

      it('returns unknown for vision-trait refs', () => {
        const result = evaluatePrereq('low-light vision', makeCtx());
        expect('unknown' in result && result.unknown).toBe(true);
      });

      it('returns unknown for free-text patterns the parser cannot classify', () => {
        const result = evaluatePrereq('You worship a god of fire and destruction', makeCtx());
        expect('unknown' in result && result.unknown).toBe(true);
      });
    });
    ```

    Run `cd server && npm test -- prereq-evaluator.spec.ts` — must FAIL. Commit: `test(01): RED — prereq-evaluator spec`

    **GREEN phase — implement the parser + evaluator** in `server/src/modules/leveling/lib/prereq-evaluator.ts`. Implementation must:

    1. Define internal AST node types: `SkillRankAtom`, `FeatAtom`, `HeritageAtom`, `ClassAtom`, `AncestryAtom`, `LevelAtom`, `UnknownAtom`, `AndNode`, `OrNode`.
    2. Export `evaluatePrereq(prereqString: string | null, ctx: CharacterContext): EvalResult`.
    3. Internal regex patterns based on RESEARCH.md grammar (lines 519-531):
       - `/^(trained|expert|master|legendary)\s+in\s+(.+?)$/i` → SkillRankAtom
       - `/(.+?)\s+heritage$/i` → HeritageAtom
       - `/^level\s+(\d+)$/i` → LevelAtom
       - Class names: lookup against the 16 D-16 class names hardcoded in the module
       - Spellcasting/deity/age/vision: regex blacklist → UnknownAtom
       - Bare capitalized phrase as last-resort feat lookup → FeatAtom
    4. Splitter: split on `;` for AND clauses, then within a clause split on `,` and ` or ` for OR-lists (see RESEARCH.md A5 — heuristic "comma inside Trained in X, Y, or Z = OR-list").
    5. UNKNOWN-aggressive: if ANY atom resolves to UnknownAtom, the whole prereq returns `{ unknown: true, raw: <originalString> }` per RESEARCH.md line 550.
    6. Failure reasons in German (D-15 — UI is German throughout). Examples:
       - `Du benötigst mindestens 'Trained' in Athletics` (skill rank fail)
       - `Dir fehlt das Talent: Power Attack` (feat fail)
       - `Voraussetzung nicht erfüllt: <raw>` (generic fallback)

    Sketched implementation skeleton (planner outlines key shapes; executor fills in regex correctness and German strings):

    ```typescript
    import type { CharacterContext, EvalResult, Proficiency } from './types';

    const PROFICIENCY_RANK_ORDER: readonly Proficiency[] = ['UNTRAINED', 'TRAINED', 'EXPERT', 'MASTER', 'LEGENDARY'];

    const KNOWN_CLASS_NAMES = new Set([
      'Alchemist', 'Barbarian', 'Bard', 'Champion', 'Cleric', 'Druid',
      'Fighter', 'Investigator', 'Monk', 'Oracle', 'Ranger', 'Rogue',
      'Sorcerer', 'Swashbuckler', 'Witch', 'Wizard',
    ]);

    const NON_EVALUABLE_PATTERNS: readonly RegExp[] = [
      /spellcasting\s+class\s+feature/i,
      /\bspellcaster\b/i,
      /(divine|arcane|primal|occult)\s+spells?/i,
      /\bcantrip\b/i,
      /worship(?:per|s|ing)?\s+of/i,
      /follower\s+of/i,
      /\bdeity\b/i,
      /at\s+least\s+\d+\s+years?\s+old/i,
      /\bethnicity\b/i,
      /low-light\s+vision/i,
      /\bdarkvision\b/i,
      /\bscent\b/i,
    ];

    function rankAtLeast(have: Proficiency, need: Proficiency): boolean {
      return PROFICIENCY_RANK_ORDER.indexOf(have) >= PROFICIENCY_RANK_ORDER.indexOf(need);
    }

    type Atom =
      | { kind: 'skill'; skill: string; required: Proficiency }
      | { kind: 'heritage'; name: string }
      | { kind: 'class'; name: string }
      | { kind: 'level'; min: number }
      | { kind: 'feat'; name: string }
      | { kind: 'unknown'; raw: string };

    type Node =
      | { kind: 'atom'; atom: Atom }
      | { kind: 'and'; children: Node[] }
      | { kind: 'or'; children: Node[] };

    // parse(): tokenize on ';' then ',' then ' or '; classify each token
    // evaluate(): walk tree, short-circuit OR, propagate UNKNOWN
    // formatReason(): German strings

    export function evaluatePrereq(prereqString: string | null, ctx: CharacterContext): EvalResult {
      if (!prereqString || prereqString.trim() === '') return { ok: true };
      // 1. Quick UNKNOWN check
      if (NON_EVALUABLE_PATTERNS.some(rx => rx.test(prereqString))) {
        return { unknown: true, raw: prereqString };
      }
      // 2. Parse + evaluate
      // ... (executor implements; tests are the contract)
    }
    ```

    Run `cd server && npm test -- prereq-evaluator.spec.ts` — must PASS (all tests). Commit: `feat(01): implement prereq-evaluator (D-01..D-04)`

    **REFACTOR phase (if needed):** extract regex constants, extract German string formatter, ensure no `any` types.
  </action>
  <verify>
    <automated>cd server &amp;&amp; npm test -- prereq-evaluator.spec.ts</automated>
  </verify>
  <acceptance_criteria>
    - File `server/src/modules/leveling/lib/prereq-evaluator.ts` exists
    - File exports `evaluatePrereq`
    - File contains NO `@nestjs/` imports
    - File contains NO Prisma client import
    - File contains NO `: any` outside comments
    - File `server/src/modules/leveling/lib/prereq-evaluator.spec.ts` exists with at least 17 `it(` invocations
    - `cd server && npm test -- prereq-evaluator.spec.ts` exits 0 — all tests pass
    - The spec covers EVERY VALIDATION.md row from 1-W1-12 to 1-W1-21
    - Failure reasons returned by evaluator are in German (verifiable: spec asserts a German-language regex match for at least one failure case)
  </acceptance_criteria>
  <done>
    Spec written first; all 17+ tests pass; UNKNOWN-aggressive behavior verified for D-02 patterns; German reason strings on failures.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 4: recompute-derived-stats module (RED → GREEN, Pitfall #9 enforced)</name>
  <files>server/src/modules/leveling/lib/recompute-derived-stats.ts, server/src/modules/leveling/lib/recompute-derived-stats.spec.ts</files>
  <read_first>
    - server/src/modules/leveling/lib/types.ts (CharacterContext, DerivedStats, ClassProgressionRow, WizardChoices, PROFICIENCY_BASE_BONUS)
    - server/src/modules/leveling/lib/apply-attribute-boost.ts (applyAttributeBoost — must use this, not re-implement)
    - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 552-575 — Pattern 5 inputs/outputs; lines 624-633 — Pitfall 2 hpCurrent rule)
    - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W1-22 to 1-W1-25)
    - server/src/modules/characters/pathbuilder-import.service.ts (lines 42-62 — proficiencyFromValue helper for reference; do not import — re-implement here)
  </read_first>
  <behavior>
    See `<feature>` block above. Critical Pitfall #9 invariant: output object MUST NOT contain `hpCurrent` or `hpTemp`. Spec has dedicated assertion for this.
  </behavior>
  <action>
    **RED phase — write spec FIRST:**

    Create `server/src/modules/leveling/lib/recompute-derived-stats.spec.ts`:

    ```typescript
    import { recomputeDerivedStats } from './recompute-derived-stats';
    import type { CharacterContext, ClassProgressionRow, WizardChoices } from './types';

    function baseCharacter(overrides: Partial<CharacterContext> = {}): CharacterContext & { ancestryHp: number; classHp: number; armorAc: number; armorProficiency: 'UNTRAINED'|'TRAINED'|'EXPERT'|'MASTER'|'LEGENDARY'; dexCap: number } {
      return {
        level: 4,
        className: 'Fighter',
        ancestryName: 'Human',
        heritageName: undefined,
        abilities: { STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 12, CHA: 10 },
        skills: {},
        feats: new Set(),
        ancestryHp: 8,
        classHp: 10,
        armorAc: 4,
        armorProficiency: 'EXPERT',
        dexCap: 2,
        ...overrides,
      } as never;
    }

    function emptyProgression(level: number): ClassProgressionRow {
      return {
        className: 'Fighter',
        level,
        grants: [],
        proficiencyChanges: {},
      };
    }

    describe('recomputeDerivedStats — hpMax', () => {
      it('computes hpMax = ancestryHP + (classHP + conMod) × newLevel for L4 → L5 Fighter, CON 16', () => {
        // CON 16 → mod +3; classHP 10 + 3 = 13 per level; ancestryHP 8; L5: 8 + 13×5 = 73
        const ch = baseCharacter();
        const choices: WizardChoices = {};
        const result = recomputeDerivedStats(ch, choices, emptyProgression(5));
        expect(result.hpMax).toBe(73);
      });

      it('respects boost-cap-at-18 when CON is boosted from 18', () => {
        // CON starts at 18, boost target includes CON → new CON = 19 (not 20). conMod = +4.
        // L5: 8 + (10+4)×5 = 78
        const ch = baseCharacter({ abilities: { STR: 16, DEX: 14, CON: 18, INT: 10, WIS: 12, CHA: 10 } });
        const choices: WizardChoices = { boostTargets: ['CON', 'STR', 'DEX', 'INT'] };
        const result = recomputeDerivedStats(ch, choices, emptyProgression(5));
        expect(result.hpMax).toBe(78);
      });
    });

    describe('recomputeDerivedStats — proficiencyChanges from ClassProgression', () => {
      it('applies proficiencyChanges from ClassProgression at the new level (fortitude EXPERT)', () => {
        // Fighter L4: fort proficiency was TRAINED (assume base). Progression bumps to EXPERT.
        // CON mod +3, level 5; fort = conMod + (level + 4) = 3 + 9 = 12
        const ch = baseCharacter();
        const progression: ClassProgressionRow = {
          ...emptyProgression(5),
          proficiencyChanges: { fortitude: 'EXPERT' },
        };
        const result = recomputeDerivedStats(ch, {}, progression);
        // fort = 3 + (5 + 4) = 12   (proficiencyBase EXPERT = 4; PF2e prof bonus = base + level when trained+)
        expect(result.fortitude).toBe(12);
      });
    });

    describe('recomputeDerivedStats — Pitfall #9 (hpCurrent must not be in output)', () => {
      it('does NOT include hpCurrent in the result object', () => {
        const ch = baseCharacter();
        const result = recomputeDerivedStats(ch, {}, emptyProgression(5));
        expect(result).not.toHaveProperty('hpCurrent');
        expect(result).not.toHaveProperty('hpTemp');
      });
    });

    describe('recomputeDerivedStats — level passthrough', () => {
      it('returns the new level in the output', () => {
        const ch = baseCharacter();
        const result = recomputeDerivedStats(ch, {}, emptyProgression(5));
        expect(result.level).toBe(5);
      });
    });
    ```

    Run — must FAIL. Commit: `test(01): RED — recompute-derived-stats spec`

    **GREEN phase — implement** in `server/src/modules/leveling/lib/recompute-derived-stats.ts`:

    The function signature MUST be:
    ```typescript
    export function recomputeDerivedStats(
      character: CharacterContext & {
        ancestryHp: number;
        classHp: number;
        armorAc: number;
        armorProficiency: Proficiency;
        dexCap: number;
      },
      choices: WizardChoices,
      progression: ClassProgressionRow,
    ): DerivedStats;
    ```

    Implementation requirements:
    1. Apply `applyAttributeBoost` to each ability listed in `choices.boostTargets`. Compute new abilities map.
    2. `abilityModifier(score) = Math.floor((score - 10) / 2)`.
    3. `proficiencyBonus(rank, level) = rank === 'UNTRAINED' ? 0 : (PROFICIENCY_BASE_BONUS[rank] + level)`.
    4. `hpMax = ancestryHp + (classHp + abilityModifier(newAbilities.CON)) * progression.level`.
    5. `ac = 10 + Math.min(abilityModifier(newAbilities.DEX), character.dexCap) + character.armorAc + proficiencyBonus(character.armorProficiency, progression.level)` — note `armorProficiency` may have been bumped by `progression.proficiencyChanges.ac`; if so, use the new rank.
    6. `classDc = 10 + abilityModifier(newAbilities[keyAbility]) + proficiencyBonus(classDcRank, progression.level)`. For tests above, assume keyAbility is STR and classDcRank is TRAINED (Fighter L1). The spec only asserts hpMax + fortitude + Pitfall #9; remaining fields tested in later integration.
    7. `perception = abilityModifier(newAbilities.WIS) + proficiencyBonus(perceptionRank, progression.level)`.
    8. `fortitude = abilityModifier(newAbilities.CON) + proficiencyBonus(fortRank, progression.level)`. Use `progression.proficiencyChanges.fortitude ?? <previous rank>`.
    9. Same for reflex (DEX) and will (WIS).
    10. Return ONLY the keys defined in DerivedStats. Do NOT spread the input character. Do NOT include hpCurrent.

    Run `cd server && npm test -- recompute-derived-stats.spec.ts` — must PASS. Commit: `feat(01): implement recompute-derived-stats (Pitfall #8/#9 safe)`
  </action>
  <verify>
    <automated>cd server &amp;&amp; npm test -- recompute-derived-stats.spec.ts</automated>
  </verify>
  <acceptance_criteria>
    - File `server/src/modules/leveling/lib/recompute-derived-stats.ts` exists
    - File exports `recomputeDerivedStats`
    - File contains the literal string `applyAttributeBoost` (proves it imports from the existing module — no math duplication)
    - File contains NO `@nestjs/` imports
    - File contains NO Prisma client import
    - File contains NO `: any` outside comments
    - File contains NO `hpCurrent` or `hpTemp` literal strings (defensive: cannot accidentally output them)
    - File `server/src/modules/leveling/lib/recompute-derived-stats.spec.ts` exists with at least 5 `it(` invocations
    - Spec contains an `expect(result).not.toHaveProperty('hpCurrent')` assertion (Pitfall #9 enforcement)
    - `cd server && npm test -- recompute-derived-stats.spec.ts` exits 0 — all tests pass
  </acceptance_criteria>
  <done>
    Spec written first; recompute is pure; Pitfall #8 (boost cap) and Pitfall #9 (no hpCurrent mutation) both enforced by tests.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 5: compute-applicable-steps module (RED → GREEN)</name>
  <files>server/src/modules/leveling/lib/compute-applicable-steps.ts, server/src/modules/leveling/lib/compute-applicable-steps.spec.ts</files>
  <read_first>
    - server/src/modules/leveling/lib/types.ts (StepKind type)
    - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W1-26 to 1-W1-29)
    - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-10 — step ordering; D-13 — FA toggle, D-18 — spellcaster)
    - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 696-722 — Boost step / spellcaster / FA-archetype analysis)
  </read_first>
  <behavior>
    See `<feature>` block above. Step ordering is fixed per D-10. Function output is deterministic given inputs.
  </behavior>
  <action>
    **RED phase — write spec FIRST:**

    Create `server/src/modules/leveling/lib/compute-applicable-steps.spec.ts`:

    ```typescript
    import { computeApplicableSteps } from './compute-applicable-steps';
    import type { StepKind } from './types';

    describe('computeApplicableSteps — Fighter (martial, no FA, no caster)', () => {
      it('at L5 returns [class-features, boost, skill-increase, feat-class, feat-skill, feat-ancestry, review]', () => {
        const steps = computeApplicableSteps({
          targetLevel: 5,
          className: 'Fighter',
          hasFreeArchetype: false,
          isCaster: false,
          isSpontaneousCaster: false,
          classProgressionHasChoiceType: false,
        });
        expect(steps).toEqual([
          'class-features',
          'boost',
          'skill-increase',
          'feat-class',
          'feat-skill',
          'feat-ancestry',
          'review',
        ]);
      });

      it('at L4 (not a boost level) does NOT contain boost', () => {
        const steps = computeApplicableSteps({
          targetLevel: 4,
          className: 'Fighter',
          hasFreeArchetype: false,
          isCaster: false,
          isSpontaneousCaster: false,
          classProgressionHasChoiceType: false,
        });
        expect(steps).not.toContain('boost');
      });

      it('at L4 (even level) contains feat-class AND feat-skill', () => {
        const steps = computeApplicableSteps({
          targetLevel: 4,
          className: 'Fighter',
          hasFreeArchetype: false,
          isCaster: false,
          isSpontaneousCaster: false,
          classProgressionHasChoiceType: false,
        });
        expect(steps).toContain('feat-class');
        expect(steps).toContain('feat-skill');
      });

      it('at L3 contains skill-increase AND feat-general but NOT feat-class (odd level)', () => {
        const steps = computeApplicableSteps({
          targetLevel: 3,
          className: 'Fighter',
          hasFreeArchetype: false,
          isCaster: false,
          isSpontaneousCaster: false,
          classProgressionHasChoiceType: false,
        });
        expect(steps).toContain('skill-increase');
        expect(steps).toContain('feat-general');
        expect(steps).not.toContain('feat-class');
      });

      it('at L2 (even but no skill-increase yet) contains feat-class+feat-skill but NOT skill-increase', () => {
        const steps = computeApplicableSteps({
          targetLevel: 2,
          className: 'Fighter',
          hasFreeArchetype: false,
          isCaster: false,
          isSpontaneousCaster: false,
          classProgressionHasChoiceType: false,
        });
        expect(steps).toContain('feat-class');
        expect(steps).toContain('feat-skill');
        expect(steps).not.toContain('skill-increase');
      });
    });

    describe('computeApplicableSteps — Free Archetype', () => {
      it('with FA enabled at L5 includes feat-archetype', () => {
        const steps = computeApplicableSteps({
          targetLevel: 5,
          className: 'Fighter',
          hasFreeArchetype: true,
          isCaster: false,
          isSpontaneousCaster: false,
          classProgressionHasChoiceType: false,
        });
        expect(steps).toContain('feat-archetype');
      });

      it('without FA never includes feat-archetype', () => {
        const steps = computeApplicableSteps({
          targetLevel: 5,
          className: 'Fighter',
          hasFreeArchetype: false,
          isCaster: false,
          isSpontaneousCaster: false,
          classProgressionHasChoiceType: false,
        });
        expect(steps).not.toContain('feat-archetype');
      });
    });

    describe('computeApplicableSteps — Spellcaster', () => {
      it('with isCaster=true includes spellcaster step', () => {
        const steps = computeApplicableSteps({
          targetLevel: 5,
          className: 'Wizard',
          hasFreeArchetype: false,
          isCaster: true,
          isSpontaneousCaster: false,
          classProgressionHasChoiceType: false,
        });
        expect(steps).toContain('spellcaster');
      });

      it('with isCaster=false never includes spellcaster step', () => {
        const steps = computeApplicableSteps({
          targetLevel: 5,
          className: 'Fighter',
          hasFreeArchetype: false,
          isCaster: false,
          isSpontaneousCaster: false,
          classProgressionHasChoiceType: false,
        });
        expect(steps).not.toContain('spellcaster');
      });
    });

    describe('computeApplicableSteps — class-feature-choice (D-19)', () => {
      it('includes class-feature-choice when ClassProgression carries choiceType', () => {
        const steps = computeApplicableSteps({
          targetLevel: 1,
          className: 'Cleric',
          hasFreeArchetype: false,
          isCaster: true,
          isSpontaneousCaster: false,
          classProgressionHasChoiceType: true,
        });
        expect(steps).toContain('class-feature-choice');
      });

      it('does NOT include class-feature-choice when no choiceType', () => {
        const steps = computeApplicableSteps({
          targetLevel: 5,
          className: 'Fighter',
          hasFreeArchetype: false,
          isCaster: false,
          isSpontaneousCaster: false,
          classProgressionHasChoiceType: false,
        });
        expect(steps).not.toContain('class-feature-choice');
      });
    });

    describe('computeApplicableSteps — invariants', () => {
      it('always starts with class-features', () => {
        for (const level of [1, 2, 3, 5, 10, 15, 20]) {
          const steps = computeApplicableSteps({
            targetLevel: level,
            className: 'Fighter',
            hasFreeArchetype: false,
            isCaster: false,
            isSpontaneousCaster: false,
            classProgressionHasChoiceType: false,
          });
          expect(steps[0]).toBe('class-features');
        }
      });

      it('always ends with review', () => {
        for (const level of [1, 2, 3, 5, 10, 15, 20]) {
          const steps = computeApplicableSteps({
            targetLevel: level,
            className: 'Fighter',
            hasFreeArchetype: false,
            isCaster: false,
            isSpontaneousCaster: false,
            classProgressionHasChoiceType: false,
          });
          expect(steps[steps.length - 1]).toBe('review');
        }
      });
    });
    ```

    Run — must FAIL. Commit: `test(01): RED — compute-applicable-steps spec`

    **GREEN phase — implement** in `server/src/modules/leveling/lib/compute-applicable-steps.ts`:

    ```typescript
    import type { StepKind } from './types';

    export interface ComputeStepsInput {
      targetLevel: number;
      className: string;
      hasFreeArchetype: boolean;
      isCaster: boolean;
      isSpontaneousCaster: boolean;
      classProgressionHasChoiceType: boolean;
    }

    const BOOST_LEVELS: ReadonlySet<number> = new Set([5, 10, 15, 20]);
    const SKILL_INCREASE_LEVELS: ReadonlySet<number> = new Set([3, 5, 7, 9, 11, 13, 15, 17, 19]);
    const GENERAL_FEAT_LEVELS: ReadonlySet<number> = new Set([3, 7, 11, 15, 19]);
    const ANCESTRY_FEAT_LEVELS: ReadonlySet<number> = new Set([1, 5, 9, 13, 17]);

    /**
     * Returns the ordered list of wizard step kinds for a level-up.
     * Per D-10 ordering, conditional on the inputs. Pure function.
     */
    export function computeApplicableSteps(input: ComputeStepsInput): StepKind[] {
      const { targetLevel, hasFreeArchetype, isCaster, classProgressionHasChoiceType } = input;
      const steps: StepKind[] = ['class-features'];

      if (classProgressionHasChoiceType) steps.push('class-feature-choice');
      if (BOOST_LEVELS.has(targetLevel)) steps.push('boost');
      if (SKILL_INCREASE_LEVELS.has(targetLevel)) steps.push('skill-increase');
      if (targetLevel % 2 === 0) {
        steps.push('feat-class');
        steps.push('feat-skill');
      }
      if (GENERAL_FEAT_LEVELS.has(targetLevel)) steps.push('feat-general');
      if (ANCESTRY_FEAT_LEVELS.has(targetLevel) && targetLevel !== 1) steps.push('feat-ancestry');
      if (hasFreeArchetype) steps.push('feat-archetype');
      if (isCaster) steps.push('spellcaster');

      steps.push('review');
      return steps;
    }
    ```

    Note on ancestry: PF2e ancestry feats are at levels 1 (start) and 5/9/13/17 (level-ups). Since this function is for level-ups (not character creation), L1 is excluded.

    Run `cd server && npm test -- compute-applicable-steps.spec.ts` — must PASS. Commit: `feat(01): implement compute-applicable-steps`
  </action>
  <verify>
    <automated>cd server &amp;&amp; npm test -- compute-applicable-steps.spec.ts</automated>
  </verify>
  <acceptance_criteria>
    - File `server/src/modules/leveling/lib/compute-applicable-steps.ts` exists
    - File exports `computeApplicableSteps`
    - File contains NO `@nestjs/` imports
    - File contains NO Prisma client import
    - File contains NO `: any` outside comments
    - File `server/src/modules/leveling/lib/compute-applicable-steps.spec.ts` exists with at least 11 `it(` invocations
    - `cd server && npm test -- compute-applicable-steps.spec.ts` exits 0 — all tests pass
    - All four VALIDATION.md rows 1-W1-26 through 1-W1-29 are covered
  </acceptance_criteria>
  <done>
    Spec written first; all 11+ tests pass; step ordering invariants enforced; function is pure.
  </done>
</task>

<task type="auto" tdd="false">
  <name>Task 6: Run full leveling test suite — gate before Wave 2</name>
  <files>(no file writes — verification only)</files>
  <read_first>
    - server/src/modules/leveling/lib/ (verify all 5 .ts + 5 .spec.ts files present)
  </read_first>
  <action>
    Run the full leveling test suite to confirm no test-file cross-contamination:

    ```bash
    cd server && npm test -- --testPathPattern=leveling
    ```

    Expected: at least 5 spec files run, total tests ≥ 50 (9 boost + 9 skill + 17 prereq + 5 recompute + 11 steps), 0 fail.

    If any test fails: STOP. Fix in the originating task — do NOT mark this plan complete.

    Then run a quick TS check across the lib folder:

    ```bash
    cd server && npx tsc --noEmit -p tsconfig.json
    ```

    Expected: exits 0 (no type errors anywhere in the project after these additions).
  </action>
  <verify>
    <automated>cd server &amp;&amp; npm test -- --testPathPattern=leveling</automated>
  </verify>
  <acceptance_criteria>
    - `cd server && npm test -- --testPathPattern=leveling` exits 0
    - Test summary shows at least 5 test suites passing
    - Test summary shows at least 50 tests passing
    - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 (no type errors)
    - All five files exist: apply-attribute-boost.ts, skill-increase-cap.ts, prereq-evaluator.ts, recompute-derived-stats.ts, compute-applicable-steps.ts (and types.ts)
    - All five spec files exist: apply-attribute-boost.spec.ts, skill-increase-cap.spec.ts, prereq-evaluator.spec.ts, recompute-derived-stats.spec.ts, compute-applicable-steps.spec.ts
  </acceptance_criteria>
  <done>
    Whole leveling lib suite green; TS clean; ready to be consumed by Plan 03 (seed) and Plan 04 (LevelingService).
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| (none — pure functions) | This plan introduces only pure-function modules with no I/O. No trust boundary is crossed at runtime by any code in this plan. |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-1-W1-01 | Tampering | prereq-evaluator returns `{ok: true}` for a malformed prereq string when running on the server, allowing a client to bypass intent | mitigate | UNKNOWN-aggressive design (RESEARCH.md line 550): when ANY atom is non-classifiable, the entire prereq returns `{unknown: true, raw}`. The server consumer (Plan 04 feat-filter) treats `{unknown:true}` as "show with warning" — never as "show as if met". Spec covers spellcasting/deity/age/vision/free-text returning unknown. |
| T-1-W1-02 | Tampering | recompute-derived-stats accidentally mutates the input `character` object via shared object references | mitigate | Pure function contract: spec asserts the result object does NOT contain hpCurrent (Pitfall #9). Implementation uses property reads only on input; output is a fresh object literal. Future regression caught by the not.toHaveProperty test. |
| T-1-W1-03 | Information Disclosure | Prereq evaluator includes raw character data in failure reason strings, leaking PII into logs | accept | Failure reasons name skills/feats/heritages by their canonical PF2e name, not character-specific data (no character name, no user ID). Self-hosted single-tenant; no PII risk surface. |
</threat_model>

<verification>
After all tasks complete, run:

```bash
# Full lib suite
cd server && npm test -- --testPathPattern=leveling

# Should report ≥5 suites, ≥50 tests passing, 0 failing.

# TS strict
cd server && npx tsc --noEmit -p tsconfig.json

# Should exit 0.

# Verify file presence
ls server/src/modules/leveling/lib/
# Should list: apply-attribute-boost.ts, apply-attribute-boost.spec.ts,
#              skill-increase-cap.ts, skill-increase-cap.spec.ts,
#              prereq-evaluator.ts, prereq-evaluator.spec.ts,
#              recompute-derived-stats.ts, recompute-derived-stats.spec.ts,
#              compute-applicable-steps.ts, compute-applicable-steps.spec.ts,
#              types.ts

# Verify no `any` types crept in
grep -rE ": any\b" server/src/modules/leveling/lib/ --include="*.ts" | grep -v "spec.ts" | grep -v "//.*any"
# Should produce no output
```

If any check fails, the plan is NOT done.
</verification>

<success_criteria>
- 5 production modules + types.ts created in server/src/modules/leveling/lib/
- 5 spec files created alongside; all tests passing
- TDD discipline observed: each module had spec written first (RED commit), then implementation (GREEN commit)
- Pitfall #8 (boost-cap-at-18) covered by apply-attribute-boost (Plan 01) AND recompute-derived-stats specs
- Pitfall #9 (no hpCurrent mutation) enforced by recompute-derived-stats spec via `not.toHaveProperty`
- Prereq evaluator handles all D-01 evaluable patterns and returns `{unknown:true}` for D-02 non-evaluable patterns
- Step ordering matches D-10
- Zero `: any` types in production code
- Zero NestJS or Prisma imports in any lib file
- `cd server && npm test -- --testPathPattern=leveling` exits 0 with ≥50 tests
- `cd server && npx tsc --noEmit` exits 0
</success_criteria>

<output>
After completion, create `.planning/phases/01-level-up-pf2e-regelkonform/01-02-SUMMARY.md` documenting:
- Total test count (per module + suite total)
- Any deviations from the planned regex patterns / German strings / function shapes
- Notes on prereq-evaluator parsing edge cases discovered during TDD (for future grammar tuning)
- Confirmation that types.ts exports are imported by all four downstream modules (no duplication)
- Test run output (pass/fail summary) for the audit trail
</output>
