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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user