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