"""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