From 4921db7b2864b9aa3bbbf363b284b0acba8bd1fa Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:55:58 +0200 Subject: [PATCH] feat(combat): Combat Reflexes extra AoOs --- src/pf1e_simulator/combat.py | 37 +++++++++------ tests/test_combat.py | 89 ++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 13 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 03ed837..2feb279 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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: diff --git a/tests/test_combat.py b/tests/test_combat.py index 91adc34..fed575a 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -8,6 +8,7 @@ from __future__ import annotations from typing import Literal +from pf1e_simulator.abilities import AbilitySpec from pf1e_simulator.combat import ( Action, CombatantState, @@ -1331,3 +1332,91 @@ def test_5ft_step_blocks_move() -> None: "round 1 orc: wait", "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