fix(rules): total defense denies attacks of opportunity (SRD Total Defense: allowance drops to 0 while the bonus is active)
This commit is contained in:
@@ -744,9 +744,12 @@ class CombatantState:
|
|||||||
# granted by total defense (+4) and fighting defensively (+2), and the
|
# granted by total defense (+4) and fighting defensively (+2), and the
|
||||||
# fighting-defensively flag (-4 on attack and crit confirm). Both reset
|
# 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
|
# 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
|
defensive_ac_bonus: int = 0
|
||||||
fight_defensively: bool = False
|
fight_defensively: bool = False
|
||||||
|
total_defense: bool = False
|
||||||
# Aid another & run (not in the frozen data model): aid bonuses granted to
|
# 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
|
# 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
|
# its next attack roll, aid_ac_bonus applies against the next attack that
|
||||||
@@ -1776,6 +1779,7 @@ class CombatEngine:
|
|||||||
state.power_attack = False
|
state.power_attack = False
|
||||||
state.defensive_ac_bonus = 0
|
state.defensive_ac_bonus = 0
|
||||||
state.fight_defensively = False
|
state.fight_defensively = False
|
||||||
|
state.total_defense = False
|
||||||
state.aid_attack_bonus = 0
|
state.aid_attack_bonus = 0
|
||||||
state.aid_ac_bonus = 0
|
state.aid_ac_bonus = 0
|
||||||
state.running = False
|
state.running = False
|
||||||
@@ -2693,7 +2697,12 @@ class CombatEngine:
|
|||||||
|
|
||||||
Base is 1. Combat Reflexes adds the DEX modifier on top (clamped to >= 1
|
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).
|
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"):
|
if not self._has_feat(state, "Combat Reflexes"):
|
||||||
return 1
|
return 1
|
||||||
return max(1, 1 + ((state.combatant.abilities.dex_score - 10) // 2))
|
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:
|
def _execute_total_defense(self, state: CombatantState) -> ActionResult:
|
||||||
"""Execute the total defense action (PF1e): standard action granting a
|
"""Execute the total defense action (PF1e): standard action granting a
|
||||||
+4 dodge bonus to AC until the start of the combatant's next turn.
|
+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
|
state.defensive_ac_bonus += _TOTAL_DEFENSE_AC_BONUS
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: "
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
|
|||||||
@@ -224,3 +224,35 @@ def test_no_defensive_actions_transcript_is_pinned() -> None:
|
|||||||
for state in eng._states:
|
for state in eng._states:
|
||||||
assert state.defensive_ac_bonus == 0
|
assert state.defensive_ac_bonus == 0
|
||||||
assert state.fight_defensively is False
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user