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
This commit is contained in:
@@ -3904,6 +3904,11 @@ class CombatEngine:
|
|||||||
straight-line distance in feet to the chosen anchor on success, 0 on
|
straight-line distance in feet to the chosen anchor on success, 0 on
|
||||||
failure. Never mutates state nor consumes RNG.
|
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(
|
if not has_line_of_effect_cells(
|
||||||
self._grid, state.occupied_cells(), target.occupied_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)")
|
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
|
# SRD "Conditions — Fatigued": "A fatigued character can neither run
|
||||||
# nor charge" (same for exhausted/entangled via cant_charge).
|
# nor charge" (same for exhausted/entangled via cant_charge). SRD
|
||||||
if self.has_condition_flag(state, "cant_charge"):
|
# "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)")
|
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot run)")
|
||||||
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
||||||
if action.destination is not None:
|
if action.destination is not None:
|
||||||
@@ -8146,11 +8156,21 @@ class CombatEngine:
|
|||||||
Subsequent squares provoke AoOs normally (step-by-step, as for move).
|
Subsequent squares provoke AoOs normally (step-by-step, as for move).
|
||||||
Like every other movement it is mutually exclusive with movement
|
Like every other movement it is mutually exclusive with movement
|
||||||
already spent this turn (``moved_this_turn``).
|
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:
|
if state.moved_this_turn:
|
||||||
return
|
return
|
||||||
if threat is None:
|
if threat is None:
|
||||||
return
|
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)
|
path = self._flee_path(state, threat)
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user