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)
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
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.
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 suitecd server && npm test -- --testPathPattern=leveling
# Should report ≥5 suites, ≥50 tests passing, 0 failing.# TS strictcd 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