fix(rules): block immediate actions while flat-footed

SRD 'Immediate Actions': 'You also cannot use an immediate action if you are flat-footed.' _check_reactions now skips states carrying the flat_footed condition before consulting the reaction policy. The engine never applies flat_footed itself (latent gate), so the unit test injects the condition via active_conditions, with a control test proving the same setup fires when not flat-footed.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-12 14:08:44 +02:00
parent 7810d88488
commit a4487a85f9
3 changed files with 107 additions and 7 deletions
+7 -1
View File
@@ -670,7 +670,13 @@ Règles modélisées :
immédiate ne part ce round ; le swift du tour **suivant** reste disponible
(contrairement à l'immédiate hors-tour). Simplification documentée :
l'interdiction RAW d'une seconde immédiate s'étend jusqu'au début du tour
suivant, le moteur la borne au round en cours.
suivant, le moteur la borne au round en cours. Conformance L5 (SRD
« Immediate Actions ») : « You also cannot use an immediate action if you
are flat-footed » — un combattant portant la condition `flat_footed` est
**sauté** par la porte de réaction (`_check_reactions`) : aucune immédiate
ne part tant qu'il est à plat (la condition est injectable via
`active_conditions` ; le moteur ne la pose jamais lui-même — voie
d'entrée future).
Les actions préparées (`ready`) sont traitées sur ces mêmes
événements **avant** et indépendamment de la politique de réaction (voir
« Préparation » plus haut).
+8 -1
View File
@@ -3914,6 +3914,8 @@ class CombatEngine:
at most one trigger resolves per event (a fired readied action
suppresses the policy loop). One immediate per event: the first active
combatant (in state order) whose policy returns an action gets to act.
PF1e (SRD "Immediate Actions"): "You also cannot use an immediate
action if you are flat-footed" — flat-footed states are skipped.
Reactions never cascade — while a reaction is executing, further
events are ignored.
"""
@@ -3924,7 +3926,12 @@ class CombatEngine:
if self._reaction_policy is None:
return
for state in self._states:
if not state.active or state.immediate_used or state.swift_used_this_turn:
if (
not state.active
or state.immediate_used
or state.swift_used_this_turn
or self._has_condition_named(state, "flat_footed")
):
continue
action = self._reaction_policy(self, state, event)
if action is None:
+87
View File
@@ -19,7 +19,9 @@ from pf1e_simulator.combat import (
ReactionPolicy,
default_policy,
)
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import ActiveCondition
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
@@ -189,6 +191,91 @@ def test_immediate_attack_reaction_executes() -> None:
assert goblin_state.hp < 30
def test_flat_footed_cannot_use_immediate_action() -> None:
"""PF1e (SRD "Immediate Actions"): "You also cannot use an immediate
action if you are flat-footed." Given: a flat-footed ally whose reaction
policy would fire an immediate attack When: the goblin attacks the hero
Then: no immediate fires and the ally never strikes."""
hero = make_combatant("hero", initiative_mod=5)
goblin = make_combatant("goblin")
ally = make_combatant("ally")
ally_state = make_state(ally, "players", (1, 2))
ally_state.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["flat_footed"],
rounds_left=100,
source="test",
)
)
states = [
ally_state,
make_state(hero, "players", (1, 0)),
make_state(goblin, "monsters", (1, 3)),
]
def wait_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id in ("hero", "ally"):
return (Action(kind="wait"),)
return default_policy(_engine, state)
def reaction_policy(
_engine: CombatEngine, state: CombatantState, event: ReactionEvent
) -> Action | None:
if state.combatant.id == "ally" and event.kind == "attack_resolved":
return Action(kind="immediate", target_id="goblin")
return None
engine = CombatEngine(
SeededRng(4),
make_grid(),
states,
policy=wait_policy,
reaction_policy=reaction_policy,
)
result = engine.run()
assert not any("ally: short sword vs goblin" in line for line in result.transcript)
def test_not_flat_footed_ally_immediate_fires_control() -> None:
"""Control: the same ally WITHOUT the flat_footed condition does fire the
immediate reaction — the block above is the condition, not the setup."""
hero = make_combatant("hero", initiative_mod=5)
goblin = make_combatant("goblin")
ally = make_combatant("ally")
ally_state = make_state(ally, "players", (1, 2))
states = [
ally_state,
make_state(hero, "players", (1, 0)),
make_state(goblin, "monsters", (1, 3)),
]
def wait_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id in ("hero", "ally"):
return (Action(kind="wait"),)
return default_policy(_engine, state)
def reaction_policy(
_engine: CombatEngine, state: CombatantState, event: ReactionEvent
) -> Action | None:
if state.combatant.id == "ally" and event.kind == "attack_resolved":
return Action(kind="immediate", target_id="goblin")
return None
engine = CombatEngine(
SeededRng(4),
make_grid(),
states,
policy=wait_policy,
reaction_policy=reaction_policy,
)
result = engine.run()
assert any("ally: short sword vs goblin" in line for line in result.transcript)
def test_immediate_consumes_next_swift() -> None:
"""An OFF-TURN immediate action consumes the swift action of the next turn."""
hero = make_combatant("hero", initiative_mod=5)