fix(rules): restricted charge - a creature limited to a standard action (staggered/disabled, surprise round) can still charge at 1x speed

This commit is contained in:
2026-09-17 23:06:03 +02:00
parent a5bd3c5a85
commit e253b03ffe
4 changed files with 74 additions and 20 deletions
+29 -13
View File
@@ -3759,6 +3759,18 @@ class CombatEngine:
state.pos = orig state.pos = orig
return weapon is not None return weapon is not None
def _charge_speed_mult(self, state: CombatantState) -> int:
"""Charge speed multiplier: 2x normally; 1x for a creature restricted
to a single standard action — PF1e (SRD "Charge"): "If you are able to
take only a standard action on your turn, you can still charge, but
you are only allowed to move up to your speed (instead of up to double
your speed)" — staggered/disabled creatures and the surprise round
(mirrors the Restricted Withdraw exception).
"""
if self._surprise_round or self._is_staggered_limited(state):
return 1
return 2
def can_charge(self, state: CombatantState, target: CombatantState) -> ChargeResult | None: def can_charge(self, state: CombatantState, target: CombatantState) -> ChargeResult | None:
"""Return a ``ChargeResult`` if ``state`` can charge ``target`` this turn, else None. """Return a ``ChargeResult`` if ``state`` can charge ``target`` this turn, else None.
@@ -3782,7 +3794,7 @@ class CombatEngine:
): ):
return ChargeResult(weapon=None, distance_ft=0, reason="no_line_of_effect") return ChargeResult(weapon=None, distance_ft=0, reason="no_line_of_effect")
speed_cells = _speed_ft(state) // _SQUARE_FT speed_cells = _speed_ft(state) // _SQUARE_FT
max_cells = 2 * speed_cells max_cells = self._charge_speed_mult(state) * speed_cells
blocked = self._blocked(state, target) blocked = self._blocked(state, target)
melee_weapons = [w for w in state.usable_attacks() if w.kind == "melee"] melee_weapons = [w for w in state.usable_attacks() if w.kind == "melee"]
if not melee_weapons: if not melee_weapons:
@@ -3905,7 +3917,7 @@ class CombatEngine:
return return
inner_cells, reach_cells = _melee_reach_zone_cells(attacker, weapon) inner_cells, reach_cells = _melee_reach_zone_cells(attacker, weapon)
speed_cells = _speed_ft(attacker) // _SQUARE_FT speed_cells = _speed_ft(attacker) // _SQUARE_FT
max_cells = 2 * speed_cells max_cells = self._charge_speed_mult(attacker) * speed_cells
blocked = self._blocked(attacker, target) blocked = self._blocked(attacker, target)
end = self._charge_end( end = self._charge_end(
attacker, target, reach_cells, max_cells, blocked, min_reach_cells=inner_cells attacker, target, reach_cells, max_cells, blocked, min_reach_cells=inner_cells
@@ -8003,11 +8015,14 @@ def _staggered_filter(
``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, run, a full-round/one-round cast, full-round action (charge, run, a full-round/one-round cast,
spell_combat) is dropped. Exception (SRD "Full-Round Actions — Restricted spell_combat) is dropped. Exceptions (SRD "Full-Round Actions — Restricted
Withdraw"): "If you are limited to taking only a standard action each Withdraw", SRD "Charge — restricted"): "If you are limited to taking only
round you can withdraw as a standard action. In this case, you may move a standard action each round you can withdraw as a standard action. In
up to your speed" — a ``withdraw`` is kept as the one standard action and this case, you may move up to your speed" / "If you are able to take only
``_flee_path`` halves its budget to single speed. a standard action on your turn, you can still charge, but you are only
allowed to move up to your speed" — ``withdraw`` and ``charge`` are kept
as the one standard action and their movement budgets halve to single
speed (``_flee_path``, ``_charge_speed_mult``).
""" """
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
@@ -8021,8 +8036,8 @@ def _staggered_filter(
continue continue
slot = _effective_slot(action, cast_time, free_draw=free_draw) slot = _effective_slot(action, cast_time, free_draw=free_draw)
if slot == "full_round": if slot == "full_round":
if action.kind == "withdraw" and not slot_used: if action.kind in ("withdraw", "charge") and not slot_used:
# Restricted Withdraw: standard action, move up to speed. # Restricted Withdraw / Restricted Charge: standard action.
kept.append(action) kept.append(action)
slot_used = True slot_used = True
continue continue
@@ -8050,9 +8065,10 @@ def _surprise_filter(
grants neither a swift action nor a full-round action, so swift and grants neither a swift action nor a full-round action, so swift and
full-round kinds are dropped — with the same conventions as full-round kinds are dropped — with the same conventions as
``_staggered_filter``: a ``full_attack`` collapses to a single standard ``_staggered_filter``: a ``full_attack`` collapses to a single standard
``attack``, and ``withdraw`` survives as the standard-action Restricted ``attack``, and ``withdraw`` / ``charge`` survive as the standard-action
Withdraw (SRD "Full-Round Actions": legal "if you are limited to taking Restricted Withdraw / Restricted Charge (SRD "Full-Round Actions",
only a standard action each round"). "Charge": legal "if you are able to take only a standard action on your
turn", at single speed).
""" """
kept: list[Action] = [] kept: list[Action] = []
slot_used = False # the single standard-or-move slot is spent slot_used = False # the single standard-or-move slot is spent
@@ -8064,7 +8080,7 @@ def _surprise_filter(
continue continue
slot = _effective_slot(action, cast_time, free_draw=free_draw) slot = _effective_slot(action, cast_time, free_draw=free_draw)
if slot == "full_round": if slot == "full_round":
if action.kind == "withdraw" and not slot_used: if action.kind in ("withdraw", "charge") and not slot_used:
kept.append(action) kept.append(action)
slot_used = True slot_used = True
continue continue
+25
View File
@@ -205,6 +205,31 @@ def test_charge_result_no_charge_path_too_far() -> None:
assert result.distance_ft == 0 assert result.distance_ft == 0
def test_restricted_charge_halves_the_budget() -> None:
"""SRD "Charge": a creature able to take only a standard action (here a
disabled one at 0 hp) can still charge, but only up to its speed (1x,
6 cells at 30 ft) instead of double speed — a target 6 cells away stays
chargeable, one 10 cells away is not."""
a = make_state(make_combatant("a"), "players", (0, 0))
near = make_state(make_combatant("near"), "monsters", (0, 6))
engine = make_engine([a, near], make_grid(12))
a.hp = 0 # disabled -> staggered -> restricted charge
result = engine.can_charge(a, near)
assert isinstance(result, ChargeResult)
assert result.weapon is not None # end cell 5 steps away <= 1x budget
a2 = make_state(make_combatant("a2"), "players", (0, 0))
far = make_state(make_combatant("far"), "monsters", (0, 10))
engine2 = make_engine([a2, far], make_grid(12))
a2.hp = 0
res2 = engine2.can_charge(a2, far)
assert isinstance(res2, ChargeResult)
assert res2.weapon is None # 9 steps to the closest attack cell needs 2x
a2.hp = 20 # standing again: full 2x charge is legal
res3 = engine2.can_charge(a2, far)
assert isinstance(res3, ChargeResult)
assert res3.weapon is not None
# --- Test 5: no melee weapon ------------------------------------------------- # --- Test 5: no melee weapon -------------------------------------------------
+10 -4
View File
@@ -291,7 +291,9 @@ def _add_condition(state: CombatantState, name: str) -> None:
def test_staggered_after_budget_keeps_single_legal_action() -> None: def test_staggered_after_budget_keeps_single_legal_action() -> None:
# (charge, attack): the budget keeps the charge and drops the attack as # (charge, attack): the budget keeps the charge and drops the attack as
# surplus standard; the staggered filter then drops the full-round charge. # surplus standard; the staggered filter then keeps the charge as the
# Restricted Charge standard action (SRD "Charge": able to take only a
# standard action -> charge at up to 1x speed).
def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]: def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]:
if s.combatant.id == "hero": if s.combatant.id == "hero":
return ( return (
@@ -307,7 +309,8 @@ def test_staggered_after_budget_keeps_single_legal_action() -> None:
eng._take_turn(hero) eng._take_turn(hero)
reasons = [(r.kind, r.reason) for r in hero.last_action_results] reasons = [(r.kind, r.reason) for r in hero.last_action_results]
assert ("attack", "downgraded:action_economy") in reasons assert ("attack", "downgraded:action_economy") in reasons
assert ("charge", "downgraded:staggered") in reasons assert ("charge", "downgraded:staggered") not in reasons
assert ("charge", "ok") in reasons
# ── (d2) the staggered filter keeps swift + free, one move-OR-standard ───────── # ── (d2) the staggered filter keeps swift + free, one move-OR-standard ─────────
@@ -344,8 +347,11 @@ def test_staggered_filter_keeps_only_one_move_or_standard() -> None:
def test_staggered_filter_drops_full_round_and_downgrades_full_attack() -> None: def test_staggered_filter_drops_full_round_and_downgrades_full_attack() -> None:
# A leading full_attack collapses to a single standard attack (kept). # A leading full_attack collapses to a single standard attack (kept).
assert _staggered_filter((_foe("full_attack"),)) == (_foe("attack"),) assert _staggered_filter((_foe("full_attack"),)) == (_foe("attack"),)
# A full-round charge is dropped; a following standard fills the slot. # SRD "Charge": restricted to a standard action, charge survives as the
assert _staggered_filter((_foe("charge"), _foe("attack"))) == (_foe("attack"),) # Restricted Charge (standard, 1x speed) and spends the slot; run and
# other full-round kinds stay dropped.
assert _staggered_filter((_foe("charge"), _foe("attack"))) == (_foe("charge"),)
assert _staggered_filter((_foe("run"), _foe("attack"))) == (_foe("attack"),)
# full_attack after a spent slot is dropped (the slot is already used). # full_attack after a spent slot is dropped (the slot is already used).
assert _staggered_filter((_foe("move"), _foe("full_attack"))) == (_foe("move"),) assert _staggered_filter((_foe("move"), _foe("full_attack"))) == (_foe("move"),)
+10 -3
View File
@@ -153,14 +153,21 @@ def test_surprise_filter_keeps_one_standard_or_move_plus_free() -> None:
def test_surprise_filter_collapses_full_attack_and_drops_full_rounds() -> None: def test_surprise_filter_collapses_full_attack_and_drops_full_rounds() -> None:
# full_attack collapses to a single standard attack (same convention as # full_attack collapses to a single standard attack (same convention as
# the staggered filter); charge/run and a one-round cast are full-round # the staggered filter); run and a one-round cast are full-round and
# and illegal; withdraw survives as the Restricted Withdraw standard. # illegal; withdraw survives as the Restricted Withdraw standard, and
# charge as the Restricted Charge (SRD "Charge": "If you are able to take
# only a standard action on your turn, you can still charge, but you are
# only allowed to move up to your speed").
full = Action(kind="full_attack", target_id="foe") full = Action(kind="full_attack", target_id="foe")
assert _surprise_filter((full,)) == (Action(kind="attack", target_id="foe"),) assert _surprise_filter((full,)) == (Action(kind="attack", target_id="foe"),)
assert _surprise_filter((Action(kind="charge", target_id="foe"),)) == () charge = Action(kind="charge", target_id="foe")
assert _surprise_filter((charge,)) == (charge,)
assert _surprise_filter((Action(kind="run", target_id="foe"),)) == () assert _surprise_filter((Action(kind="run", target_id="foe"),)) == ()
withdraw = Action(kind="withdraw", target_id="foe") withdraw = Action(kind="withdraw", target_id="foe")
assert _surprise_filter((withdraw,)) == (withdraw,) assert _surprise_filter((withdraw,)) == (withdraw,)
attack = Action(kind="attack", target_id="foe")
assert _surprise_filter((charge, attack)) == (charge,)
assert _surprise_filter((attack, charge)) == (attack,)
summon = Action(kind="cast_spell", target_id="foe", spell_name="Summon") summon = Action(kind="cast_spell", target_id="foe", spell_name="Summon")
assert _surprise_filter((summon,), cast_time=lambda _a: "one_round") == () assert _surprise_filter((summon,), cast_time=lambda _a: "one_round") == ()
bolt = Action(kind="cast_spell", target_id="foe", spell_name="Bolt") bolt = Action(kind="cast_spell", target_id="foe", spell_name="Bolt")