From 6011024e87a71c036b31b70811cc26c69c50b22d Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:11:58 +0200 Subject: [PATCH] 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, + }; +}