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:
@@ -4097,6 +4097,11 @@ class CombatEngine:
|
|||||||
if self.has_condition_flag(state, "cant_move"):
|
if self.has_condition_flag(state, "cant_move"):
|
||||||
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot move)")
|
||||||
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
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 is not None:
|
||||||
if action.destination == state.pos:
|
if action.destination == state.pos:
|
||||||
path: list[Pos] = [] # already there: a valid zero-length run
|
path: list[Pos] = [] # already there: a valid zero-length run
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ Part (b): Condition behavioral effects enforced via action economy / AC modifier
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
|
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.dice import parse_dice
|
||||||
from pf1e_simulator.effects import ActiveCondition, StatModifier
|
from pf1e_simulator.effects import ActiveCondition, StatModifier
|
||||||
from pf1e_simulator.grid import Grid
|
from pf1e_simulator.grid import Grid
|
||||||
@@ -226,7 +233,76 @@ class TestStaggeredActionEconomy:
|
|||||||
assert h.pos != (10, 10)
|
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:
|
class TestNauseatedActionEconomy:
|
||||||
|
|||||||
Reference in New Issue
Block a user