fix(rules): total defense and fighting defensively cannot be combined (SRD Total Defense) - no stacking, second declaration rejected
This commit is contained in:
@@ -1064,7 +1064,7 @@ ACTION_RESULT_REASONS: frozenset[str] = frozenset({
|
|||||||
"downgraded:surprise",
|
"downgraded:surprise",
|
||||||
"delayed", "already_delayed", "ready",
|
"delayed", "already_delayed", "ready",
|
||||||
"not_prone", "already_prone", "unreachable_destination",
|
"not_prone", "already_prone", "unreachable_destination",
|
||||||
"weapon_unavailable", "invalid_destination",
|
"weapon_unavailable", "invalid_destination", "cannot_combine",
|
||||||
})
|
})
|
||||||
_REASON_OK = "ok"
|
_REASON_OK = "ok"
|
||||||
_REASON_UNSUPPORTED_KIND = "unsupported_kind"
|
_REASON_UNSUPPORTED_KIND = "unsupported_kind"
|
||||||
@@ -1086,6 +1086,7 @@ _REASON_INVALID_DESTINATION = "invalid_destination"
|
|||||||
_REASON_NO_WEAPON = "no_weapon"
|
_REASON_NO_WEAPON = "no_weapon"
|
||||||
_REASON_OUT_OF_RANGE = "out_of_range"
|
_REASON_OUT_OF_RANGE = "out_of_range"
|
||||||
_REASON_NO_LINE_OF_EFFECT = "no_line_of_effect"
|
_REASON_NO_LINE_OF_EFFECT = "no_line_of_effect"
|
||||||
|
_REASON_CANNOT_COMBINE = "cannot_combine"
|
||||||
|
|
||||||
# Action kinds that are declared but have no engine dispatch (U1 surfaces
|
# Action kinds that are declared but have no engine dispatch (U1 surfaces
|
||||||
# them as explicit rejections instead of silent no-ops).
|
# them as explicit rejections instead of silent no-ops).
|
||||||
@@ -4115,10 +4116,14 @@ class CombatEngine:
|
|||||||
+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
|
PF1e (SRD "Total Defense"): "You can't make attacks of opportunity
|
||||||
while using total defense" — the flag drops the AoO allowance to 0
|
while using total defense" — the flag drops the AoO allowance to 0
|
||||||
(see ``_aoo_allowance``).
|
(see ``_aoo_allowance``) — and "You can't combine total defense with
|
||||||
|
fighting defensively": the bonus is SET (never stacked on a prior
|
||||||
|
+2) and a pending fighting-defensively declaration is voided (under
|
||||||
|
total defense no attack is made, so its -4 is moot).
|
||||||
"""
|
"""
|
||||||
state.total_defense = True
|
state.total_defense = True
|
||||||
state.defensive_ac_bonus += _TOTAL_DEFENSE_AC_BONUS
|
state.fight_defensively = False
|
||||||
|
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}: "
|
||||||
f"total defense (+{_TOTAL_DEFENSE_AC_BONUS} AC)"
|
f"total defense (+{_TOTAL_DEFENSE_AC_BONUS} AC)"
|
||||||
@@ -4129,7 +4134,18 @@ class CombatEngine:
|
|||||||
"""Execute the fighting defensively action (PF1e): standard action
|
"""Execute the fighting defensively action (PF1e): standard action
|
||||||
granting a +2 dodge bonus to AC until the start of the combatant's
|
granting a +2 dodge bonus to AC until the start of the combatant's
|
||||||
next turn, at the cost of -4 on attack and crit confirmation rolls.
|
next turn, at the cost of -4 on attack and crit confirmation rolls.
|
||||||
|
PF1e (SRD "Total Defense"): "You can't combine total defense with
|
||||||
|
fighting defensively" — the declaration is rejected while total
|
||||||
|
defense is active.
|
||||||
"""
|
"""
|
||||||
|
if state.total_defense:
|
||||||
|
self._log(
|
||||||
|
f"round {self._current_round} {state.combatant.id}: "
|
||||||
|
f"fight_defensively rejected ({_REASON_CANNOT_COMBINE})"
|
||||||
|
)
|
||||||
|
return ActionResult(
|
||||||
|
kind="fight_defensively", ok=False, reason=_REASON_CANNOT_COMBINE
|
||||||
|
)
|
||||||
state.fight_defensively = True
|
state.fight_defensively = True
|
||||||
state.defensive_ac_bonus += _FIGHT_DEFENSIVELY_AC_BONUS
|
state.defensive_ac_bonus += _FIGHT_DEFENSIVELY_AC_BONUS
|
||||||
self._log(
|
self._log(
|
||||||
|
|||||||
@@ -256,3 +256,48 @@ def test_control_moving_foe_does_provoke_aoo() -> None:
|
|||||||
eng = _duel(moving_foe, [10, 5, 8, 3], round_cap=1)
|
eng = _duel(moving_foe, [10, 5, 8, 3], round_cap=1)
|
||||||
t = eng.run().transcript
|
t = eng.run().transcript
|
||||||
assert any("AoO vs foe" in line for line in t)
|
assert any("AoO vs foe" in line for line in t)
|
||||||
|
|
||||||
|
|
||||||
|
# ── (g) total defense and fighting defensively do not stack ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_fight_defensively_rejected_while_total_defense_active() -> None:
|
||||||
|
# SRD "Total Defense": "You can't combine total defense with fighting
|
||||||
|
# defensively." Declaring it after total defense is rejected; the AC
|
||||||
|
# bonus stays exactly +4.
|
||||||
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if state.combatant.id == "hero":
|
||||||
|
return (
|
||||||
|
Action(kind="total_defense"),
|
||||||
|
Action(kind="fight_defensively"),
|
||||||
|
)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _duel(policy, [10, 5], round_cap=1)
|
||||||
|
t = eng.run().transcript
|
||||||
|
hero = eng._states[0]
|
||||||
|
assert "round 1 hero: fight_defensively rejected (cannot_combine)" in t
|
||||||
|
assert hero.defensive_ac_bonus == 4
|
||||||
|
assert hero.fight_defensively is False
|
||||||
|
assert ("fight_defensively", False, "cannot_combine") in tuple(
|
||||||
|
(r.kind, r.ok, r.reason) for r in hero.last_action_results
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_total_defense_supersedes_a_fighting_defensively_declaration() -> None:
|
||||||
|
# The reverse order: the +2 declaration is voided by the later total
|
||||||
|
# defense — the bonus is set to +4 (not +6) and the -4 attack flag clears.
|
||||||
|
def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||||
|
if state.combatant.id == "hero":
|
||||||
|
return (
|
||||||
|
Action(kind="fight_defensively"),
|
||||||
|
Action(kind="total_defense"),
|
||||||
|
)
|
||||||
|
return _WAIT
|
||||||
|
|
||||||
|
eng = _duel(policy, [10, 5], round_cap=1)
|
||||||
|
eng.run()
|
||||||
|
hero = eng._states[0]
|
||||||
|
assert hero.defensive_ac_bonus == 4
|
||||||
|
assert hero.fight_defensively is False
|
||||||
|
assert hero.total_defense is True
|
||||||
|
|||||||
Reference in New Issue
Block a user