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>
This commit is contained in:
2026-06-09 17:16:33 +02:00
parent da3dbbedba
commit f602c832e6
6 changed files with 182 additions and 40 deletions
+22 -10
View File
@@ -6,6 +6,13 @@ import type { AbilityKey, AdvState } from './types';
* hardcoded `if`s in combat code. `deriveState` (creatureState.ts) folds these
* into a single query the tracker and dice roller ask.
*/
/**
* Which statistic family a PF2e value-scaled status penalty applies to. Different
* families are independent (they target different rolls) and must never be compared
* against each other — clumsy hits Dex-based rolls, enfeebled hits Str-based rolls, etc.
*/
export type PenaltyTarget = 'all' | 'str' | 'dex' | 'con' | 'mental';
export interface ConditionEffect {
/** sets effective speed to 0 */
speedZero?: boolean;
@@ -19,8 +26,11 @@ export interface ConditionEffect {
saves?: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>>;
/** cannot take actions or reactions */
incapacitated?: boolean;
/** pf2e: a numeric status penalty scaling with the condition's value */
statusPenalty?: boolean;
/**
* pf2e: a value-scaled status penalty applied to this statistic family. The
* penalty magnitude is the condition's value; family decides which rolls it hits.
*/
statusPenalty?: PenaltyTarget;
}
const ALL_PHYS: Partial<Record<AbilityKey, 'auto-fail'>> = { str: 'auto-fail', dex: 'auto-fail' };
@@ -48,22 +58,24 @@ export const CONDITION_EFFECTS_5E: Record<string, ConditionEffect> = {
*/
export const CONDITION_EFFECTS_PF2E: Record<string, ConditionEffect> = {
blinded: { attacksAgainst: 'advantage' },
clumsy: { statusPenalty: true },
drained: { statusPenalty: true },
// Each valued condition penalises a DIFFERENT statistic family (never compared):
clumsy: { statusPenalty: 'dex' }, // AC, Reflex, Dex attacks/skills
drained: { statusPenalty: 'con' }, // Fortitude (also reduces HP — applied separately)
enfeebled: { statusPenalty: 'str' }, // Str attacks/damage, Athletics
stupefied: { statusPenalty: 'mental' }, // spell attacks/DCs, mental checks
frightened: { statusPenalty: 'all' }, // every check and DC; decays 1/turn
sickened: { statusPenalty: 'all' }, // every check and DC
dazzled: { attacksAgainst: 'advantage' },
enfeebled: { statusPenalty: true },
fatigued: { statusPenalty: true },
frightened: { statusPenalty: true },
// fatigued (flat -1 AC/saves) and slowed (action loss) are NOT value-scaled — see deriveState.
fatigued: {},
slowed: {},
grabbed: { speedZero: true, attacksAgainst: 'advantage' },
immobilized: { speedZero: true },
paralyzed: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage' },
petrified: { incapacitated: true, speedZero: true },
prone: { attacksAgainst: 'advantage' },
restrained: { speedZero: true, incapacitated: false, attacksAgainst: 'advantage' },
sickened: { statusPenalty: true },
slowed: { statusPenalty: true },
stunned: { incapacitated: true },
stupefied: { statusPenalty: true },
unconscious: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage' },
'off-guard': { attacksAgainst: 'advantage' },
};