fix(combat): Block run action when cant_charge flag is set

SRD Conditions Fatigued: A fatigued character can neither run nor
charge. _execute_charge already checked cant_charge; _execute_run did
not -- a fatigued/exhausted/entangled combatant could still run.

Added cant_charge guard to _execute_run (mirrors _execute_charge),
logging wait (cannot run) and returning ok. Two engine-level tests:
fatigued cannot run, exhausted cannot run.
This commit is contained in:
2026-09-16 13:28:17 +02:00
parent 2cc2ba1907
commit 7fc7a1d6ec
2 changed files with 83 additions and 2 deletions
+78 -2
View File
@@ -8,7 +8,14 @@ Part (b): Condition behavioral effects enforced via action economy / AC modifier
from __future__ import annotations
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
from pf1e_simulator.conditions import NAUSEATED, PARALYZED, PRONE, STAGGERED
from pf1e_simulator.conditions import (
EXHAUSTED,
FATIGUED,
NAUSEATED,
PARALYZED,
PRONE,
STAGGERED,
)
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import ActiveCondition, StatModifier
from pf1e_simulator.grid import Grid
@@ -226,7 +233,76 @@ class TestStaggeredActionEconomy:
assert h.pos != (10, 10)
# ── Test 4: nauseated → single move action only ───────────────────────────────
# ── Test 3b: fatigued/exhausted cannot run ────────────────────────────────────
class TestFatiguedCannotRun:
"""Given: a fatigued or exhausted combatant
When: the engine executes a run action
Then: the run is blocked (SRD: "A fatigued character can neither run nor
charge"; exhausted and entangled share the cant_charge flag)."""
def test_fatigued_cannot_run(self) -> None:
legend = {".": TerrainType(type="floor", move_cost=1)}
grid = Grid.from_spec(
MapSpec(name="big", terrain=tuple(["." * 20] * 20), legend=legend)
)
hero = _make_hero()
foe = _make_foe()
h = CombatantState(combatant=hero, side="players", pos=(0, 0), hp=hero.hp_max)
f = CombatantState(combatant=foe, side="monsters", pos=(10, 0), hp=foe.hp_max)
h.active_conditions.append(
ActiveCondition(condition=FATIGUED, rounds_left=5, source="test")
)
engine = CombatEngine(
ScriptedRng([10, 9]),
grid,
[h, f],
round_cap=1,
policy=lambda _e, s: (
(Action(kind="run", target_id="foe"),)
if s.combatant.id == hero.id
else (Action(kind="wait"),)
),
)
engine.run()
run_lines = [
line for line in engine._transcript
if "hero: run" in line or "hero: wait" in line
]
assert any("wait (cannot run)" in line for line in run_lines)
assert h.pos == (0, 0)
def test_exhausted_cannot_run(self) -> None:
legend = {".": TerrainType(type="floor", move_cost=1)}
grid = Grid.from_spec(
MapSpec(name="big", terrain=tuple(["." * 20] * 20), legend=legend)
)
hero = _make_hero()
foe = _make_foe()
h = CombatantState(combatant=hero, side="players", pos=(0, 0), hp=hero.hp_max)
f = CombatantState(combatant=foe, side="monsters", pos=(10, 0), hp=foe.hp_max)
h.active_conditions.append(
ActiveCondition(condition=EXHAUSTED, rounds_left=5, source="test")
)
engine = CombatEngine(
ScriptedRng([10, 9]),
grid,
[h, f],
round_cap=1,
policy=lambda _e, s: (
(Action(kind="run", target_id="foe"),)
if s.combatant.id == hero.id
else (Action(kind="wait"),)
),
)
engine.run()
run_lines = [
line for line in engine._transcript
if "hero: run" in line or "hero: wait" in line
]
assert any("wait (cannot run)" in line for line in run_lines)
assert h.pos == (0, 0)
class TestNauseatedActionEconomy: