fix(rules): consume the current turn's swift for an in-turn immediate

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." The engine now tracks the acting combatant (_turn_actor); an immediate fired during its OWN turn sets swift_used_this_turn instead of immediate_used, so swift actions later in the turn's sequence are dropped (downgraded:immediate_swift) and no second immediate fires that round, while the next turn's swift stays free. Off-turn immediates keep consuming the next swift. The off-turn consumption test was retargeted to a true off-turn event and an in-turn test added; README updated (Actions immediates + budget classification).

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 10:00:25 +02:00
parent dde6bc1f5d
commit 1363bb7f06
3 changed files with 140 additions and 35 deletions
+17 -6
View File
@@ -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
+63 -27
View File
@@ -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).
+60 -2
View File
@@ -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)