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:
@@ -161,6 +161,19 @@ describe('HP transitions', () => {
|
||||
expect(applyDamage(c, Number.NaN).hp.current).toBe(10);
|
||||
expect(applyHealing(c, -5).hp.current).toBe(10);
|
||||
});
|
||||
|
||||
it('applies typed-damage resistances when defenses are present', () => {
|
||||
const base = { ...mk('a', 0, 20), damageDefenses: { resist: ['fire'], immune: ['poison'], vulnerable: ['cold'], conditionImmune: [], notes: [] } };
|
||||
expect(applyDamage(base, 10, 'fire').hp.current).toBe(15); // resisted → 5
|
||||
expect(applyDamage(base, 10, 'poison').hp.current).toBe(20); // immune → 0
|
||||
expect(applyDamage(base, 10, 'cold').hp.current).toBe(0); // vulnerable → 20
|
||||
expect(applyDamage(base, 10, 'acid').hp.current).toBe(10); // untracked type → full
|
||||
expect(applyDamage(base, 10).hp.current).toBe(10); // no type → full (back-compat)
|
||||
});
|
||||
|
||||
it('typeless damage is unchanged when no defenses are set', () => {
|
||||
expect(applyDamage(mk('a', 0, 10), 4, 'fire').hp.current).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('endEncounter', () => {
|
||||
|
||||
@@ -207,11 +207,26 @@ export function moveCombatant(enc: Encounter, id: string, direction: -1 | 1): En
|
||||
|
||||
// ---------------- HP transitions ----------------
|
||||
|
||||
/** Apply damage: temporary HP absorbs first, then current (may go negative). */
|
||||
export function applyDamage(c: Combatant, amount: number): Combatant {
|
||||
/**
|
||||
* Apply damage of an optional type. Resistances are consulted first (immune → 0,
|
||||
* resistant → halved and rounded down, vulnerable → doubled), then temporary HP
|
||||
* absorbs, then current HP (which may go negative). Passing no `type` reproduces
|
||||
* the original typeless behaviour exactly.
|
||||
*/
|
||||
export function applyDamage(c: Combatant, amount: number, type?: string): Combatant {
|
||||
if (!Number.isFinite(amount) || amount <= 0) return c;
|
||||
const absorbed = Math.min(c.hp.temp, amount);
|
||||
const remaining = amount - absorbed;
|
||||
|
||||
let dmg = amount;
|
||||
const def = c.damageDefenses;
|
||||
if (type && def) {
|
||||
if (def.immune.includes(type)) dmg = 0;
|
||||
else if (def.vulnerable.includes(type)) dmg = amount * 2;
|
||||
else if (def.resist.includes(type)) dmg = Math.floor(amount / 2);
|
||||
}
|
||||
if (dmg <= 0) return c; // fully resisted / immune — no change
|
||||
|
||||
const absorbed = Math.min(c.hp.temp, dmg);
|
||||
const remaining = dmg - absorbed;
|
||||
return {
|
||||
...c,
|
||||
hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - remaining },
|
||||
|
||||
@@ -59,3 +59,8 @@ export function castSpell(c: Character, spellId: string, atLevel?: number): Enfo
|
||||
export function dropConcentration(c: Character): Partial<Character> {
|
||||
return c.concentration ? { concentration: null } : {};
|
||||
}
|
||||
|
||||
/** 5e Constitution save DC to maintain concentration after taking `damage`. */
|
||||
export function concentrationDC(damage: number): number {
|
||||
return Math.max(10, Math.floor(damage / 2));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deriveState } from './creatureState';
|
||||
import { concentrationDC } from './cast';
|
||||
import type { Condition } from '@/lib/schemas/common';
|
||||
|
||||
const cond = (name: string, value?: number): Condition => (value !== undefined ? { name, value } : { name });
|
||||
|
||||
describe('deriveState (5e)', () => {
|
||||
it('grappled zeroes speed', () => {
|
||||
const s = deriveState('5e', 30, [cond('Grappled')]);
|
||||
expect(s.speed).toBe(0);
|
||||
expect(s.badges).toContain('Speed 0');
|
||||
});
|
||||
|
||||
it('poisoned imposes disadvantage on attacks and ability checks', () => {
|
||||
const s = deriveState('5e', 30, [cond('Poisoned')]);
|
||||
expect(s.attackModifier).toBe('disadvantage');
|
||||
expect(s.abilityCheckDisadvantage).toBe(true);
|
||||
});
|
||||
|
||||
it('restrained: speed 0, disadv attacks, attacked with advantage, dex save disadvantage', () => {
|
||||
const s = deriveState('5e', 30, [cond('Restrained')]);
|
||||
expect(s.speed).toBe(0);
|
||||
expect(s.attackModifier).toBe('disadvantage');
|
||||
expect(s.attacksAgainstModifier).toBe('advantage');
|
||||
expect(s.saveModifiers.dex).toBe('disadvantage');
|
||||
});
|
||||
|
||||
it('paralyzed auto-fails str/dex saves and incapacitates', () => {
|
||||
const s = deriveState('5e', 30, [cond('Paralyzed')]);
|
||||
expect(s.incapacitated).toBe(true);
|
||||
expect(s.saveModifiers.str).toBe('auto-fail');
|
||||
expect(s.saveModifiers.dex).toBe('auto-fail');
|
||||
});
|
||||
|
||||
it('opposing advantage and disadvantage cancel to normal', () => {
|
||||
// invisible grants advantage to attack; poisoned imposes disadvantage → normal
|
||||
const s = deriveState('5e', 30, [cond('Invisible'), cond('Poisoned')]);
|
||||
expect(s.attackModifier).toBe('normal');
|
||||
});
|
||||
|
||||
it('unknown/homebrew condition contributes nothing', () => {
|
||||
const s = deriveState('5e', 30, [cond('Inspired by bards')]);
|
||||
expect(s).toMatchObject({ speed: 30, attackModifier: 'normal', incapacitated: false });
|
||||
expect(s.badges).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveState (pf2e)', () => {
|
||||
it('frightened contributes a numeric status penalty equal to its value', () => {
|
||||
const s = deriveState('pf2e', 25, [cond('Frightened', 2)]);
|
||||
expect(s.statusPenalty).toBe(2);
|
||||
expect(s.badges).toContain('−2 status');
|
||||
});
|
||||
|
||||
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('grabbed zeroes speed and grants advantage to attackers', () => {
|
||||
const s = deriveState('pf2e', 25, [cond('Grabbed')]);
|
||||
expect(s.speed).toBe(0);
|
||||
expect(s.attacksAgainstModifier).toBe('advantage');
|
||||
});
|
||||
});
|
||||
|
||||
describe('concentrationDC', () => {
|
||||
it('is half the damage, floored, minimum 10', () => {
|
||||
expect(concentrationDC(8)).toBe(10); // floor(4)=4 → min 10
|
||||
expect(concentrationDC(22)).toBe(11);
|
||||
expect(concentrationDC(40)).toBe(20);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -15,9 +15,11 @@ export type { EnforceResult } from './result';
|
||||
export { normalizeSpell5e, normalizeSpellPf2e } from './normalize/spell';
|
||||
export { normalizeArmor5e } from './normalize/armor';
|
||||
export { normalizeMonsterDefenses } from './normalize/monsterDefenses';
|
||||
export { castSpell, dropConcentration } from './cast';
|
||||
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';
|
||||
|
||||
let spellMechCache: Map<string, SpellMechanics> | null = null;
|
||||
let armorMechCache: Map<string, ArmorMechanics> | null = null;
|
||||
|
||||
@@ -3,6 +3,17 @@ import { conditionSchema, hpSchema, int } from './common';
|
||||
|
||||
export const combatantKindSchema = z.enum(['pc', 'npc', 'monster']);
|
||||
|
||||
/** Damage/condition defenses snapshotted from the bestiary at add time, so the
|
||||
* combat engine can apply resistances without an async compendium load. */
|
||||
export const damageDefensesSchema = z.object({
|
||||
resist: z.array(z.string()).default([]),
|
||||
immune: z.array(z.string()).default([]),
|
||||
vulnerable: z.array(z.string()).default([]),
|
||||
conditionImmune: z.array(z.string()).default([]),
|
||||
notes: z.array(z.string()).default([]),
|
||||
});
|
||||
export type CombatantDamageDefenses = z.infer<typeof damageDefensesSchema>;
|
||||
|
||||
export const combatantSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(120),
|
||||
@@ -19,6 +30,8 @@ export const combatantSchema = z.object({
|
||||
hp: hpSchema,
|
||||
conditions: z.array(conditionSchema).default([]),
|
||||
notes: z.string().max(2000).default(''),
|
||||
/** damage resistances/immunities snapshotted from the bestiary, if known */
|
||||
damageDefenses: damageDefensesSchema.optional(),
|
||||
/** 5e challenge rating (for difficulty budget), if known */
|
||||
cr: z.number().finite().nonnegative().optional(),
|
||||
/** PF2e creature level (for difficulty budget), if known */
|
||||
|
||||
Reference in New Issue
Block a user