V2 Phase 3: combat rules automation (conditions, damage types, reminders)

Completes the deployable Phase 1-3 chunk: the combat tracker now runs the math.

- condition-effects engine: declarative CONDITION_EFFECTS_5E/PF2E table +
  deriveState(system, speed, conditions) → effective speed / advantage state /
  incapacitation / pf2e status penalty. Tracker shows effect badges
  ("Speed 0", "Disadv. attacks", "−2 status"); pure + 10 unit tests.
- damage types: applyDamage(c, amount, type?) consults snapshotted monster
  DamageDefenses (immune→0, resist→halve, vulnerable→double), back-compatible.
  Monster add snapshots damageDefenses; tracker shows a type picker only when a
  creature has typed defenses, and the log notes resisted/vulnerable amounts.
- concentration + death saves are surfaced as REMINDERS, never auto-rolled:
  damaging a concentrating PC logs "roll a DC N Con save…"; a downed PC's turn
  logs "make a death saving throw." The player rolls and resolves via the Drop
  button / death-save pips they already own. (Per user: no auto dice rolls —
  it removes the player from their character.)

Build + 271 unit tests green. Verified live: grapple → Speed 0 badge;
fire-immune creature shows the immune-labelled damage picker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 01:23:52 +02:00
parent 2651ac03b7
commit 8ba38d893d
12 changed files with 359 additions and 12 deletions
+70
View File
@@ -0,0 +1,70 @@
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.
*/
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 numeric status penalty scaling with the condition's value */
statusPenalty?: boolean;
}
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' },
clumsy: { statusPenalty: true },
enfeebled: { statusPenalty: true },
frightened: { statusPenalty: true },
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' },
};
export function conditionEffects(system: SystemId): Record<string, ConditionEffect> {
return system === 'pf2e' ? CONDITION_EFFECTS_PF2E : CONDITION_EFFECTS_5E;
}