feat(combat): add attacks of opportunity (movement + ranged-in-melee triggers)

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent ac18435695
commit 004d6f1c2b
4 changed files with 231 additions and 13 deletions
+14 -5
View File
@@ -273,8 +273,7 @@ Règles modélisées :
hors de portée (déplacement à pleine vitesse le long du chemin Dijkstra,
puis attaque si une arme est utilisable) et `(attack,)` sinon. Les actions
immédiates (hors-tour, consomment le prochain swift) et les actions
spéciales (AoO, flanquement, attaque à outrance, charge, retraite) ne sont
pas encore modélisées.
spéciales (charge, retraite) ne sont pas encore modélisées.
- Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons »)
résout `count` balayages indépendants dans la même action ; les balayages
restants sont perdus si la cible tombe (inconsciente ou morte) en cours de
@@ -298,6 +297,15 @@ Règles modélisées :
de mêlée) menace la cible depuis la bordure ou le coin opposé. Les attaques à
distance ignorent le flanquement ; un allié désarmé ou à distance ne compte
pas pour le flanquement.
- Attaques d'opportunité : un combattant actif doté d'une arme de mêlée menace
les 8 cases adjacentes. Deux déclencheurs sont modélisés : (1) quitter une
case menacée — le mouvement est résolu pas à pas, et chaque ennemi qui menace
la case actuelle porte une AoO avant que le mouvant ne la quitte ; (2) tirer
avec une arme à distance depuis une case menacée — chaque ennemi menaçant
porte une AoO avant chaque tir. Une AoO par combattant par round (piste
`_aoo_used`, réinitialisée au début du round) ; les AoO sont toujours portées
(PF1e permet de les décliner, le simulateur ne le fait pas). Le pas de
placement (5 ft) et la retraite (à venir) évitent les AoO de mouvement.
- Distance : pénalité cumulative de 2 par incrément de portée complet au-delà
du premier, appliquée au jet d'attaque et à la confirmation de critique ;
l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet
@@ -307,7 +315,7 @@ Non modélisé en Phase 0 (couches `elevation`/`markers` présentes mais non
appliquées dans la résolution) :
- Sorts, jets de sauvegarde, conditions et états.
- Attaques d'opportunité, manœuvres de combat.
- Manœuvres de combat.
- Effets mécaniques de hauteur/élévation.
- Tailles Large+ (2×2), allonge > 5 ft, actions spéciales
(charge, pas de placement, retraite) et actions immédiates hors-tour.
@@ -344,7 +352,7 @@ appliquées dans la résolution) :
La gate de validation complète (tests + lint + types) :
```bash
uv run pytest -q # 206 tests
uv run pytest -q # 210 tests
uv run ruff check src tests
uv run basedpyright src # mode strict
```
@@ -358,7 +366,8 @@ uv run basedpyright src # mode strict
## Feuille de route
- **Phase 1** — magie et états : jets de sauvegarde, sorts modélisés comme
effets paramétrés, conditions, flanquement, attaques d'opportunité.
effets paramétrés, conditions, manœuvres de combat. Flanquement, attaques à
outrance et attaques d'opportunité sont déjà modélisés.
- **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
en politiques, balayage de matrices de positionnement.
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et
+1 -1
View File
@@ -47,7 +47,7 @@ allowed-confusables = ["σ"]
[tool.ruff.lint.per-file-ignores]
# Tests: assert is expected, magic numbers are fine, annotations are noisy,
# parametrized golden tests legitimately take many arguments
"tests/**" = ["S101", "PLR2004", "ANN", "PLR0913", "PLR0917"]
"tests/**" = ["S101", "PLR2004", "ANN", "PLR0913", "PLR0917", "ARG001"]
# rng.py: `random.Random` is used for reproducible Monte Carlo streams, not cryptography
"src/pf1e_simulator/rng.py" = ["S311"]
# cli.py: stdout printing is the point of a CLI
+86 -4
View File
@@ -10,7 +10,8 @@ Phase 0 documented deviations from PF1e (conventions):
`(attack,)` when already in range. The move action may replace the standard
action (e.g. a double move) but the default policy does not need this.
Immediate actions (off-turn, consume next swift) and special actions
(AoO, flanking, full attack, charge, withdraw) are deferred.
(charge, withdraw) are deferred; flanking and full attack are modeled;
attacks of opportunity are resolved inline (see below).
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
in one attack action; remaining swings are lost once the target drops
(down or dead) mid-routine. BAB iterative attacks (full-round action
@@ -35,6 +36,15 @@ Phase 0 documented deviations from PF1e (conventions):
- Flanking: +2 on melee attack rolls when an active ally with a melee weapon
threatens the target from the opposite border or corner. Ranged attacks do
not benefit from flanking; allies without melee weapons do not threaten.
- Attacks of opportunity: a combatant threatens the 8 adjacent squares if
active and wielding a melee weapon. Two triggers are modeled: (1) moving
out of a threatened square — movement is resolved step-by-step, and each
enemy that threatens the current square gets one AoO before the mover
leaves it; (2) making a ranged attack while in a threatened square — each
threatening enemy gets one AoO before each ranged swing. One AoO per
combatant per round (tracked in ``_aoo_used``, cleared at round start);
AoOs always strike (PF1e allows declining, the simulator does not). A
5-foot step and the withdraw action (deferred) avoid AoOs from movement.
- Ranged: cumulative -2 per full range increment beyond the first, up to 10
range increments; the penalty applies to attack and crit-confirm rolls.
Thrown weapons (5 increments max) are not distinguished.
@@ -183,6 +193,7 @@ class CombatEngine:
self._transcript: list[str] = []
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
self._current_round = 0
self._aoo_used: set[str] = set()
def _take_turn(self, state: CombatantState) -> bool:
"""Execute one combatant's full turn; return True if the battle is over."""
@@ -204,6 +215,7 @@ class CombatEngine:
round_no = 1
while round_no <= self._round_cap:
self._current_round = round_no
self._aoo_used.clear()
for state in order:
if not state.active:
continue
@@ -358,6 +370,55 @@ class CombatEngine:
return active_sides.pop()
return None
def _threatened_squares(self, state: CombatantState) -> frozenset[Pos]:
"""Squares this combatant threatens with a melee weapon (8 adjacent for 5 ft reach)."""
if not state.active:
return frozenset()
if not any(w.kind == "melee" for w in state.combatant.attacks):
return frozenset()
r, c = state.pos
return frozenset((r + dr, c + dc) for dr, dc in _STEP_DELTAS)
def _enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]:
"""Active enemies whose threatened squares include pos (in self._states order)."""
return [
s
for s in self._states
if s is not state
and s.active
and s.side != state.side
and pos in self._threatened_squares(s)
]
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).
Returns True if the target dropped.
"""
self._aoo_used.add(attacker.combatant.id)
weapon = next((w for w in attacker.combatant.attacks if w.kind == "melee"), None)
if weapon is None:
return False
return self._resolve_swing(
attacker, target, weapon, label="AoO"
)
def _check_provoked_aoo(self, attacker: CombatantState, weapon: AttackSpec) -> bool:
"""Check for ranged-AoO: firing a ranged weapon in a threatened square provokes.
Returns True if the attacker dropped (caller must stop attacking).
"""
if weapon.kind != "ranged":
return False
for enemy in self._enemies_threatening(attacker, attacker.pos):
if enemy.combatant.id in self._aoo_used:
continue
self._resolve_aoo(enemy, attacker)
if not attacker.active:
return True
return False
def _execute(self, state: CombatantState, action: Action) -> None:
target = next((s for s in self._states if s.combatant.id == action.target_id), None)
if action.kind in ("attack", "full_attack"):
@@ -429,6 +490,10 @@ class CombatEngine:
self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec
) -> None:
for swing in range(1, weapon.count + 1):
if not attacker.active:
return
if self._check_provoked_aoo(attacker, weapon):
return
label = weapon.name if weapon.count == 1 else f"{weapon.name} #{swing}"
if self._resolve_swing(attacker, target, weapon, label=label):
return
@@ -444,6 +509,10 @@ class CombatEngine:
1 + max(0, (attacker.combatant.bab - 1) // _ITERATIVE_PENALTY),
)
for i in range(n):
if not attacker.active:
return
if self._check_provoked_aoo(attacker, weapon):
return
label = weapon.name if n == 1 else f"{weapon.name} #{i + 1}"
bonus = weapon.attack_bonus - _ITERATIVE_PENALTY * i
if self._resolve_swing(
@@ -455,9 +524,22 @@ class CombatEngine:
path = self._move_path(state, target)
if not path:
return
coords = "->".join(f"({p[0]},{p[1]})" for p in (state.pos, *path))
self._log(f"round {self._current_round} {state.combatant.id}: move {coords}")
state.pos = path[-1]
start = state.pos
actual_path: list[Pos] = []
for step in path:
for enemy in self._enemies_threatening(state, state.pos):
if enemy.combatant.id in self._aoo_used:
continue
self._resolve_aoo(enemy, state)
if not state.active:
break
if not state.active:
break
state.pos = step
actual_path.append(step)
if actual_path:
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log(f"round {self._current_round} {state.combatant.id}: move {coords}")
def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]:
"""Full movement path toward the target, up to the combatant's speed."""
+130 -3
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
from typing import Literal
from pf1e_simulator.combat import (
Action,
CombatantState,
CombatantStats,
CombatEngine,
@@ -536,7 +537,7 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
target = make_combatant("target", speed=0)
states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))]
rng = ScriptedRng([10, 9, 15, 3, 10, 12, 2, 8, 14, 1])
rng = ScriptedRng([10, 9, 5, 15, 3, 10, 3, 12, 2, 8, 2, 14, 1])
engine = CombatEngine(rng, grid, states, round_cap=4)
result = engine.run()
assert result.winner == "players"
@@ -544,10 +545,13 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
"initiative: archer d20=10+0=10",
"initiative: target d20=9+0=9",
"round 1 archer: move (0,1)->(1,2)->(0,3)",
"round 1 target: AoO vs archer d20=5+2=7 AC 13 -> MISS",
"round 1 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)",
"round 1 target: short sword vs archer d20=10+2=12 AC 13 -> MISS",
"round 2 target: AoO vs archer d20=3+2=5 AC 13 -> MISS",
"round 2 archer: short bow vs target d20=12+4=16 AC 13 -> HIT 2 damage (3->1)",
"round 2 target: short sword vs archer d20=8+2=10 AC 13 -> MISS",
"round 3 target: AoO vs archer d20=2+2=4 AC 13 -> MISS",
"round 3 archer: short bow vs target d20=14+4=18 AC 13 -> HIT 1 damage (1->0)",
"round 3 archer: target down",
"battle over: players win in 3 rounds",
@@ -775,7 +779,7 @@ def test_no_flanking_without_opposite_ally() -> None:
def test_ranged_attack_ignores_flanking() -> None:
"""Ranged attacks get no flanking bonus, and ranged allies don't threaten."""
"""Ranged attacks get no flanking bonus; ranged in melee provokes AoO."""
arc = make_combatant("arc", attack_bonus=2, kind="ranged", range_increment_ft=60, speed=0)
aly = make_combatant("aly", attack_bonus=0, speed=0)
tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0)
@@ -784,12 +788,13 @@ def test_ranged_attack_ignores_flanking() -> None:
make_state(aly, "players", (5, 4)),
make_state(tgt, "monsters", (4, 4)),
]
engine = make_engine([10, 9, 8, 12, 10, 5], states, round_cap=1)
engine = make_engine([10, 9, 8, 15, 3, 12, 10, 5], states, round_cap=1)
result = engine.run()
assert result.transcript == (
"initiative: arc d20=10+0=10",
"initiative: aly d20=9+0=9",
"initiative: tgt d20=8+0=8",
"round 1 tgt: AoO vs arc d20=15+2=17 AC 13 -> HIT 3 damage (6->3)",
"round 1 arc: short bow vs tgt d20=12+2=14 AC 15 -> MISS",
"round 1 aly: short sword vs tgt d20=10+0=10 AC 15 -> MISS",
"round 1 tgt: short sword vs arc d20=5+2=7 AC 13 -> MISS",
@@ -949,3 +954,125 @@ def test_full_attack_low_bab_single_swing() -> None:
"round 1 tgt: short sword vs war d20=5+0=5 AC 13 -> MISS",
"battle over: draw after 1 rounds",
)
# --------------------------------------------------------------------------- #
# Attacks of opportunity (CRB: movement + ranged-in-melee, 1/round) #
# --------------------------------------------------------------------------- #
def _move_to_dest(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
"""Test policy: always move toward the 'dest' combatant."""
return (Action(kind="move", target_id="dest"),)
def test_movement_provokes_aoo() -> None:
"""Moving through a threatened square provokes an AoO; movement continues after."""
runner = make_combatant("runner", hp=10, ac=12, speed=30)
guard = make_combatant("guard", hp=10, ac=15, attack_bonus=5, speed=0)
dest = make_combatant("dest", hp=10, ac=15, attack_bonus=0, speed=0)
states = [
make_state(runner, "players", (4, 1)),
make_state(guard, "monsters", (3, 1)),
make_state(dest, "monsters", (4, 5)),
]
engine = CombatEngine(
ScriptedRng([10, 9, 8, 15, 3, 5]),
make_grid(), states, round_cap=1, policy=_move_to_dest,
)
result = engine.run()
assert result.transcript == (
"initiative: runner d20=10+0=10",
"initiative: guard d20=9+0=9",
"initiative: dest d20=8+0=8",
"round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (10->7)",
"round 1 runner: move (4,1)->(3,2)->(3,3)->(3,4)",
"round 1 guard: wait",
"round 1 dest: wait",
"battle over: draw after 1 rounds",
)
def test_aoo_drops_mover() -> None:
"""If an AoO drops the mover, movement stops and the mover's side loses."""
runner = make_combatant("runner", hp=3, ac=12, speed=30)
guard = make_combatant("guard", hp=10, ac=15, attack_bonus=5, speed=0)
dest = make_combatant("dest", hp=10, ac=15, attack_bonus=0, speed=0)
states = [
make_state(runner, "players", (4, 1)),
make_state(guard, "monsters", (3, 1)),
make_state(dest, "monsters", (4, 5)),
]
engine = CombatEngine(
ScriptedRng([10, 9, 8, 15, 3]),
make_grid(), states, round_cap=1, policy=_move_to_dest,
)
result = engine.run()
assert result.winner == "monsters"
assert result.transcript == (
"initiative: runner d20=10+0=10",
"initiative: guard d20=9+0=9",
"initiative: dest d20=8+0=8",
"round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (3->0)",
"round 1 guard: runner down",
"battle over: monsters win in 1 rounds",
)
def test_aoo_limit_one_per_round() -> None:
"""A combatant can only make one AoO per round, even if multiple squares are threatened."""
runner = make_combatant("runner", hp=20, ac=12, speed=30)
guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5, speed=0)
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
states = [
make_state(runner, "players", (0, 0)),
make_state(guard, "monsters", (2, 2)),
make_state(dest, "monsters", (4, 4)),
]
engine = CombatEngine(
ScriptedRng([10, 9, 8, 15, 3]),
make_grid(), states, round_cap=1, policy=_move_to_dest,
)
result = engine.run()
assert result.transcript == (
"initiative: runner d20=10+0=10",
"initiative: guard d20=9+0=9",
"initiative: dest d20=8+0=8",
"round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)",
"round 1 runner: move (0,0)->(0,1)->(1,2)->(2,3)->(3,3)",
"round 1 guard: wait",
"round 1 dest: wait",
"battle over: draw after 1 rounds",
)
def test_two_guards_each_one_aoo() -> None:
"""Two enemies threatening the same square each get their own AoO (1/round each)."""
runner = make_combatant("runner", hp=20, ac=12, speed=30)
g1 = make_combatant("g1", hp=20, ac=15, attack_bonus=5, speed=0)
g2 = make_combatant("g2", hp=20, ac=15, attack_bonus=5, speed=0)
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
states = [
make_state(runner, "players", (4, 1)),
make_state(g1, "monsters", (3, 1)),
make_state(g2, "monsters", (3, 2)),
make_state(dest, "monsters", (4, 6)),
]
engine = CombatEngine(
ScriptedRng([10, 9, 8, 7, 15, 3, 15, 3]),
make_grid(), states, round_cap=1, policy=_move_to_dest,
)
result = engine.run()
assert result.transcript == (
"initiative: runner d20=10+0=10",
"initiative: g1 d20=9+0=9",
"initiative: g2 d20=8+0=8",
"initiative: dest d20=7+0=7",
"round 1 g1: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)",
"round 1 g2: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (17->14)",
"round 1 runner: move (4,1)->(4,2)->(3,3)->(3,4)->(3,5)",
"round 1 g1: wait",
"round 1 g2: wait",
"round 1 dest: wait",
"battle over: draw after 1 rounds",
)