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
+2 -2
View File
@@ -89,8 +89,8 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
/** Advance the turn; remind (don't roll) when a downed PC's turn begins. */
const advanceTurn = () => {
mutate(nextTurn);
const upcoming = currentCombatant(nextTurn(encounter));
mutate((e) => nextTurn(e, campaign.system));
const upcoming = currentCombatant(nextTurn(encounter, campaign.system));
if (upcoming && upcoming.kind !== 'monster' && upcoming.hp.current <= 0 && pcCharacter(upcoming)) {
void encountersRepo.mutate(encounter.id, (e) => logEvent(e, `${upcoming.name} is down — make a death saving throw.`));
}
+23 -5
View File
@@ -1,4 +1,6 @@
import type { Combatant, Encounter } from '@/lib/schemas/encounter';
import type { SystemId } from '@/lib/rules';
import { tickConditionsEndOfTurn } from '@/lib/mechanics/creatureState';
/**
* Pure combat-state transitions. Every function returns a NEW encounter and
@@ -64,14 +66,30 @@ export function endEncounter(enc: Encounter): Encounter {
/**
* Advance to the next combatant; wrapping past the end increments the round.
* Ticks the newly-active combatant's timed conditions and logs expirations.
*
* When `system` is provided, the combatant whose turn is *ending* also has its
* end-of-turn condition decay applied (PF2e Frightened drops by 1).
*/
export function nextTurn(enc: Encounter): Encounter {
export function nextTurn(enc: Encounter, system?: SystemId): Encounter {
if (enc.combatants.length === 0) return { ...enc, turnIndex: 0 };
const atEnd = enc.turnIndex >= enc.combatants.length - 1;
const turnIndex = atEnd ? 0 : enc.turnIndex + 1;
const round = atEnd ? enc.round + 1 : enc.round;
let next: Encounter = { ...enc, turnIndex, round };
// End-of-turn auto-decay for the combatant whose turn is ending (pf2e Frightened).
let from: Encounter = enc;
if (system) {
const outgoing = enc.combatants[enc.turnIndex];
if (outgoing) {
const decayed = tickConditionsEndOfTurn(system, outgoing.conditions);
if (decayed !== outgoing.conditions) {
from = { ...enc, combatants: enc.combatants.map((c, i) => (i === enc.turnIndex ? { ...c, conditions: decayed } : c)) };
}
}
}
const atEnd = from.turnIndex >= from.combatants.length - 1;
const turnIndex = atEnd ? 0 : from.turnIndex + 1;
const round = atEnd ? from.round + 1 : from.round;
let next: Encounter = { ...from, turnIndex, round };
if (atEnd) next = logEvent(next, `— Round ${round}`);
// Tick the combatant whose turn is now beginning.
+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' },
};
+42 -7
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { deriveState } from './creatureState';
import { deriveState, tickConditionsEndOfTurn } from './creatureState';
import { concentrationDC } from './cast';
import type { Condition } from '@/lib/schemas/common';
@@ -44,18 +44,46 @@ describe('deriveState (5e)', () => {
expect(s).toMatchObject({ speed: 30, attackModifier: 'normal', incapacitated: false });
expect(s.badges).toEqual([]);
});
it('exhaustion applies cumulative level effects (3 = ability-check + attack + save disadvantage, speed halved)', () => {
const s = deriveState('5e', 30, [cond('Exhaustion', 3)]);
expect(s.abilityCheckDisadvantage).toBe(true);
expect(s.attackModifier).toBe('disadvantage');
expect(s.saveModifiers.wis).toBe('disadvantage');
expect(s.speed).toBe(15); // halved at level 2+
expect(s.badges).toContain('Exhaustion 3');
});
it('exhaustion 5 zeroes speed', () => {
expect(deriveState('5e', 30, [cond('Exhaustion', 5)]).speed).toBe(0);
});
it('prone badge is range-qualified (advantage melee, disadvantage ranged)', () => {
const s = deriveState('5e', 30, [cond('Prone')]);
expect(s.badges).toContain('Attacked: adv. melee / disadv. ranged');
});
});
describe('deriveState (pf2e)', () => {
it('frightened contributes a numeric status penalty equal to its value', () => {
it('frightened is an all-checks penalty equal to its value', () => {
const s = deriveState('pf2e', 25, [cond('Frightened', 2)]);
expect(s.statusPenalty).toBe(2);
expect(s.badges).toContain('2 status');
expect(s.statusPenalties.all).toBe(2);
expect(s.badges).toContain('2 all checks');
});
it('status penalties do not stack — worst value wins', () => {
const s = deriveState('pf2e', 25, [cond('Frightened', 1), cond('Sickened', 3)]);
expect(s.statusPenalty).toBe(3);
it('same-family penalties take the worst; different families stay independent', () => {
const both = deriveState('pf2e', 25, [cond('Frightened', 1), cond('Sickened', 3)]);
expect(both.statusPenalties.all).toBe(3); // both target "all" → worst wins
const mixed = deriveState('pf2e', 25, [cond('Enfeebled', 2), cond('Clumsy', 1)]);
expect(mixed.statusPenalties.str).toBe(2); // Str-based
expect(mixed.statusPenalties.dex).toBe(1); // Dex-based — NOT merged with enfeebled
expect(mixed.badges).toEqual(expect.arrayContaining(['2 Str', '1 Dex/AC']));
});
it('slowed is action loss, not a check penalty', () => {
const s = deriveState('pf2e', 25, [cond('Slowed', 2)]);
expect(s.statusPenalties).toEqual({});
expect(s.badges).toContain('Slowed 2 (2 actions)');
});
it('grabbed zeroes speed and grants advantage to attackers', () => {
@@ -63,6 +91,13 @@ describe('deriveState (pf2e)', () => {
expect(s.speed).toBe(0);
expect(s.attacksAgainstModifier).toBe('advantage');
});
it('frightened decays by 1 at end of turn, removed at 0', () => {
expect(tickConditionsEndOfTurn('pf2e', [cond('Frightened', 2)])).toEqual([{ name: 'Frightened', value: 1 }]);
expect(tickConditionsEndOfTurn('pf2e', [cond('Frightened', 1)])).toEqual([]);
// 5e frightened (unvalued) is untouched
expect(tickConditionsEndOfTurn('5e', [cond('Frightened')])).toEqual([{ name: 'Frightened' }]);
});
});
describe('concentrationDC', () => {
+91 -14
View File
@@ -1,10 +1,10 @@
import type { SystemId } from '@/lib/rules';
import { ABILITY_KEYS, type SystemId } from '@/lib/rules';
import type { Condition } from '@/lib/schemas/common';
import { conditionEffects } from './conditionEffects';
import { conditionEffects, type PenaltyTarget } from './conditionEffects';
import type { AbilityKey, AdvState } from './types';
export interface MechanicalState {
/** effective speed after conditions (0 if any condition zeroes it) */
/** 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;
@@ -16,9 +16,13 @@ export interface MechanicalState {
saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>>;
/** disadvantage on ability checks (5e) */
abilityCheckDisadvantage: boolean;
/** pf2e: aggregated numeric status penalty (worst single value, penalties don't stack) */
statusPenalty: number;
/** short human-readable badges for the UI, e.g. "Speed 0", "Disadv. attacks" */
/**
* 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[];
}
@@ -30,6 +34,14 @@ function combineAdv(a: AdvState, b: AdvState): AdvState {
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
@@ -41,48 +53,113 @@ function combineAdv(a: AdvState, b: AdvState): AdvState {
*/
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 statusPenalty = 0;
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 eff = table[cond.name.trim().toLowerCase()];
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: status penalties don't stack — take the worst single value.
if (eff.statusPenalty && cond.value !== undefined) statusPenalty = Math.max(statusPenalty, cond.value);
// 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') badges.push('Attacked w/ adv.');
if (statusPenalty > 0) badges.push(`${statusPenalty} status`);
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 : baseSpeed,
speed: speedZero ? 0 : speed,
attackModifier,
attacksAgainstModifier,
incapacitated,
saveModifiers,
abilityCheckDisadvantage,
statusPenalty,
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;
}
+2 -2
View File
@@ -18,8 +18,8 @@ export { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e, type Pf2eDefens
export { castSpell, dropConcentration, concentrationDC } from './cast';
export { spendResource, regainResource } from './resources';
export { rollDeathSave, type DeathSaveOutcome } from './deathSaves';
export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect } from './conditionEffects';
export { deriveState, type MechanicalState } from './creatureState';
export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect, type PenaltyTarget } from './conditionEffects';
export { deriveState, tickConditionsEndOfTurn, type MechanicalState } from './creatureState';
let spellMechCache: Map<string, SpellMechanics> | null = null;
let armorMechCache: Map<string, ArmorMechanics> | null = null;