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
+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: