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