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:
@@ -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">
|
||||
|
||||
@@ -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}).`);
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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