Files
ttrpg_manager/src/lib/mechanics/creatureState.ts
T
NilsBriggen f602c832e6 Fix condition mechanics: per-statistic PF2e penalties, 5e exhaustion, slowed, frightened decay
- PF2e clumsy/enfeebled/drained/stupefied now penalise their correct, independent
  statistic family (Dex/Str/Con/mental) instead of collapsing into one max'd '−N status'.
  Frightened/sickened are 'all checks'. Distinct badges per family.
- Slowed is action loss only (no roll penalty); fatigued shows flat −1 AC/saves —
  neither is value-scaled anymore.
- 5e Exhaustion now derives its cumulative level effects (ability-check disadvantage L1,
  speed halved L2, attack + all-save disadvantage L3, speed 0 L5, dead L6).
- 5e Prone badge is range-qualified (adv. melee / disadv. ranged).
- New tickConditionsEndOfTurn: PF2e Frightened decays by 1 at end of turn (wired into
  nextTurn via the combat tracker), removed at 0.

MechanicalState.statusPenalty (single number) → statusPenalties (per-family map).
Tests updated + added. Deferred: Drained HP/max-HP reduction (needs creature level
threaded into the damage flow).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:16:33 +02:00

166 lines
6.7 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>>;
/** short human-readable badges for the UI, e.g. "Speed 0", "2 Str" */
badges: string[];
}
/** 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[]): 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 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 >= 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;
}
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);
}
}
// 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]}`);
}
badges.push(...extraBadges);
return {
speed: speedZero ? 0 : speed,
attackModifier,
attacksAgainstModifier,
incapacitated,
saveModifiers,
abilityCheckDisadvantage,
statusPenalties,
badges,
};
}
/**
* 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;
}