diff --git a/README.md b/README.md index c31851b..b5da625 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index a669cde..ad9d558 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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: diff --git a/tests/test_aoo_defense.py b/tests/test_aoo_defense.py new file mode 100644 index 0000000..00c3add --- /dev/null +++ b/tests/test_aoo_defense.py @@ -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)