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:
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user