--- phase: 01-level-up-pf2e-regelkonform plan: 02 type: tdd wave: 1 depends_on: ["01-01"] files_modified: - server/src/modules/leveling/lib/skill-increase-cap.ts - server/src/modules/leveling/lib/skill-increase-cap.spec.ts - server/src/modules/leveling/lib/prereq-evaluator.ts - server/src/modules/leveling/lib/prereq-evaluator.spec.ts - server/src/modules/leveling/lib/recompute-derived-stats.ts - server/src/modules/leveling/lib/recompute-derived-stats.spec.ts - server/src/modules/leveling/lib/compute-applicable-steps.ts - server/src/modules/leveling/lib/compute-applicable-steps.spec.ts - server/src/modules/leveling/lib/types.ts autonomous: true requirements: [LVL-02, LVL-06, LVL-09, LVL-10, LVL-01, LVL-13, LVL-14] tags: [pure-functions, jest, tdd, level-up, prereq-evaluator, recompute] must_haves: truths: - "skill-increase-cap correctly enforces PF2e rule: TRAINED→EXPERT only at L3+, EXPERT→MASTER only at L7+, MASTER→LEGENDARY only at L15+" - "prereq-evaluator returns {ok:true} for met prereqs, {ok:false, reason} for evaluable+failed, {unknown:true, raw} for non-evaluable patterns (Deity, Spellcasting-Tradition, etc. — D-02)" - "prereq-evaluator handles all D-01 patterns: skill-rank, feat-possession, level, class, ancestry, heritage" - "recompute-derived-stats produces correct hpMax, AC, classDC, perception, fortitude, reflex, will using boost-cap-at-18 (Pitfall #8) and never mutates hpCurrent (Pitfall #9)" - "compute-applicable-steps returns the right step list per (targetLevel, class, hasFA, isCaster) — boost only at L5/10/15/20, FA-step only when hasFA, spellcaster-step only for casters, etc." - "All four pure-function modules have NO NestJS decorators, NO Prisma imports, NO `any` types" - "Every behavior in 01-VALIDATION.md rows 1-W1-06 through 1-W1-29 has a passing test" artifacts: - path: "server/src/modules/leveling/lib/types.ts" provides: "Shared types — Proficiency, EvalResult, CharacterContext, DerivedStats, StepKind, AbilityAbbreviation" exports: ["Proficiency", "EvalResult", "CharacterContext", "DerivedStats", "StepKind", "AbilityAbbreviation"] - path: "server/src/modules/leveling/lib/skill-increase-cap.ts" provides: "Pure function canIncreaseSkill(currentRank, characterLevel)" exports: ["canIncreaseSkill", "SKILL_INCREASE_LEVELS"] - path: "server/src/modules/leveling/lib/prereq-evaluator.ts" provides: "Pure function evaluatePrereq(prereqString, ctx) returning discriminated union" exports: ["evaluatePrereq", "parsePrereq"] - path: "server/src/modules/leveling/lib/recompute-derived-stats.ts" provides: "Pure function recomputeDerivedStats(character, choices, progression) returning DerivedStats" exports: ["recomputeDerivedStats"] - path: "server/src/modules/leveling/lib/compute-applicable-steps.ts" provides: "Pure function computeApplicableSteps(targetLevel, className, hasFreeArchetype, isCaster) returning StepKind[]" exports: ["computeApplicableSteps"] key_links: - from: "prereq-evaluator.ts" to: "CharacterContext type" via: "import from ./types" pattern: "from ['\"]\\./types['\"]" - from: "recompute-derived-stats.ts" to: "applyAttributeBoost (Plan 01)" via: "import" pattern: "from ['\"]\\./apply-attribute-boost['\"]" - from: "All four modules" to: "Their .spec.ts siblings" via: "Jest testRegex" pattern: ".*\\.spec\\.ts$" --- Build the five pure-function modules that hold all the math for the Level-Up system: `skill-increase-cap`, `prereq-evaluator`, `recompute-derived-stats`, `compute-applicable-steps`, plus a shared `types.ts`. Every module is fully unit-tested using strict TDD (write failing test → write minimal implementation → green). These modules are the source-of-truth for PF2e rules math; they have NO NestJS imports, NO Prisma, NO I/O, and they will be called from the LevelingService (Plan 04) and from the React wizard's preview path. Purpose: This phase establishes the test discipline (per ROADMAP First-Phase Note) and isolates the bug-prone math (Pitfall #8 boost-cap, Pitfall #9 hp-current, prereq edge cases) behind a fully-tested boundary. Plan 04's integration tests can then trust this math and only test orchestration (transactions, broadcast, access control). Output: 5 production modules + 5 spec files, all tests passing under `cd server && npm test -- --testPathPattern=leveling`. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/REQUIREMENTS.md @.planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md @.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md @.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md @.planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md @.planning/phases/01-level-up-pf2e-regelkonform/01-01-SUMMARY.md @server/src/modules/leveling/lib/apply-attribute-boost.ts @server/src/modules/characters/pathbuilder-import.service.ts @client/src/features/characters/components/add-feat-modal.tsx ```typescript // From server/src/modules/leveling/lib/apply-attribute-boost.ts export type AbilityScore = number; export type AbilityAbbreviation = 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA'; export function applyAttributeBoost(currentScore: AbilityScore): AbilityScore; export function isValidBoostSet(targets: readonly string[]): boolean; ``` ```typescript // Existing client-side pattern (partial analog only — server module is fuller): function checkSkillPrerequisites( prerequisites: string | undefined, skills: CharacterSkill[], ): { met: boolean; unmetReason?: string } { /* skill rank regex matching */ } ``` ```typescript function proficiencyFromValue(value: number): Proficiency { switch (value) { case 8: return Proficiency.LEGENDARY; case 6: return Proficiency.MASTER; case 4: return Proficiency.EXPERT; case 2: return Proficiency.TRAINED; default: return Proficiency.UNTRAINED; } } ``` skill-increase-cap — PF2e Skill Increase Rule server/src/modules/leveling/lib/skill-increase-cap.ts, server/src/modules/leveling/lib/skill-increase-cap.spec.ts PF2e rule: skill-increase steps occur at level 3 and every 2 levels thereafter. Cap rule: - TRAINED → EXPERT requires character level >= 3 - EXPERT → MASTER requires character level >= 7 - MASTER → LEGENDARY requires character level >= 15 - UNTRAINED → TRAINED is always allowed when a skill-increase step occurs Test cases (from VALIDATION.md rows 1-W1-06 to 1-W1-11): - canIncreaseSkill('TRAINED', 2) === false - canIncreaseSkill('TRAINED', 3) === true - canIncreaseSkill('EXPERT', 6) === false - canIncreaseSkill('EXPERT', 7) === true - canIncreaseSkill('MASTER', 14) === false - canIncreaseSkill('MASTER', 15) === true - canIncreaseSkill('LEGENDARY', 20) === false (already maxed) - canIncreaseSkill('UNTRAINED', 1) === true (untrained → trained always allowed at any skill-increase level) Also export `SKILL_INCREASE_LEVELS: readonly number[] = [3, 5, 7, 9, 11, 13, 15, 17, 19]` (per PF2e CRB). prereq-evaluator — DSL parser + evaluator + formatter (D-01..D-04) server/src/modules/leveling/lib/prereq-evaluator.ts, server/src/modules/leveling/lib/prereq-evaluator.spec.ts Three-layer module: parser produces an AST, evaluator walks AST against CharacterContext, formatter produces a German user-facing reason for failures. Discriminated union return: `{ok:true} | {ok:false, reason:string} | {unknown:true, raw:string}`. Evaluable patterns (D-01): - Skill rank: `Trained in Athletics`, `Expert in Acrobatics`, `Master in Stealth`, `Legendary in Religion` — case-insensitive - Disjunctive: `Trained in Arcana, Trained in Nature, or Trained in Religion` — comma+OR-list (any one match → ok:true) - Conjunctive: `Trained in Deception; Trained in Stealth` — semicolon = AND (all must match → ok:true) - Bare feat-name: `Power Attack` — checks ctx.feats Set - Heritage ref: `Unbreakable Goblin heritage` — checks ctx.heritageId/heritageName - Ancestry ref: `Human`, `Elf`, etc. (one of D-16 16 ancestries) — checks ctx.ancestryName - Class ref: `Fighter`, `Wizard`, etc. — checks ctx.className - Level ref: `level 5` — checks ctx.level Non-evaluable (D-02 — return `{unknown:true, raw}`): - Deity refs: `worshipper of Droskar`, `worship of`, `follower of` - Spellcasting-tradition refs: `spellcasting class feature`, `divine spells`, `spellcaster` - Age/ethnicity: `at least 100 years old`, `Tian-Dan ethnicity` - Vision/sense traits: `low-light vision`, `darkvision` - Free-text: anything that doesn't match a known pattern Test cases (from VALIDATION.md rows 1-W1-12 to 1-W1-21): - evaluatePrereq("Trained in Athletics", { skills: { Athletics: 'TRAINED' }, ... }) → {ok:true} - Same prereq with skills.Athletics = 'UNTRAINED' → {ok:false, reason: "Du benötigst mindestens 'Trained' in Acrobatics" or German equivalent — wording at planner discretion but must be German} - "Trained in Arcana, Trained in Nature, or Trained in Religion" with skills.Arcana = 'TRAINED' → {ok:true} - "Trained in Deception; Trained in Stealth" with skills.Deception = 'TRAINED', skills.Stealth = 'UNTRAINED' → {ok:false} - "Power Attack" with feats Set containing 'Power Attack' → {ok:true} - "Unbreakable Goblin heritage" with ctx.heritageName = 'Unbreakable Goblin' → {ok:true} - "Fighter" with ctx.className = 'Fighter' → {ok:true} - "spellcasting class feature" → {unknown:true, raw: "spellcasting class feature"} - "worshipper of Droskar" → {unknown:true, raw: "worshipper of Droskar"} - evaluatePrereq(null, ctx) → {ok:true} (no prereq is always met) - evaluatePrereq('', ctx) → {ok:true} Implementation: Use regex-based parser per RESEARCH.md §Pattern 4 grammar (lines 519-531). UNKNOWN-aggressive: any non-classifiable atom marks the whole clause unknown. recompute-derived-stats — pure recompute pipeline (Pitfall #9) server/src/modules/leveling/lib/recompute-derived-stats.ts, server/src/modules/leveling/lib/recompute-derived-stats.spec.ts Pure function `recomputeDerivedStats(character, choices, progression) → DerivedStats`. No DB writes; no side effects. Inputs: - `character`: Character snapshot with current ancestryHP, classHP, abilities (STR/DEX/CON/INT/WIS/CHA), skills (Map), feats (Set), level, hpCurrent - `choices`: WizardState.choices subset (boostTargets, skillIncrease, classFeatureChoices) - `progression`: ClassProgression row for (className, targetLevel) — proficiencyChanges Json, spellSlotIncrement, etc. Outputs (DerivedStats type): - level: number (the new targetLevel) - hpMax: number (= ancestryHP + (classHP + conMod) × newLevel + bonusPerLevel × newLevel) - ac: number (= 10 + clamp(dexMod, dexCap) + armor.ac + proficiencyBonus(armor)) - classDc: number (= 10 + keyAbility-mod + proficiencyBonus(class)) - perception: number (= wisMod + proficiencyBonus(perception)) - fortitude: number (= conMod + proficiencyBonus(fort)) - reflex: number (= dexMod + proficiencyBonus(ref)) - will: number (= wisMod + proficiencyBonus(will)) Test cases (from VALIDATION.md rows 1-W1-22 to 1-W1-25): - Given character L4 → L5 with CON 16 (mod +3), classHP 8, ancestryHP 8: new hpMax = 8 + (8+3)×5 = 63 - Boost-cap-at-18 honored: if boostTargets includes CON and current CON = 18, new CON = 19 (not 20) → conMod 4 → hpMax uses 4 - proficiencyChanges from ClassProgression are applied: if progression.proficiencyChanges = {fortitude: "EXPERT"} and old fort proficiency was TRAINED, new will use EXPERT bonus - Output object MUST NOT contain `hpCurrent` field (Pitfall #9 — verify with `expect(result).not.toHaveProperty('hpCurrent')`) - Output object MUST NOT contain `hpTemp` field Helper functions inside this module (private or exported as needed): - `proficiencyBonus(rank: Proficiency, level: number): number` — PF2e: untrained 0, trained 2+L, expert 4+L, master 6+L, legendary 8+L - `abilityModifier(score: number): number` — Math.floor((score - 10) / 2) compute-applicable-steps — Wizard step list per character/level (LVL-01, LVL-13, LVL-14) server/src/modules/leveling/lib/compute-applicable-steps.ts, server/src/modules/leveling/lib/compute-applicable-steps.spec.ts Pure function `computeApplicableSteps(targetLevel, className, hasFreeArchetype, isCaster, isSpontaneousCaster, classProgressionHasChoiceType): StepKind[]` returning the ordered list of wizard steps for this level-up. Step ordering (per D-10): class-features → class-feature-choice (if choiceType present) → boost (if L5/10/15/20) → skill-increase (if L3+) → feat-class (if even level) → feat-skill (if even level) → feat-general (if L3/7/11/15/19) → feat-ancestry (if L5/9/13/17) → feat-archetype (if hasFA) → spellcaster (if isCaster) → review. StepKind type (per RESEARCH.md §Pattern 1 line 357): `'class-features' | 'class-feature-choice' | 'boost' | 'skill-increase' | 'feat-class' | 'feat-skill' | 'feat-general' | 'feat-ancestry' | 'feat-archetype' | 'spellcaster' | 'review'` Test cases (from VALIDATION.md rows 1-W1-26 to 1-W1-29): - computeApplicableSteps(5, 'Fighter', false, false, false, false) returns ['class-features', 'boost', 'skill-increase', 'feat-class', 'feat-skill', 'feat-ancestry', 'review'] (no FA, no spellcaster, no choiceType) - computeApplicableSteps(4, 'Fighter', false, false, false, false) does NOT contain 'boost' (4 is not a boost level) - computeApplicableSteps(5, 'Fighter', true, false, false, false) contains 'feat-archetype' - computeApplicableSteps(5, 'Wizard', false, true, false, false) contains 'spellcaster' - computeApplicableSteps(3, 'Bard', false, true, true, false) contains 'spellcaster' AND 'feat-general' AND 'skill-increase' - computeApplicableSteps(2, 'Fighter', false, false, false, false) contains 'feat-class' AND 'feat-skill' but NOT 'boost' AND NOT 'skill-increase' (skill-increase starts at L3) - computeApplicableSteps(1, 'Cleric', false, true, false, true) contains 'class-feature-choice' (Cleric L1 doctrine) - Result ALWAYS ends with 'review' as the last step - Result ALWAYS starts with 'class-features' (auto-summary, even if empty grants for that level) Task 1: Shared types module (types.ts) server/src/modules/leveling/lib/types.ts - server/src/modules/leveling/lib/apply-attribute-boost.ts (existing exports — extend the type vocabulary) - server/src/generated/prisma/index.d.ts (search for `Proficiency` enum — must align) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 369-401 — WizardState shape; lines 537-548 — EvalResult; lines 562-573 — DerivedStats) No tests for types-only file. Types file MUST contain (verifiable via grep): - `export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY'` - `export type EvalResult = { ok: true } | { ok: false; reason: string } | { unknown: true; raw: string }` - `export type StepKind = 'class-features' | 'class-feature-choice' | 'boost' | 'skill-increase' | 'feat-class' | 'feat-skill' | 'feat-general' | 'feat-ancestry' | 'feat-archetype' | 'spellcaster' | 'review'` - `export interface CharacterContext { level: number; className: string; ancestryName: string; heritageName?: string; abilities: Record; skills: Record; feats: Set }` - `export interface DerivedStats { level: number; hpMax: number; ac: number; classDc: number; perception: number; fortitude: number; reflex: number; will: number }` Create `server/src/modules/leveling/lib/types.ts` with the following exact content: ```typescript /** * Shared types for the Level-Up pure-function library. * No runtime dependencies — types only. */ import type { AbilityAbbreviation } from './apply-attribute-boost'; export type { AbilityAbbreviation }; /** PF2e proficiency ranks (mirrors Prisma `Proficiency` enum). */ export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY'; /** Numeric proficiency bonus per rank, for use in proficiencyBonus(rank, level) calculation. */ export const PROFICIENCY_BASE_BONUS: Record = { UNTRAINED: 0, TRAINED: 2, EXPERT: 4, MASTER: 6, LEGENDARY: 8, }; /** Discriminated union for prereq evaluation result. */ export type EvalResult = | { ok: true } | { ok: false; reason: string } | { unknown: true; raw: string }; /** Ordered union of wizard step kinds (UI-SPEC + RESEARCH §Pattern 1). */ export type StepKind = | 'class-features' | 'class-feature-choice' | 'boost' | 'skill-increase' | 'feat-class' | 'feat-skill' | 'feat-general' | 'feat-ancestry' | 'feat-archetype' | 'spellcaster' | 'review'; /** Snapshot a character's mechanical state for prereq evaluation and recompute. */ export interface CharacterContext { level: number; className: string; ancestryName: string; heritageName?: string; abilities: Record; skills: Record; feats: Set; } /** Output of recomputeDerivedStats — never includes hpCurrent (Pitfall #9). */ export interface DerivedStats { level: number; hpMax: number; ac: number; classDc: number; perception: number; fortitude: number; reflex: number; will: number; } /** ClassProgression row shape — read-only input to recompute pipeline. */ export interface ClassProgressionRow { className: string; level: number; grants: string[]; proficiencyChanges: Partial>; spellSlotIncrement?: { tradition: string; spellLevel: number; count: number } | null; cantripIncrement?: number | null; repertoireIncrement?: number | null; choiceType?: string | null; choiceOptionsRef?: string | null; } /** Wizard choices subset — what the user picked across the wizard. */ export interface WizardChoices { boostTargets?: AbilityAbbreviation[]; skillIncrease?: { skillName: string; toRank: Proficiency }; featClassId?: string; featSkillId?: string; featGeneralId?: string; featAncestryId?: string; featArchetypeId?: string; classFeatureChoices?: Record; spellcasterRepertoirePicks?: string[]; } ``` cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -v "^$" | head -20 || echo "tsc clean" - File `server/src/modules/leveling/lib/types.ts` exists - File contains `export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY'` - File contains `export type EvalResult =` - File contains `export type StepKind =` - File contains `export interface CharacterContext` - File contains `export interface DerivedStats` - File contains `export interface ClassProgressionRow` - File contains `export interface WizardChoices` - File contains NO `: any` outside comments - File contains NO `@nestjs/` imports - File contains NO Prisma client import (only `import type` from sibling for AbilityAbbreviation) - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 (no type errors) types.ts exports the full vocabulary used by the next four modules. No production code yet — no test needed because the file has no behavior beyond type aliases. Task 2: skill-increase-cap module (RED → GREEN → REFACTOR) server/src/modules/leveling/lib/skill-increase-cap.ts, server/src/modules/leveling/lib/skill-increase-cap.spec.ts - server/src/modules/leveling/lib/types.ts (Proficiency type) - server/src/modules/leveling/lib/apply-attribute-boost.ts (style template — strict TS, named exports, JSDoc) - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W1-06 to 1-W1-11) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 967-979 — Phase Requirements → Test Map for LVL-06) - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (LVL-06: T→E ab L3, E→M ab L7, M→L ab L15) See `` block above. RED: write spec first with all 8 test cases. GREEN: implement minimal `canIncreaseSkill` to pass all tests. REFACTOR: extract magic numbers to a SKILL_RANK_LEVEL_REQUIREMENTS constant. **RED phase — write the failing spec FIRST:** Create `server/src/modules/leveling/lib/skill-increase-cap.spec.ts`: ```typescript import { canIncreaseSkill, SKILL_INCREASE_LEVELS } from './skill-increase-cap'; describe('canIncreaseSkill', () => { it('rejects TRAINED → EXPERT at level 2 (T→E requires L3+)', () => { expect(canIncreaseSkill('TRAINED', 2)).toBe(false); }); it('allows TRAINED → EXPERT at level 3', () => { expect(canIncreaseSkill('TRAINED', 3)).toBe(true); }); it('rejects EXPERT → MASTER at level 6 (E→M requires L7+)', () => { expect(canIncreaseSkill('EXPERT', 6)).toBe(false); }); it('allows EXPERT → MASTER at level 7', () => { expect(canIncreaseSkill('EXPERT', 7)).toBe(true); }); it('rejects MASTER → LEGENDARY at level 14 (M→L requires L15+)', () => { expect(canIncreaseSkill('MASTER', 14)).toBe(false); }); it('allows MASTER → LEGENDARY at level 15', () => { expect(canIncreaseSkill('MASTER', 15)).toBe(true); }); it('rejects LEGENDARY at any level (already maxed)', () => { expect(canIncreaseSkill('LEGENDARY', 20)).toBe(false); }); it('allows UNTRAINED → TRAINED at any level >= 1 (no cap on first training)', () => { expect(canIncreaseSkill('UNTRAINED', 1)).toBe(true); expect(canIncreaseSkill('UNTRAINED', 20)).toBe(true); }); }); describe('SKILL_INCREASE_LEVELS', () => { it('exposes the PF2e skill-increase level list', () => { expect(SKILL_INCREASE_LEVELS).toEqual([3, 5, 7, 9, 11, 13, 15, 17, 19]); }); }); ``` Run `cd server && npm test -- skill-increase-cap.spec.ts` — must FAIL (module doesn't exist yet). Commit: `test(01): RED — skill-increase-cap spec` **GREEN phase — write minimal implementation:** Create `server/src/modules/leveling/lib/skill-increase-cap.ts`: ```typescript import type { Proficiency } from './types'; /** Levels at which a skill-increase step occurs (PF2e CRB). */ export const SKILL_INCREASE_LEVELS: readonly number[] = [3, 5, 7, 9, 11, 13, 15, 17, 19]; /** * Minimum character level required to advance a skill from `currentRank`. * UNTRAINED → TRAINED is unrestricted (any level >= 1 with a skill-increase step). */ const SKILL_RANK_LEVEL_REQUIREMENTS: Record = { UNTRAINED: 1, // → TRAINED TRAINED: 3, // → EXPERT (PF2e CRB) EXPERT: 7, // → MASTER MASTER: 15, // → LEGENDARY LEGENDARY: null, // already maxed }; /** * PF2e skill-increase cap rule (LVL-06). * Returns true if the character at `characterLevel` may advance a skill that is * currently at `currentRank` to the next rank. */ export function canIncreaseSkill( currentRank: Proficiency, characterLevel: number, ): boolean { const required = SKILL_RANK_LEVEL_REQUIREMENTS[currentRank]; if (required === null) return false; return characterLevel >= required; } ``` Run `cd server && npm test -- skill-increase-cap.spec.ts` — must PASS (8 + 1 = 9 tests). Commit: `feat(01): implement skill-increase-cap` cd server && npm test -- skill-increase-cap.spec.ts - File `server/src/modules/leveling/lib/skill-increase-cap.ts` exists - File exports `canIncreaseSkill` and `SKILL_INCREASE_LEVELS` - File contains NO `@nestjs/` imports - File contains NO `: any` - File `server/src/modules/leveling/lib/skill-increase-cap.spec.ts` exists with at least 9 `it(` invocations - `cd server && npm test -- skill-increase-cap.spec.ts` exits 0 with `9 passed` (or `Tests: 9 passed`) - All test cases from VALIDATION.md rows 1-W1-06 to 1-W1-11 are present in the spec Spec written first and observed failing; minimal implementation green; all 9 tests pass. Task 3: prereq-evaluator module (RED → GREEN → REFACTOR) server/src/modules/leveling/lib/prereq-evaluator.ts, server/src/modules/leveling/lib/prereq-evaluator.spec.ts - server/src/modules/leveling/lib/types.ts (CharacterContext, EvalResult, Proficiency) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 514-548 — Pattern 4 grammar; lines 727-748 — Code Examples §2 real prereq strings) - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W1-12 to 1-W1-21 — exact assertions) - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-01..D-04 — evaluable patterns and warning behavior) - client/src/features/characters/components/add-feat-modal.tsx (lines 27-74 — partial client-side analog `checkSkillPrerequisites`) See `` block above for full behavior. RED: spec all 14+ test cases. GREEN: implement parser + evaluator + formatter using regex patterns from grammar in RESEARCH.md. **RED phase — write the failing spec FIRST:** Create `server/src/modules/leveling/lib/prereq-evaluator.spec.ts`: ```typescript import { evaluatePrereq } from './prereq-evaluator'; import type { CharacterContext } from './types'; function makeCtx(overrides: Partial = {}): CharacterContext { return { level: 5, className: 'Fighter', ancestryName: 'Human', heritageName: undefined, abilities: { STR: 16, DEX: 14, CON: 14, INT: 10, WIS: 12, CHA: 10 }, skills: {}, feats: new Set(), ...overrides, }; } describe('evaluatePrereq — empty/null', () => { it('returns ok for null prereq', () => { expect(evaluatePrereq(null, makeCtx())).toEqual({ ok: true }); }); it('returns ok for empty string', () => { expect(evaluatePrereq('', makeCtx())).toEqual({ ok: true }); }); }); describe('evaluatePrereq — skill rank', () => { it('returns ok when skill rank meets requirement', () => { const ctx = makeCtx({ skills: { Athletics: 'TRAINED' } }); expect(evaluatePrereq('Trained in Athletics', ctx)).toEqual({ ok: true }); }); it('returns ok when skill rank exceeds requirement', () => { const ctx = makeCtx({ skills: { Athletics: 'EXPERT' } }); expect(evaluatePrereq('Trained in Athletics', ctx)).toEqual({ ok: true }); }); it('returns ok:false with German reason when skill rank below requirement', () => { const ctx = makeCtx({ skills: { Athletics: 'UNTRAINED' } }); const result = evaluatePrereq('Trained in Athletics', ctx); expect(result.ok).toBe(false); if (result.ok === false) { expect(result.reason).toMatch(/Athletics/i); } }); it('returns ok:false when skill is missing entirely', () => { const ctx = makeCtx({ skills: {} }); const result = evaluatePrereq('Trained in Athletics', ctx); expect(result.ok).toBe(false); }); }); describe('evaluatePrereq — disjunctive (OR-list)', () => { it('returns ok when any of the listed skills matches', () => { const ctx = makeCtx({ skills: { Arcana: 'TRAINED' } }); expect(evaluatePrereq('Trained in Arcana, Trained in Nature, or Trained in Religion', ctx)).toEqual({ ok: true }); }); it('returns ok:false when no listed skill matches', () => { const ctx = makeCtx({ skills: { Athletics: 'TRAINED' } }); const result = evaluatePrereq('Trained in Arcana, Trained in Nature, or Trained in Religion', ctx); expect(result.ok).toBe(false); }); }); describe('evaluatePrereq — conjunctive (semicolon AND)', () => { it('returns ok when both clauses match', () => { const ctx = makeCtx({ skills: { Deception: 'TRAINED', Stealth: 'TRAINED' } }); expect(evaluatePrereq('Trained in Deception; Trained in Stealth', ctx)).toEqual({ ok: true }); }); it('returns ok:false when one clause is missing', () => { const ctx = makeCtx({ skills: { Deception: 'TRAINED' } }); const result = evaluatePrereq('Trained in Deception; Trained in Stealth', ctx); expect(result.ok).toBe(false); }); }); describe('evaluatePrereq — bare feat name', () => { it('returns ok when the feat is held', () => { const ctx = makeCtx({ feats: new Set(['Power Attack']) }); expect(evaluatePrereq('Power Attack', ctx)).toEqual({ ok: true }); }); it('returns ok:false when the feat is missing', () => { const ctx = makeCtx({ feats: new Set() }); const result = evaluatePrereq('Power Attack', ctx); expect(result.ok).toBe(false); }); }); describe('evaluatePrereq — heritage', () => { it('returns ok when heritage matches', () => { const ctx = makeCtx({ heritageName: 'Unbreakable Goblin' }); expect(evaluatePrereq('Unbreakable Goblin heritage', ctx)).toEqual({ ok: true }); }); }); describe('evaluatePrereq — class ref', () => { it('returns ok when class matches', () => { const ctx = makeCtx({ className: 'Fighter' }); expect(evaluatePrereq('Fighter', ctx)).toEqual({ ok: true }); }); }); describe('evaluatePrereq — non-evaluable patterns (D-02 → unknown)', () => { it('returns unknown for spellcasting refs', () => { const result = evaluatePrereq('spellcasting class feature', makeCtx()); expect(result).toEqual({ unknown: true, raw: 'spellcasting class feature' }); }); it('returns unknown for deity refs', () => { const result = evaluatePrereq('worshipper of Droskar', makeCtx()); expect('unknown' in result && result.unknown).toBe(true); }); it('returns unknown for age refs', () => { const result = evaluatePrereq('at least 100 years old', makeCtx()); expect('unknown' in result && result.unknown).toBe(true); }); it('returns unknown for vision-trait refs', () => { const result = evaluatePrereq('low-light vision', makeCtx()); expect('unknown' in result && result.unknown).toBe(true); }); it('returns unknown for free-text patterns the parser cannot classify', () => { const result = evaluatePrereq('You worship a god of fire and destruction', makeCtx()); expect('unknown' in result && result.unknown).toBe(true); }); }); ``` Run `cd server && npm test -- prereq-evaluator.spec.ts` — must FAIL. Commit: `test(01): RED — prereq-evaluator spec` **GREEN phase — implement the parser + evaluator** in `server/src/modules/leveling/lib/prereq-evaluator.ts`. Implementation must: 1. Define internal AST node types: `SkillRankAtom`, `FeatAtom`, `HeritageAtom`, `ClassAtom`, `AncestryAtom`, `LevelAtom`, `UnknownAtom`, `AndNode`, `OrNode`. 2. Export `evaluatePrereq(prereqString: string | null, ctx: CharacterContext): EvalResult`. 3. Internal regex patterns based on RESEARCH.md grammar (lines 519-531): - `/^(trained|expert|master|legendary)\s+in\s+(.+?)$/i` → SkillRankAtom - `/(.+?)\s+heritage$/i` → HeritageAtom - `/^level\s+(\d+)$/i` → LevelAtom - Class names: lookup against the 16 D-16 class names hardcoded in the module - Spellcasting/deity/age/vision: regex blacklist → UnknownAtom - Bare capitalized phrase as last-resort feat lookup → FeatAtom 4. Splitter: split on `;` for AND clauses, then within a clause split on `,` and ` or ` for OR-lists (see RESEARCH.md A5 — heuristic "comma inside Trained in X, Y, or Z = OR-list"). 5. UNKNOWN-aggressive: if ANY atom resolves to UnknownAtom, the whole prereq returns `{ unknown: true, raw: }` per RESEARCH.md line 550. 6. Failure reasons in German (D-15 — UI is German throughout). Examples: - `Du benötigst mindestens 'Trained' in Athletics` (skill rank fail) - `Dir fehlt das Talent: Power Attack` (feat fail) - `Voraussetzung nicht erfüllt: ` (generic fallback) Sketched implementation skeleton (planner outlines key shapes; executor fills in regex correctness and German strings): ```typescript import type { CharacterContext, EvalResult, Proficiency } from './types'; const PROFICIENCY_RANK_ORDER: readonly Proficiency[] = ['UNTRAINED', 'TRAINED', 'EXPERT', 'MASTER', 'LEGENDARY']; const KNOWN_CLASS_NAMES = new Set([ 'Alchemist', 'Barbarian', 'Bard', 'Champion', 'Cleric', 'Druid', 'Fighter', 'Investigator', 'Monk', 'Oracle', 'Ranger', 'Rogue', 'Sorcerer', 'Swashbuckler', 'Witch', 'Wizard', ]); const NON_EVALUABLE_PATTERNS: readonly RegExp[] = [ /spellcasting\s+class\s+feature/i, /\bspellcaster\b/i, /(divine|arcane|primal|occult)\s+spells?/i, /\bcantrip\b/i, /worship(?:per|s|ing)?\s+of/i, /follower\s+of/i, /\bdeity\b/i, /at\s+least\s+\d+\s+years?\s+old/i, /\bethnicity\b/i, /low-light\s+vision/i, /\bdarkvision\b/i, /\bscent\b/i, ]; function rankAtLeast(have: Proficiency, need: Proficiency): boolean { return PROFICIENCY_RANK_ORDER.indexOf(have) >= PROFICIENCY_RANK_ORDER.indexOf(need); } type Atom = | { kind: 'skill'; skill: string; required: Proficiency } | { kind: 'heritage'; name: string } | { kind: 'class'; name: string } | { kind: 'level'; min: number } | { kind: 'feat'; name: string } | { kind: 'unknown'; raw: string }; type Node = | { kind: 'atom'; atom: Atom } | { kind: 'and'; children: Node[] } | { kind: 'or'; children: Node[] }; // parse(): tokenize on ';' then ',' then ' or '; classify each token // evaluate(): walk tree, short-circuit OR, propagate UNKNOWN // formatReason(): German strings export function evaluatePrereq(prereqString: string | null, ctx: CharacterContext): EvalResult { if (!prereqString || prereqString.trim() === '') return { ok: true }; // 1. Quick UNKNOWN check if (NON_EVALUABLE_PATTERNS.some(rx => rx.test(prereqString))) { return { unknown: true, raw: prereqString }; } // 2. Parse + evaluate // ... (executor implements; tests are the contract) } ``` Run `cd server && npm test -- prereq-evaluator.spec.ts` — must PASS (all tests). Commit: `feat(01): implement prereq-evaluator (D-01..D-04)` **REFACTOR phase (if needed):** extract regex constants, extract German string formatter, ensure no `any` types. cd server && npm test -- prereq-evaluator.spec.ts - File `server/src/modules/leveling/lib/prereq-evaluator.ts` exists - File exports `evaluatePrereq` - File contains NO `@nestjs/` imports - File contains NO Prisma client import - File contains NO `: any` outside comments - File `server/src/modules/leveling/lib/prereq-evaluator.spec.ts` exists with at least 17 `it(` invocations - `cd server && npm test -- prereq-evaluator.spec.ts` exits 0 — all tests pass - The spec covers EVERY VALIDATION.md row from 1-W1-12 to 1-W1-21 - Failure reasons returned by evaluator are in German (verifiable: spec asserts a German-language regex match for at least one failure case) Spec written first; all 17+ tests pass; UNKNOWN-aggressive behavior verified for D-02 patterns; German reason strings on failures. Task 4: recompute-derived-stats module (RED → GREEN, Pitfall #9 enforced) server/src/modules/leveling/lib/recompute-derived-stats.ts, server/src/modules/leveling/lib/recompute-derived-stats.spec.ts - server/src/modules/leveling/lib/types.ts (CharacterContext, DerivedStats, ClassProgressionRow, WizardChoices, PROFICIENCY_BASE_BONUS) - server/src/modules/leveling/lib/apply-attribute-boost.ts (applyAttributeBoost — must use this, not re-implement) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 552-575 — Pattern 5 inputs/outputs; lines 624-633 — Pitfall 2 hpCurrent rule) - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W1-22 to 1-W1-25) - server/src/modules/characters/pathbuilder-import.service.ts (lines 42-62 — proficiencyFromValue helper for reference; do not import — re-implement here) See `` block above. Critical Pitfall #9 invariant: output object MUST NOT contain `hpCurrent` or `hpTemp`. Spec has dedicated assertion for this. **RED phase — write spec FIRST:** Create `server/src/modules/leveling/lib/recompute-derived-stats.spec.ts`: ```typescript import { recomputeDerivedStats } from './recompute-derived-stats'; import type { CharacterContext, ClassProgressionRow, WizardChoices } from './types'; function baseCharacter(overrides: Partial = {}): CharacterContext & { ancestryHp: number; classHp: number; armorAc: number; armorProficiency: 'UNTRAINED'|'TRAINED'|'EXPERT'|'MASTER'|'LEGENDARY'; dexCap: number } { return { level: 4, className: 'Fighter', ancestryName: 'Human', heritageName: undefined, abilities: { STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 12, CHA: 10 }, skills: {}, feats: new Set(), ancestryHp: 8, classHp: 10, armorAc: 4, armorProficiency: 'EXPERT', dexCap: 2, ...overrides, } as never; } function emptyProgression(level: number): ClassProgressionRow { return { className: 'Fighter', level, grants: [], proficiencyChanges: {}, }; } describe('recomputeDerivedStats — hpMax', () => { it('computes hpMax = ancestryHP + (classHP + conMod) × newLevel for L4 → L5 Fighter, CON 16', () => { // CON 16 → mod +3; classHP 10 + 3 = 13 per level; ancestryHP 8; L5: 8 + 13×5 = 73 const ch = baseCharacter(); const choices: WizardChoices = {}; const result = recomputeDerivedStats(ch, choices, emptyProgression(5)); expect(result.hpMax).toBe(73); }); it('respects boost-cap-at-18 when CON is boosted from 18', () => { // CON starts at 18, boost target includes CON → new CON = 19 (not 20). conMod = +4. // L5: 8 + (10+4)×5 = 78 const ch = baseCharacter({ abilities: { STR: 16, DEX: 14, CON: 18, INT: 10, WIS: 12, CHA: 10 } }); const choices: WizardChoices = { boostTargets: ['CON', 'STR', 'DEX', 'INT'] }; const result = recomputeDerivedStats(ch, choices, emptyProgression(5)); expect(result.hpMax).toBe(78); }); }); describe('recomputeDerivedStats — proficiencyChanges from ClassProgression', () => { it('applies proficiencyChanges from ClassProgression at the new level (fortitude EXPERT)', () => { // Fighter L4: fort proficiency was TRAINED (assume base). Progression bumps to EXPERT. // CON mod +3, level 5; fort = conMod + (level + 4) = 3 + 9 = 12 const ch = baseCharacter(); const progression: ClassProgressionRow = { ...emptyProgression(5), proficiencyChanges: { fortitude: 'EXPERT' }, }; const result = recomputeDerivedStats(ch, {}, progression); // fort = 3 + (5 + 4) = 12 (proficiencyBase EXPERT = 4; PF2e prof bonus = base + level when trained+) expect(result.fortitude).toBe(12); }); }); describe('recomputeDerivedStats — Pitfall #9 (hpCurrent must not be in output)', () => { it('does NOT include hpCurrent in the result object', () => { const ch = baseCharacter(); const result = recomputeDerivedStats(ch, {}, emptyProgression(5)); expect(result).not.toHaveProperty('hpCurrent'); expect(result).not.toHaveProperty('hpTemp'); }); }); describe('recomputeDerivedStats — level passthrough', () => { it('returns the new level in the output', () => { const ch = baseCharacter(); const result = recomputeDerivedStats(ch, {}, emptyProgression(5)); expect(result.level).toBe(5); }); }); ``` Run — must FAIL. Commit: `test(01): RED — recompute-derived-stats spec` **GREEN phase — implement** in `server/src/modules/leveling/lib/recompute-derived-stats.ts`: The function signature MUST be: ```typescript export function recomputeDerivedStats( character: CharacterContext & { ancestryHp: number; classHp: number; armorAc: number; armorProficiency: Proficiency; dexCap: number; }, choices: WizardChoices, progression: ClassProgressionRow, ): DerivedStats; ``` Implementation requirements: 1. Apply `applyAttributeBoost` to each ability listed in `choices.boostTargets`. Compute new abilities map. 2. `abilityModifier(score) = Math.floor((score - 10) / 2)`. 3. `proficiencyBonus(rank, level) = rank === 'UNTRAINED' ? 0 : (PROFICIENCY_BASE_BONUS[rank] + level)`. 4. `hpMax = ancestryHp + (classHp + abilityModifier(newAbilities.CON)) * progression.level`. 5. `ac = 10 + Math.min(abilityModifier(newAbilities.DEX), character.dexCap) + character.armorAc + proficiencyBonus(character.armorProficiency, progression.level)` — note `armorProficiency` may have been bumped by `progression.proficiencyChanges.ac`; if so, use the new rank. 6. `classDc = 10 + abilityModifier(newAbilities[keyAbility]) + proficiencyBonus(classDcRank, progression.level)`. For tests above, assume keyAbility is STR and classDcRank is TRAINED (Fighter L1). The spec only asserts hpMax + fortitude + Pitfall #9; remaining fields tested in later integration. 7. `perception = abilityModifier(newAbilities.WIS) + proficiencyBonus(perceptionRank, progression.level)`. 8. `fortitude = abilityModifier(newAbilities.CON) + proficiencyBonus(fortRank, progression.level)`. Use `progression.proficiencyChanges.fortitude ?? `. 9. Same for reflex (DEX) and will (WIS). 10. Return ONLY the keys defined in DerivedStats. Do NOT spread the input character. Do NOT include hpCurrent. Run `cd server && npm test -- recompute-derived-stats.spec.ts` — must PASS. Commit: `feat(01): implement recompute-derived-stats (Pitfall #8/#9 safe)` cd server && npm test -- recompute-derived-stats.spec.ts - File `server/src/modules/leveling/lib/recompute-derived-stats.ts` exists - File exports `recomputeDerivedStats` - File contains the literal string `applyAttributeBoost` (proves it imports from the existing module — no math duplication) - File contains NO `@nestjs/` imports - File contains NO Prisma client import - File contains NO `: any` outside comments - File contains NO `hpCurrent` or `hpTemp` literal strings (defensive: cannot accidentally output them) - File `server/src/modules/leveling/lib/recompute-derived-stats.spec.ts` exists with at least 5 `it(` invocations - Spec contains an `expect(result).not.toHaveProperty('hpCurrent')` assertion (Pitfall #9 enforcement) - `cd server && npm test -- recompute-derived-stats.spec.ts` exits 0 — all tests pass Spec written first; recompute is pure; Pitfall #8 (boost cap) and Pitfall #9 (no hpCurrent mutation) both enforced by tests. Task 5: compute-applicable-steps module (RED → GREEN) server/src/modules/leveling/lib/compute-applicable-steps.ts, server/src/modules/leveling/lib/compute-applicable-steps.spec.ts - server/src/modules/leveling/lib/types.ts (StepKind type) - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W1-26 to 1-W1-29) - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-10 — step ordering; D-13 — FA toggle, D-18 — spellcaster) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 696-722 — Boost step / spellcaster / FA-archetype analysis) See `` block above. Step ordering is fixed per D-10. Function output is deterministic given inputs. **RED phase — write spec FIRST:** Create `server/src/modules/leveling/lib/compute-applicable-steps.spec.ts`: ```typescript import { computeApplicableSteps } from './compute-applicable-steps'; import type { StepKind } from './types'; describe('computeApplicableSteps — Fighter (martial, no FA, no caster)', () => { it('at L5 returns [class-features, boost, skill-increase, feat-class, feat-skill, feat-ancestry, review]', () => { const steps = computeApplicableSteps({ targetLevel: 5, className: 'Fighter', hasFreeArchetype: false, isCaster: false, isSpontaneousCaster: false, classProgressionHasChoiceType: false, }); expect(steps).toEqual([ 'class-features', 'boost', 'skill-increase', 'feat-class', 'feat-skill', 'feat-ancestry', 'review', ]); }); it('at L4 (not a boost level) does NOT contain boost', () => { const steps = computeApplicableSteps({ targetLevel: 4, className: 'Fighter', hasFreeArchetype: false, isCaster: false, isSpontaneousCaster: false, classProgressionHasChoiceType: false, }); expect(steps).not.toContain('boost'); }); it('at L4 (even level) contains feat-class AND feat-skill', () => { const steps = computeApplicableSteps({ targetLevel: 4, className: 'Fighter', hasFreeArchetype: false, isCaster: false, isSpontaneousCaster: false, classProgressionHasChoiceType: false, }); expect(steps).toContain('feat-class'); expect(steps).toContain('feat-skill'); }); it('at L3 contains skill-increase AND feat-general but NOT feat-class (odd level)', () => { const steps = computeApplicableSteps({ targetLevel: 3, className: 'Fighter', hasFreeArchetype: false, isCaster: false, isSpontaneousCaster: false, classProgressionHasChoiceType: false, }); expect(steps).toContain('skill-increase'); expect(steps).toContain('feat-general'); expect(steps).not.toContain('feat-class'); }); it('at L2 (even but no skill-increase yet) contains feat-class+feat-skill but NOT skill-increase', () => { const steps = computeApplicableSteps({ targetLevel: 2, className: 'Fighter', hasFreeArchetype: false, isCaster: false, isSpontaneousCaster: false, classProgressionHasChoiceType: false, }); expect(steps).toContain('feat-class'); expect(steps).toContain('feat-skill'); expect(steps).not.toContain('skill-increase'); }); }); describe('computeApplicableSteps — Free Archetype', () => { it('with FA enabled at L5 includes feat-archetype', () => { const steps = computeApplicableSteps({ targetLevel: 5, className: 'Fighter', hasFreeArchetype: true, isCaster: false, isSpontaneousCaster: false, classProgressionHasChoiceType: false, }); expect(steps).toContain('feat-archetype'); }); it('without FA never includes feat-archetype', () => { const steps = computeApplicableSteps({ targetLevel: 5, className: 'Fighter', hasFreeArchetype: false, isCaster: false, isSpontaneousCaster: false, classProgressionHasChoiceType: false, }); expect(steps).not.toContain('feat-archetype'); }); }); describe('computeApplicableSteps — Spellcaster', () => { it('with isCaster=true includes spellcaster step', () => { const steps = computeApplicableSteps({ targetLevel: 5, className: 'Wizard', hasFreeArchetype: false, isCaster: true, isSpontaneousCaster: false, classProgressionHasChoiceType: false, }); expect(steps).toContain('spellcaster'); }); it('with isCaster=false never includes spellcaster step', () => { const steps = computeApplicableSteps({ targetLevel: 5, className: 'Fighter', hasFreeArchetype: false, isCaster: false, isSpontaneousCaster: false, classProgressionHasChoiceType: false, }); expect(steps).not.toContain('spellcaster'); }); }); describe('computeApplicableSteps — class-feature-choice (D-19)', () => { it('includes class-feature-choice when ClassProgression carries choiceType', () => { const steps = computeApplicableSteps({ targetLevel: 1, className: 'Cleric', hasFreeArchetype: false, isCaster: true, isSpontaneousCaster: false, classProgressionHasChoiceType: true, }); expect(steps).toContain('class-feature-choice'); }); it('does NOT include class-feature-choice when no choiceType', () => { const steps = computeApplicableSteps({ targetLevel: 5, className: 'Fighter', hasFreeArchetype: false, isCaster: false, isSpontaneousCaster: false, classProgressionHasChoiceType: false, }); expect(steps).not.toContain('class-feature-choice'); }); }); describe('computeApplicableSteps — invariants', () => { it('always starts with class-features', () => { for (const level of [1, 2, 3, 5, 10, 15, 20]) { const steps = computeApplicableSteps({ targetLevel: level, className: 'Fighter', hasFreeArchetype: false, isCaster: false, isSpontaneousCaster: false, classProgressionHasChoiceType: false, }); expect(steps[0]).toBe('class-features'); } }); it('always ends with review', () => { for (const level of [1, 2, 3, 5, 10, 15, 20]) { const steps = computeApplicableSteps({ targetLevel: level, className: 'Fighter', hasFreeArchetype: false, isCaster: false, isSpontaneousCaster: false, classProgressionHasChoiceType: false, }); expect(steps[steps.length - 1]).toBe('review'); } }); }); ``` Run — must FAIL. Commit: `test(01): RED — compute-applicable-steps spec` **GREEN phase — implement** in `server/src/modules/leveling/lib/compute-applicable-steps.ts`: ```typescript import type { StepKind } from './types'; export interface ComputeStepsInput { targetLevel: number; className: string; hasFreeArchetype: boolean; isCaster: boolean; isSpontaneousCaster: boolean; classProgressionHasChoiceType: boolean; } const BOOST_LEVELS: ReadonlySet = new Set([5, 10, 15, 20]); const SKILL_INCREASE_LEVELS: ReadonlySet = new Set([3, 5, 7, 9, 11, 13, 15, 17, 19]); const GENERAL_FEAT_LEVELS: ReadonlySet = new Set([3, 7, 11, 15, 19]); const ANCESTRY_FEAT_LEVELS: ReadonlySet = new Set([1, 5, 9, 13, 17]); /** * Returns the ordered list of wizard step kinds for a level-up. * Per D-10 ordering, conditional on the inputs. Pure function. */ export function computeApplicableSteps(input: ComputeStepsInput): StepKind[] { const { targetLevel, hasFreeArchetype, isCaster, classProgressionHasChoiceType } = input; const steps: StepKind[] = ['class-features']; if (classProgressionHasChoiceType) steps.push('class-feature-choice'); if (BOOST_LEVELS.has(targetLevel)) steps.push('boost'); if (SKILL_INCREASE_LEVELS.has(targetLevel)) steps.push('skill-increase'); if (targetLevel % 2 === 0) { steps.push('feat-class'); steps.push('feat-skill'); } if (GENERAL_FEAT_LEVELS.has(targetLevel)) steps.push('feat-general'); if (ANCESTRY_FEAT_LEVELS.has(targetLevel) && targetLevel !== 1) steps.push('feat-ancestry'); if (hasFreeArchetype) steps.push('feat-archetype'); if (isCaster) steps.push('spellcaster'); steps.push('review'); return steps; } ``` Note on ancestry: PF2e ancestry feats are at levels 1 (start) and 5/9/13/17 (level-ups). Since this function is for level-ups (not character creation), L1 is excluded. Run `cd server && npm test -- compute-applicable-steps.spec.ts` — must PASS. Commit: `feat(01): implement compute-applicable-steps` cd server && npm test -- compute-applicable-steps.spec.ts - File `server/src/modules/leveling/lib/compute-applicable-steps.ts` exists - File exports `computeApplicableSteps` - File contains NO `@nestjs/` imports - File contains NO Prisma client import - File contains NO `: any` outside comments - File `server/src/modules/leveling/lib/compute-applicable-steps.spec.ts` exists with at least 11 `it(` invocations - `cd server && npm test -- compute-applicable-steps.spec.ts` exits 0 — all tests pass - All four VALIDATION.md rows 1-W1-26 through 1-W1-29 are covered Spec written first; all 11+ tests pass; step ordering invariants enforced; function is pure. Task 6: Run full leveling test suite — gate before Wave 2 (no file writes — verification only) - server/src/modules/leveling/lib/ (verify all 5 .ts + 5 .spec.ts files present) Run the full leveling test suite to confirm no test-file cross-contamination: ```bash cd server && npm test -- --testPathPattern=leveling ``` Expected: at least 5 spec files run, total tests ≥ 50 (9 boost + 9 skill + 17 prereq + 5 recompute + 11 steps), 0 fail. If any test fails: STOP. Fix in the originating task — do NOT mark this plan complete. Then run a quick TS check across the lib folder: ```bash cd server && npx tsc --noEmit -p tsconfig.json ``` Expected: exits 0 (no type errors anywhere in the project after these additions). cd server && npm test -- --testPathPattern=leveling - `cd server && npm test -- --testPathPattern=leveling` exits 0 - Test summary shows at least 5 test suites passing - Test summary shows at least 50 tests passing - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 (no type errors) - All five files exist: apply-attribute-boost.ts, skill-increase-cap.ts, prereq-evaluator.ts, recompute-derived-stats.ts, compute-applicable-steps.ts (and types.ts) - All five spec files exist: apply-attribute-boost.spec.ts, skill-increase-cap.spec.ts, prereq-evaluator.spec.ts, recompute-derived-stats.spec.ts, compute-applicable-steps.spec.ts Whole leveling lib suite green; TS clean; ready to be consumed by Plan 03 (seed) and Plan 04 (LevelingService). ## Trust Boundaries | Boundary | Description | |----------|-------------| | (none — pure functions) | This plan introduces only pure-function modules with no I/O. No trust boundary is crossed at runtime by any code in this plan. | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-1-W1-01 | Tampering | prereq-evaluator returns `{ok: true}` for a malformed prereq string when running on the server, allowing a client to bypass intent | mitigate | UNKNOWN-aggressive design (RESEARCH.md line 550): when ANY atom is non-classifiable, the entire prereq returns `{unknown: true, raw}`. The server consumer (Plan 04 feat-filter) treats `{unknown:true}` as "show with warning" — never as "show as if met". Spec covers spellcasting/deity/age/vision/free-text returning unknown. | | T-1-W1-02 | Tampering | recompute-derived-stats accidentally mutates the input `character` object via shared object references | mitigate | Pure function contract: spec asserts the result object does NOT contain hpCurrent (Pitfall #9). Implementation uses property reads only on input; output is a fresh object literal. Future regression caught by the not.toHaveProperty test. | | T-1-W1-03 | Information Disclosure | Prereq evaluator includes raw character data in failure reason strings, leaking PII into logs | accept | Failure reasons name skills/feats/heritages by their canonical PF2e name, not character-specific data (no character name, no user ID). Self-hosted single-tenant; no PII risk surface. | After all tasks complete, run: ```bash # Full lib suite cd server && npm test -- --testPathPattern=leveling # Should report ≥5 suites, ≥50 tests passing, 0 failing. # TS strict cd server && npx tsc --noEmit -p tsconfig.json # Should exit 0. # Verify file presence ls server/src/modules/leveling/lib/ # Should list: apply-attribute-boost.ts, apply-attribute-boost.spec.ts, # skill-increase-cap.ts, skill-increase-cap.spec.ts, # prereq-evaluator.ts, prereq-evaluator.spec.ts, # recompute-derived-stats.ts, recompute-derived-stats.spec.ts, # compute-applicable-steps.ts, compute-applicable-steps.spec.ts, # types.ts # Verify no `any` types crept in grep -rE ": any\b" server/src/modules/leveling/lib/ --include="*.ts" | grep -v "spec.ts" | grep -v "//.*any" # Should produce no output ``` If any check fails, the plan is NOT done. - 5 production modules + types.ts created in server/src/modules/leveling/lib/ - 5 spec files created alongside; all tests passing - TDD discipline observed: each module had spec written first (RED commit), then implementation (GREEN commit) - Pitfall #8 (boost-cap-at-18) covered by apply-attribute-boost (Plan 01) AND recompute-derived-stats specs - Pitfall #9 (no hpCurrent mutation) enforced by recompute-derived-stats spec via `not.toHaveProperty` - Prereq evaluator handles all D-01 evaluable patterns and returns `{unknown:true}` for D-02 non-evaluable patterns - Step ordering matches D-10 - Zero `: any` types in production code - Zero NestJS or Prisma imports in any lib file - `cd server && npm test -- --testPathPattern=leveling` exits 0 with ≥50 tests - `cd server && npx tsc --noEmit` exits 0 After completion, create `.planning/phases/01-level-up-pf2e-regelkonform/01-02-SUMMARY.md` documenting: - Total test count (per module + suite total) - Any deviations from the planned regex patterns / German strings / function shapes - Notes on prereq-evaluator parsing edge cases discovered during TDD (for future grammar tuning) - Confirmation that types.ts exports are imported by all four downstream modules (no duplication) - Test run output (pass/fail summary) for the audit trail