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`
|
- Invalide (conformance P2, CRB « Effects of Death ») : à `hp = 0`
|
||||||
exactement, une créature est **invalide** — elle reste active mais
|
exactement, une créature est **invalide** — elle reste active mais
|
||||||
**staggered** (une action de mouvement OU une action standard par tour, pas
|
**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
|
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
|
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é.
|
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
|
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
|
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,
|
attaque, manœuvre **et incantation** (CRB : un nauséeux ne peut ni attaquer,
|
||||||
ni incanter, ni se concentrer) ; `staggered` restreint à une action
|
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é à
|
`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
|
la CA (CA flat-footed) ; `miss_chance` (flou 20 %, déplacement 50 %, invisible
|
||||||
50 %) impose la
|
50 %) impose la
|
||||||
|
|||||||
@@ -6054,19 +6054,40 @@ def _staggered_filter(
|
|||||||
*,
|
*,
|
||||||
cast_time: CastTimeResolver = _default_cast_time,
|
cast_time: CastTimeResolver = _default_cast_time,
|
||||||
) -> tuple[Action, ...]:
|
) -> 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
|
PF1e (CRB, Conditions — Staggered): "A staggered creature may take a
|
||||||
``attack``; anything whose effective slot is full-round (charge,
|
single move action or standard action each round (but not both, nor can
|
||||||
withdraw, run, a full-round/one-round cast, spell_combat) is dropped.
|
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:
|
for action in actions:
|
||||||
if action.kind == "full_attack":
|
if action.kind == "full_attack":
|
||||||
return (Action(kind="attack", target_id=action.target_id),)
|
# A full attack is a full-round action; staggered it collapses to
|
||||||
if _effective_slot(action, cast_time) == "full_round":
|
# 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
|
continue
|
||||||
return (action,)
|
slot = _effective_slot(action, cast_time)
|
||||||
return ()
|
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, ...]:
|
def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ from pf1e_simulator.combat import (
|
|||||||
_cast_time_slot,
|
_cast_time_slot,
|
||||||
_dropped_kinds,
|
_dropped_kinds,
|
||||||
_dropped_kinds_multiset,
|
_dropped_kinds_multiset,
|
||||||
|
_staggered_filter,
|
||||||
default_policy,
|
default_policy,
|
||||||
)
|
)
|
||||||
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
|
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
|
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:
|
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