From 32c09df26481ff77e9627a115287efd997a9d6c8 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 11 Sep 2026 20:59:19 +0200 Subject: [PATCH] fix(rules): keep swift and free actions for staggered combatants PF1e (CRB, Staggered): a staggered creature takes a single move OR standard action but can still take free, swift, and immediate actions. _staggered_filter returned only the first non-full-round action, so a disabled combatant with a queued swift bonus (deed/inspire_courage) lost its standard action. It now keeps every swift and free action plus the first move-or-standard, preserving the full_attack -> single attack downgrade. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 9 ++++-- src/pf1e_simulator/combat.py | 37 ++++++++++++++++----- tests/test_conformance_action_economy.py | 41 ++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f9a6a9e..e07efdd 100644 --- a/README.md +++ b/README.md @@ -339,7 +339,8 @@ Règles modélisées : - Invalide (conformance P2, CRB « Effects of Death ») : à `hp = 0` exactement, une créature est **invalide** — elle reste active mais **staggered** (une action de mouvement OU une action standard par tour, pas - d'action à round complet), se déplace à **moitié vitesse**, et achever une + d'action à round complet, mais les actions swift, libre et immédiate restent + permises — CRB « Staggered »), se déplace à **moitié vitesse**, et achever une action non-mouvement (standard ou pénible) lui coûte **1 pv** à la fin de cette action (pv → −1, agonie). Simplification documentée : si l'action elle-même soigne la créature au-dessus de 0 pv, ce coût n'est pas appliqué. @@ -856,8 +857,10 @@ Règles modélisées : endormi, inerme, fasciné) annule le tour ; `cant_attack` (nauséeux) n'autorise qu'une unique action de mouvement (`move`, `5foot_step`, `stand_up`, `drop_prone`) et bloque toute attaque, manœuvre **et incantation** (CRB : un nauséeux ne peut ni attaquer, - ni incanter, ni se concentrer) ; `staggered` restreint à une action - simple (pas d'attaque à outrance) ; `cant_move` bloque le déplacement ; + ni incanter, ni se concentrer) ; `staggered` restreint à une action + simple (une action de mouvement OU standard, pas d'attaque à outrance ni + d'action à round complet, mais swift/libre/immédiate conservées) ; + `cant_move` bloque le déplacement ; `denies_dex_to_ac` (flat-footed, paralysé, aveuglé…) fait perdre le bonus de Dextérité à la CA (CA flat-footed) ; `miss_chance` (flou 20 %, déplacement 50 %, invisible 50 %) impose la diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index b2023ad..7476441 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -6054,19 +6054,40 @@ def _staggered_filter( *, cast_time: CastTimeResolver = _default_cast_time, ) -> tuple[Action, ...]: - """Staggered: one move OR one standard action per round, no full-round. + """Staggered: one move OR one standard action, plus every swift and free. - A leading ``full_attack`` (already in range) becomes a single standard - ``attack``; anything whose effective slot is full-round (charge, - withdraw, run, a full-round/one-round cast, spell_combat) is dropped. + PF1e (CRB, Conditions — Staggered): "A staggered creature may take a + single move action or standard action each round (but not both, nor can + he take full-round actions). A staggered creature can still take free, + swift, and immediate actions." So the filter walks the sequence in order + and keeps every swift and free action, the FIRST move-or-standard action, + 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. """ + kept: list[Action] = [] + slot_used = False # the single move-OR-standard slot is spent for action in actions: if action.kind == "full_attack": - return (Action(kind="attack", target_id=action.target_id),) - if _effective_slot(action, cast_time) == "full_round": + # A full attack is a full-round action; staggered it collapses to + # a single standard attack (the kept move-or-standard action). + if not slot_used: + kept.append(Action(kind="attack", target_id=action.target_id)) + slot_used = True continue - return (action,) - return () + slot = _effective_slot(action, cast_time) + if slot == "full_round": + continue + if slot in ("standard", "move"): + if slot_used: + continue + kept.append(action) + slot_used = True + else: # swift / free — always allowed while staggered (CRB) + kept.append(action) + return tuple(kept) def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]: diff --git a/tests/test_conformance_action_economy.py b/tests/test_conformance_action_economy.py index 2dd1939..f0e45a5 100644 --- a/tests/test_conformance_action_economy.py +++ b/tests/test_conformance_action_economy.py @@ -33,6 +33,7 @@ from pf1e_simulator.combat import ( _cast_time_slot, _dropped_kinds, _dropped_kinds_multiset, + _staggered_filter, default_policy, ) from pf1e_simulator.conditions import CONDITIONS_BY_NAME @@ -309,6 +310,46 @@ def test_staggered_after_budget_keeps_single_legal_action() -> None: assert ("charge", "downgraded:staggered") in reasons +# ── (d2) the staggered filter keeps swift + free, one move-OR-standard ───────── + + +def test_staggered_filter_keeps_swift_and_free_actions() -> None: + # PF1e (CRB, Staggered): a single move OR standard, but free/swift/immediate + # actions are still allowed. The policy's swift deed must not be dropped. + assert _staggered_filter((_foe("deed"), _foe("attack"))) == ( + _foe("deed"), + _foe("attack"), + ) + # A swift taken AFTER the standard is likewise kept. + assert _staggered_filter((_foe("attack"), _foe("deed"))) == ( + _foe("attack"), + _foe("deed"), + ) + # A free 5-foot step survives alongside the single standard action. + assert _staggered_filter((_foe("5foot_step"), _foe("attack"))) == ( + _foe("5foot_step"), + _foe("attack"), + ) + + +def test_staggered_filter_keeps_only_one_move_or_standard() -> None: + # move then standard → the move wins (first move-or-standard). + assert _staggered_filter((_foe("move"), _foe("attack"))) == (_foe("move"),) + # standard then move → the standard wins. + assert _staggered_filter((_foe("attack"), _foe("move"))) == (_foe("attack"),) + # two standards → only the first survives. + assert _staggered_filter((_foe("attack"), _foe("attack"))) == (_foe("attack"),) + + +def test_staggered_filter_drops_full_round_and_downgrades_full_attack() -> None: + # A leading full_attack collapses to a single standard attack (kept). + assert _staggered_filter((_foe("full_attack"),)) == (_foe("attack"),) + # A full-round charge is dropped; a following standard fills the slot. + assert _staggered_filter((_foe("charge"), _foe("attack"))) == (_foe("attack"),) + # full_attack after a spent slot is dropped (the slot is already used). + assert _staggered_filter((_foe("move"), _foe("full_attack"))) == (_foe("move"),) + + 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.