From 8ba38d893db2c1708a1c834e8da3875bc668d69c Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Tue, 9 Jun 2026 01:23:52 +0200 Subject: [PATCH] V2 Phase 3: combat rules automation (conditions, damage types, reminders) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/features/combat/EncounterTracker.tsx | 70 ++++++++++++++++- src/features/compendium/CompendiumPage.tsx | 4 +- src/features/compendium/registry.tsx | 5 +- src/lib/combat/engine.test.ts | 13 ++++ src/lib/combat/engine.ts | 23 +++++- src/lib/mechanics/cast.ts | 5 ++ src/lib/mechanics/conditionEffects.ts | 70 +++++++++++++++++ src/lib/mechanics/creatureState.test.ts | 74 ++++++++++++++++++ src/lib/mechanics/creatureState.ts | 88 ++++++++++++++++++++++ src/lib/mechanics/index.ts | 4 +- src/lib/schemas/encounter.ts | 13 ++++ tsconfig.app.tsbuildinfo | 2 +- 12 files changed, 359 insertions(+), 12 deletions(-) create mode 100644 src/lib/mechanics/conditionEffects.ts create mode 100644 src/lib/mechanics/creatureState.test.ts create mode 100644 src/lib/mechanics/creatureState.ts diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index 6083a8a..27ef1b7 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -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 = {}; 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 -