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