fix(rules): allow a staggered creature to withdraw as a standard action

SRD 'Full-Round Actions - Restricted Withdraw': 'If you are limited to taking only a standard action each round you can withdraw as a standard action. In this case, you may move up to your speed.' _staggered_filter now keeps withdraw as the one move-or-standard action (spending the slot) instead of dropping it, and _flee_path halves the budget to single speed for a staggered/disabled creature (new _is_staggered_limited helper). Filter-level and engine-level tests added.

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 14:21:04 +02:00
parent a4487a85f9
commit 317c68ecdb
4 changed files with 95 additions and 12 deletions
+8 -2
View File
@@ -735,12 +735,18 @@ Règles modélisées :
ligne droite doit être dégagée (pas de terrain difficile, d'obstacles ni de ligne droite doit être dégagée (pas de terrain difficile, d'obstacles ni de
créatures) ; distance minimum 2 cases (10 ft). `default_policy` choisit la créatures) ; distance minimum 2 cases (10 ft). `default_policy` choisit la
charge quand l'ennemi est hors de portée mais joignable en ligne droite. charge quand l'ennemi est hors de portée mais joignable en ligne droite.
- Retraite : action à round complet. Le combattant se déplace jusqu'à 2× sa - Retraite : action à round complet. Le combattant se déplace jusqu'à 2× sa
vitesse en s'éloignant de l'ennemi le plus proche (ascension gloutonne du vitesse en s'éloignant de l'ennemi le plus proche (ascension gloutonne du
champ de coût Dijkstra depuis la menace). La case de départ n'est pas champ de coût Dijkstra depuis la menace). La case de départ n'est pas
considérée comme menacée — aucune AoO en la quittant. Les cases considérée comme menacée — aucune AoO en la quittant. Les cases
ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour
le mouvement). `default_policy` ne choisit pas la retraite (disponible via le mouvement). Conformance L6 (SRD « Full-Round Actions — Restricted
Withdraw ») : « If you are limited to taking only a standard action each
round you can withdraw as a standard action. In this case, you may move up
to your speed » — un combattant `staggered`/invalidé qui reçoit `withdraw`
le conserve comme **action standard** (le filtre staggered ne le jette
plus) avec un budget de **1× vitesse** au lieu de 2× (`_flee_path` via
`_is_staggered_limited`). `default_policy` ne choisit pas la retraite (disponible via
une politique personnalisée). une politique personnalisée).
- Course (`Action(kind="run")`, action à round complet, U5e) : le combattant - Course (`Action(kind="run")`, action à round complet, U5e) : le combattant
se déplace jusqu'à **4× sa vitesse** vers `target_id` (auto-dirigé, se déplace jusqu'à **4× sa vitesse** vers `target_id` (auto-dirigé,
+33 -4
View File
@@ -1641,7 +1641,7 @@ class CombatEngine:
) )
actions = kept actions = kept
state.immediate_used = False state.immediate_used = False
if self._is_disabled(state) or self._has_condition_named(state, "staggered"): if self._is_staggered_limited(state):
kept = _staggered_filter(actions, cast_time=self._cast_time_of) kept = _staggered_filter(actions, cast_time=self._cast_time_of)
results.extend( results.extend(
ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_STAGGERED) ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_STAGGERED)
@@ -1677,6 +1677,16 @@ class CombatEngine:
disabled — staggered, half speed, and a strenuous action costs 1 hp.""" disabled — staggered, half speed, and a strenuous action costs 1 hp."""
return state.hp == 0 return state.hp == 0
def _is_staggered_limited(self, state: CombatantState) -> bool:
"""True when the creature is limited to one move OR one standard
action per round (CRB "Conditions — Staggered"; a disabled creature
at 0 hp is staggered). Such a creature cannot take full-round actions
— except Restricted Withdraw (SRD "Full-Round Actions"), which it may
take as a standard action moving up to its speed."""
return self._is_disabled(state) or self._has_condition_named(
state, "staggered"
)
def _is_helpless(self, state: CombatantState) -> bool: def _is_helpless(self, state: CombatantState) -> bool:
"""PF1e (CRB): helpless = paralyzed, held, bound, sleeping, unconscious. """PF1e (CRB): helpless = paralyzed, held, bound, sleeping, unconscious.
@@ -6479,11 +6489,18 @@ class CombatEngine:
def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]: def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]:
"""Movement path away from ``threat``, up to double speed. """Movement path away from ``threat``, up to double speed.
A staggered/disabled creature taking Restricted Withdraw (SRD "Full-Round
Actions": "you can withdraw as a standard action. In this case, you may
move up to your speed") gets a single-speed budget instead.
Uses the Dijkstra cost field from the threat and greedily picks the Uses the Dijkstra cost field from the threat and greedily picks the
step that maximizes distance (cost) from the threat each cell. step that maximizes distance (cost) from the threat each cell.
""" """
speed_mult = self._get_speed_mult(state) speed_mult = self._get_speed_mult(state)
max_cells = int(2 * (_speed_ft(state) // _SQUARE_FT) * speed_mult) withdraw_mult = 1 if self._is_staggered_limited(state) else 2
max_cells = int(
withdraw_mult * (_speed_ft(state) // _SQUARE_FT) * speed_mult
)
if max_cells <= 0: if max_cells <= 0:
return [] return []
blocked = self._blocked(state) blocked = self._blocked(state)
@@ -6537,6 +6554,10 @@ class CombatEngine:
def _withdraw(self, state: CombatantState, threat: CombatantState | None) -> None: def _withdraw(self, state: CombatantState, threat: CombatantState | None) -> None:
"""Full-round action: move up to 2x speed away from the nearest enemy. """Full-round action: move up to 2x speed away from the nearest enemy.
Restricted Withdraw (SRD "Full-Round Actions"): a staggered/disabled
creature takes this as a standard action and moves up to its speed
(single, not double — see ``_flee_path``).
The starting square is not threatened — no AoO when leaving it. The starting square is not threatened — no AoO when leaving it.
Subsequent squares provoke AoOs normally (step-by-step, as for move). Subsequent squares provoke AoOs normally (step-by-step, as for move).
Like every other movement it is mutually exclusive with movement Like every other movement it is mutually exclusive with movement
@@ -6821,8 +6842,12 @@ def _staggered_filter(
and drops full-round actions plus any SECOND move-or-standard. A leading and drops full-round actions plus any SECOND move-or-standard. A leading
``full_attack`` (already in range) is downgraded to a single standard ``full_attack`` (already in range) is downgraded to a single standard
``attack`` that consumes the one move-or-standard slot; a further ``attack`` that consumes the one move-or-standard slot; a further
full-round action (charge, withdraw, run, a full-round/one-round cast, full-round action (charge, run, a full-round/one-round cast,
spell_combat) is dropped. spell_combat) is dropped. Exception (SRD "Full-Round Actions — Restricted
Withdraw"): "If you are limited to taking only a standard action each
round you can withdraw as a standard action. In this case, you may move
up to your speed" — a ``withdraw`` is kept as the one standard action and
``_flee_path`` halves its budget to single speed.
""" """
kept: list[Action] = [] kept: list[Action] = []
slot_used = False # the single move-OR-standard slot is spent slot_used = False # the single move-OR-standard slot is spent
@@ -6836,6 +6861,10 @@ def _staggered_filter(
continue continue
slot = _effective_slot(action, cast_time) slot = _effective_slot(action, cast_time)
if slot == "full_round": if slot == "full_round":
if action.kind == "withdraw" and not slot_used:
# Restricted Withdraw: standard action, move up to speed.
kept.append(action)
slot_used = True
continue continue
if slot in ("standard", "move"): if slot in ("standard", "move"):
if slot_used: if slot_used:
+37 -1
View File
@@ -7,7 +7,7 @@ Part (b): Condition behavioral effects enforced via action economy / AC modifier
from __future__ import annotations from __future__ import annotations
from pf1e_simulator.combat import CombatantState, CombatEngine from pf1e_simulator.combat import Action, CombatantState, CombatEngine
from pf1e_simulator.conditions import NAUSEATED, PARALYZED, PRONE, STAGGERED from pf1e_simulator.conditions import NAUSEATED, PARALYZED, PRONE, STAGGERED
from pf1e_simulator.dice import parse_dice from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import ActiveCondition, StatModifier from pf1e_simulator.effects import ActiveCondition, StatModifier
@@ -188,6 +188,42 @@ class TestStaggeredActionEconomy:
result = engine._take_turn(h) result = engine._take_turn(h)
assert result is False assert result is False
def test_staggered_restricted_withdraw_moves_single_speed(self) -> None:
# SRD "Full-Round Actions — Restricted Withdraw": "If you are limited
# to taking only a standard action each round you can withdraw as a
# standard action. In this case, you may move up to your speed" — 30
# ft = 6 cells, not the 2x full-round budget.
legend = {".": TerrainType(type="floor", move_cost=1)}
grid = Grid.from_spec(
MapSpec(name="big", terrain=tuple(["." * 20] * 20), legend=legend)
)
hero = _make_hero()
foe = _make_foe()
h = CombatantState(combatant=hero, side="players", pos=(10, 10), hp=hero.hp_max)
f = CombatantState(combatant=foe, side="monsters", pos=(10, 9), hp=foe.hp_max)
h.active_conditions.append(
ActiveCondition(condition=STAGGERED, rounds_left=5, source="test")
)
engine = CombatEngine(
ScriptedRng([10, 9]),
grid,
[h, f],
round_cap=1,
policy=lambda _e, s: (
(Action(kind="withdraw", target_id="foe"),)
if s.combatant.id == hero.id
else (Action(kind="wait"),)
),
)
engine.run()
withdraw_lines = [
line for line in engine._transcript if "withdraw" in line
]
assert len(withdraw_lines) == 1
# 6 steps from (10,10) — exactly the single-speed budget.
assert withdraw_lines[0].count("->") == 6
assert h.pos != (10, 10)
# ── Test 4: nauseated → single move action only ─────────────────────────────── # ── Test 4: nauseated → single move action only ───────────────────────────────
+12
View File
@@ -350,6 +350,18 @@ def test_staggered_filter_drops_full_round_and_downgrades_full_attack() -> None:
assert _staggered_filter((_foe("move"), _foe("full_attack"))) == (_foe("move"),) assert _staggered_filter((_foe("move"), _foe("full_attack"))) == (_foe("move"),)
def test_staggered_filter_keeps_withdraw_as_restricted_standard() -> None:
# SRD "Full-Round Actions — Restricted Withdraw": a creature limited to a
# standard action each round may withdraw as a standard action (moving up
# to its speed) — the staggered filter keeps it and spends the slot.
assert _staggered_filter((_foe("withdraw"),)) == (_foe("withdraw"),)
# After the one move-or-standard slot is spent, withdraw is dropped.
assert _staggered_filter((_foe("attack"), _foe("withdraw"))) == (_foe("attack"),)
assert _staggered_filter((_foe("move"), _foe("withdraw"))) == (_foe("move"),)
# A leading withdraw spends the slot; the following standard is dropped.
assert _staggered_filter((_foe("withdraw"), _foe("attack"))) == (_foe("withdraw"),)
def test_nauseated_after_budget_keeps_single_move() -> None: def test_nauseated_after_budget_keeps_single_move() -> None:
# (move, attack, move): the budget drops the second move; the nauseated # (move, attack, move): the budget drops the second move; the nauseated
# filter then keeps only the first move and drops the standard attack. # filter then keeps only the first move and drops the standard attack.