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

1284 lines
61 KiB
Markdown
Raw Blame History

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