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
+88
View File
@@ -0,0 +1,88 @@
import type { SystemId } from '@/lib/rules';
import type { Condition } from '@/lib/schemas/common';
import { conditionEffects } from './conditionEffects';
import type { AbilityKey, AdvState } from './types';
export interface MechanicalState {
/** effective speed after conditions (0 if any condition zeroes it) */
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: 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" */
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
}
/**
* 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 speedZero = false;
let attackModifier: AdvState = 'normal';
let attacksAgainstModifier: AdvState = 'normal';
let incapacitated = false;
let abilityCheckDisadvantage = false;
let statusPenalty = 0;
const saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>> = {};
for (const cond of conditions) {
const eff = table[cond.name.trim().toLowerCase()];
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 (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);
}
const badges: string[] = [];
if (speedZero) badges.push('Speed 0');
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`);
return {
speed: speedZero ? 0 : baseSpeed,
attackModifier,
attacksAgainstModifier,
incapacitated,
saveModifiers,
abilityCheckDisadvantage,
statusPenalty,
badges,
};
}