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