diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index b777d53..a5cf42a 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -3759,6 +3759,18 @@ class CombatEngine: state.pos = orig 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: """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") 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) melee_weapons = [w for w in state.usable_attacks() if w.kind == "melee"] if not melee_weapons: @@ -3905,7 +3917,7 @@ class CombatEngine: return inner_cells, reach_cells = _melee_reach_zone_cells(attacker, weapon) 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) end = self._charge_end( 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 ``attack`` that consumes the one move-or-standard slot; a further 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. + spell_combat) is dropped. Exceptions (SRD "Full-Round Actions — Restricted + Withdraw", SRD "Charge — restricted"): "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" / "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" — ``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] = [] slot_used = False # the single move-OR-standard slot is spent @@ -8021,8 +8036,8 @@ def _staggered_filter( continue slot = _effective_slot(action, cast_time, free_draw=free_draw) if slot == "full_round": - if action.kind == "withdraw" and not slot_used: - # Restricted Withdraw: standard action, move up to speed. + if action.kind in ("withdraw", "charge") and not slot_used: + # Restricted Withdraw / Restricted Charge: standard action. kept.append(action) slot_used = True continue @@ -8050,9 +8065,10 @@ def _surprise_filter( grants neither a swift action nor a full-round action, so swift and full-round kinds are dropped — with the same conventions as ``_staggered_filter``: a ``full_attack`` collapses to a single standard - ``attack``, and ``withdraw`` survives as the standard-action Restricted - Withdraw (SRD "Full-Round Actions": legal "if you are limited to taking - only a standard action each round"). + ``attack``, and ``withdraw`` / ``charge`` survive as the standard-action + Restricted Withdraw / Restricted Charge (SRD "Full-Round Actions", + "Charge": legal "if you are able to take only a standard action on your + turn", at single speed). """ kept: list[Action] = [] slot_used = False # the single standard-or-move slot is spent @@ -8064,7 +8080,7 @@ def _surprise_filter( continue slot = _effective_slot(action, cast_time, free_draw=free_draw) 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) slot_used = True continue diff --git a/tests/test_charge_result.py b/tests/test_charge_result.py index 50dbfa3..bc8fae3 100644 --- a/tests/test_charge_result.py +++ b/tests/test_charge_result.py @@ -205,6 +205,31 @@ def test_charge_result_no_charge_path_too_far() -> None: 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 ------------------------------------------------- diff --git a/tests/test_conformance_action_economy.py b/tests/test_conformance_action_economy.py index 238991b..23e6091 100644 --- a/tests/test_conformance_action_economy.py +++ b/tests/test_conformance_action_economy.py @@ -291,7 +291,9 @@ def _add_condition(state: CombatantState, name: str) -> None: def test_staggered_after_budget_keeps_single_legal_action() -> None: # (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, ...]: if s.combatant.id == "hero": return ( @@ -307,7 +309,8 @@ def test_staggered_after_budget_keeps_single_legal_action() -> None: eng._take_turn(hero) reasons = [(r.kind, r.reason) for r in hero.last_action_results] 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 ───────── @@ -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: # 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"),) + # SRD "Charge": restricted to a standard action, charge survives as the + # 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). assert _staggered_filter((_foe("move"), _foe("full_attack"))) == (_foe("move"),) diff --git a/tests/test_surprise_round.py b/tests/test_surprise_round.py index c80d538..fd58ac1 100644 --- a/tests/test_surprise_round.py +++ b/tests/test_surprise_round.py @@ -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: # full_attack collapses to a single standard attack (same convention as - # the staggered filter); charge/run and a one-round cast are full-round - # and illegal; withdraw survives as the Restricted Withdraw standard. + # the staggered filter); run and a one-round cast are full-round and + # 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") 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"),)) == () withdraw = Action(kind="withdraw", target_id="foe") 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") assert _surprise_filter((summon,), cast_time=lambda _a: "one_round") == () bolt = Action(kind="cast_spell", target_id="foe", spell_name="Bolt")