From a5bd3c5a853e49488e77cabfe23f1218b155187a Mon Sep 17 00:00:00 2001 From: ctan Date: Thu, 17 Sep 2026 23:00:10 +0200 Subject: [PATCH] fix(rules): total defense denies attacks of opportunity (SRD Total Defense: allowance drops to 0 while the bonus is active) --- src/pf1e_simulator/combat.py | 15 ++++++++++++++- tests/test_defensive_actions.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 45a0a20..b777d53 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -744,9 +744,12 @@ class CombatantState: # granted by total defense (+4) and fighting defensively (+2), and the # fighting-defensively flag (-4 on attack and crit confirm). Both reset # at the start of the combatant's NEXT turn, so they last through the - # opponents' turns in between. + # opponents' turns in between. The total_defense flag additionally marks + # that the combatant is using total defense — PF1e (SRD "Total Defense"): + # "You can't make attacks of opportunity while using total defense." defensive_ac_bonus: int = 0 fight_defensively: bool = False + total_defense: bool = False # Aid another & run (not in the frozen data model): aid bonuses granted to # this combatant by an ally's aid another — aid_attack_bonus is consumed by # its next attack roll, aid_ac_bonus applies against the next attack that @@ -1776,6 +1779,7 @@ class CombatEngine: state.power_attack = False state.defensive_ac_bonus = 0 state.fight_defensively = False + state.total_defense = False state.aid_attack_bonus = 0 state.aid_ac_bonus = 0 state.running = False @@ -2693,7 +2697,12 @@ class CombatEngine: 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). + PF1e (SRD "Total Defense"): "You can't make attacks of opportunity + while using total defense" — the allowance drops to 0 while the + total-defense bonus is active (until the start of the next turn). """ + if state.total_defense: + return 0 if not self._has_feat(state, "Combat Reflexes"): return 1 return max(1, 1 + ((state.combatant.abilities.dex_score - 10) // 2)) @@ -4089,7 +4098,11 @@ class CombatEngine: def _execute_total_defense(self, state: CombatantState) -> ActionResult: """Execute the total defense action (PF1e): standard action granting a +4 dodge bonus to AC until the start of the combatant's next turn. + PF1e (SRD "Total Defense"): "You can't make attacks of opportunity + while using total defense" — the flag drops the AoO allowance to 0 + (see ``_aoo_allowance``). """ + state.total_defense = True state.defensive_ac_bonus += _TOTAL_DEFENSE_AC_BONUS self._log( f"round {self._current_round} {state.combatant.id}: " diff --git a/tests/test_defensive_actions.py b/tests/test_defensive_actions.py index 4c18e94..9f223f9 100644 --- a/tests/test_defensive_actions.py +++ b/tests/test_defensive_actions.py @@ -224,3 +224,35 @@ def test_no_defensive_actions_transcript_is_pinned() -> None: for state in eng._states: assert state.defensive_ac_bonus == 0 assert state.fight_defensively is False + + +# ── (f) total defense denies attacks of opportunity ──────────────────────────── + + +def test_total_defense_denies_attacks_of_opportunity() -> None: + # SRD "Total Defense": "You can't make attacks of opportunity while using + # total defense." A foe that leaves the hero's threatened squares takes + # no AoO while the hero is using it (no die rolled, allowance 0). + def moving_foe(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "hero": + return (Action(kind="total_defense"),) + return (Action(kind="move", destination=(0, 4)),) + + eng = _duel(moving_foe, [10, 5], round_cap=1) + t = eng.run().transcript + assert not any("AoO" in line for line in t) + hero = eng._states[0] + assert hero.total_defense is True + assert eng._aoo_allowance(hero) == 0 + + +def test_control_moving_foe_does_provoke_aoo() -> None: + def moving_foe(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "hero": + return _WAIT + return (Action(kind="move", destination=(0, 4)),) + + # AoO d20=8+5=13 vs AC 10 -> HIT (d6=3). + eng = _duel(moving_foe, [10, 5, 8, 3], round_cap=1) + t = eng.run().transcript + assert any("AoO vs foe" in line for line in t)