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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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é.
|
||||
@@ -857,7 +858,9 @@ Règles modélisées :
|
||||
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 ;
|
||||
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
|
||||
|
||||
@@ -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, ...]:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user