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
+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)