Files
pf1e-simulator/tests/test_aoo_defense.py
T

157 lines
6.5 KiB
Python

"""A4 conformance: no attack of opportunity against a target with cover or
total concealment relative to the attacker.
PF1e (CRB "Cover"): "You can't execute an attack of opportunity against an
opponent with cover relative to you." (CRB "Concealment"): "You can't execute
an attack of opportunity against an opponent with total concealment, even if
you know what square or squares the opponent occupies." A suppressed
opportunity consumes no dice and does not spend the attacker's per-round AoO
budget; partial concealment (e.g. blurred 20%) does not suppress.
"""
from __future__ import annotations
from pf1e_simulator.combat import 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)
_WALL = TerrainType(type="wall", move_cost=None, blocks_los=True)
def _grid(rows: tuple[str, ...]) -> Grid:
legend = {".": _FLOOR, "#": _WALL}
return Grid.from_spec(MapSpec(name="a4", terrain=rows, legend=legend))
def _add_condition(state: CombatantState, name: str) -> None:
state.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME[name], rounds_left=5, source="test"
)
)
def test_aoo_suppressed_when_target_has_cover() -> None:
"""Given: a wall square between the guard and the runner (cover)
When: the guard resolves the AoO
Then: it is suppressed — no die rolled, no budget spent."""
grid = _grid(("....", "....", ".#.."))
runner = make_combatant("runner", hp=20, ac=12)
guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5)
states = [
make_state(runner, "players", (2, 2)),
make_state(guard, "monsters", (2, 0)),
]
engine = CombatEngine(ScriptedRng([15, 3]), grid, states)
runner_s, guard_s = engine._states
assert engine._resolve_aoo(guard_s, runner_s) is False
assert engine._aoo_used.get("guard", 0) == 0
assert len(engine._rng._queue) == 2
assert runner_s.hp == 20
assert any("suppressed (cover)" in line for line in engine._transcript)
def test_aoo_suppressed_vs_total_concealment() -> None:
"""Given: the runner has total concealment (displaced, 50% miss)
When: the guard resolves the AoO
Then: it is suppressed — no die rolled, no budget spent."""
runner = make_combatant("runner", hp=20, ac=12)
guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5)
states = [
make_state(runner, "players", (2, 1)),
make_state(guard, "monsters", (2, 0)),
]
engine = CombatEngine(ScriptedRng([15, 3]), _grid(("....", "....")), states)
runner_s, guard_s = engine._states
_add_condition(runner_s, "displaced")
assert engine._resolve_aoo(guard_s, runner_s) is False
assert engine._aoo_used.get("guard", 0) == 0
assert len(engine._rng._queue) == 2
assert runner_s.hp == 20
assert any(
"suppressed (total concealment)" in line for line in engine._transcript
)
def test_aoo_suppressed_when_attacker_is_blinded() -> None:
"""Given: the guard is blinded and the runner has no concealment
When: the guard resolves the AoO
Then: it is suppressed — a blinded character's opponents all have total
concealment (CRB Conditions — Blinded) — no die, no budget spent."""
runner = make_combatant("runner", hp=20, ac=12)
guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5)
states = [
make_state(runner, "players", (2, 1)),
make_state(guard, "monsters", (2, 0)),
]
engine = CombatEngine(ScriptedRng([15, 3]), _grid(("....", "....")), states)
runner_s, guard_s = engine._states
_add_condition(guard_s, "blinded")
assert engine._resolve_aoo(guard_s, runner_s) is False
assert engine._aoo_used.get("guard", 0) == 0
assert len(engine._rng._queue) == 2
assert runner_s.hp == 20
assert any(
"suppressed (total concealment)" in line for line in engine._transcript
)
def test_aoo_still_strikes_in_the_open() -> None:
"""Given: no cover and no concealment between guard and runner
When: the guard resolves the AoO
Then: it strikes normally and spends one opportunity."""
runner = make_combatant("runner", hp=20, ac=12)
guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5)
states = [
make_state(runner, "players", (2, 1)),
make_state(guard, "monsters", (2, 0)),
]
engine = CombatEngine(ScriptedRng([15, 3]), _grid(("....", "....")), states)
runner_s, guard_s = engine._states
assert engine._resolve_aoo(guard_s, runner_s) is False # runner did not drop
assert engine._aoo_used.get("guard", 0) == 1
assert runner_s.hp == 17
assert not any("suppressed" in line for line in engine._transcript)
def test_aoo_not_suppressed_by_partial_concealment() -> None:
"""Given: the runner is only blurred (20% — not total concealment)
When: the guard resolves the AoO
Then: the strike still happens (d100 decides hit vs miss afterwards)."""
runner = make_combatant("runner", hp=20, ac=12)
guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5)
states = [
make_state(runner, "players", (2, 1)),
make_state(guard, "monsters", (2, 0)),
]
engine = CombatEngine(ScriptedRng([15, 90, 3]), _grid(("....", "....")), states)
runner_s, guard_s = engine._states
_add_condition(runner_s, "blurred")
assert engine._resolve_aoo(guard_s, runner_s) is False
assert engine._aoo_used.get("guard", 0) == 1
assert not any("suppressed" in line for line in engine._transcript)
def test_nauseated_guard_makes_no_aoo() -> None:
"""Given: the guard is nauseated (SRD: "unable to attack")
When: the runner provokes an AoO
Then: no opportunity is taken — no die rolled, no budget spent."""
runner = make_combatant("runner", hp=20, ac=12)
guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5)
states = [
make_state(runner, "players", (2, 1)),
make_state(guard, "monsters", (2, 0)),
]
engine = CombatEngine(ScriptedRng([15, 3]), _grid(("....", "....")), states)
runner_s, guard_s = engine._states
_add_condition(guard_s, "nauseated")
assert engine._resolve_aoo(guard_s, runner_s) is False
assert engine._aoo_used.get("guard", 0) == 0
assert len(engine._rng._queue) == 2
assert runner_s.hp == 20