feat(combat): add attacks of opportunity (movement + ranged-in-melee triggers)
This commit is contained in:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user