diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 1663f93..3e32c93 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -4097,6 +4097,11 @@ class CombatEngine: if self.has_condition_flag(state, "cant_move"): self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)") return ActionResult(kind=kind, ok=True, reason=_REASON_OK) + # SRD "Conditions — Fatigued": "A fatigued character can neither run + # nor charge" (same for exhausted/entangled via cant_charge). + if self.has_condition_flag(state, "cant_charge"): + self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot run)") + return ActionResult(kind=kind, ok=True, reason=_REASON_OK) if action.destination is not None: if action.destination == state.pos: path: list[Pos] = [] # already there: a valid zero-length run diff --git a/tests/test_conditions_effects.py b/tests/test_conditions_effects.py index 762a271..73395a4 100644 --- a/tests/test_conditions_effects.py +++ b/tests/test_conditions_effects.py @@ -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: