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:
122
server/src/modules/leveling/lib/recompute-derived-stats.ts
Normal file
122
server/src/modules/leveling/lib/recompute-derived-stats.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user