feat(combat): Combat Reflexes extra AoOs
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user