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
+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."""