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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user