35c8d101b5
CRB 'Conditions - Blinded': 'All opponents are considered to have total concealment (50% miss chance) against the blinded character.' The sneak-attack gate and the AoO suppression gate now apply the same max(target_concealment, 50) convention as _resolve_hit_damage: a blinded attacker cannot sneak attack and its AoOs are suppressed without spending the budget. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
138 lines
5.7 KiB
Python
138 lines
5.7 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)
|