Files
pf1e-simulator/tests/test_conformance_blinded_actions.py

122 lines
4.0 KiB
Python

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