feat(combat): support off-turn immediate actions via reaction policy

This commit is contained in:
2026-09-05 17:03:51 +02:00
parent ab99ab67e3
commit 66b6d1f387
2 changed files with 481 additions and 4 deletions
+128 -4
View File
@@ -9,9 +9,13 @@ Phase 0 documented deviations from PF1e (conventions):
straight-line charge path exists, `(5foot_step, full_attack)` when a single
step brings the target into range, or `(move, attack)` otherwise (move at
full speed along the Dijkstra path, then single attack). Immediate actions
(off-turn, consume next swift) are deferred; flanking, full attack, charge,
withdraw, and 5-foot step are modeled; attacks of opportunity are resolved
inline (see below).
(off-turn, consume next swift) are modeled via an opt-in ``reaction_policy``
hook: each resolved attack, movement step, spell cast, and damage application
emits a ``ReactionEvent``; the first combatant whose policy returns an
``Action(kind="immediate")`` executes it immediately, and its next turn's
swift action is consumed. Flanking, full attack, charge, withdraw, and
5-foot step are modeled; attacks of opportunity are resolved inline (see
below).
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
in one attack action; remaining swings are lost once the target drops
(down or dead) mid-routine. BAB iterative attacks (full-round action
@@ -246,6 +250,7 @@ class CombatantState:
power_attack: bool = False
grappled_with: str | None = None
deed_up_close_and_deadly: bool = False
immediate_used: bool = False
summon_duration: int = 0
summon_caster_id: str | None = None
# Runtime weapon durability (not in the frozen data model): accumulated
@@ -363,6 +368,26 @@ class Action:
channel_mode: Literal["heal", "harm"] | None = None # channel energy polarity for this use
@dataclass(frozen=True)
class ReactionEvent:
"""An off-turn event a ``reaction_policy`` may react to.
``kind`` describes what just happened: ``attack_resolved`` (a swing was
resolved, ``actor`` is the attacker, ``target`` the defender, ``damage``
the damage dealt), ``movement_step`` (``actor`` moved one cell),
``spell_cast`` (``actor`` finished casting at ``target``), or
``damage_taken`` (``target`` lost ``damage`` hp from a non-attack source
such as a spell or channel energy). ``action`` is reserved for future
use (the triggering action is not threaded through the engine today).
"""
kind: Literal["attack_resolved", "movement_step", "spell_cast", "damage_taken"]
actor: CombatantState
target: CombatantState | None = None
damage: int = 0
action: Action | None = None
@dataclass(frozen=True)
class CombatResult:
"""Outcome of a full battle: winner, rounds, log, and per-combatant stats."""
@@ -403,6 +428,7 @@ class CombatEngine:
round_cap: int = 100,
policy: Policy | None = None,
spell_registry: dict[str, SpellSpec] | None = None,
reaction_policy: ReactionPolicy | None = None,
) -> None:
self._rng = rng
self._grid = grid
@@ -410,6 +436,8 @@ class CombatEngine:
self._round_cap = round_cap
self._policy = policy if policy is not None else default_policy
self._spell_registry = spell_registry or {}
self._reaction_policy = reaction_policy
self._in_reaction = False
self._transcript: list[str] = []
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
self._current_round = 0
@@ -443,6 +471,9 @@ class CombatEngine:
state.moved_this_turn = False
state.power_attack = False
actions = self._policy(self, state)
if state.immediate_used:
actions = _without_swift_bonus(actions)
state.immediate_used = False
if self._has_condition_named(state, "staggered"):
actions = _staggered_filter(actions)
elif self.has_condition_flag(state, "cant_attack"):
@@ -1407,6 +1438,11 @@ class CombatEngine:
break
attacker.pos = step
actual_path.append(step)
self._check_reactions(
ReactionEvent(kind="movement_step", actor=attacker)
)
if not attacker.active:
break
if actual_path:
attacker.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
@@ -1440,6 +1476,8 @@ class CombatEngine:
self._withdraw(state, target)
elif kind in ("attack", "full_attack"):
self._execute_attack(state, target, kind)
elif kind == "immediate":
self._execute_immediate(state, target, action)
else:
self._execute_movement(state, target, kind)
@@ -1476,6 +1514,46 @@ class CombatEngine:
elif kind == "wait":
self._log(f"round {self._current_round} {state.combatant.id}: wait")
def _execute_immediate(
self, state: CombatantState, target: CombatantState | None, action: Action
) -> None:
"""Execute an immediate action (off-turn or in-turn).
The immediate action consumes the combatant's next swift action
(``immediate_used``), which suppresses swift/free bonus actions on the
following turn. The effect is resolved by the action's carrier fields:
a spell, a maneuver, a class ability, or a single attack on ``target``.
"""
state.immediate_used = True
if action.spell_name is not None:
self._execute_cast_spell(state, target, action.spell_name)
elif action.maneuver is not None:
self._execute_maneuver(state, target, action.maneuver)
elif action.class_ability is not None:
self._execute_class_ability(state, target, action)
elif target is not None:
self._execute_attack(state, target, "attack")
def _check_reactions(self, event: ReactionEvent) -> None:
"""Offer an event to the reaction policy; execute the first immediate.
One immediate per event: the first active combatant (in state order)
whose policy returns an action gets to act. Reactions never cascade —
while an immediate is executing, further events are ignored.
"""
if self._reaction_policy is None or self._in_reaction:
return
for state in self._states:
if not state.active or state.immediate_used:
continue
action = self._reaction_policy(self, state, event)
if action is None:
continue
self._in_reaction = True
self._execute(state, action)
self._in_reaction = False
return
def _execute_cast_spell(
self,
state: CombatantState,
@@ -1887,6 +1965,14 @@ class CombatEngine:
f"round {self._current_round} {state.combatant.id}: "
f"{affected_state.combatant.id} dead"
)
self._check_reactions(
ReactionEvent(
kind="damage_taken",
actor=state,
target=affected_state,
damage=dmg,
)
)
def _execute_hex(
self, state: CombatantState, target: CombatantState | None, hex_name: str | None
@@ -2182,6 +2268,10 @@ class CombatEngine:
f"round {self._current_round} {caster.combatant.id}: "
f"{target.combatant.id} dead"
)
self._check_reactions(
ReactionEvent(kind="spell_cast", actor=caster, target=target)
)
def _apply_spell_effects(
self,
spell: SpellSpec,
@@ -2246,7 +2336,7 @@ class CombatEngine:
if isinstance(effect, DamageEffect):
return self._apply_damage_effect(
effect, target, caster_live, target_live,
saved=saved, empower=empower, maximize=maximize,
saved=saved, empower=empower, maximize=maximize, caster=caster,
)
if isinstance(effect, ConditionEffect):
return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name)
@@ -2264,6 +2354,7 @@ class CombatEngine:
saved: bool,
empower: bool,
maximize: bool,
caster: CombatantState,
) -> str:
raw = effect.formula.roll(self._rng)
if maximize:
@@ -2279,6 +2370,11 @@ class CombatEngine:
target.hp -= dmg
caster_live.damage_dealt += dmg
target_live.damage_taken += dmg
self._check_reactions(
ReactionEvent(
kind="damage_taken", actor=caster, target=target, damage=dmg
)
)
return f"{dmg} damage"
def _apply_condition_effect(
@@ -2576,6 +2672,14 @@ class CombatEngine:
f"{target.combatant.id} down"
)
return True
self._check_reactions(
ReactionEvent(
kind="attack_resolved",
actor=attacker,
target=target,
damage=result.damage,
)
)
return False
def _attack(
@@ -2652,6 +2756,9 @@ class CombatEngine:
break
state.pos = step
actual_path.append(step)
self._check_reactions(ReactionEvent(kind="movement_step", actor=state))
if not state.active:
break
if actual_path:
state.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
@@ -2791,6 +2898,9 @@ class CombatEngine:
break
state.pos = step
actual_path.append(step)
self._check_reactions(ReactionEvent(kind="movement_step", actor=state))
if not state.active:
break
if actual_path:
state.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
@@ -2926,6 +3036,16 @@ _FULL_ROUND_KINDS: frozenset[str] = frozenset({"full_attack", "charge", "withdra
_STANDARD_KINDS: frozenset[str] = frozenset({
"attack", "cast_spell", "maneuver", "hex", "deed", "spell_combat", "channel_energy",
})
_SWIFT_BONUS_KINDS: frozenset[str] = frozenset({"deed", "inspire_courage", "swift"})
def _without_swift_bonus(actions: tuple[Action, ...]) -> tuple[Action, ...]:
"""Drop swift/free bonus actions (deed, inspire_courage, swift).
An immediate action consumes the combatant's next swift action; the
policy-returned sequence is filtered so no swift bonus fires that turn.
"""
return tuple(a for a in actions if a.kind not in _SWIFT_BONUS_KINDS)
def _staggered_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
@@ -2953,6 +3073,10 @@ def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
ReactionPolicy = Callable[
[CombatEngine, CombatantState, ReactionEvent], Action | None
]
def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
"""Full-attack the nearest enemy; cast spells; charge, 5ft-step, or approach if out of range.