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

61 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase plan type wave depends_on files_modified autonomous requirements tags must_haves
01-level-up-pf2e-regelkonform 02 tdd 1
01-01
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
true
LVL-02
LVL-06
LVL-09
LVL-10
LVL-01
LVL-13
LVL-14
pure-functions
jest
tdd
level-up
prereq-evaluator
recompute
truths artifacts key_links
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
path provides exports
server/src/modules/leveling/lib/types.ts Shared types — Proficiency, EvalResult, CharacterContext, DerivedStats, StepKind, AbilityAbbreviation
Proficiency
EvalResult
CharacterContext
DerivedStats
StepKind
AbilityAbbreviation
path provides exports
server/src/modules/leveling/lib/skill-increase-cap.ts Pure function canIncreaseSkill(currentRank, characterLevel)
canIncreaseSkill
SKILL_INCREASE_LEVELS
path provides exports
server/src/modules/leveling/lib/prereq-evaluator.ts Pure function evaluatePrereq(prereqString, ctx) returning discriminated union
evaluatePrereq
parsePrereq
path provides exports
server/src/modules/leveling/lib/recompute-derived-stats.ts Pure function recomputeDerivedStats(character, choices, progression) returning DerivedStats
recomputeDerivedStats
path provides exports
server/src/modules/leveling/lib/compute-applicable-steps.ts Pure function computeApplicableSteps(targetLevel, className, hasFreeArchetype, isCaster) returning StepKind[]
computeApplicableSteps
from to via pattern
prereq-evaluator.ts CharacterContext type import from ./types from ['"]./types['"]
from to via pattern
recompute-derived-stats.ts applyAttributeBoost (Plan 01) import from ['"]./apply-attribute-boost['"]
from to via pattern
All four modules Their .spec.ts siblings Jest testRegex .*.spec.ts$
Build the five pure-function modules that hold all the math for the Level-Up system: `skill-increase-cap`, `prereq-evaluator`, `recompute-derived-stats`, `compute-applicable-steps`, plus a shared `types.ts`. Every module is fully unit-tested using strict TDD (write failing test → write minimal implementation → green). These modules are the source-of-truth for PF2e rules math; they have NO NestJS imports, NO Prisma, NO I/O, and they will be called from the LevelingService (Plan 04) and from the React wizard's preview path.

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

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

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 ```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 pattern (partial analog only — server module is fuller):
function checkSkillPrerequisites(
  prerequisites: string | undefined,
  skills: CharacterSkill[],
): { met: boolean; unmetReason?: string } { /* skill rank regex matching */ }
function proficiencyFromValue(value: number): Proficiency {
  switch (value) {
    case 8: return Proficiency.LEGENDARY;
    case 6: return Proficiency.MASTER;
    case 4: return Proficiency.EXPERT;
    case 2: return Proficiency.TRAINED;
    default: return Proficiency.UNTRAINED;
  }
}
skill-increase-cap — PF2e Skill Increase Rule server/src/modules/leveling/lib/skill-increase-cap.ts, server/src/modules/leveling/lib/skill-increase-cap.spec.ts PF2e rule: skill-increase steps occur at level 3 and every 2 levels thereafter. Cap rule: - TRAINED → EXPERT requires character level >= 3 - EXPERT → MASTER requires character level >= 7 - MASTER → LEGENDARY requires character level >= 15 - UNTRAINED → TRAINED is always allowed when a skill-increase step occurs Test cases (from VALIDATION.md rows 1-W1-06 to 1-W1-11): - canIncreaseSkill('TRAINED', 2) === false - canIncreaseSkill('TRAINED', 3) === true - canIncreaseSkill('EXPERT', 6) === false - canIncreaseSkill('EXPERT', 7) === true - canIncreaseSkill('MASTER', 14) === false - canIncreaseSkill('MASTER', 15) === true - canIncreaseSkill('LEGENDARY', 20) === false (already maxed) - canIncreaseSkill('UNTRAINED', 1) === true (untrained → trained always allowed at any skill-increase level) Also export `SKILL_INCREASE_LEVELS: readonly number[] = [3, 5, 7, 9, 11, 13, 15, 17, 19]` (per PF2e CRB). prereq-evaluator — DSL parser + evaluator + formatter (D-01..D-04) server/src/modules/leveling/lib/prereq-evaluator.ts, server/src/modules/leveling/lib/prereq-evaluator.spec.ts Three-layer module: parser produces an AST, evaluator walks AST against CharacterContext, formatter produces a German user-facing reason for failures. Discriminated union return: `{ok:true} | {ok:false, reason:string} | {unknown:true, raw:string}`. Evaluable patterns (D-01): - Skill rank: `Trained in Athletics`, `Expert in Acrobatics`, `Master in Stealth`, `Legendary in Religion` — case-insensitive - Disjunctive: `Trained in Arcana, Trained in Nature, or Trained in Religion` — comma+OR-list (any one match → ok:true) - Conjunctive: `Trained in Deception; Trained in Stealth` — semicolon = AND (all must match → ok:true) - Bare feat-name: `Power Attack` — checks ctx.feats Set - Heritage ref: `Unbreakable Goblin heritage` — checks ctx.heritageId/heritageName - Ancestry ref: `Human`, `Elf`, etc. (one of D-16 16 ancestries) — checks ctx.ancestryName - Class ref: `Fighter`, `Wizard`, etc. — checks ctx.className - Level ref: `level 5` — checks ctx.level Non-evaluable (D-02 — return `{unknown:true, raw}`): - Deity refs: `worshipper of Droskar`, `worship of`, `follower of` - Spellcasting-tradition refs: `spellcasting class feature`, `divine spells`, `spellcaster` - Age/ethnicity: `at least 100 years old`, `Tian-Dan ethnicity` - Vision/sense traits: `low-light vision`, `darkvision` - Free-text: anything that doesn't match a known pattern Test cases (from VALIDATION.md rows 1-W1-12 to 1-W1-21): - evaluatePrereq("Trained in Athletics", { skills: { Athletics: 'TRAINED' }, ... }) → {ok:true} - Same prereq with skills.Athletics = 'UNTRAINED' → {ok:false, reason: "Du benötigst mindestens 'Trained' in Acrobatics" or German equivalent — wording at planner discretion but must be German} - "Trained in Arcana, Trained in Nature, or Trained in Religion" with skills.Arcana = 'TRAINED' → {ok:true} - "Trained in Deception; Trained in Stealth" with skills.Deception = 'TRAINED', skills.Stealth = 'UNTRAINED' → {ok:false} - "Power Attack" with feats Set containing 'Power Attack' → {ok:true} - "Unbreakable Goblin heritage" with ctx.heritageName = 'Unbreakable Goblin' → {ok:true} - "Fighter" with ctx.className = 'Fighter' → {ok:true} - "spellcasting class feature" → {unknown:true, raw: "spellcasting class feature"} - "worshipper of Droskar" → {unknown:true, raw: "worshipper of Droskar"} - evaluatePrereq(null, ctx) → {ok:true} (no prereq is always met) - evaluatePrereq('', ctx) → {ok:true} Implementation: Use regex-based parser per RESEARCH.md §Pattern 4 grammar (lines 519-531). UNKNOWN-aggressive: any non-classifiable atom marks the whole clause unknown. recompute-derived-stats — pure recompute pipeline (Pitfall #9) server/src/modules/leveling/lib/recompute-derived-stats.ts, server/src/modules/leveling/lib/recompute-derived-stats.spec.ts Pure function `recomputeDerivedStats(character, choices, progression) → DerivedStats`. No DB writes; no side effects. Inputs: - `character`: Character snapshot with current ancestryHP, classHP, abilities (STR/DEX/CON/INT/WIS/CHA), skills (Map), feats (Set), level, hpCurrent - `choices`: WizardState.choices subset (boostTargets, skillIncrease, classFeatureChoices) - `progression`: ClassProgression row for (className, targetLevel) — proficiencyChanges Json, spellSlotIncrement, etc. Outputs (DerivedStats type): - level: number (the new targetLevel) - hpMax: number (= ancestryHP + (classHP + conMod) × newLevel + bonusPerLevel × newLevel) - ac: number (= 10 + clamp(dexMod, dexCap) + armor.ac + proficiencyBonus(armor)) - classDc: number (= 10 + keyAbility-mod + proficiencyBonus(class)) - perception: number (= wisMod + proficiencyBonus(perception)) - fortitude: number (= conMod + proficiencyBonus(fort)) - reflex: number (= dexMod + proficiencyBonus(ref)) - will: number (= wisMod + proficiencyBonus(will)) Test cases (from VALIDATION.md rows 1-W1-22 to 1-W1-25): - Given character L4 → L5 with CON 16 (mod +3), classHP 8, ancestryHP 8: new hpMax = 8 + (8+3)×5 = 63 - Boost-cap-at-18 honored: if boostTargets includes CON and current CON = 18, new CON = 19 (not 20) → conMod 4 → hpMax uses 4 - proficiencyChanges from ClassProgression are applied: if progression.proficiencyChanges = {fortitude: "EXPERT"} and old fort proficiency was TRAINED, new will use EXPERT bonus - Output object MUST NOT contain `hpCurrent` field (Pitfall #9 — verify with `expect(result).not.toHaveProperty('hpCurrent')`) - Output object MUST NOT contain `hpTemp` field Helper functions inside this module (private or exported as needed): - `proficiencyBonus(rank: Proficiency, level: number): number` — PF2e: untrained 0, trained 2+L, expert 4+L, master 6+L, legendary 8+L - `abilityModifier(score: number): number` — Math.floor((score - 10) / 2) compute-applicable-steps — Wizard step list per character/level (LVL-01, LVL-13, LVL-14) server/src/modules/leveling/lib/compute-applicable-steps.ts, server/src/modules/leveling/lib/compute-applicable-steps.spec.ts Pure function `computeApplicableSteps(targetLevel, className, hasFreeArchetype, isCaster, isSpontaneousCaster, classProgressionHasChoiceType): StepKind[]` returning the ordered list of wizard steps for this level-up. Step ordering (per D-10): class-features → class-feature-choice (if choiceType present) → boost (if L5/10/15/20) → skill-increase (if L3+) → feat-class (if even level) → feat-skill (if even level) → feat-general (if L3/7/11/15/19) → feat-ancestry (if L5/9/13/17) → feat-archetype (if hasFA) → spellcaster (if isCaster) → review. StepKind type (per RESEARCH.md §Pattern 1 line 357): `'class-features' | 'class-feature-choice' | 'boost' | 'skill-increase' | 'feat-class' | 'feat-skill' | 'feat-general' | 'feat-ancestry' | 'feat-archetype' | 'spellcaster' | 'review'` Test cases (from VALIDATION.md rows 1-W1-26 to 1-W1-29): - computeApplicableSteps(5, 'Fighter', false, false, false, false) returns ['class-features', 'boost', 'skill-increase', 'feat-class', 'feat-skill', 'feat-ancestry', 'review'] (no FA, no spellcaster, no choiceType) - computeApplicableSteps(4, 'Fighter', false, false, false, false) does NOT contain 'boost' (4 is not a boost level) - computeApplicableSteps(5, 'Fighter', true, false, false, false) contains 'feat-archetype' - computeApplicableSteps(5, 'Wizard', false, true, false, false) contains 'spellcaster' - computeApplicableSteps(3, 'Bard', false, true, true, false) contains 'spellcaster' AND 'feat-general' AND 'skill-increase' - computeApplicableSteps(2, 'Fighter', false, false, false, false) contains 'feat-class' AND 'feat-skill' but NOT 'boost' AND NOT 'skill-increase' (skill-increase starts at L3) - computeApplicableSteps(1, 'Cleric', false, true, false, true) contains 'class-feature-choice' (Cleric L1 doctrine) - Result ALWAYS ends with 'review' as the last step - Result ALWAYS starts with 'class-features' (auto-summary, even if empty grants for that level) Task 1: Shared types module (types.ts) server/src/modules/leveling/lib/types.ts - server/src/modules/leveling/lib/apply-attribute-boost.ts (existing exports — extend the type vocabulary) - server/src/generated/prisma/index.d.ts (search for `Proficiency` enum — must align) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 369-401 — WizardState shape; lines 537-548 — EvalResult; lines 562-573 — DerivedStats) No tests for types-only file. Types file MUST contain (verifiable via grep): - `export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY'` - `export type EvalResult = { ok: true } | { ok: false; reason: string } | { unknown: true; raw: string }` - `export type StepKind = 'class-features' | 'class-feature-choice' | 'boost' | 'skill-increase' | 'feat-class' | 'feat-skill' | 'feat-general' | 'feat-ancestry' | 'feat-archetype' | 'spellcaster' | 'review'` - `export interface CharacterContext { level: number; className: string; ancestryName: string; heritageName?: string; abilities: Record; skills: Record; feats: Set }` - `export interface DerivedStats { level: number; hpMax: number; ac: number; classDc: number; perception: number; fortitude: number; reflex: number; will: number }` Create `server/src/modules/leveling/lib/types.ts` with the following exact content:
```typescript
/**
 * Shared types for the Level-Up pure-function library.
 * No runtime dependencies — types only.
 */
import type { AbilityAbbreviation } from './apply-attribute-boost';

export type { AbilityAbbreviation };

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

/** Numeric proficiency bonus per rank, for use in proficiencyBonus(rank, level) calculation. */
export const PROFICIENCY_BASE_BONUS: Record<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[];
}
```
cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -v "^$" | head -20 || echo "tsc clean" - File `server/src/modules/leveling/lib/types.ts` exists - File contains `export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY'` - File contains `export type EvalResult =` - File contains `export type StepKind =` - File contains `export interface CharacterContext` - File contains `export interface DerivedStats` - File contains `export interface ClassProgressionRow` - File contains `export interface WizardChoices` - File contains NO `: any` outside comments - File contains NO `@nestjs/` imports - File contains NO Prisma client import (only `import type` from sibling for AbilityAbbreviation) - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 (no type errors) types.ts exports the full vocabulary used by the next four modules. No production code yet — no test needed because the file has no behavior beyond type aliases. Task 2: skill-increase-cap module (RED → GREEN → REFACTOR) server/src/modules/leveling/lib/skill-increase-cap.ts, server/src/modules/leveling/lib/skill-increase-cap.spec.ts - server/src/modules/leveling/lib/types.ts (Proficiency type) - server/src/modules/leveling/lib/apply-attribute-boost.ts (style template — strict TS, named exports, JSDoc) - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W1-06 to 1-W1-11) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 967-979 — Phase Requirements → Test Map for LVL-06) - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (LVL-06: T→E ab L3, E→M ab L7, M→L ab L15) See `` block above. RED: write spec first with all 8 test cases. GREEN: implement minimal `canIncreaseSkill` to pass all tests. REFACTOR: extract magic numbers to a SKILL_RANK_LEVEL_REQUIREMENTS constant. **RED phase — write the failing spec FIRST:**
Create `server/src/modules/leveling/lib/skill-increase-cap.spec.ts`:

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

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

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

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

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

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

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

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

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

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

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

