diff --git a/README.md b/README.md index 7d4450b..2ffd99b 100644 --- a/README.md +++ b/README.md @@ -367,7 +367,8 @@ Règles modélisées : `default_policy` renvoie `(full_attack,)` si l'ennemi est en portée, `(charge,)` si une ligne droite existe, `(5foot_step, full_attack)` si un seul pas suffit à entrer en portée, ou `(move, attack)` sinon. Les actions immédiates - (hors-tour, consomment le prochain swift) sont modélisées via une politique + (hors-tour : prochain swift ; pendant son tour : swift du tour courant — + conformance L10) sont modélisées via une politique de réaction opt-in (`reaction_policy`) — voir « Actions immédiates » ci-dessous. **Application du budget (conformance X4)** : le moteur applique désormais le budget d'action PF1e (CRB « Actions in Combat ») à TOUTE séquence renvoyée par @@ -403,8 +404,9 @@ Règles modélisées : `run` (round complet en RAW — il consacre le standard ET le mouvement, tout en posant `moved_this_turn` et `running`) ; **libre/conditionnel (hors budget)** = `wait`, `free`, `immediate` (hors-tour ; - il consomme le PROCHAIN swift via `immediate_used`, pas les compartiments du - tour courant), `5foot_step` (pas libre, exclusif avec tout autre mouvement via + il consomme le PROCHAIN swift via `immediate_used` ; pendant son propre tour, + il consomme le swift du tour courant via `swift_used_this_turn` — conformance + L10), `5foot_step` (pas libre, exclusif avec tout autre mouvement via `moved_this_turn`), `drop_prone`, `activate_power_attack` et `fight_defensively` (des déclarations de mode appliquées à l'attaque qui les suit, comme le basculement Power Attack). Ordre de composition : le pré-filtre des temps @@ -644,9 +646,18 @@ Règles modélisées : `reaction_policy(engine, state, event)` (défaut : aucune) ; sur événement (`attack_resolved`, `movement_step`, `spell_cast`, `damage_taken`) une réaction `Action(kind="immediate")` peut être exécutée hors tour — une par - événement — et consomme le prochain swift de l'état (immédiate pendant le - tour : exécutée, consomme aussi le prochain swift — simplification - documentée). Les actions préparées (`ready`) sont traitées sur ces mêmes + événement — et consomme le prochain swift de l'état (`immediate_used`). + Conformance L10 (SRD « Immediate Actions ») : « Using an immediate action + on your turn is the same as using a swift action and counts as your swift + action for that turn » — une immédiate déclenchée **pendant le tour** du + combattant consomme le swift du **tour courant** (`swift_used_this_turn`, + 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). - Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 39360b4..1122edc 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -558,6 +558,10 @@ class CombatantState: grappled_with: str | None = None deed_up_close_and_deadly: bool = False immediate_used: bool = False + # PF1e (SRD Immediate Actions): an immediate used ON your turn counts as + # your swift action for that turn; it also caps the round at one + # immediate. Reset at the start of your next turn (like moved_this_turn). + swift_used_this_turn: bool = False summon_duration: int = 0 summon_caster_id: str | None = None # Runtime weapon durability (not in the frozen data model): accumulated @@ -1019,6 +1023,10 @@ class CombatEngine: for st in states: self._apply_simple_monster_death(st) self._in_reaction = False + # The combatant currently executing `_take_turn` (None between turns): + # lets an immediate action tell in-turn from off-turn use (SRD + # Immediate Actions). + self._turn_actor: CombatantState | None = None self._transcript: list[str] = [] self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states} self._current_round = 0 @@ -1511,31 +1519,49 @@ class CombatEngine: state.aid_attack_bonus = 0 state.aid_ac_bonus = 0 state.running = False + state.swift_used_this_turn = False original = self._policy(self, state) results: list[ActionResult] = [] actions = self._filter_turn_actions(state, original, results) before = len(self._transcript) was_delayed = state.delayed_this_round disabled = self._is_disabled(state) - for action in actions: - if not state.active: - break - results.append(self._execute(state, action)) - if disabled and state.hp == 0 and action.kind not in _DISABLED_MOVE_KINDS: - # PF1e (CRB, Disabled): completing a standard (or otherwise - # strenuous) action costs 1 hp — the creature slips to -1 and - # is dying. Simplification: if the action itself healed the - # creature above 0 hp the cost is not applied. - state.hp -= 1 - self._log( - f"round {self._current_round} {state.combatant.id}: " - f"disabled: strenuous action costs 1 hp (hp 0 -> -1, dying)" - ) - if state.delayed_this_round and not was_delayed: - # A fresh delay forfeits the rest of the turn. - break - if self._winner_side() is not None: - break + self._turn_actor = state + try: + for action in actions: + if not state.active: + break + if state.swift_used_this_turn and ( + _effective_slot(action, self._cast_time_of) == "swift" + ): + # PF1e (SRD Immediate Actions): the in-turn immediate + # already spent this turn's swift slot. + results.append( + ActionResult( + kind=action.kind, + ok=False, + reason=_REASON_DOWNGRADED_IMMEDIATE_SWIFT, + ) + ) + continue + results.append(self._execute(state, action)) + if disabled and state.hp == 0 and action.kind not in _DISABLED_MOVE_KINDS: + # PF1e (CRB, Disabled): completing a standard (or otherwise + # strenuous) action costs 1 hp — the creature slips to -1 and + # is dying. Simplification: if the action itself healed the + # creature above 0 hp the cost is not applied. + state.hp -= 1 + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"disabled: strenuous action costs 1 hp (hp 0 -> -1, dying)" + ) + if state.delayed_this_round and not was_delayed: + # A fresh delay forfeits the rest of the turn. + break + if self._winner_side() is not None: + break + finally: + self._turn_actor = None if state.active and len(self._transcript) == before: self._log(f"round {self._current_round} {state.combatant.id}: wait") # Assigned at the END of the turn (never reset at the start): the @@ -3773,12 +3799,21 @@ class CombatEngine: ) -> 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``. + PF1e (SRD Immediate Actions): "Using an immediate action on your turn + is the same as using a swift action and counts as your swift action + for that turn." An immediate fired during the creature's OWN turn + therefore spends the CURRENT turn's swift (``swift_used_this_turn``: + swift actions later in the sequence are dropped, and no further + immediate fires this round). Off-turn, the immediate consumes the + NEXT turn's swift (``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 self._turn_actor is state: + state.swift_used_this_turn = True + else: + state.immediate_used = True if action.spell_name is not None: self._execute_cast_spell( state, target, action.spell_name, target_pos=action.target_pos, @@ -3822,7 +3857,7 @@ class CombatEngine: if self._reaction_policy is None: return for state in self._states: - if not state.active or state.immediate_used: + if not state.active or state.immediate_used or state.swift_used_this_turn: continue action = self._reaction_policy(self, state, event) if action is None: @@ -6537,8 +6572,9 @@ def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]: # (spell + full attack in one round), run (4x-speed movement, PF1e # full-round); consumes the standard AND the move slot # - free/conditional (everything else, no budget): wait, free, immediate -# (off-turn; it consumes the NEXT swift via immediate_used, not this turn's -# slots), 5foot_step (free step, mutually exclusive with other movement via +# (off-turn it consumes the NEXT swift via immediate_used; on your own turn +# it consumes THIS turn's swift via swift_used_this_turn — SRD Immediate +# Actions), 5foot_step (free step, mutually exclusive with other movement via # moved_this_turn), drop_prone, activate_power_attack and fight_defensively # (mode declarations applied to the attack that follows them, like the # Power Attack toggle), summon is not a kind (summons arrive via cast_spell). diff --git a/tests/test_immediate_actions.py b/tests/test_immediate_actions.py index 43ae7c2..1708ba9 100644 --- a/tests/test_immediate_actions.py +++ b/tests/test_immediate_actions.py @@ -190,7 +190,7 @@ def test_immediate_attack_reaction_executes() -> None: def test_immediate_consumes_next_swift() -> None: - """An immediate action consumes the swift action of the next turn.""" + """An OFF-TURN immediate action consumes the swift action of the next turn.""" hero = make_combatant("hero", initiative_mod=5) goblin = make_combatant("goblin") hero_state = make_state(hero, "players", (1, 1)) @@ -218,7 +218,12 @@ def test_immediate_consumes_next_swift() -> None: _engine: CombatEngine, state: CombatantState, event: ReactionEvent ) -> Action | None: nonlocal reacted - if state.combatant.id == "hero" and event.kind == "attack_resolved" and not reacted: + if ( + state.combatant.id == "hero" + and event.kind == "attack_resolved" + and event.actor.combatant.id == "goblin" + and not reacted + ): reacted = True return Action(kind="immediate", target_id="goblin") return None @@ -239,6 +244,59 @@ def test_immediate_consumes_next_swift() -> None: assert deed_rounds == [1, 3] +def test_in_turn_immediate_consumes_current_swift() -> None: + """SRD Immediate Actions: "Using an immediate action on your turn is the + same as using a swift action and counts as your swift action for that + turn." The hero attacks; its own attack_resolved event triggers its + in-turn reaction, spending the current swift — so the deed that follows + in the same sequence is dropped, and the next turn's swift stays free.""" + hero = make_combatant("hero", initiative_mod=5) + goblin = make_combatant("goblin") + hero_state = make_state(hero, "players", (1, 1)) + hero_state.resources_left = {"grit": 2} + states = [hero_state, make_state(goblin, "monsters", (1, 2))] + reacted = False + + def deed_policy( + _engine: CombatEngine, state: CombatantState + ) -> tuple[Action, ...]: + if state.combatant.id == "hero": + return ( + Action(kind="attack", target_id="goblin"), + Action(kind="deed", target_id="goblin", deed_name="up_close_and_deadly"), + ) + return (Action(kind="wait"),) + + def reaction_policy( + _engine: CombatEngine, state: CombatantState, event: ReactionEvent + ) -> Action | None: + nonlocal reacted + if ( + state.combatant.id == "hero" + and event.kind == "attack_resolved" + and event.actor.combatant.id == "hero" + and not reacted + ): + reacted = True + return Action(kind="immediate", target_id="goblin") + return None + + engine = CombatEngine( + SeededRng(4), + make_grid(), + states, + policy=deed_policy, + reaction_policy=reaction_policy, + ) + engine._roll_initiative() + engine._take_turn(hero_state) + # The in-turn immediate spent the current swift: the deed is dropped... + reasons = [(r.kind, r.reason) for r in hero_state.last_action_results] + assert ("deed", "downgraded:immediate_swift") in reasons + # ...and the NEXT turn's swift is NOT consumed (unlike the off-turn rule). + assert hero_state.immediate_used is False + + def test_immediate_in_turn_sequence_executes() -> None: """An immediate action in the turn sequence executes like an attack.""" hero = make_combatant("hero", initiative_mod=5)