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
+66 -4
View File
@@ -23,6 +23,7 @@ import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation';
import { getSystem, getConditions, type SystemId } from '@/lib/rules';
import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget';
import { deriveState, DAMAGE_TYPES, concentrationDC } from '@/lib/mechanics';
import { useCharacters, useAllPcs } from '@/features/characters/hooks';
import { useConditionGlossary } from './useConditionGlossary';
import {
@@ -66,6 +67,36 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
if (prev) void encountersRepo.save(prev);
};
/** The Character backing a PC/NPC combatant, if any (for cross-doc rules state). */
const pcCharacter = (combatant: Combatant): Character | undefined =>
combatant.kind !== 'monster' && combatant.characterId
? [...allPcs, ...characters].find((ch) => ch.id === combatant.characterId)
: undefined;
/**
* After a concentrating PC takes damage, the app computes the Constitution
* save DC and surfaces it — but never rolls. The player rolls their own save
* and, on a failure, uses the Drop button on their sheet/panel. (Auto-rolling
* would take the moment away from the player.)
*/
const noteConcentrationCheck = (combatant: Combatant, damage: number) => {
if (damage <= 0) return;
const ch = pcCharacter(combatant);
if (!ch?.concentration) return;
const dc = concentrationDC(damage);
void encountersRepo.mutate(encounter.id, (e) =>
logEvent(e, `${ch.name}: roll a DC ${dc} Constitution save or lose concentration on ${ch.concentration!.spellName}.`));
};
/** Advance the turn; remind (don't roll) when a downed PC's turn begins. */
const advanceTurn = () => {
mutate(nextTurn);
const upcoming = currentCombatant(nextTurn(encounter));
if (upcoming && upcoming.kind !== 'monster' && upcoming.hp.current <= 0 && pcCharacter(upcoming)) {
void encountersRepo.mutate(encounter.id, (e) => logEvent(e, `${upcoming.name} is down — make a death saving throw.`));
}
};
const rollAllInitiative = () => {
const rolls: Record<string, number> = {};
for (const c of encounter.combatants) rolls[c.id] = rollDice('1d20', createRng()).total + c.initBonus;
@@ -148,7 +179,7 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
<Button variant="secondary" onClick={() => mutate(previousTurn)}>
<ChevronLeft size={15} aria-hidden /> Prev
</Button>
<Button variant="primary" onClick={() => mutate(nextTurn)}>
<Button variant="primary" onClick={advanceTurn}>
Next turn <ChevronRight size={15} aria-hidden />
</Button>
<Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}>
@@ -185,7 +216,15 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
glossary={glossary}
isCurrent={isActive && idx === encounter.turnIndex}
onChange={(patch) => mutate((e) => updateCombatant(e, c.id, patch))}
onDamage={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyDamage(c, amt).hp }), `${c.name} takes ${amt} damage`))}
onDamage={(amt, type) => {
const after = applyDamage(c, amt, type);
const hpLoss = (c.hp.current - after.hp.current) + (c.hp.temp - after.hp.temp);
const note = hpLoss === amt
? `${c.name} takes ${amt}${type ? ` ${type}` : ''} damage`
: `${c.name} takes ${hpLoss}${type ? ` ${type}` : ''} damage (${amt} before ${hpLoss < amt ? 'resistance' : 'vulnerability'})`;
mutate((e) => logEvent(updateCombatant(e, c.id, { hp: after.hp }), note));
noteConcentrationCheck(c, hpLoss);
}}
onHeal={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }), `${c.name} heals ${amt}`))}
onMove={(dir) => mutate((e) => moveCombatant(e, c.id, dir))}
onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))}
@@ -357,12 +396,16 @@ function CombatantRow({
glossary: Map<string, string>;
isCurrent: boolean;
onChange: (patch: Partial<Combatant>) => void;
onDamage: (amt: number) => void;
onDamage: (amt: number, type?: string) => void;
onHeal: (amt: number) => void;
onMove: (dir: -1 | 1) => void;
onRemove: () => void;
}) {
const [delta, setDelta] = useState(0);
const [dmgType, setDmgType] = useState('');
// Only show the damage-type picker when this creature actually has typed defenses.
const def = c.damageDefenses;
const hasDefenses = !!def && (def.resist.length > 0 || def.immune.length > 0 || def.vulnerable.length > 0);
const dead = c.hp.current <= 0;
const foe = c.kind === 'monster';
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
@@ -440,12 +483,31 @@ function CombatantRow({
))}
</div>
)}
{/* Mechanical consequences the conditions impose (enforced read-model). */}
{(() => {
const st = deriveState(system, 30, c.conditions);
return st.badges.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1" aria-label="Condition effects">
{st.badges.map((b) => (
<span key={b} className="rounded border border-line bg-surface px-1.5 py-0.5 text-[10px] font-medium text-muted">{b}</span>
))}
</div>
) : null;
})()}
</div>
{/* HP controls */}
<div className="flex items-center gap-1">
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP amount`} />
<Button size="sm" variant="danger" disabled={delta <= 0} onClick={() => { onDamage(delta); setDelta(0); }} title="Apply damage">
{hasDefenses && (
<Select className="w-auto py-1 text-xs" value={dmgType} onChange={(e) => setDmgType(e.target.value)} aria-label={`${c.name} damage type`}>
<option value="">untyped</option>
{DAMAGE_TYPES.filter((t) => t !== 'untyped').map((t) => (
<option key={t} value={t}>{def!.immune.includes(t) ? `${t} (immune)` : def!.resist.includes(t) ? `${t} (resist)` : def!.vulnerable.includes(t) ? `${t} (vuln)` : t}</option>
))}
</Select>
)}
<Button size="sm" variant="danger" disabled={delta <= 0} onClick={() => { onDamage(delta, dmgType || undefined); setDelta(0); }} title="Apply damage">
<Sword size={14} aria-hidden /> Dmg
</Button>
<Button size="sm" variant="secondary" disabled={delta <= 0} onClick={() => { onHeal(delta); setDelta(0); }} title="Heal">
+3 -1
View File
@@ -11,6 +11,7 @@ import { addCombatant } from '@/lib/combat/engine';
import { charactersRepo, encountersRepo } from '@/lib/db/repositories';
import { type Character, type InventoryItem, type SpellEntry, newSpellEntry } from '@/lib/schemas';
import type { Spell, CompendiumEntry } from '@/lib/compendium/types';
import type { CombatantDamageDefenses } from '@/lib/schemas/encounter';
import { normalizeSpell5e, normalizeSpellPf2e } from '@/lib/mechanics';
import { useUiStore } from '@/stores/uiStore';
import { useActiveCampaign } from '@/features/campaigns/hooks';
@@ -295,7 +296,7 @@ function ActionBar({ children }: { children: React.ReactNode }) {
);
}
function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number } }) {
function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number; damageDefenses?: CombatantDamageDefenses } }) {
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
const [msg, setMsg] = useState<string | null>(null);
@@ -312,6 +313,7 @@ function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number;
conditions: [], notes: '',
...(stats.cr !== undefined ? { cr: stats.cr } : {}),
...(stats.level !== undefined ? { level: stats.level } : {}),
...(stats.damageDefenses ? { damageDefenses: stats.damageDefenses } : {}),
}),
);
setMsg(`Added to "${enc.name}" (init ${initiative}).`);
+4 -1
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react';
import type { SystemId } from '@/lib/rules';
import { abilityModifier } from '@/lib/rules';
import { normalizeMonsterDefenses } from '@/lib/mechanics';
import type { CombatantDamageDefenses } from '@/lib/schemas/encounter';
import type { CompendiumEntry, Monster, Spell, MagicItem } from '@/lib/compendium/types';
import {
loadMonsters,
@@ -49,7 +51,7 @@ export interface CategoryDef {
/** enables "add to character" for spells/items */
linkAs?: 'spell' | 'item';
/** enables "add to combat" for monsters/creatures */
toCombatant?: (e: Entry) => { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number };
toCombatant?: (e: Entry) => { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number; damageDefenses?: CombatantDamageDefenses };
/** optional numeric sort axis (e.g. CR, spell level) in addition to name */
numericSort?: { label: string; get: (e: Entry) => number };
}
@@ -169,6 +171,7 @@ export const CATEGORIES: CategoryDef[] = [
name: m.name, ac: m.armor_class ?? 10, hp: m.hit_points ?? 1,
initBonus: abilityModifier(m.dexterity ?? 10),
...(m.cr !== undefined ? { cr: m.cr } : {}),
damageDefenses: normalizeMonsterDefenses(m),
};
},
numericSort: { label: 'CR', get: (e) => Number(e.cr) },
+13
View File
@@ -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', () => {
+19 -4
View File
@@ -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 },
+5
View File
@@ -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));
}
+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;
}
+74
View File
@@ -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);
});
});
+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,
};
}
+3 -1
View File
@@ -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;
+13
View File
@@ -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 */
File diff suppressed because one or more lines are too long