**GREEN phase — write minimal implementation:**

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

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

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

/**
 * Minimum character level required to advance a skill from `currentRank`.
 * UNTRAINED → TRAINED is unrestricted (any level >= 1 with a skill-increase step).
 */
const SKILL_RANK_LEVEL_REQUIREMENTS: Record<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`
cd server && npm test -- skill-increase-cap.spec.ts - File `server/src/modules/leveling/lib/skill-increase-cap.ts` exists - File exports `canIncreaseSkill` and `SKILL_INCREASE_LEVELS` - File contains NO `@nestjs/` imports - File contains NO `: any` - File `server/src/modules/leveling/lib/skill-increase-cap.spec.ts` exists with at least 9 `it(` invocations - `cd server && npm test -- skill-increase-cap.spec.ts` exits 0 with `9 passed` (or `Tests: 9 passed`) - All test cases from VALIDATION.md rows 1-W1-06 to 1-W1-11 are present in the spec Spec written first and observed failing; minimal implementation green; all 9 tests pass. Task 3: prereq-evaluator module (RED → GREEN → REFACTOR) server/src/modules/leveling/lib/prereq-evaluator.ts, server/src/modules/leveling/lib/prereq-evaluator.spec.ts - server/src/modules/leveling/lib/types.ts (CharacterContext, EvalResult, Proficiency) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 514-548 — Pattern 4 grammar; lines 727-748 — Code Examples §2 real prereq strings) - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W1-12 to 1-W1-21 — exact assertions) - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-01..D-04 — evaluable patterns and warning behavior) - client/src/features/characters/components/add-feat-modal.tsx (lines 27-74 — partial client-side analog `checkSkillPrerequisites`) See `` block above for full behavior. RED: spec all 14+ test cases. GREEN: implement parser + evaluator + formatter using regex patterns from grammar in RESEARCH.md. **RED phase — write the failing spec FIRST:**
Create `server/src/modules/leveling/lib/prereq-evaluator.spec.ts`:

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

function makeCtx(overrides: Partial<CharacterContext> = {}): 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.
cd server && npm test -- prereq-evaluator.spec.ts - File `server/src/modules/leveling/lib/prereq-evaluator.ts` exists - File exports `evaluatePrereq` - File contains NO `@nestjs/` imports - File contains NO Prisma client import - File contains NO `: any` outside comments - File `server/src/modules/leveling/lib/prereq-evaluator.spec.ts` exists with at least 17 `it(` invocations - `cd server && npm test -- prereq-evaluator.spec.ts` exits 0 — all tests pass - The spec covers EVERY VALIDATION.md row from 1-W1-12 to 1-W1-21 - Failure reasons returned by evaluator are in German (verifiable: spec asserts a German-language regex match for at least one failure case) Spec written first; all 17+ tests pass; UNKNOWN-aggressive behavior verified for D-02 patterns; German reason strings on failures. Task 4: recompute-derived-stats module (RED → GREEN, Pitfall #9 enforced) server/src/modules/leveling/lib/recompute-derived-stats.ts, server/src/modules/leveling/lib/recompute-derived-stats.spec.ts - server/src/modules/leveling/lib/types.ts (CharacterContext, DerivedStats, ClassProgressionRow, WizardChoices, PROFICIENCY_BASE_BONUS) - server/src/modules/leveling/lib/apply-attribute-boost.ts (applyAttributeBoost — must use this, not re-implement) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 552-575 — Pattern 5 inputs/outputs; lines 624-633 — Pitfall 2 hpCurrent rule) - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W1-22 to 1-W1-25) - server/src/modules/characters/pathbuilder-import.service.ts (lines 42-62 — proficiencyFromValue helper for reference; do not import — re-implement here) See `` block above. Critical Pitfall #9 invariant: output object MUST NOT contain `hpCurrent` or `hpTemp`. Spec has dedicated assertion for this. **RED phase — write spec FIRST:**
Create `server/src/modules/leveling/lib/recompute-derived-stats.spec.ts`:

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

function baseCharacter(overrides: Partial<CharacterContext> = {}): 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)`
cd server && npm test -- recompute-derived-stats.spec.ts - File `server/src/modules/leveling/lib/recompute-derived-stats.ts` exists - File exports `recomputeDerivedStats` - File contains the literal string `applyAttributeBoost` (proves it imports from the existing module — no math duplication) - File contains NO `@nestjs/` imports - File contains NO Prisma client import - File contains NO `: any` outside comments - File contains NO `hpCurrent` or `hpTemp` literal strings (defensive: cannot accidentally output them) - File `server/src/modules/leveling/lib/recompute-derived-stats.spec.ts` exists with at least 5 `it(` invocations - Spec contains an `expect(result).not.toHaveProperty('hpCurrent')` assertion (Pitfall #9 enforcement) - `cd server && npm test -- recompute-derived-stats.spec.ts` exits 0 — all tests pass Spec written first; recompute is pure; Pitfall #8 (boost cap) and Pitfall #9 (no hpCurrent mutation) both enforced by tests. Task 5: compute-applicable-steps module (RED → GREEN) server/src/modules/leveling/lib/compute-applicable-steps.ts, server/src/modules/leveling/lib/compute-applicable-steps.spec.ts - server/src/modules/leveling/lib/types.ts (StepKind type) - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W1-26 to 1-W1-29) - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-10 — step ordering; D-13 — FA toggle, D-18 — spellcaster) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 696-722 — Boost step / spellcaster / FA-archetype analysis) See `` block above. Step ordering is fixed per D-10. Function output is deterministic given inputs. **RED phase — write spec FIRST:**
Create `server/src/modules/leveling/lib/compute-applicable-steps.spec.ts`:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

const BOOST_LEVELS: ReadonlySet<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`
cd server && npm test -- compute-applicable-steps.spec.ts - File `server/src/modules/leveling/lib/compute-applicable-steps.ts` exists - File exports `computeApplicableSteps` - File contains NO `@nestjs/` imports - File contains NO Prisma client import - File contains NO `: any` outside comments - File `server/src/modules/leveling/lib/compute-applicable-steps.spec.ts` exists with at least 11 `it(` invocations - `cd server && npm test -- compute-applicable-steps.spec.ts` exits 0 — all tests pass - All four VALIDATION.md rows 1-W1-26 through 1-W1-29 are covered Spec written first; all 11+ tests pass; step ordering invariants enforced; function is pure. Task 6: Run full leveling test suite — gate before Wave 2 (no file writes — verification only) - server/src/modules/leveling/lib/ (verify all 5 .ts + 5 .spec.ts files present) Run the full leveling test suite to confirm no test-file cross-contamination:
```bash
cd server && npm test -- --testPathPattern=leveling
```

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

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

Then run a quick TS check across the lib folder:

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

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

<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>
After all tasks complete, run:
# 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.

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