Files
ttrpg_manager/src/lib/mechanics/conditionEffects.ts
T
NilsBriggen b3914b1dda Bug hunt + 5e/PF2e parity + character-build UX overhaul
Audited every calculation (26-agent adversarial workflow + hand-verification).
Core math was sound; fixed real bugs, closed parity gaps, and overhauled the
character-build UX. 396 -> 424 tests; lint clean; build green.

Correctness fixes:
- Heavy armor no longer applies a negative DEX penalty to AC (3 paths + default)
- 5e Exhaustion 4 (HP-max halved) implemented via deriveEffectiveMaxHp + badge
- PF2e dazzled no longer makes a creature easier to hit
- PF2e immunity 'all' zeroes all damage (was misfiled as a condition immunity)
- Director schema bounds; PF2e spell-save basis from the `basic` flag; 5e
  disadvantage-only save guard; remove bogus Concentrating, add Broken/Quickened
- Fix 3 lint errors (useCloud hooks naming, useless escape, explicit any)

PF2e survival subsystem (parity with 5e death saves):
- mechanics/dying.ts (maxDying/isDead/knockOut/applyRecovery/pf2eRestDecay)
- DefensesSection knock-out + recovery-check + death UI; rest-decay in applyRest;
  drained HP reduction

Character-build UX:
- Persisted abilityBuild (base + per-source contributions); sheet & wizard show
  every ability source; higher-level PF2e gains L5/10/15/20 boosts
- Creation surfaces granted skills (incl. new PF2e background skill parsing)
- LevelUpModal enumerates features gained + prompts every owed choice (subclass,
  feats, fighting style, ...) via collectChoices/collectFeatures/subclassPrompt

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

89 lines
4.5 KiB
TypeScript

import type { SystemId } from '@/lib/rules';
import type { AbilityKey, AdvState } from './types';
/**
* The mechanical effect a condition imposes — a declarative table, never
* 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;
/** this creature's attack rolls */
attackRolls?: AdvState;
/** attack rolls made AGAINST this creature */
attacksAgainst?: AdvState;
/** disadvantage on ability checks (5e) */
abilityChecks?: boolean;
/** per-ability save penalty (auto-fail dominates disadvantage) */
saves?: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>>;
/** cannot take actions or reactions */
incapacitated?: 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' };
/** D&D 5e condition mechanics (keys lowercased to match stored names). */
export const CONDITION_EFFECTS_5E: Record<string, ConditionEffect> = {
blinded: { attackRolls: 'disadvantage', attacksAgainst: 'advantage' },
frightened: { attackRolls: 'disadvantage', abilityChecks: true },
grappled: { speedZero: true },
incapacitated: { incapacitated: true },
invisible: { attackRolls: 'advantage', attacksAgainst: 'disadvantage' },
paralyzed: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage', saves: ALL_PHYS },
petrified: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage', saves: ALL_PHYS },
poisoned: { attackRolls: 'disadvantage', abilityChecks: true },
prone: { attackRolls: 'disadvantage', attacksAgainst: 'advantage' },
restrained: { speedZero: true, attackRolls: 'disadvantage', attacksAgainst: 'advantage', saves: { dex: 'disadvantage' } },
stunned: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage', saves: ALL_PHYS },
unconscious: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage', saves: ALL_PHYS },
};
/**
* Pathfinder 2e condition mechanics. PF2e uses flat numeric penalties rather
* than advantage/disadvantage; valued conditions (frightened N, clumsy N, …)
* contribute a status penalty aggregated by `deriveState`.
*/
export const CONDITION_EFFECTS_PF2E: Record<string, ConditionEffect> = {
blinded: { attacksAgainst: 'advantage' },
// 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 imposes concealment on the dazzled creature's OWN targets (a flat check on
// its attacks) — it does NOT lower its AC. The app can't model the flat-check, so we
// record no advantage to attackers here rather than wrongly making it easier to hit.
dazzled: {},
// 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' },
stunned: { incapacitated: true },
unconscious: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage' },
'off-guard': { attacksAgainst: 'advantage' },
};
export function conditionEffects(system: SystemId): Record<string, ConditionEffect> {
return system === 'pf2e' ? CONDITION_EFFECTS_PF2E : CONDITION_EFFECTS_5E;
}