feat(01-02): implement recompute-derived-stats (Pitfall #8/#9 safe)

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.
This commit is contained in:
2026-04-27 14:11:58 +02:00
parent 8dd55b6fa9
commit 6011024e87

View File

@@ -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<AbilityAbbreviation, number>,
boostTargets: readonly AbilityAbbreviation[] | undefined,
): Record<AbilityAbbreviation, number> {
const next: Record<AbilityAbbreviation, number> = { ...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,
};
}