fix(rules): suppress attacks of opportunity through cover or total concealment

PF1e (CRB Cover): "You can't execute an attack of opportunity against an
opponent with cover relative to you"; (CRB Concealment): the same holds
against total concealment. _resolve_aoo now checks terrain cover
(cover_bonus_cells, melee line) and miss_chance >= 50 before striking,
logs the suppression and leaves the per-round budget unspent. Partial
concealment (blurred 20%) and normal attacks are unaffected.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-11 23:23:11 +02:00
parent be2a755d17
commit 843cbf881c
3 changed files with 155 additions and 2 deletions
+5 -1
View File
@@ -676,7 +676,11 @@ Règles modélisées :
`_aoo_used`, réinitialisée au début du round) ; le budget restant est exposé
dans l'Observation via la clé `aoo_remaining` par combattant ; les AoO sont
toujours portées
(PF1e permet de les décliner, le simulateur ne le fait pas). Le pas de
(PF1e permet de les décliner, le simulateur ne le fait pas) — **sauf** si la
cible a un couvert ou une dissimulation totale par rapport à l'attaquant :
l'AoO est alors supprimée et non consommée (conformance A4, CRB « Cover » :
« You can't execute an attack of opportunity against an opponent with cover
relative to you » ; CRB « Concealment » : dissimulation totale). Le pas de
placement (5 ft) ne provoque jamais d'AoO (voir ci-dessous) ; la retraite ne
protège que la case de départ (voir ci-dessous).
- Charge : action à round complet. Le combattant se déplace en ligne droite
+36 -1
View File
@@ -80,7 +80,10 @@ Phase 0 documented deviations from PF1e (conventions):
(tracked in ``_move_aoo_provoked``, cleared at round start); the ranged,
casting, stand-up and maneuver triggers are separate and each still
provokes. AoOs always strike (PF1e allows declining, the simulator
does not). A 5-foot step never provokes AoOs (it is not a move action); the
does not) — except that an opponent with cover or total concealment
relative to the attacker cannot be struck at all (the opportunity is
suppressed, not spent; CRB "Cover"/"Concealment"). A 5-foot step never
provokes AoOs (it is not a move action); the
withdraw action protects only the starting square (see below). A 5-foot
step and any other movement (move, charge, withdraw) are mutually exclusive
within the same turn (``moved_this_turn`` flag, cleared in ``_take_turn``).
@@ -2931,14 +2934,46 @@ class CombatEngine:
return None
return cost_cells * _SQUARE_FT
def _aoo_defense_block(
self, attacker: CombatantState, target: CombatantState
) -> str | None:
"""Why an AoO cannot be executed against the target, or None.
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" (miss chance >= 50%). Melee line: terrain cover
only — creatures grant no melee cover.
"""
if self._concealment_chance(target) >= _TOTAL_CONCEALMENT_PCT:
return "total concealment"
if cover_bonus_cells(
self._grid,
attacker.occupied_cells(),
target.occupied_cells(),
ranged=False,
occupied=frozenset(),
):
return "cover"
return None
def _resolve_aoo(self, attacker: CombatantState, target: CombatantState) -> bool:
"""Resolve one attack of opportunity (single melee attack at normal bonus).
AoOs are always taken (PF1e allows declining, but the simulator always strikes).
A target with cover or total concealment relative to the attacker cannot be
struck at all — the opportunity is suppressed without spending it.
Returns True if the target dropped.
"""
if self.has_condition_flag(attacker, "cant_aoo"):
return False
blocked = self._aoo_defense_block(attacker, target)
if blocked is not None:
self._log(
f"round {self._current_round} {attacker.combatant.id}: "
f"AoO vs {target.combatant.id} suppressed ({blocked})"
)
return False
self._aoo_used[attacker.combatant.id] = self._aoo_used.get(attacker.combatant.id, 0) + 1
weapon = next((w for w in attacker.usable_attacks() if w.kind == "melee"), None)
if weapon is None:
+114
View File
@@ -0,0 +1,114 @@
"""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_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)