Files
ttrpg_manager/src/lib/mechanics/creatureState.ts
T
NilsBriggen 8fd530df73 Perfection pass: complete level-up/build flows + dying polish
Second adversarial audit of the freshly-shipped code; fixed bugs + completeness gaps.

- abilityBuild stays in sync with totals on level-up (appendLevelIncreases)
- 5e feat picker in level-up; PF2e feat-name autocomplete; expertise sets ranks
- Higher-level creation collects PF2e skill increases + 5e expertise
- PF2e heritage: schema field, wizard picker, sheet, Pathbuilder import
- 5e Hit Dice as a tracked resource (half-level long-rest recovery via recoverStep)
- Dying flow: knockout sets HP 0, stays unconscious at 0 HP, healing wakes +Wounded,
  Heroic Recovery (spend hero points); damage-while-dying + recovery reminders (pf2e)
- Healing caps at effective max (drained/exhaustion-4) on sheet, tracker, and rest;
  massive-damage death uses effective max; persistent-damage badge
- UX: in-place valued-condition steppers, point-buy budget caps, prepared-vs-known
  spell guidance, granted skills in review, system-gated score generator

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:37:01 +02:00

231 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ABILITY_KEYS, type SystemId } from '@/lib/rules';
import type { Condition } from '@/lib/schemas/common';
import { conditionEffects, type PenaltyTarget } from './conditionEffects';
import type { AbilityKey, AdvState } from './types';
export interface MechanicalState {
/** effective speed after conditions (0 if zeroed, halved by exhaustion 2-4, etc.) */
speed: number;
/** advantage state on THIS creature's attack rolls (5e) */
attackModifier: AdvState;
/** advantage state on attacks made AGAINST this creature (5e) */
attacksAgainstModifier: AdvState;
/** cannot take actions/reactions */
incapacitated: boolean;
/** per-ability save penalties (auto-fail dominates disadvantage) */
saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>>;
/** disadvantage on ability checks (5e) */
abilityCheckDisadvantage: boolean;
/**
* pf2e: value-scaled status penalties, by statistic family. Penalties within a
* family don't stack (worst value wins); different families are independent and
* apply to different rolls, so they are tracked separately — never summed.
*/
statusPenalties: Partial<Record<PenaltyTarget, number>>;
/**
* Reduction to maximum HP from conditions: 5e exhaustion 4 halves the max; pf2e
* drained N subtracts level × N. 0 when no reduction applies (or when the caller
* passes no hpMax/level context).
*/
hpMaxReduction: number;
/** short human-readable badges for the UI, e.g. "Speed 0", "2 Str" */
badges: string[];
}
/** Optional numeric context so deriveState can compute HP-max reductions. */
export interface DeriveContext {
/** the creature's *base* maximum HP (before reduction), for 5e exhaustion 4. */
hpMax?: number;
/** the creature's level, for pf2e drained (level × value). */
level?: number;
}
/** Combine two advantage states by the 5e rule: opposing sources cancel to normal. */
function combineAdv(a: AdvState, b: AdvState): AdvState {
if (a === b) return a;
if (a === 'normal') return b;
if (b === 'normal') return a;
return 'normal'; // one advantage + one disadvantage cancel
}
const PENALTY_LABEL: Record<PenaltyTarget, string> = {
all: 'all checks',
str: 'Str',
dex: 'Dex/AC',
con: 'Fort',
mental: 'spell/mental',
};
/**
* Fold a creature's conditions into a single mechanical read-model. Pure and
* synchronous — combat, the dice roller, and the player view all call this
* (the player view ships the same table and derives effects locally from the
* broadcast condition names, so no derived state crosses the wire).
*
* Unknown / homebrew condition names contribute nothing (they remain visible
* labels), exactly as before.
*/
export function deriveState(system: SystemId, baseSpeed: number, conditions: Condition[], ctx?: DeriveContext): MechanicalState {
const table = conditionEffects(system);
let speed = baseSpeed;
let speedZero = false;
let attackModifier: AdvState = 'normal';
let attacksAgainstModifier: AdvState = 'normal';
let incapacitated = false;
let abilityCheckDisadvantage = false;
let allSavesDisadvantage = false;
let hpMaxReduction = 0;
let prone5e = false;
const statusPenalties: Partial<Record<PenaltyTarget, number>> = {};
const saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>> = {};
const extraBadges: string[] = [];
for (const cond of conditions) {
const name = cond.name.trim().toLowerCase();
// --- 5e Exhaustion: cumulative level-based effects (PHB). ---
if (system === '5e' && name === 'exhaustion' && cond.value) {
const lvl = cond.value;
if (lvl >= 1) abilityCheckDisadvantage = true;
if (lvl >= 2) speed = Math.min(speed, Math.floor(baseSpeed / 2)); // speed halved
if (lvl >= 3) { attackModifier = combineAdv(attackModifier, 'disadvantage'); allSavesDisadvantage = true; }
if (lvl >= 4 && ctx?.hpMax) hpMaxReduction = Math.floor(ctx.hpMax / 2); // HP maximum halved
if (lvl >= 5) speedZero = true; // speed 0
extraBadges.push(lvl >= 6 ? 'Exhaustion 6 (dead)' : `Exhaustion ${lvl}`);
continue;
}
// --- pf2e Slowed: action loss only, no roll penalty. ---
if (system === 'pf2e' && name === 'slowed') {
extraBadges.push(cond.value ? `Slowed ${cond.value} (${cond.value} actions)` : 'Slowed');
continue;
}
// --- pf2e Fatigued: flat 1 to AC and saves (not value-scaled). ---
if (system === 'pf2e' && name === 'fatigued') {
extraBadges.push('Fatigued (1 AC & saves)');
continue;
}
// --- pf2e Quickened: grants one extra action; no roll penalty. ---
if (system === 'pf2e' && name === 'quickened') {
extraBadges.push('Quickened (+1 action)');
continue;
}
// --- pf2e Persistent Damage: the human rolls it; we surface the procedure. ---
if (system === 'pf2e' && name === 'persistent damage') {
extraBadges.push(cond.value
? `Persistent damage ${cond.value} (apply at end of turn, then DC 15 flat check to end)`
: 'Persistent damage (apply at end of turn, then DC 15 flat check to end)');
continue;
}
const eff = table[name];
if (!eff) continue;
if (eff.speedZero) speedZero = true;
if (eff.incapacitated) incapacitated = true;
if (eff.abilityChecks) abilityCheckDisadvantage = true;
if (eff.attackRolls) attackModifier = combineAdv(attackModifier, eff.attackRolls);
if (eff.attacksAgainst) attacksAgainstModifier = combineAdv(attacksAgainstModifier, eff.attacksAgainst);
if (system === '5e' && name === 'prone') prone5e = true;
if (eff.saves) {
for (const [ability, level] of Object.entries(eff.saves) as [AbilityKey, 'disadvantage' | 'auto-fail'][]) {
// auto-fail dominates disadvantage
if (level === 'auto-fail' || saveModifiers[ability] === undefined) saveModifiers[ability] = level;
}
}
// pf2e: value-scaled penalty per statistic family — worst value within a family.
if (eff.statusPenalty && cond.value !== undefined) {
const fam = eff.statusPenalty;
statusPenalties[fam] = Math.max(statusPenalties[fam] ?? 0, cond.value);
}
// pf2e Drained N also reduces maximum HP by level × N.
if (system === 'pf2e' && name === 'drained' && cond.value && ctx?.level) {
hpMaxReduction += ctx.level * cond.value;
}
}
// 5e exhaustion 3+ imposes disadvantage on all saves (unless already auto-fail).
if (allSavesDisadvantage) {
for (const a of ABILITY_KEYS) if (saveModifiers[a] !== 'auto-fail') saveModifiers[a] = 'disadvantage';
}
const badges: string[] = [];
if (speedZero) badges.push('Speed 0');
else if (speed !== baseSpeed) badges.push(`Speed ${speed}`);
if (incapacitated) badges.push('Incapacitated');
if (attackModifier === 'disadvantage') badges.push('Disadv. attacks');
if (attackModifier === 'advantage') badges.push('Adv. attacks');
if (attacksAgainstModifier === 'advantage') {
// Prone is range-dependent: melee attackers have advantage, ranged have disadvantage.
badges.push(prone5e ? 'Attacked: adv. melee / disadv. ranged' : 'Attacked w/ adv.');
}
for (const [fam, val] of Object.entries(statusPenalties) as [PenaltyTarget, number][]) {
if (val > 0) badges.push(`${val} ${PENALTY_LABEL[fam]}`);
}
if (hpMaxReduction > 0) badges.push(`Max HP ${hpMaxReduction}`);
badges.push(...extraBadges);
return {
speed: speedZero ? 0 : speed,
attackModifier,
attacksAgainstModifier,
incapacitated,
saveModifiers,
abilityCheckDisadvantage,
statusPenalties,
hpMaxReduction,
badges,
};
}
/**
* Effective maximum HP after condition-based reductions: 5e exhaustion 4 halves the
* maximum; pf2e Drained N subtracts level × N. Pure — the sheet and tracker show this
* reduced max but never mutate the stored `hp.max`, matching the surface-don't-apply
* rule. Returns the reduced max plus human-readable reasons for the UI to explain it.
*/
export function deriveEffectiveMaxHp(input: {
system: SystemId;
baseMaxHp: number;
level: number;
/** 5e exhaustion level (from defenses), if tracked separately from conditions. */
exhaustion?: number;
conditions: Condition[];
}): { max: number; reduction: number; reasons: string[] } {
let reduction = 0;
const reasons: string[] = [];
if (input.system === '5e' && (input.exhaustion ?? 0) >= 4) {
reduction += Math.floor(input.baseMaxHp / 2);
reasons.push('Exhaustion 4 — maximum HP halved');
}
if (input.system === 'pf2e') {
const drained = input.conditions.find((c) => c.name.trim().toLowerCase() === 'drained')?.value ?? 0;
if (drained > 0) {
const drop = input.level * drained;
reduction += drop;
reasons.push(`Drained ${drained}${drop} maximum HP`);
}
}
return { max: Math.max(1, input.baseMaxHp - reduction), reduction, reasons };
}
/**
* End-of-turn auto-decay for valued conditions. In PF2e, Frightened decreases by 1
* at the end of each of the affected creature's turns (removed at 0). Returns a new
* conditions array; callers persist it when a combatant's turn ends.
*/
export function tickConditionsEndOfTurn(system: SystemId, conditions: Condition[]): Condition[] {
if (system !== 'pf2e') return conditions;
let changed = false;
const next: Condition[] = [];
for (const c of conditions) {
if (c.name.trim().toLowerCase() === 'frightened' && c.value !== undefined) {
changed = true;
const value = c.value - 1;
if (value > 0) next.push({ ...c, value });
// value 0 → condition ends, drop it
} else {
next.push(c);
}
}
return changed ? next : conditions;
}