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">