From 778d6a86209da6f9aeba4b06e2ed8e06524a22a6 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 19 Sep 2026 16:24:31 +0200 Subject: [PATCH] fix(rules): a blinded creature cannot charge, run, or withdraw (SRD charge.md: 'If you don't have line of sight to the opponent at the start of your turn, you can't charge that opponent'; full-round-actions.md: 'You can't run ... if you can't see where you're going' and 'you can't withdraw from combat if you're blinded') - can_charge gates blinded with no_line_of_effect, _execute_run joins the cant_charge no-op, _withdraw rejects blinded --- src/pf1e_simulator/combat.py | 24 ++++- tests/test_conformance_blinded_actions.py | 121 ++++++++++++++++++++++ 2 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 tests/test_conformance_blinded_actions.py diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index d8a0add..21a6f89 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -3904,6 +3904,11 @@ class CombatEngine: straight-line distance in feet to the chosen anchor on success, 0 on failure. Never mutates state nor consumes RNG. """ + if self._has_condition_named(state, "blinded"): + # SRD "Charge" (charge.md): "If you don't have line of sight to + # the opponent at the start of your turn, you can't charge that + # opponent." A blinded creature has line of sight to nothing. + return ChargeResult(weapon=None, distance_ft=0, reason="no_line_of_effect") if not has_line_of_effect_cells( self._grid, state.occupied_cells(), target.occupied_cells() ): @@ -4444,8 +4449,13 @@ class CombatEngine: 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"): + # nor charge" (same for exhausted/entangled via cant_charge). SRD + # "Full-Round Actions — Run" (full-round-actions.md): "You can't run + # across difficult terrain or if you can't see where you're going" — + # a blinded creature sees nowhere. + if self.has_condition_flag(state, "cant_charge") or self._has_condition_named( + state, "blinded" + ): 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: @@ -8146,11 +8156,21 @@ class CombatEngine: Subsequent squares provoke AoOs normally (step-by-step, as for move). Like every other movement it is mutually exclusive with movement already spent this turn (``moved_this_turn``). + + SRD "Full-Round Actions — Withdraw" (full-round-actions.md): "you + can't withdraw from combat if you're blinded" — a blinded creature + cannot even take the restricted (standard-action) withdraw. """ if state.moved_this_turn: return if threat is None: return + if self._has_condition_named(state, "blinded"): + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"withdraw rejected (blinded)" + ) + return path = self._flee_path(state, threat) if not path: return diff --git a/tests/test_conformance_blinded_actions.py b/tests/test_conformance_blinded_actions.py new file mode 100644 index 0000000..a9016fa --- /dev/null +++ b/tests/test_conformance_blinded_actions.py @@ -0,0 +1,121 @@ +"""A blinded creature cannot charge, run, or withdraw (conformance). + +PF1e SRD "Charge" (charge.md): "If you don't have line of sight to the +opponent at the start of your turn, you can't charge that opponent." SRD +"Full-Round Actions -- Run" (full-round-actions.md): "You can't run across +difficult terrain or if you can't see where you're going." SRD "Full-Round +Actions -- Withdraw": "you can't withdraw from combat if you're blinded." +The blinded condition (CRB Conditions: sight-based perception checks fail +automatically) removes sight entirely, so all three actions are gated on it. +""" + +from __future__ import annotations + +from pf1e_simulator.combat import Action, CombatantState, CombatEngine +from pf1e_simulator.conditions import CONDITIONS_BY_NAME +from pf1e_simulator.effects import ActiveCondition +from pf1e_simulator.grid import Grid +from pf1e_simulator.map import MapSpec, TerrainType +from pf1e_simulator.rng import ScriptedRng +from tests.test_combat import make_combatant, make_state + +_FLOOR = TerrainType(type="floor", move_cost=1) + + +def _open_grid(size: int = 8) -> Grid: + spec = MapSpec( + name="blinded-actions", + terrain=tuple(["." * size] * size), + legend={".": _FLOOR}, + ) + return Grid.from_spec(spec) + + +def _add_condition(state: CombatantState, name: str) -> None: + state.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME[name], rounds_left=5, source="test" + ) + ) + + +def _pair( + *, blinded: bool +) -> tuple[CombatEngine, CombatantState, CombatantState]: + charger = make_combatant("charger", hp=20) + foe = make_combatant("foe", hp=20) + c = make_state(charger, "players", (1, 0)) + f = make_state(foe, "monsters", (1, 5)) + if blinded: + _add_condition(c, "blinded") + engine = CombatEngine(ScriptedRng([]), _open_grid(), [c, f]) + return engine, engine._states[0], engine._states[1] + + +# --------------------------------------------------------------------------- +# (a) charge +# --------------------------------------------------------------------------- + + +def test_blinded_cannot_charge() -> None: + engine, c, f = _pair(blinded=True) + result = engine.can_charge(c, f) + assert result is not None + assert result.weapon is None + assert result.reason == "no_line_of_effect" + + +def test_sighted_creature_still_charges() -> None: + engine, c, f = _pair(blinded=False) + result = engine.can_charge(c, f) + assert result is not None + assert result.weapon is not None + assert result.reason is None + + +def test_blinded_charge_action_does_not_move_or_attack() -> None: + engine, c, f = _pair(blinded=True) + engine._execute_charge(c, f) + assert c.pos == (1, 0) + assert f.hp == 20 + + +# --------------------------------------------------------------------------- +# (b) run +# --------------------------------------------------------------------------- + + +def test_blinded_cannot_run() -> None: + engine, c, f = _pair(blinded=True) + result = engine._execute_run(c, f, Action(kind="run", target_id="foe")) + assert result.ok is True + assert c.pos == (1, 0) + assert c.running is False + assert any("wait (cannot run)" in line for line in engine._transcript) + + +def test_sighted_creature_still_runs() -> None: + engine, c, f = _pair(blinded=False) + engine._execute_run(c, f, Action(kind="run", target_id="foe")) + assert c.pos != (1, 0) + assert c.running is True + + +# --------------------------------------------------------------------------- +# (c) withdraw +# --------------------------------------------------------------------------- + + +def test_blinded_cannot_withdraw() -> None: + engine, c, f = _pair(blinded=True) + engine._withdraw(c, f) + assert c.pos == (1, 0) + assert any("withdraw rejected (blinded)" in line for line in engine._transcript) + + +def test_sighted_creature_still_withdraws() -> None: + engine, c, f = _pair(blinded=False) + c.pos = (1, 4) # adjacent to the foe: a real retreat is possible + engine._withdraw(c, f) + assert c.pos != (1, 4) + assert c.moved_this_turn is True