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