From 8dd55b6fa9a3998de6fd1024caeba2b835f4fb59 Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:10:28 +0200 Subject: [PATCH] =?UTF-8?q?test(01-02):=20RED=20=E2=80=94=20recompute-deri?= =?UTF-8?q?ved-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); + }); +});