diff --git a/README.md b/README.md index 1567bdc..9b6ae7b 100644 --- a/README.md +++ b/README.md @@ -668,12 +668,18 @@ Règles modélisées : posé par le moteur sur l'acteur du tour) : tout swift encore présent dans la séquence est retiré (`downgraded:immediate_swift`) et aucune seconde 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. - 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). + (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. 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). - Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de visée. diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 3262e8c..58ef226 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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: diff --git a/tests/test_immediate_actions.py b/tests/test_immediate_actions.py index 1708ba9..7fefcbd 100644 --- a/tests/test_immediate_actions.py +++ b/tests/test_immediate_actions.py @@ -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)