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:
@@ -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 -------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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"),)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user