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