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 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 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 threatening enemy gets one AoO before each ranged swing. One AoO per
combatant per round (tracked in ``_aoo_used``, cleared at round start); combatant per round by default (tracked in ``_aoo_used``, cleared at round
AoOs always strike (PF1e allows declining, the simulator does not). A start); the Combat Reflexes feat raises the allowance to ``max(1, 1 + DEX
5-foot step never provokes AoOs (it is not a move action); the withdraw mod)`` per round. AoOs always strike (PF1e allows declining, the simulator
action protects only the starting square (see below). A 5-foot step and does not). A 5-foot step never provokes AoOs (it is not a move action); the
any other movement (move, charge, withdraw) are mutually exclusive within withdraw action protects only the starting square (see below). A 5-foot
the same turn (``moved_this_turn`` flag, cleared in ``_take_turn``). 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 - 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 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 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._transcript: list[str] = []
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states} self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
self._current_round = 0 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._initiative_keys: dict[str, tuple[int, int, int]] = {}
self._pending_summons: list[CombatantState] = [] self._pending_summons: list[CombatantState] = []
@@ -581,6 +582,16 @@ class CombatEngine:
"""True if the combatant has a feature with the given name.""" """True if the combatant has a feature with the given name."""
return any(f.name == feat_name for f in state.combatant.features) 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: def _power_attack_amt(self, attacker: CombatantState) -> int:
"""Power Attack exchange amount: X = BAB//4 + 1 (min 1). Returns 0 if inactive.""" """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"): 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"): if self.has_condition_flag(attacker, "cant_aoo"):
return False 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) weapon = next((w for w in attacker.combatant.attacks if w.kind == "melee"), None)
if weapon is None: if weapon is None:
return False return False
@@ -826,7 +837,7 @@ class CombatEngine:
if weapon.kind != "ranged": if weapon.kind != "ranged":
return False return False
for enemy in self._enemies_threatening(attacker, attacker.pos): 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 continue
self._resolve_aoo(enemy, attacker) self._resolve_aoo(enemy, attacker)
if not attacker.active: if not attacker.active:
@@ -839,7 +850,7 @@ class CombatEngine:
Returns True if the caster dropped or failed concentration (spell fizzles). Returns True if the caster dropped or failed concentration (spell fizzles).
""" """
for enemy in self._enemies_threatening(caster, caster.pos): 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 continue
hp_before = caster.hp hp_before = caster.hp
self._resolve_aoo(enemy, caster) self._resolve_aoo(enemy, caster)
@@ -1025,7 +1036,7 @@ class CombatEngine:
actual_path: list[Pos] = [] actual_path: list[Pos] = []
for step in path: for step in path:
for enemy in self._enemies_threatening(attacker, attacker.pos): 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 continue
self._resolve_aoo(enemy, attacker) self._resolve_aoo(enemy, attacker)
if not attacker.active: if not attacker.active:
@@ -2087,7 +2098,7 @@ class CombatEngine:
actual_path: list[Pos] = [] actual_path: list[Pos] = []
for step in path: for step in path:
for enemy in self._enemies_threatening(state, state.pos): 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 continue
self._resolve_aoo(enemy, state) self._resolve_aoo(enemy, state)
if not state.active: if not state.active:
@@ -2209,7 +2220,7 @@ class CombatEngine:
for i, step in enumerate(path): for i, step in enumerate(path):
if i > 0: if i > 0:
for enemy in self._enemies_threatening(state, state.pos): 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 continue
self._resolve_aoo(enemy, state) self._resolve_aoo(enemy, state)
if not state.active: if not state.active:
+89
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
from typing import Literal from typing import Literal
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import ( from pf1e_simulator.combat import (
Action, Action,
CombatantState, CombatantState,
@@ -1331,3 +1332,91 @@ def test_5ft_step_blocks_move() -> None:
"round 1 orc: wait", "round 1 orc: wait",
"battle over: draw after 1 rounds", "battle over: draw after 1 rounds",
) )
def _make_guard_cr(dex: int, *, combat_reflexes: bool) -> Combatant:
"""Guard with given DEX and optional Combat Reflexes feat."""
return Combatant(
id="guard",
name="guard",
level=3,
size="Medium",
abilities=AbilityScores(
str_score=14,
dex_score=dex,
con_score=12,
int_score=10,
wis_score=10,
cha_score=10,
),
hp_max=20,
ac=ACProfile(total=15, touch=15, flat_footed=15),
bab=3,
initiative_mod=(dex - 10) // 2,
speed_land_ft=0,
saves=Saves(fort=4, ref=3, will=1),
attacks=[
AttackSpec(
id="guard-w",
name="short sword",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["slashing"])],
crit_range=20,
crit_mult=2,
)
],
features=[AbilitySpec(name="Combat Reflexes", category="feat")] if combat_reflexes else [],
)
def test_combat_reflexes_multiple_aoos() -> None:
"""Guard with Combat Reflexes + DEX 16 takes multiple AoOs when enemies move."""
runner1 = make_combatant("runner1", hp=20, ac=12, speed=30)
runner2 = make_combatant("runner2", hp=20, ac=12, speed=30)
runner3 = make_combatant("runner3", hp=20, ac=12, speed=30)
guard = _make_guard_cr(dex=16, combat_reflexes=True)
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
states = [
make_state(runner1, "players", (2, 3)),
make_state(runner2, "players", (4, 3)),
make_state(runner3, "players", (3, 2)),
make_state(guard, "monsters", (3, 5)),
make_state(dest, "monsters", (6, 5)),
]
engine = CombatEngine(
ScriptedRng([15, 14, 13, 5, 4, 15, 3, 14, 2, 13, 4, 12, 1]),
make_grid(),
states,
round_cap=1,
policy=_move_to_dest,
)
result = engine.run()
aoo_lines = [line for line in result.transcript if "AoO" in line]
assert len(aoo_lines) >= 3
def test_no_combat_reflexes_single_aoo() -> None:
"""Without Combat Reflexes, the guard still takes exactly1 AoO per round."""
runner1 = make_combatant("runner1", hp=20, ac=12, speed=30)
runner2 = make_combatant("runner2", hp=20, ac=12, speed=30)
runner3 = make_combatant("runner3", hp=20, ac=12, speed=30)
guard = _make_guard_cr(dex=16, combat_reflexes=False)
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
states = [
make_state(runner1, "players", (2, 3)),
make_state(runner2, "players", (4, 3)),
make_state(runner3, "players", (3, 2)),
make_state(guard, "monsters", (3, 5)),
make_state(dest, "monsters", (6, 5)),
]
engine = CombatEngine(
ScriptedRng([15, 14, 13, 5, 4, 15, 3]),
make_grid(),
states,
round_cap=1,
policy=_move_to_dest,
)
result = engine.run()
aoo_lines = [line for line in result.transcript if "AoO" in line]
assert len(aoo_lines) == 1