feat(combat): Combat Reflexes extra AoOs

This commit is contained in:
2026-09-04 22:55:58 +02:00
parent 57fcd2ac12
commit 4921db7b28
2 changed files with 113 additions and 13 deletions
+24 -13
View File
@@ -42,12 +42,13 @@ Phase 0 documented deviations from PF1e (conventions):
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 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``).
combatant per round by default (tracked in ``_aoo_used``, cleared at round
start); the Combat Reflexes feat raises the allowance to ``max(1, 1 + DEX
mod)`` per round. AoOs always strike (PF1e allows declining, the simulator
does not). 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``).
- Charge: a full-round action that moves in a straight line (Bresenham) up to
2x speed toward the closest square from which the charger can melee the
target, then makes a single melee attack at +2. The charger takes -2 AC
@@ -338,7 +339,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()
self._aoo_used: dict[str, int] = {}
self._initiative_keys: dict[str, tuple[int, int, int]] = {}
self._pending_summons: list[CombatantState] = []
@@ -581,6 +582,16 @@ class CombatEngine:
"""True if the combatant has a feature with the given name."""
return any(f.name == feat_name for f in state.combatant.features)
def _aoo_allowance(self, state: CombatantState) -> int:
"""Number of AoOs allowed per round for a combatant.
Base is 1. Combat Reflexes adds the DEX modifier on top (clamped to >= 1
so a non-positive DEX mod never reduces the normal single AoO).
"""
if not self._has_feat(state, "Combat Reflexes"):
return 1
return max(1, 1 + ((state.combatant.abilities.dex_score - 10) // 2))
def _power_attack_amt(self, attacker: CombatantState) -> int:
"""Power Attack exchange amount: X = BAB//4 + 1 (min 1). Returns 0 if inactive."""
if not attacker.power_attack or not self._has_feat(attacker, "Power Attack"):
@@ -810,7 +821,7 @@ class CombatEngine:
"""
if self.has_condition_flag(attacker, "cant_aoo"):
return False
self._aoo_used.add(attacker.combatant.id)
self._aoo_used[attacker.combatant.id] = self._aoo_used.get(attacker.combatant.id, 0) + 1
weapon = next((w for w in attacker.combatant.attacks if w.kind == "melee"), None)
if weapon is None:
return False
@@ -826,7 +837,7 @@ class CombatEngine:
if weapon.kind != "ranged":
return False
for enemy in self._enemies_threatening(attacker, attacker.pos):
if enemy.combatant.id in self._aoo_used:
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
continue
self._resolve_aoo(enemy, attacker)
if not attacker.active:
@@ -839,7 +850,7 @@ class CombatEngine:
Returns True if the caster dropped or failed concentration (spell fizzles).
"""
for enemy in self._enemies_threatening(caster, caster.pos):
if enemy.combatant.id in self._aoo_used:
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
continue
hp_before = caster.hp
self._resolve_aoo(enemy, caster)
@@ -1025,7 +1036,7 @@ class CombatEngine:
actual_path: list[Pos] = []
for step in path:
for enemy in self._enemies_threatening(attacker, attacker.pos):
if enemy.combatant.id in self._aoo_used:
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
continue
self._resolve_aoo(enemy, attacker)
if not attacker.active:
@@ -2087,7 +2098,7 @@ class CombatEngine:
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:
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
continue
self._resolve_aoo(enemy, state)
if not state.active:
@@ -2209,7 +2220,7 @@ class CombatEngine:
for i, step in enumerate(path):
if i > 0:
for enemy in self._enemies_threatening(state, state.pos):
if enemy.combatant.id in self._aoo_used:
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
continue
self._resolve_aoo(enemy, state)
if not state.active: