1284 lines
61 KiB
Markdown
1284 lines
61 KiB
Markdown
---
|
||
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 && npx tsc --noEmit -p tsconfig.json 2>&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 && 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 && 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 && 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 && 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 && 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>
|