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é `_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 dans l'Observation via la clé `aoo_remaining` par combattant ; les AoO sont
toujours portées 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 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). protège que la case de départ (voir ci-dessous).
- Charge : action à round complet. Le combattant se déplace en ligne droite - 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, (tracked in ``_move_aoo_provoked``, cleared at round start); the ranged,
casting, stand-up and maneuver triggers are separate and each still casting, stand-up and maneuver triggers are separate and each still
provokes. AoOs always strike (PF1e allows declining, the simulator 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 withdraw action protects only the starting square (see below). A 5-foot
step and any other movement (move, charge, withdraw) are mutually exclusive step and any other movement (move, charge, withdraw) are mutually exclusive
within the same turn (``moved_this_turn`` flag, cleared in ``_take_turn``). within the same turn (``moved_this_turn`` flag, cleared in ``_take_turn``).
@@ -2931,14 +2934,46 @@ class CombatEngine:
return None return None
return cost_cells * _SQUARE_FT 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: def _resolve_aoo(self, attacker: CombatantState, target: CombatantState) -> bool:
"""Resolve one attack of opportunity (single melee attack at normal bonus). """Resolve one attack of opportunity (single melee attack at normal bonus).
AoOs are always taken (PF1e allows declining, but the simulator always strikes). 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. Returns True if the target dropped.
""" """
if self.has_condition_flag(attacker, "cant_aoo"): if self.has_condition_flag(attacker, "cant_aoo"):
return False 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 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) weapon = next((w for w in attacker.usable_attacks() if w.kind == "melee"), None)
if weapon is 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)