diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 28b45a6..5ad1520 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -1064,7 +1064,7 @@ ACTION_RESULT_REASONS: frozenset[str] = frozenset({ "downgraded:surprise", "delayed", "already_delayed", "ready", "not_prone", "already_prone", "unreachable_destination", - "weapon_unavailable", "invalid_destination", + "weapon_unavailable", "invalid_destination", "cannot_combine", }) _REASON_OK = "ok" _REASON_UNSUPPORTED_KIND = "unsupported_kind" @@ -1086,6 +1086,7 @@ _REASON_INVALID_DESTINATION = "invalid_destination" _REASON_NO_WEAPON = "no_weapon" _REASON_OUT_OF_RANGE = "out_of_range" _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 # 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. 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``). + (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.defensive_ac_bonus += _TOTAL_DEFENSE_AC_BONUS + state.fight_defensively = False + state.defensive_ac_bonus = _TOTAL_DEFENSE_AC_BONUS self._log( f"round {self._current_round} {state.combatant.id}: " f"total defense (+{_TOTAL_DEFENSE_AC_BONUS} AC)" @@ -4129,7 +4134,18 @@ class CombatEngine: """Execute the fighting defensively action (PF1e): standard action 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. + 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.defensive_ac_bonus += _FIGHT_DEFENSIVELY_AC_BONUS self._log( diff --git a/tests/test_defensive_actions.py b/tests/test_defensive_actions.py index 9f223f9..23cd276 100644 --- a/tests/test_defensive_actions.py +++ b/tests/test_defensive_actions.py @@ -256,3 +256,48 @@ def test_control_moving_foe_does_provoke_aoo() -> None: 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) + + +# ── (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