"""The PF1e defensive standard actions (Package U5a). ``total_defense`` grants +4 dodge AC and ``fight_defensively`` grants +2 dodge AC plus -4 on attack and crit confirmation rolls, until the start of the combatant's NEXT turn (reset in ``_take_turn`` alongside the other per-turn flags). The bonus is applied in ``resolve_attack`` (target's ``defensive_ac_bonus`` added to AC; attacker's ``fight_defensively`` subtracted from the attack total and the confirm total). Both keys are exposed additively in the per-combatant snapshot. """ from __future__ import annotations from collections.abc import Callable from pf1e_simulator.combat import ( Action, CombatantState, CombatEngine, ) from pf1e_simulator.dice import parse_dice from pf1e_simulator.models import ( AbilityScores, ACProfile, AttackSpec, Combatant, DamageComponent, Saves, ) from pf1e_simulator.rng import ScriptedRng from tests.test_observation_enriched import _snapshot, make_grid, make_state def _combatant(id_: str, *, initiative_mod: int = 0) -> Combatant: return Combatant( id=id_, name=id_, level=1, abilities=AbilityScores( str_score=10, dex_score=10, con_score=10, int_score=10, wis_score=10, cha_score=10, ), ac=ACProfile(total=10, touch=10, flat_footed=10), bab=1, initiative_mod=initiative_mod, speed_land_ft=30, hp_max=100, saves=Saves(fort=0, ref=0, will=0), attacks=[ AttackSpec( id=f"{id_}-sword", name="short sword", kind="melee", attack_bonus=5, damage=[ DamageComponent(formula=parse_dice("1d6"), types=["slashing"]) ], ) ], ) PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] _WAIT: tuple[Action, ...] = (Action(kind="wait"),) def _duel(policy: PolicyFn, rng: list[int], round_cap: int) -> CombatEngine: """hero (state order first, ties act first) adjacent to foe.""" hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0)) foe = make_state(_combatant("foe"), "monsters", (0, 1)) return CombatEngine( ScriptedRng(rng), make_grid(), [hero, foe], policy=policy, round_cap=round_cap, ) def _attack_foe(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: if state.combatant.id == "hero": return _WAIT return (Action(kind="attack", target_id="hero"),) # ── (a) total defense raises AC by 4: same roll hits without it, misses with it ─ def test_attack_that_hits_normally_misses_total_defense() -> None: # Control: no defensive action — d20 8 + 5 = 13 hits AC 10 (d6 3 damage). eng_plain = _duel(_attack_foe, [10, 5, 8, 3], round_cap=1) plain = eng_plain.run().transcript assert ( "round 1 foe: short sword vs hero d20=8+5=13 AC 10 -> HIT 3 damage (100->97)" in plain ) # Same scripted roll against a hero who took total defense: AC 14, miss. def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: if state.combatant.id == "hero": return (Action(kind="total_defense"),) return (Action(kind="attack", target_id="hero"),) eng = _duel(policy, [10, 5, 8], round_cap=1) result = eng.run() t = result.transcript assert "round 1 hero: total defense (+4 AC)" in t assert "round 1 foe: short sword vs hero d20=8+5=13 AC 14 -> MISS" in t hero = eng._states[0] assert hero.defensive_ac_bonus == 4 assert hero.fight_defensively is False assert hero.hp == 100 # ── (b) the bonus resets at the combatant's next turn ───────────────────────── def test_total_defense_resets_at_next_turn() -> None: calls: dict[str, int] = {} def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: cid = state.combatant.id n = calls.get(cid, 0) calls[cid] = n + 1 if cid == "hero": return (Action(kind="total_defense"),) if n == 0 else _WAIT return (Action(kind="attack", target_id="hero"),) eng = _duel(policy, [10, 5, 8, 8, 3], round_cap=2) t = eng.run().transcript # Round 1: protected (AC 14) — the same roll misses. assert "round 1 foe: short sword vs hero d20=8+5=13 AC 14 -> MISS" in t # Round 2: hero waited, the bonus reset — the same roll hits AC 10. assert ( "round 2 foe: short sword vs hero d20=8+5=13 AC 10 -> HIT 3 damage (100->97)" in t ) assert eng._states[0].defensive_ac_bonus == 0 # ── (c) fight defensively: +2 AC AND -4 on the attack roll ──────────────────── def test_fight_defensively_grants_ac_and_takes_attack_penalty() -> None: def policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: if state.combatant.id == "hero": return ( Action(kind="fight_defensively"), Action(kind="attack", target_id="foe"), ) return (Action(kind="attack", target_id="hero"),) eng = _duel(policy, [10, 5, 8, 7, 3], round_cap=1) t = eng.run().transcript assert "round 1 hero: fighting defensively (-4 attack, +2 AC)" in t # Hero's swing: d20 8 + 5 - 4 = 9 misses AC 10. assert "round 1 hero: short sword vs foe d20=8+5=9 AC 10 -> MISS" in t # Foe's swing vs the +2 AC: d20 7 + 5 = 12 exactly hits AC 12. assert ( "round 1 foe: short sword vs hero d20=7+5=12 AC 12 -> HIT 3 damage (100->97)" in t ) hero = eng._states[0] assert hero.fight_defensively is True assert hero.defensive_ac_bonus == 2 # ── (d) the snapshot exposes both keys with correct values ──────────────────── def test_snapshot_exposes_defensive_keys() -> None: hero_snaps: list[dict[str, object]] = [] def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: if state.combatant.id == "foe": obs = _snapshot(engine, state) hero_snaps.append( next(c for c in obs["combatants"] if c["id"] == "hero") ) return _WAIT if engine._current_round == 1: return (Action(kind="total_defense"),) if engine._current_round == 2: return (Action(kind="fight_defensively"),) return _WAIT eng = _duel(policy, [10, 5], round_cap=3) eng.run() assert len(hero_snaps) == 3 # Round 1 (foe acts after hero's total defense): +4, flag False. assert hero_snaps[0]["defensive_ac_bonus"] == 4 assert hero_snaps[0]["fight_defensively"] is False # Round 2 (after hero's fighting defensively): +2, flag True. assert hero_snaps[1]["defensive_ac_bonus"] == 2 assert hero_snaps[1]["fight_defensively"] is True # Round 3 (hero waited, everything reset): 0, False. assert hero_snaps[2]["defensive_ac_bonus"] == 0 assert hero_snaps[2]["fight_defensively"] is False # The action feedback for both defensive kinds is ok (the snapshot is # built on the foe's turn, which follows the hero's in the same round). results_r1 = hero_snaps[0]["last_turn_results"] assert results_r1 == ({"kind": "total_defense", "ok": True, "reason": "ok"},) results_r2 = hero_snaps[1]["last_turn_results"] assert results_r2 == ({"kind": "fight_defensively", "ok": True, "reason": "ok"},) # ── (e) combatants that never use them are unchanged ────────────────────────── def test_no_defensive_actions_transcript_is_pinned() -> None: eng = _duel(lambda _e, _s: _WAIT, [10, 5], round_cap=2) result = eng.run() assert result.transcript == ( "initiative: hero d20=10+2=12", "initiative: foe d20=5+0=5", "round 1 hero: wait", "round 1 foe: wait", "round 2 hero: wait", "round 2 foe: wait", "battle over: draw after 2 rounds", ) assert result.winner is 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) # ── (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