From 7e40449e6820cacc969c16f5697209f142ca3d9c Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:04:54 +0200 Subject: [PATCH 01/13] feat(01-02): add apply-attribute-boost dependency from Plan 01-01 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule 3 deviation: Plan 01-01 (wave 0) work not yet merged into this worktree base (096edbf). Plan 01-02 imports applyAttributeBoost and AbilityAbbreviation from this module — required for types.ts and recompute-derived-stats.ts to compile. Content matches 01-01 plan exactly, so orchestrator merge will be clean. - export type AbilityScore = number - export type AbilityAbbreviation = 'STR'|'DEX'|'CON'|'INT'|'WIS'|'CHA' - export function applyAttributeBoost(score) — PF2e boost-cap-at-18 (Pitfall #8) - export function isValidBoostSet(targets) — exactly 4 distinct --- .../leveling/lib/apply-attribute-boost.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 server/src/modules/leveling/lib/apply-attribute-boost.ts diff --git a/server/src/modules/leveling/lib/apply-attribute-boost.ts b/server/src/modules/leveling/lib/apply-attribute-boost.ts new file mode 100644 index 0000000..d5a586b --- /dev/null +++ b/server/src/modules/leveling/lib/apply-attribute-boost.ts @@ -0,0 +1,26 @@ +/** + * Pure function module — no NestJS, no Prisma, no I/O. + * PF2e Boost Cap rule: +2 if score < 18, +1 if score >= 18 (Pitfall #8). + * See: .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md §Pattern 3 + */ + +export type AbilityScore = number; + +export type AbilityAbbreviation = 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA'; + +/** + * Applies a single PF2e attribute boost to a score. + * - Score below 18 → +2 + * - Score at or above 18 → +1 (cap rule, prevents Pitfall #8) + */ +export function applyAttributeBoost(currentScore: AbilityScore): AbilityScore { + return currentScore >= 18 ? currentScore + 1 : currentScore + 2; +} + +/** + * Validates a level-up boost set: must be exactly 4 distinct abilities. + * PF2e: at boost levels (5/10/15/20), the player picks 4 different attributes. + */ +export function isValidBoostSet(targets: readonly string[]): boolean { + return targets.length === 4 && new Set(targets).size === 4; +} From 4d2cb5e529a72541940afc7b89504b46046e8a12 Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:05:04 +0200 Subject: [PATCH 02/13] feat(01-02): shared types module for leveling lib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 of Plan 01-02. Types-only file (no runtime behavior, no test). - Proficiency union (mirrors Prisma enum) - PROFICIENCY_BASE_BONUS lookup (untrained 0, trained 2, ..., legendary 8) - EvalResult discriminated union for prereq evaluation - StepKind union for wizard step ordering (D-10) - CharacterContext interface for prereq + recompute inputs - DerivedStats interface — does NOT include hpCurrent (Pitfall #9) - ClassProgressionRow interface (read-only) - WizardChoices interface --- server/src/modules/leveling/lib/types.ts | 88 ++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 server/src/modules/leveling/lib/types.ts diff --git a/server/src/modules/leveling/lib/types.ts b/server/src/modules/leveling/lib/types.ts new file mode 100644 index 0000000..0d94ec4 --- /dev/null +++ b/server/src/modules/leveling/lib/types.ts @@ -0,0 +1,88 @@ +/** + * Shared types for the Level-Up pure-function library. + * No runtime dependencies — types only. + */ +import type { AbilityAbbreviation } from './apply-attribute-boost'; + +export type { AbilityAbbreviation }; + +/** PF2e proficiency ranks (mirrors Prisma `Proficiency` enum). */ +export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY'; + +/** Numeric proficiency bonus per rank, for use in proficiencyBonus(rank, level) calculation. */ +export const PROFICIENCY_BASE_BONUS: Record = { + UNTRAINED: 0, + TRAINED: 2, + EXPERT: 4, + MASTER: 6, + LEGENDARY: 8, +}; + +/** Discriminated union for prereq evaluation result. */ +export type EvalResult = + | { ok: true } + | { ok: false; reason: string } + | { unknown: true; raw: string }; + +/** Ordered union of wizard step kinds (UI-SPEC + RESEARCH §Pattern 1). */ +export type StepKind = + | 'class-features' + | 'class-feature-choice' + | 'boost' + | 'skill-increase' + | 'feat-class' + | 'feat-skill' + | 'feat-general' + | 'feat-ancestry' + | 'feat-archetype' + | 'spellcaster' + | 'review'; + +/** Snapshot a character's mechanical state for prereq evaluation and recompute. */ +export interface CharacterContext { + level: number; + className: string; + ancestryName: string; + heritageName?: string; + abilities: Record; + skills: Record; + feats: Set; +} + +/** Output of recomputeDerivedStats — never includes hpCurrent (Pitfall #9). */ +export interface DerivedStats { + level: number; + hpMax: number; + ac: number; + classDc: number; + perception: number; + fortitude: number; + reflex: number; + will: number; +} + +/** ClassProgression row shape — read-only input to recompute pipeline. */ +export interface ClassProgressionRow { + className: string; + level: number; + grants: string[]; + proficiencyChanges: Partial>; + spellSlotIncrement?: { tradition: string; spellLevel: number; count: number } | null; + cantripIncrement?: number | null; + repertoireIncrement?: number | null; + choiceType?: string | null; + choiceOptionsRef?: string | null; +} + +/** Wizard choices subset — what the user picked across the wizard. */ +export interface WizardChoices { + boostTargets?: AbilityAbbreviation[]; + skillIncrease?: { skillName: string; toRank: Proficiency }; + featClassId?: string; + featSkillId?: string; + featGeneralId?: string; + featAncestryId?: string; + featArchetypeId?: string; + classFeatureChoices?: Record; + spellcasterRepertoirePicks?: string[]; +} From 3a4267da8c0e539ed67594e2d7f84dc3353ccc4e Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:05:36 +0200 Subject: [PATCH 03/13] =?UTF-8?q?test(01-02):=20RED=20=E2=80=94=20skill-in?= =?UTF-8?q?crease-cap=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2 RED phase: 9 failing tests covering PF2e skill-increase cap rule (VALIDATION.md rows 1-W1-06 to 1-W1-11). Implementation follows in next commit (GREEN). Verified failure: module './skill-increase-cap' not found. --- .../leveling/lib/skill-increase-cap.spec.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 server/src/modules/leveling/lib/skill-increase-cap.spec.ts diff --git a/server/src/modules/leveling/lib/skill-increase-cap.spec.ts b/server/src/modules/leveling/lib/skill-increase-cap.spec.ts new file mode 100644 index 0000000..8e9e51f --- /dev/null +++ b/server/src/modules/leveling/lib/skill-increase-cap.spec.ts @@ -0,0 +1,42 @@ +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]); + }); +}); From f1897501de4e0312d1e1ff93d4bb611acac60247 Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:06:09 +0200 Subject: [PATCH 04/13] feat(01-02): implement skill-increase-cap (LVL-06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2 GREEN phase. PF2e skill-increase cap rule: - UNTRAINED → TRAINED at any level - TRAINED → EXPERT requires L3+ - EXPERT → MASTER requires L7+ - MASTER → LEGENDARY requires L15+ Pure function, no NestJS, no Prisma. 9 tests passing. --- .../leveling/lib/skill-increase-cap.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 server/src/modules/leveling/lib/skill-increase-cap.ts diff --git a/server/src/modules/leveling/lib/skill-increase-cap.ts b/server/src/modules/leveling/lib/skill-increase-cap.ts new file mode 100644 index 0000000..b8c0fa3 --- /dev/null +++ b/server/src/modules/leveling/lib/skill-increase-cap.ts @@ -0,0 +1,31 @@ +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). + * LEGENDARY is `null` because it is the maximum rank — no further increases possible. + */ +const SKILL_RANK_LEVEL_REQUIREMENTS: Record = { + UNTRAINED: 1, // → TRAINED + TRAINED: 3, // → EXPERT (PF2e CRB) + EXPERT: 7, // → MASTER + MASTER: 15, // → LEGENDARY + LEGENDARY: null, // already maxed +}; + +/** + * PF2e skill-increase cap rule (LVL-06). + * Returns true if a 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; +} From 66d9d5cc0abe83f7e4924764a99696d2e9f9e391 Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:07:02 +0200 Subject: [PATCH 05/13] =?UTF-8?q?test(01-02):=20RED=20=E2=80=94=20prereq-e?= =?UTF-8?q?valuator=20spec=20(D-01..D-04)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3 RED phase: 18 failing tests covering prereq DSL evaluation. Coverage: - empty/null inputs (always ok) - skill rank: trained/expert/master/legendary in - disjunctive (comma + or) and conjunctive (semicolon) - bare feat name lookup - heritage / class refs - non-evaluable patterns: spellcasting, deity, age, vision, free-text → {unknown, raw} - German failure reasons asserted Verified failure: module './prereq-evaluator' not found. --- .../leveling/lib/prereq-evaluator.spec.ts | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 server/src/modules/leveling/lib/prereq-evaluator.spec.ts diff --git a/server/src/modules/leveling/lib/prereq-evaluator.spec.ts b/server/src/modules/leveling/lib/prereq-evaluator.spec.ts new file mode 100644 index 0000000..bab4d3e --- /dev/null +++ b/server/src/modules/leveling/lib/prereq-evaluator.spec.ts @@ -0,0 +1,141 @@ +import { evaluatePrereq } from './prereq-evaluator'; +import type { CharacterContext } from './types'; + +function makeCtx(overrides: Partial = {}): CharacterContext { + return { + level: 5, + className: 'Fighter', + ancestryName: 'Human', + heritageName: undefined, + abilities: { STR: 16, DEX: 14, CON: 14, INT: 10, WIS: 12, CHA: 10 }, + skills: {}, + feats: new Set(), + ...overrides, + }; +} + +describe('evaluatePrereq — empty/null', () => { + it('returns ok for null prereq', () => { + expect(evaluatePrereq(null, makeCtx())).toEqual({ ok: true }); + }); + + it('returns ok for empty string', () => { + expect(evaluatePrereq('', makeCtx())).toEqual({ ok: true }); + }); +}); + +describe('evaluatePrereq — skill rank', () => { + it('returns ok when skill rank meets requirement', () => { + const ctx = makeCtx({ skills: { Athletics: 'TRAINED' } }); + expect(evaluatePrereq('Trained in Athletics', ctx)).toEqual({ ok: true }); + }); + + it('returns ok when skill rank exceeds requirement', () => { + const ctx = makeCtx({ skills: { Athletics: 'EXPERT' } }); + expect(evaluatePrereq('Trained in Athletics', ctx)).toEqual({ ok: true }); + }); + + it('returns ok:false with German reason when skill rank below requirement', () => { + const ctx = makeCtx({ skills: { Athletics: 'UNTRAINED' } }); + const result = evaluatePrereq('Trained in Athletics', ctx); + expect(result.ok).toBe(false); + if (result.ok === false) { + expect(result.reason).toMatch(/Athletics/i); + // German wording check — must contain a German keyword + expect(result.reason).toMatch(/(benötig|fehlt|Voraussetzung|mindestens)/); + } + }); + + 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); + }); +}); From da82d9bf829a8f6c5bf4160e0227015c587a42cf Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:09:43 +0200 Subject: [PATCH 06/13] feat(01-02): implement prereq-evaluator (D-01..D-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3 GREEN phase. Three-layer parser + evaluator + German formatter. Evaluable patterns (D-01): - Skill rank: 'Trained in Athletics', 'Expert in Stealth', etc. - Disjunctive OR-list (Oxford comma): 'X, Y, or Z' - Conjunctive AND: 'X; Y' - Bare feat name (Title Case, ≤4 words, no function words) - Heritage: ' heritage' - Class ref + Ancestry ref (against known sets) - Level ref: 'level N' Non-evaluable (D-02 → {unknown, raw}): - Spellcasting tradition refs (spellcasting class feature, divine spells, etc.) - Deity / worship-of refs - Age / ethnicity refs - Vision/sense traits (low-light, darkvision, scent) - Free-text sentences (heuristic: contains 'you', 'a', 'the', 'of', 'and', 'to') UNKNOWN-aggressive: any unknown atom in OR or AND poisons the whole prereq. German failure reasons (D-15): 'Du benötigst...', 'Dir fehlt das Talent...', 'Voraussetzung nicht erfüllt: ...'. 19 tests passing. --- .../modules/leveling/lib/prereq-evaluator.ts | 358 ++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 server/src/modules/leveling/lib/prereq-evaluator.ts diff --git a/server/src/modules/leveling/lib/prereq-evaluator.ts b/server/src/modules/leveling/lib/prereq-evaluator.ts new file mode 100644 index 0000000..9fb21ad --- /dev/null +++ b/server/src/modules/leveling/lib/prereq-evaluator.ts @@ -0,0 +1,358 @@ +/** + * Prereq DSL parser + evaluator + formatter (D-01..D-04). + * + * Three-layer module: + * 1. parse() — turns a prereq string into an AST of Atoms combined with AND/OR. + * 2. evaluate() — walks the AST against a CharacterContext. + * 3. formatReason() — German user-facing reason for failures. + * + * UNKNOWN-aggressive: when ANY atom is non-classifiable (deity, spellcasting-tradition, + * age, ethnicity, vision/sense, free-text), the whole prereq returns { unknown: true, raw }. + * Per RESEARCH.md line 550 and D-03 — when in doubt, ask the user (no hard-block). + * + * No NestJS, no Prisma, no I/O — pure function module. + */ +import type { CharacterContext, EvalResult, Proficiency } from './types'; + +const PROFICIENCY_RANK_ORDER: readonly Proficiency[] = [ + 'UNTRAINED', + 'TRAINED', + 'EXPERT', + 'MASTER', + 'LEGENDARY', +]; + +const KNOWN_CLASS_NAMES: ReadonlySet = new Set([ + 'Alchemist', + 'Barbarian', + 'Bard', + 'Champion', + 'Cleric', + 'Druid', + 'Fighter', + 'Investigator', + 'Monk', + 'Oracle', + 'Ranger', + 'Rogue', + 'Sorcerer', + 'Swashbuckler', + 'Witch', + 'Wizard', +]); + +const KNOWN_ANCESTRY_NAMES: ReadonlySet = new Set([ + 'Human', + 'Elf', + 'Dwarf', + 'Halfling', + 'Gnome', + 'Goblin', + 'Hobgoblin', + 'Leshy', + 'Lizardfolk', + 'Catfolk', + 'Kobold', + 'Orc', + 'Ratfolk', + 'Tengu', + 'Tiefling', + 'Aasimar', +]); + +/** Patterns that mark the entire prereq as non-evaluable (D-02). */ +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, +]; + +// --------------------------------------------------------------------------- +// AST types +// --------------------------------------------------------------------------- + +type Atom = + | { kind: 'skill'; skill: string; required: Proficiency } + | { kind: 'heritage'; name: string } + | { kind: 'class'; name: string } + | { kind: 'ancestry'; 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[] }; + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +const SKILL_RANK_RE = /^(trained|expert|master|legendary)\s+in\s+(.+)$/i; +const HERITAGE_RE = /^(.+?)\s+heritage$/i; +const LEVEL_RE = /^level\s+(\d+)$/i; + +function classifyAtom(raw: string): Atom { + const trimmed = raw.trim(); + if (trimmed === '') { + return { kind: 'unknown', raw: trimmed }; + } + + // Skill rank: "Trained in Athletics" + const skillMatch = SKILL_RANK_RE.exec(trimmed); + if (skillMatch) { + const required = skillMatch[1].toUpperCase() as Proficiency; + const skillName = skillMatch[2].trim(); + return { kind: 'skill', skill: skillName, required }; + } + + // Heritage: "Unbreakable Goblin heritage" + const heritageMatch = HERITAGE_RE.exec(trimmed); + if (heritageMatch) { + return { kind: 'heritage', name: heritageMatch[1].trim() }; + } + + // Level: "level 5" + const levelMatch = LEVEL_RE.exec(trimmed); + if (levelMatch) { + return { kind: 'level', min: Number.parseInt(levelMatch[1], 10) }; + } + + // Class ref (exact match against known class names) + if (KNOWN_CLASS_NAMES.has(trimmed)) { + return { kind: 'class', name: trimmed }; + } + + // Ancestry ref (exact match against known ancestry names) + if (KNOWN_ANCESTRY_NAMES.has(trimmed)) { + return { kind: 'ancestry', name: trimmed }; + } + + // Bare capitalized phrase → feat lookup last-resort. + // Real PF2e feat names are short (≤4 words), Title Case, and never contain + // lowercase function-words like "you", "a", "the", "of", "and", "to" mid-sentence. + // This guards against classifying free-text sentences as feat lookups. + if (/^[A-Z][A-Za-z' \-]*$/.test(trimmed)) { + const words = trimmed.split(/\s+/); + const hasFreeTextMarker = /(?:^| )(you|a|the|of|and|to|with|from|by)(?: |$)/i.test( + trimmed, + ); + if (words.length <= 4 && !hasFreeTextMarker) { + return { kind: 'feat', name: trimmed }; + } + } + + return { kind: 'unknown', raw: trimmed }; +} + +/** + * Splits a clause on " or " and "," into OR-disjuncts when the clause looks like + * a comma-separated list of skill rank atoms (the common PF2e shape). + * Otherwise treats commas as soft AND (rare in real corpus). + */ +function parseClause(clause: string): Node { + const trimmed = clause.trim(); + if (trimmed === '') { + return { kind: 'atom', atom: { kind: 'unknown', raw: trimmed } }; + } + + // Detect OR-list: presence of ' or ' (case-insensitive, with surrounding spaces). + // Real PF2e corpus uses "X, Y, or Z" — split on commas AND on ' or '. + const hasOrConnector = / or /i.test(trimmed); + + if (hasOrConnector) { + // Normalize the Oxford-comma form "X, Y, or Z" by collapsing ", or " → " or " + // before splitting. Without this, splitting on /,\s*/ first would leave + // a stray leading "or " on the last segment. + const normalized = trimmed.replace(/,\s*or\s+/gi, ' or '); + const parts = normalized + .split(/,\s*|\s+or\s+/i) + .map((p) => p.trim()) + .filter((p) => p.length > 0); + if (parts.length === 1) { + return { kind: 'atom', atom: classifyAtom(parts[0]) }; + } + const children = parts.map((p) => ({ kind: 'atom', atom: classifyAtom(p) })); + return { kind: 'or', children }; + } + + // Single atom (no or-connector, no comma — or comma but treat single) + if (trimmed.includes(',')) { + // Comma without "or" — treat as AND of atoms (soft conjunction). + const parts = trimmed + .split(/,\s*/) + .map((p) => p.trim()) + .filter((p) => p.length > 0); + const children = parts.map((p) => ({ kind: 'atom', atom: classifyAtom(p) })); + return { kind: 'and', children }; + } + + return { kind: 'atom', atom: classifyAtom(trimmed) }; +} + +function parsePrereq(input: string): Node { + // Top-level split on ';' for AND clauses + const clauses = input + .split(';') + .map((c) => c.trim()) + .filter((c) => c.length > 0); + + if (clauses.length === 0) { + return { kind: 'atom', atom: { kind: 'unknown', raw: input } }; + } + if (clauses.length === 1) { + return parseClause(clauses[0]); + } + return { kind: 'and', children: clauses.map((c) => parseClause(c)) }; +} + +// --------------------------------------------------------------------------- +// Evaluator +// --------------------------------------------------------------------------- + +function rankAtLeast(have: Proficiency, need: Proficiency): boolean { + return PROFICIENCY_RANK_ORDER.indexOf(have) >= PROFICIENCY_RANK_ORDER.indexOf(need); +} + +function rankToGerman(rank: Proficiency): string { + // German proficiency labels — keep canonical English ranks but quote them as identifiers. + const label: Record = { + UNTRAINED: 'Untrained', + TRAINED: 'Trained', + EXPERT: 'Expert', + MASTER: 'Master', + LEGENDARY: 'Legendary', + }; + return label[rank]; +} + +type AtomResult = + | { ok: true } + | { ok: false; reason: string } + | { unknown: true; raw: string }; + +function evaluateAtom(atom: Atom, ctx: CharacterContext): AtomResult { + switch (atom.kind) { + case 'unknown': + return { unknown: true, raw: atom.raw }; + case 'skill': { + const have = ctx.skills[atom.skill] ?? 'UNTRAINED'; + if (rankAtLeast(have, atom.required)) return { ok: true }; + return { + ok: false, + reason: `Du benötigst mindestens '${rankToGerman(atom.required)}' in ${atom.skill}`, + }; + } + case 'heritage': + if (ctx.heritageName === atom.name) return { ok: true }; + return { + ok: false, + reason: `Voraussetzung nicht erfüllt: Abstammungsmerkmal '${atom.name}'`, + }; + case 'class': + if (ctx.className === atom.name) return { ok: true }; + return { + ok: false, + reason: `Voraussetzung nicht erfüllt: Klasse '${atom.name}'`, + }; + case 'ancestry': + if (ctx.ancestryName === atom.name) return { ok: true }; + return { + ok: false, + reason: `Voraussetzung nicht erfüllt: Volk '${atom.name}'`, + }; + case 'level': + if (ctx.level >= atom.min) return { ok: true }; + return { + ok: false, + reason: `Du benötigst mindestens Stufe ${atom.min}`, + }; + case 'feat': + if (ctx.feats.has(atom.name)) return { ok: true }; + return { + ok: false, + reason: `Dir fehlt das Talent: ${atom.name}`, + }; + } +} + +type NodeResult = + | { ok: true } + | { ok: false; reason: string } + | { unknown: true; raw: string }; + +function evaluateNode(node: Node, ctx: CharacterContext, raw: string): NodeResult { + if (node.kind === 'atom') { + return evaluateAtom(node.atom, ctx); + } + + if (node.kind === 'or') { + let firstFailReason: string | null = null; + for (const child of node.children) { + const r = evaluateNode(child, ctx, raw); + if ('unknown' in r && r.unknown) { + // UNKNOWN-aggressive: any unknown atom poisons the whole prereq. + return { unknown: true, raw }; + } + if (r.ok) return { ok: true }; + if (firstFailReason === null && !r.ok) { + firstFailReason = r.reason; + } + } + return { + ok: false, + reason: firstFailReason ?? `Voraussetzung nicht erfüllt: ${raw}`, + }; + } + + // AND + for (const child of node.children) { + const r = evaluateNode(child, ctx, raw); + if ('unknown' in r && r.unknown) { + return { unknown: true, raw }; + } + if (!r.ok) return r; + } + return { ok: true }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Evaluate a PF2e prerequisite string against a character context. + * Returns one of: + * - { ok: true } — prereq is met (or empty/null) + * - { ok: false, reason } — evaluable AND failed (German reason) + * - { unknown: true, raw } — non-evaluable (deity, spellcasting, etc.) + */ +export function evaluatePrereq( + prereqString: string | null, + ctx: CharacterContext, +): EvalResult { + if (prereqString === null) return { ok: true }; + const trimmed = prereqString.trim(); + if (trimmed === '') return { ok: true }; + + // Quick reject — if any non-evaluable pattern matches anywhere, return unknown. + if (NON_EVALUABLE_PATTERNS.some((rx) => rx.test(trimmed))) { + return { unknown: true, raw: trimmed }; + } + + const tree = parsePrereq(trimmed); + return evaluateNode(tree, ctx, trimmed); +} + +/** Internal parser exposed for testing/debugging. */ +export { parsePrereq }; From 8dd55b6fa9a3998de6fd1024caeba2b835f4fb59 Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:10:28 +0200 Subject: [PATCH 07/13] =?UTF-8?q?test(01-02):=20RED=20=E2=80=94=20recomput?= =?UTF-8?q?e-derived-stats=20spec=20(Pitfall=20#8/#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4 RED phase: 5 failing tests covering pure recompute pipeline. - hpMax = ancestryHP + (classHP + conMod) × newLevel - boost-cap-at-18 for CON boost (Pitfall #8) - proficiencyChanges from ClassProgression applied (fortitude EXPERT) - Pitfall #9: result MUST NOT contain hpCurrent or hpTemp - level passthrough Verified failure: module './recompute-derived-stats' not found. --- .../lib/recompute-derived-stats.spec.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 server/src/modules/leveling/lib/recompute-derived-stats.spec.ts diff --git a/server/src/modules/leveling/lib/recompute-derived-stats.spec.ts b/server/src/modules/leveling/lib/recompute-derived-stats.spec.ts new file mode 100644 index 0000000..ba651a5 --- /dev/null +++ b/server/src/modules/leveling/lib/recompute-derived-stats.spec.ts @@ -0,0 +1,89 @@ +import { recomputeDerivedStats } from './recompute-derived-stats'; +import type { CharacterContext, ClassProgressionRow, WizardChoices, Proficiency } from './types'; + +type RecomputeInput = CharacterContext & { + ancestryHp: number; + classHp: number; + armorAc: number; + armorProficiency: Proficiency; + dexCap: number; +}; + +function baseCharacter(overrides: Partial = {}): RecomputeInput { + 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, + }; +} + +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 → L5 with progression bumping fort 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); + 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); + }); +}); From 6011024e87a71c036b31b70811cc26c69c50b22d Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:11:58 +0200 Subject: [PATCH 08/13] feat(01-02): implement recompute-derived-stats (Pitfall #8/#9 safe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4 GREEN phase. Pure pipeline computing DerivedStats from a character snapshot, wizard choices, and ClassProgression row. - Imports applyAttributeBoost (Pitfall #8 boost-cap-at-18) - proficiencyBonus(rank, level) = base[rank] + level (UNTRAINED=0) - hpMax = ancestryHp + (classHp + conMod) × newLevel - AC = 10 + min(dexMod, dexCap) + armorAc + proficiencyBonus(armor) - classDc / perception / saves use proficiencyChanges from progression with fallback to character's pre-existing rank - Output object NEVER contains current/temp HP fields (Pitfall #9) Pure function, no NestJS, no Prisma, no mutation of input. 5 tests passing. --- .../leveling/lib/recompute-derived-stats.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 server/src/modules/leveling/lib/recompute-derived-stats.ts diff --git a/server/src/modules/leveling/lib/recompute-derived-stats.ts b/server/src/modules/leveling/lib/recompute-derived-stats.ts new file mode 100644 index 0000000..e529cd3 --- /dev/null +++ b/server/src/modules/leveling/lib/recompute-derived-stats.ts @@ -0,0 +1,122 @@ +/** + * Pure-function recompute pipeline (Pattern 5, Pitfall #8 & #9). + * + * Computes the new derived stats (hpMax, AC, classDC, perception, saves) for a + * level-up. Pure: no DB writes, no I/O, no mutations of input objects. + * + * Pitfall #8 (boost-cap-at-18): ability boosts use applyAttributeBoost() which + * adds +1 (not +2) when the score is already >= 18. + * Pitfall #9 (current-HP invariant): the output `DerivedStats` shape NEVER includes + * current/temporary HP fields — those are managed elsewhere by the level-up commit, + * not recomputed from scratch. + */ +import { applyAttributeBoost } from './apply-attribute-boost'; +import type { + AbilityAbbreviation, + CharacterContext, + ClassProgressionRow, + DerivedStats, + Proficiency, + WizardChoices, +} from './types'; +import { PROFICIENCY_BASE_BONUS } from './types'; + +/** Recompute pipeline input — extends CharacterContext with armor + HP-base fields. */ +export type RecomputeCharacter = CharacterContext & { + ancestryHp: number; + classHp: number; + armorAc: number; + armorProficiency: Proficiency; + dexCap: number; + /** Optional pre-existing rank for fortitude/reflex/will/perception/classDc — used as fallback. */ + fortitudeRank?: Proficiency; + reflexRank?: Proficiency; + willRank?: Proficiency; + perceptionRank?: Proficiency; + classDcRank?: Proficiency; + /** Class key ability — defaults to STR for Fighter; consumers may pass explicit value. */ + classKeyAbility?: AbilityAbbreviation; +}; + +function abilityModifier(score: number): number { + return Math.floor((score - 10) / 2); +} + +function proficiencyBonus(rank: Proficiency, level: number): number { + if (rank === 'UNTRAINED') return 0; + return PROFICIENCY_BASE_BONUS[rank] + level; +} + +/** Compute new ability scores by applying boosts from `choices.boostTargets`. */ +function applyBoosts( + current: Record, + boostTargets: readonly AbilityAbbreviation[] | undefined, +): Record { + const next: Record = { ...current }; + if (!boostTargets) return next; + for (const ability of boostTargets) { + next[ability] = applyAttributeBoost(next[ability]); + } + return next; +} + +/** + * Pure recompute pipeline. + * + * @param character - Character snapshot (current state, before commit) + * @param choices - Wizard choices (boost targets, etc.) + * @param progression - ClassProgression row for the new level + * @returns DerivedStats — fresh object, never mutates input + */ +export function recomputeDerivedStats( + character: RecomputeCharacter, + choices: WizardChoices, + progression: ClassProgressionRow, +): DerivedStats { + const newLevel = progression.level; + const newAbilities = applyBoosts(character.abilities, choices.boostTargets); + + const conMod = abilityModifier(newAbilities.CON); + const dexMod = abilityModifier(newAbilities.DEX); + const wisMod = abilityModifier(newAbilities.WIS); + const keyAbility = character.classKeyAbility ?? 'STR'; + const keyMod = abilityModifier(newAbilities[keyAbility]); + + // Resolve effective ranks: progression overrides take precedence; otherwise use + // the input character's pre-existing rank (or sensible defaults of TRAINED). + const fortRank: Proficiency = + progression.proficiencyChanges.fortitude ?? character.fortitudeRank ?? 'TRAINED'; + const refRank: Proficiency = + progression.proficiencyChanges.reflex ?? character.reflexRank ?? 'TRAINED'; + const willRank: Proficiency = + progression.proficiencyChanges.will ?? character.willRank ?? 'TRAINED'; + const percRank: Proficiency = + progression.proficiencyChanges.perception ?? character.perceptionRank ?? 'TRAINED'; + const classDcRank: Proficiency = + progression.proficiencyChanges.classDc ?? character.classDcRank ?? 'TRAINED'; + const armorRank: Proficiency = + progression.proficiencyChanges.ac ?? character.armorProficiency; + + const hpMax = character.ancestryHp + (character.classHp + conMod) * newLevel; + const ac = + 10 + + Math.min(dexMod, character.dexCap) + + character.armorAc + + proficiencyBonus(armorRank, newLevel); + const classDc = 10 + keyMod + proficiencyBonus(classDcRank, newLevel); + const perception = wisMod + proficiencyBonus(percRank, newLevel); + const fortitude = conMod + proficiencyBonus(fortRank, newLevel); + const reflex = dexMod + proficiencyBonus(refRank, newLevel); + const will = wisMod + proficiencyBonus(willRank, newLevel); + + return { + level: newLevel, + hpMax, + ac, + classDc, + perception, + fortitude, + reflex, + will, + }; +} From 70ec7bb3b70fe3b0cfdb2b86061d6403bd261281 Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:12:49 +0200 Subject: [PATCH 09/13] =?UTF-8?q?test(01-02):=20RED=20=E2=80=94=20compute-?= =?UTF-8?q?applicable-steps=20spec=20(D-10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5 RED phase: 13 failing tests covering wizard step ordering. - Fighter L5 exact step list (no FA, no caster) - L4 (boost-skip) and L3 (odd-level) edge cases - L2 has feats but no skill-increase - Free Archetype toggle (D-13) - Spellcaster toggle (D-18) - class-feature-choice when progression has choiceType (D-19) - Invariants: always starts with class-features, always ends with review Verified failure: module './compute-applicable-steps' not found. --- .../lib/compute-applicable-steps.spec.ts | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 server/src/modules/leveling/lib/compute-applicable-steps.spec.ts diff --git a/server/src/modules/leveling/lib/compute-applicable-steps.spec.ts b/server/src/modules/leveling/lib/compute-applicable-steps.spec.ts new file mode 100644 index 0000000..dc65212 --- /dev/null +++ b/server/src/modules/leveling/lib/compute-applicable-steps.spec.ts @@ -0,0 +1,184 @@ +import { computeApplicableSteps } from './compute-applicable-steps'; + +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'); + } + }); +}); From de07fc8f78fa572b4e8df6fa3922348b7669d9e0 Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:14:24 +0200 Subject: [PATCH 10/13] test(01-02): fix L5 expected step list (Rule 1 deviation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan's expected list for computeApplicableSteps(5, 'Fighter', ...) was PF2e-incorrect. Per CRB, class feats and skill feats are slotted at EVEN levels only (2, 4, 6, ..., 20). L5 is odd → no class/skill feat. Plan said: [class-features, boost, skill-increase, feat-class, feat-skill, feat-ancestry, review] PF2e-correct: [class-features, boost, skill-increase, feat-ancestry, review] The plan's other tests are internally consistent (L4 has feat-class, L3 does NOT have feat-class) — only the L5 case was misstated. Project's 'regelkonform' goal (CLAUDE.md, PROJECT.md) requires PF2e correctness above plan literalism. --- .../modules/leveling/lib/compute-applicable-steps.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/src/modules/leveling/lib/compute-applicable-steps.spec.ts b/server/src/modules/leveling/lib/compute-applicable-steps.spec.ts index dc65212..31686da 100644 --- a/server/src/modules/leveling/lib/compute-applicable-steps.spec.ts +++ b/server/src/modules/leveling/lib/compute-applicable-steps.spec.ts @@ -1,7 +1,10 @@ import { computeApplicableSteps } from './compute-applicable-steps'; describe('computeApplicableSteps — Fighter (martial, no FA, no caster)', () => { - it('at L5 returns [class-features, boost, skill-increase, feat-class, feat-skill, feat-ancestry, review]', () => { + it('at L5 returns [class-features, boost, skill-increase, feat-ancestry, review] (L5 is odd → no class/skill feat)', () => { + // PF2e CRB: class feats and skill feats are at even levels (2,4,6,...). + // L5 grants ability-boosts, skill-increase, and ancestry-feat — but no class/skill feats. + // (Plan's expected list at L5 was PF2e-incorrect; corrected here per project's "regelkonform" goal.) const steps = computeApplicableSteps({ targetLevel: 5, className: 'Fighter', @@ -14,8 +17,6 @@ describe('computeApplicableSteps — Fighter (martial, no FA, no caster)', () => 'class-features', 'boost', 'skill-increase', - 'feat-class', - 'feat-skill', 'feat-ancestry', 'review', ]); From d9ed18c86c6d640303934394015779635fc886a5 Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:14:35 +0200 Subject: [PATCH 11/13] feat(01-02): implement compute-applicable-steps (D-10 step ordering) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5 GREEN phase. Pure function returning ordered StepKind[] for a level-up wizard, conditional on: - targetLevel (boost levels, skill-increase levels, even/odd) - hasFreeArchetype (D-13) - isCaster (D-18) - classProgressionHasChoiceType (D-19) Step ordering per D-10: class-features → class-feature-choice → boost → skill-increase → feat-class/feat-skill (even levels) → feat-general (3,7,11,15,19) → feat-ancestry (5,9,13,17) → feat-archetype → spellcaster → review Invariants: always starts with class-features, always ends with review. 13 tests passing. --- .../leveling/lib/compute-applicable-steps.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 server/src/modules/leveling/lib/compute-applicable-steps.ts diff --git a/server/src/modules/leveling/lib/compute-applicable-steps.ts b/server/src/modules/leveling/lib/compute-applicable-steps.ts new file mode 100644 index 0000000..23bd2a7 --- /dev/null +++ b/server/src/modules/leveling/lib/compute-applicable-steps.ts @@ -0,0 +1,67 @@ +/** + * Pure function — given the level-up parameters, returns the ordered list of + * wizard step kinds the player must walk through (D-10 step ordering). + * + * No NestJS, no Prisma, no I/O. Output is fully determined by inputs. + */ +import type { StepKind } from './types'; + +export interface ComputeStepsInput { + targetLevel: number; + className: string; + hasFreeArchetype: boolean; + isCaster: boolean; + isSpontaneousCaster: boolean; + classProgressionHasChoiceType: boolean; +} + +/** PF2e ability-boost levels (every 5th level after 5). */ +const BOOST_LEVELS: ReadonlySet = new Set([5, 10, 15, 20]); + +/** PF2e skill-increase steps (level 3 and every 2 levels thereafter). */ +const SKILL_INCREASE_LEVELS: ReadonlySet = new Set([3, 5, 7, 9, 11, 13, 15, 17, 19]); + +/** General-feat slot levels (PF2e CRB). */ +const GENERAL_FEAT_LEVELS: ReadonlySet = new Set([3, 7, 11, 15, 19]); + +/** + * Ancestry-feat slot levels (PF2e CRB) — character creation grants level 1, but + * this function is for level-ups (1 is excluded by callers / handled below). + */ +const ANCESTRY_FEAT_LEVELS: ReadonlySet = new Set([1, 5, 9, 13, 17]); + +/** + * Returns the ordered list of wizard step kinds for a level-up. + * Step ordering follows D-10: + * class-features → class-feature-choice (if any) + * → boost (if level ∈ {5,10,15,20}) + * → skill-increase (if level ∈ {3,5,7,...,19}) + * → feat-class (if even level) + * → feat-skill (if even level) + * → feat-general (if level ∈ {3,7,11,15,19}) + * → feat-ancestry (if level ∈ {5,9,13,17}; excludes L1) + * → feat-archetype (if hasFreeArchetype) + * → spellcaster (if isCaster) + * → review + */ +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; +} From be6eaee6d0086b2099882d2a56108ab261c81fea Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:18:10 +0200 Subject: [PATCH 12/13] fix(01-02): TS-strict narrowing for EvalResult discriminated union Rule 1 deviation: TS strict mode (noImplicitAny + strict) couldn't narrow the EvalResult union via 'r.ok' direct access because the {unknown:true,raw} variant has no 'ok' property. Added explicit type-guard helpers (isUnknown, isOk, isFail) for both production and spec narrowing. Runtime behavior unchanged; tsc --noEmit now exits clean for the leveling lib. Files: - prereq-evaluator.ts: 3 type-guard functions, used in OR/AND walkers - prereq-evaluator.spec.ts: isFail() in lieu of result.ok=== checks --- .../leveling/lib/prereq-evaluator.spec.ts | 18 +++++++++------ .../modules/leveling/lib/prereq-evaluator.ts | 22 ++++++++++++++----- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/server/src/modules/leveling/lib/prereq-evaluator.spec.ts b/server/src/modules/leveling/lib/prereq-evaluator.spec.ts index bab4d3e..9dfe1a0 100644 --- a/server/src/modules/leveling/lib/prereq-evaluator.spec.ts +++ b/server/src/modules/leveling/lib/prereq-evaluator.spec.ts @@ -1,5 +1,9 @@ import { evaluatePrereq } from './prereq-evaluator'; -import type { CharacterContext } from './types'; +import type { CharacterContext, EvalResult } from './types'; + +function isFail(r: EvalResult): r is { ok: false; reason: string } { + return 'ok' in r && r.ok === false; +} function makeCtx(overrides: Partial = {}): CharacterContext { return { @@ -38,8 +42,8 @@ describe('evaluatePrereq — skill rank', () => { 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(isFail(result)).toBe(true); + if (isFail(result)) { expect(result.reason).toMatch(/Athletics/i); // German wording check — must contain a German keyword expect(result.reason).toMatch(/(benötig|fehlt|Voraussetzung|mindestens)/); @@ -49,7 +53,7 @@ describe('evaluatePrereq — skill rank', () => { 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); + expect(isFail(result)).toBe(true); }); }); @@ -67,7 +71,7 @@ describe('evaluatePrereq — disjunctive (OR-list)', () => { 'Trained in Arcana, Trained in Nature, or Trained in Religion', ctx, ); - expect(result.ok).toBe(false); + expect(isFail(result)).toBe(true); }); }); @@ -82,7 +86,7 @@ describe('evaluatePrereq — conjunctive (semicolon AND)', () => { 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); + expect(isFail(result)).toBe(true); }); }); @@ -95,7 +99,7 @@ describe('evaluatePrereq — bare feat name', () => { 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); + expect(isFail(result)).toBe(true); }); }); diff --git a/server/src/modules/leveling/lib/prereq-evaluator.ts b/server/src/modules/leveling/lib/prereq-evaluator.ts index 9fb21ad..20e2c70 100644 --- a/server/src/modules/leveling/lib/prereq-evaluator.ts +++ b/server/src/modules/leveling/lib/prereq-evaluator.ts @@ -291,6 +291,18 @@ type NodeResult = | { ok: false; reason: string } | { unknown: true; raw: string }; +function isUnknown(r: NodeResult): r is { unknown: true; raw: string } { + return 'unknown' in r; +} + +function isOk(r: NodeResult): r is { ok: true } { + return 'ok' in r && r.ok === true; +} + +function isFail(r: NodeResult): r is { ok: false; reason: string } { + return 'ok' in r && r.ok === false; +} + function evaluateNode(node: Node, ctx: CharacterContext, raw: string): NodeResult { if (node.kind === 'atom') { return evaluateAtom(node.atom, ctx); @@ -300,12 +312,12 @@ function evaluateNode(node: Node, ctx: CharacterContext, raw: string): NodeResul let firstFailReason: string | null = null; for (const child of node.children) { const r = evaluateNode(child, ctx, raw); - if ('unknown' in r && r.unknown) { + if (isUnknown(r)) { // UNKNOWN-aggressive: any unknown atom poisons the whole prereq. return { unknown: true, raw }; } - if (r.ok) return { ok: true }; - if (firstFailReason === null && !r.ok) { + if (isOk(r)) return { ok: true }; + if (firstFailReason === null && isFail(r)) { firstFailReason = r.reason; } } @@ -318,10 +330,10 @@ function evaluateNode(node: Node, ctx: CharacterContext, raw: string): NodeResul // AND for (const child of node.children) { const r = evaluateNode(child, ctx, raw); - if ('unknown' in r && r.unknown) { + if (isUnknown(r)) { return { unknown: true, raw }; } - if (!r.ok) return r; + if (isFail(r)) return r; } return { ok: true }; } From df29617c3ecc88bc5a568a4c5e4121fbc92df565 Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:21:00 +0200 Subject: [PATCH 13/13] docs(01-02): complete pure-function library plan Five pure-function modules (types + 4 logic) implemented strict-TDD. 46 passing Jest tests across 4 spec files. Pitfall #8 (boost-cap-at-18) and Pitfall #9 (no hpCurrent in output) both enforced by spec. 3 documented deviations: - Rule 3: created apply-attribute-boost.ts dependency (Plan 01-01 work not yet merged into worktree base; content matches 01-01 spec exactly) - Rule 1: corrected L5 expected step list (PF2e CRB has class/skill feats only at even levels; plan said feat-class+feat-skill at L5) - Rule 1: TS-strict type-guard helpers for EvalResult narrowing Requirements completed: LVL-02, LVL-06, LVL-09, LVL-10, LVL-01, LVL-13, LVL-14 --- .../01-02-SUMMARY.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 .planning/phases/01-level-up-pf2e-regelkonform/01-02-SUMMARY.md diff --git a/.planning/phases/01-level-up-pf2e-regelkonform/01-02-SUMMARY.md b/.planning/phases/01-level-up-pf2e-regelkonform/01-02-SUMMARY.md new file mode 100644 index 0000000..08a14d4 --- /dev/null +++ b/.planning/phases/01-level-up-pf2e-regelkonform/01-02-SUMMARY.md @@ -0,0 +1,217 @@ +--- +phase: 01-level-up-pf2e-regelkonform +plan: 02 +subsystem: testing +tags: [pure-functions, jest, tdd, level-up, prereq-evaluator, recompute, pf2e-rules] + +# Dependency graph +requires: + - phase: 01-level-up-pf2e-regelkonform + provides: applyAttributeBoost + AbilityAbbreviation (Plan 01-01) — boost-cap-at-18 helper +provides: + - Five pure-function modules (types.ts + 4 logic modules) covering all PF2e level-up math + - skill-increase-cap (LVL-06) — TRAINED→EXPERT@L3, EXPERT→MASTER@L7, MASTER→LEGENDARY@L15 + - prereq-evaluator (D-01..D-04) — DSL parser + evaluator + German formatter, UNKNOWN-aggressive + - recompute-derived-stats (Pitfall #8/#9 safe) — pure pipeline, never outputs current/temp HP fields + - compute-applicable-steps (D-10 ordering) — wizard step list per (level, FA, caster, choiceType) + - 46 passing Jest tests across 4 specs (≥17 prereq, 13 steps, 9 skill, 5 recompute) + - Test discipline pattern: strict RED→GREEN per module, separate commits +affects: [Plan 01-04 LevelingService integration, Plan 01-05 wizard UI preview, Plan 01-03 progression seed] + +# Tech tracking +tech-stack: + added: [] # No new dependencies — ts-jest + jest already present + patterns: + - "Pure-function lib (no NestJS, no Prisma, no I/O) under server/src/modules//lib/" + - "Discriminated-union return values with explicit type-guard helpers for TS-strict narrowing" + - "Sibling .spec.ts test files using Jest's testRegex auto-discovery" + - "TDD: spec written first (RED commit) → minimal implementation (GREEN commit)" + +key-files: + created: + - server/src/modules/leveling/lib/types.ts + - 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/apply-attribute-boost.ts # Rule 3 dependency from Plan 01-01 + modified: [] + +key-decisions: + - "Type-guard helpers (isUnknown, isOk, isFail) preferred over discriminant property access for TS-strict narrowing" + - "OR-list parser uses Oxford-comma normalization (', or ' → ' or ') before tokenizing to avoid leftover 'or' prefix" + - "Feat-name heuristic restricts to ≤4 words and rejects free-text function-words (you/a/the/of/and) to prevent sentence-as-feat misclassification" + - "L5 step list deviates from plan: PF2e CRB has class/skill feats only at EVEN levels; plan's L5 list was rules-incorrect" + - "Boost-cap-at-18 enforced via applyAttributeBoost() import — math is not duplicated in recompute" + - "DerivedStats output object NEVER includes hpCurrent/hpTemp fields — Pitfall #9 enforced by spec assertion AND by absence of those literal strings in production code" + +patterns-established: + - "RED commit (failing spec) → GREEN commit (minimal impl) gate sequence per module" + - "UNKNOWN-aggressive prereq evaluation: any non-classifiable atom poisons the whole clause (per D-03)" + - "ClassProgression-driven proficiency overrides with character pre-existing rank as fallback" + - "German user-facing reason strings: 'Du benötigst...', 'Dir fehlt das Talent...', 'Voraussetzung nicht erfüllt: ...'" + +requirements-completed: [LVL-02, LVL-06, LVL-09, LVL-10, LVL-01, LVL-13, LVL-14] + +# Metrics +duration: ~30min +completed: 2026-04-27 +--- + +# Phase 1 Plan 02: Level-Up Pure-Function Library Summary + +**Five pure-function modules (skill-cap, prereq-DSL, recompute, step-ordering, shared types) implemented strict-TDD with 46 passing Jest tests, establishing the test discipline for the Level-Up subsystem and isolating bug-prone PF2e math (Pitfall #8/#9) behind a fully-tested boundary.** + +## Tasks + +| # | Task | Status | Commits | +|---|------|--------|---------| +| 0 | Add apply-attribute-boost dependency (Rule 3 — Plan 01-01 work not merged into worktree base) | Done | 7e40449 | +| 1 | types.ts — shared type vocabulary | Done | 4d2cb5e | +| 2 | skill-increase-cap (RED→GREEN) | Done | 3a4267d, f189750 | +| 3 | prereq-evaluator (RED→GREEN) | Done | 66d9d5c, da82d9b | +| 4 | recompute-derived-stats (RED→GREEN) | Done | 8dd55b6, 6011024 | +| 5 | compute-applicable-steps (RED→GREEN) | Done | 70ec7bb, de07fc8, d9ed18c | +| 6 | Full leveling test suite gate | Done (46 passing) | (verification only) | +| — | TS-strict narrowing fix for discriminated unions | Done (Rule 1) | be6eaee | + +## Test Counts + +| Spec File | Tests | +|-----------|-------| +| skill-increase-cap.spec.ts | 9 | +| prereq-evaluator.spec.ts | 19 | +| recompute-derived-stats.spec.ts | 5 | +| compute-applicable-steps.spec.ts | 13 | +| **Total** | **46** | + +The plan target of "≥50 tests" assumed Plan 01-01's 9-test apply-attribute-boost.spec.ts would be present (5 specs, ≥50 tests). In this worktree, Plan 01-01 work is not yet merged into the base — only its production module was created here as a Rule 3 dependency. After orchestrator merge, the combined count will reach 55. This plan delivers all of its own 46 tests, which fully cover VALIDATION.md rows 1-W1-06 through 1-W1-29. + +Run command: +``` +cd server && npm test -- --testPathPatterns=leveling +``` + +Output: +``` +PASS src/modules/leveling/lib/recompute-derived-stats.spec.ts +PASS src/modules/leveling/lib/skill-increase-cap.spec.ts +PASS src/modules/leveling/lib/compute-applicable-steps.spec.ts +PASS src/modules/leveling/lib/prereq-evaluator.spec.ts + +Test Suites: 4 passed, 4 total +Tests: 46 passed, 46 total +``` + +`cd server && npx tsc --noEmit -p tsconfig.json` — exits 0 for the leveling lib (errors elsewhere are pre-existing Prisma-client codegen issues out of scope per Scope Boundary rule). + +## Verification Checklist + +- [x] All 5 production modules + types.ts created in `server/src/modules/leveling/lib/` +- [x] All 4 spec files created (5th — apply-attribute-boost.spec.ts — owned by Plan 01-01) +- [x] TDD discipline: each module's spec was written first and observed failing (RED commit) before implementation (GREEN commit) +- [x] Pitfall #8 (boost-cap-at-18) enforced — recompute test for CON 18 → 19 (not 20) +- [x] Pitfall #9 (no hpCurrent in output) enforced — `expect(result).not.toHaveProperty('hpCurrent')` + literal string absent from production code +- [x] D-01 evaluable patterns covered: skill rank, OR-list, AND-list, feat name, heritage, class +- [x] D-02 non-evaluable patterns return `{unknown:true}`: spellcasting, deity, age, vision, free-text +- [x] Step ordering matches D-10 (with PF2e correction at L5 — see Deviations) +- [x] Zero `: any` types in production code (only English-prose use of word "any" in a JSDoc comment) +- [x] Zero `@nestjs/` imports in any lib file +- [x] Zero Prisma client imports in any lib file +- [x] German failure reasons in prereq-evaluator (D-15) + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 — Blocking] Created apply-attribute-boost.ts in this worktree** +- **Found during:** Task 1 (types.ts imports `AbilityAbbreviation` from `./apply-attribute-boost`) +- **Issue:** Plan 01-02 depends on Plan 01-01's `apply-attribute-boost.ts` module, but Plan 01-01's work is on a separate parallel-execution worktree branch and not yet merged into this worktree's base (`096edbf`). Without it, types.ts and recompute-derived-stats.ts cannot compile. +- **Fix:** Created `server/src/modules/leveling/lib/apply-attribute-boost.ts` with content that exactly matches the Plan 01-01 specification (verbatim from 01-01-PLAN.md lines 351-380). When orchestrator merges Plan 01-01's worktree branch, content will be byte-identical and merge will be conflict-free. +- **Files modified:** `server/src/modules/leveling/lib/apply-attribute-boost.ts` +- **Commit:** 7e40449 + +**2. [Rule 1 — Bug] Fixed L5 expected step list (PF2e rules correction)** +- **Found during:** Task 5 GREEN phase (one of 13 tests failed against the implementation) +- **Issue:** The plan's expected step list for `computeApplicableSteps(5, 'Fighter', ...)` included `feat-class` and `feat-skill`. PF2e CRB places class feats and skill feats at EVEN levels only (2, 4, 6, ..., 20). L5 is odd → no class/skill feat slot. The plan's other tests at L4 (even, has feat-class+feat-skill) and L3 (odd, NOT has feat-class) are internally consistent with the even-level rule; only L5 was misstated. +- **Fix:** Changed expected list at L5 to `['class-features', 'boost', 'skill-increase', 'feat-ancestry', 'review']` and added a comment block explaining the deviation. The implementation rule "feat-class/feat-skill on even levels" is unchanged because the project's "regelkonform" goal (CLAUDE.md / PROJECT.md) requires PF2e correctness above plan literalism. +- **Files modified:** `server/src/modules/leveling/lib/compute-applicable-steps.spec.ts` +- **Commit:** de07fc8 + +**3. [Rule 1 — Bug] Type-guard helpers for TS-strict EvalResult narrowing** +- **Found during:** Task 6 (`tsc --noEmit` after all RED/GREEN commits) +- **Issue:** TypeScript strict mode could not narrow `EvalResult = {ok:true} | {ok:false; reason} | {unknown:true; raw}` via `r.ok` access — the unknown variant has no `ok` property. Tests passed at runtime but `tsc` reported `error TS2339: Property 'ok' does not exist on type 'EvalResult'` in 8 lines of the spec and 4 lines of the production walker. +- **Fix:** Added explicit type-guard helpers (`isUnknown`, `isOk`, `isFail` in production; `isFail` in spec) that use `'ok' in r` membership checks. Replaced direct `r.ok` accesses with guard calls. Runtime behavior unchanged. +- **Files modified:** `server/src/modules/leveling/lib/prereq-evaluator.ts`, `server/src/modules/leveling/lib/prereq-evaluator.spec.ts` +- **Commit:** be6eaee + +### Authentication Gates + +None — this plan introduces no I/O. + +### Architectural Changes + +None — pure-function modules only. + +## Notes on Prereq-Evaluator Edge Cases (for future grammar tuning) + +While implementing the parser, two interesting edge cases came up that future PF2e prereq corpora may stress: + +1. **Oxford-comma "X, Y, or Z" splitting.** A naive `,\s*|\s+or\s+` regex split leaves a stray leading "or " on the last segment because the `,\s*` alternative consumes the comma but stops before the "or" keyword. Solution: pre-normalize `,\s*or\s+` → ` or ` before splitting. + +2. **Free-text-as-feat misclassification.** Without guards, a sentence like "You worship a god of fire and destruction" matches the feat fallback regex `^[A-Z][A-Za-z' \-]*$` (it's all letters/spaces and starts with uppercase). Added two heuristics: ≤4 words AND no presence of common function-words (you, a, the, of, and, to, with, from, by). Future PF2e feats with names like "Heir to the Vows of Asmodeus" would fail this guard — but no such canonical feat exists today, and the planner's UNKNOWN-aggressive intent (D-03) prefers false-unknown over false-evaluable. + +## TDD Gate Compliance + +For each of Tasks 2–5, a `test(...)` commit (RED gate) precedes the corresponding `feat(...)` commit (GREEN gate). Verified in git log: + +| Module | RED commit | GREEN commit | +|--------|-----------|---------------| +| skill-increase-cap | 3a4267d | f189750 | +| prereq-evaluator | 66d9d5c | da82d9b | +| recompute-derived-stats | 8dd55b6 | 6011024 | +| compute-applicable-steps | 70ec7bb | d9ed18c | + +The compute-applicable-steps module additionally has commit de07fc8 (test fix between RED and GREEN, separated as a deviation rather than amended into RED to preserve the audit trail of the plan-vs-PF2e-rules conflict). + +## Confirmation: types.ts Imported by All Downstream Modules + +```bash +grep -l "from './types'" server/src/modules/leveling/lib/*.ts +``` +Result: +- `compute-applicable-steps.ts` (StepKind) +- `prereq-evaluator.ts` (CharacterContext, EvalResult, Proficiency) +- `recompute-derived-stats.ts` (CharacterContext, ClassProgressionRow, DerivedStats, Proficiency, WizardChoices, PROFICIENCY_BASE_BONUS, AbilityAbbreviation) +- `skill-increase-cap.ts` (Proficiency) + +All four logic modules import from `./types` — no duplicate type definitions. + +## Self-Check: PASSED + +- `server/src/modules/leveling/lib/types.ts` — FOUND +- `server/src/modules/leveling/lib/skill-increase-cap.ts` — FOUND +- `server/src/modules/leveling/lib/skill-increase-cap.spec.ts` — FOUND +- `server/src/modules/leveling/lib/prereq-evaluator.ts` — FOUND +- `server/src/modules/leveling/lib/prereq-evaluator.spec.ts` — FOUND +- `server/src/modules/leveling/lib/recompute-derived-stats.ts` — FOUND +- `server/src/modules/leveling/lib/recompute-derived-stats.spec.ts` — FOUND +- `server/src/modules/leveling/lib/compute-applicable-steps.ts` — FOUND +- `server/src/modules/leveling/lib/compute-applicable-steps.spec.ts` — FOUND +- `server/src/modules/leveling/lib/apply-attribute-boost.ts` — FOUND (Rule 3 dependency) +- Commit 7e40449 — FOUND +- Commit 4d2cb5e — FOUND +- Commit 3a4267d — FOUND +- Commit f189750 — FOUND +- Commit 66d9d5c — FOUND +- Commit da82d9b — FOUND +- Commit 8dd55b6 — FOUND +- Commit 6011024 — FOUND +- Commit 70ec7bb — FOUND +- Commit de07fc8 — FOUND +- Commit d9ed18c — FOUND +- Commit be6eaee — FOUND