diff --git a/README.md b/README.md index 9b6ae7b..93b46e4 100644 --- a/README.md +++ b/README.md @@ -735,13 +735,19 @@ Règles modélisées : 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 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 - 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 - 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 - le mouvement). `default_policy` ne choisit pas la retraite (disponible via - une politique personnalisée). + - 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 + 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 + ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour + 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). - Course (`Action(kind="run")`, action à round complet, U5e) : le combattant se déplace jusqu'à **4× sa vitesse** vers `target_id` (auto-dirigé, s'arrête adjacent à la cible) ou vers une `destination` explicite (U5c), en diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 58ef226..a62aa52 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -1641,7 +1641,7 @@ class CombatEngine: ) actions = kept 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) results.extend( 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.""" 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: """PF1e (CRB): helpless = paralyzed, held, bound, sleeping, unconscious. @@ -6479,11 +6489,18 @@ class CombatEngine: def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]: """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 step that maximizes distance (cost) from the threat each cell. """ 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: return [] blocked = self._blocked(state) @@ -6537,6 +6554,10 @@ class CombatEngine: def _withdraw(self, state: CombatantState, threat: CombatantState | None) -> None: """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. Subsequent squares provoke AoOs normally (step-by-step, as for move). 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 ``full_attack`` (already in range) is downgraded to a single standard ``attack`` that consumes the one move-or-standard slot; a further - full-round action (charge, withdraw, run, a full-round/one-round cast, - spell_combat) is dropped. + full-round action (charge, run, a full-round/one-round cast, + 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] = [] slot_used = False # the single move-OR-standard slot is spent @@ -6836,6 +6861,10 @@ def _staggered_filter( continue slot = _effective_slot(action, cast_time) 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 if slot in ("standard", "move"): if slot_used: diff --git a/tests/test_conditions_effects.py b/tests/test_conditions_effects.py index 61a6cf8..44fd064 100644 --- a/tests/test_conditions_effects.py +++ b/tests/test_conditions_effects.py @@ -7,7 +7,7 @@ Part (b): Condition behavioral effects enforced via action economy / AC modifier 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.dice import parse_dice from pf1e_simulator.effects import ActiveCondition, StatModifier @@ -188,6 +188,42 @@ class TestStaggeredActionEconomy: result = engine._take_turn(h) 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 ─────────────────────────────── diff --git a/tests/test_conformance_action_economy.py b/tests/test_conformance_action_economy.py index f0e45a5..013fcd9 100644 --- a/tests/test_conformance_action_economy.py +++ b/tests/test_conformance_action_economy.py @@ -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"),) +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: # (move, attack, move): the budget drops the second move; the nauseated # filter then keeps only the first move and drops the standard attack.