From ebdff4f7e5a0e5870c9c3ff8eaf6e1cee27d7a1d Mon Sep 17 00:00:00 2001 From: ctan Date: Thu, 10 Sep 2026 12:52:47 +0200 Subject: [PATCH] feat(combat): add total defense and fighting defensively --- README.md | 20 ++- src/pf1e_simulator/combat.py | 58 +++++++- tests/test_defensive_actions.py | 226 ++++++++++++++++++++++++++++++++ 3 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 tests/test_defensive_actions.py diff --git a/README.md b/README.md index a1af29e..f94d412 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,20 @@ Règles modélisées : exécutable. Le snapshot expose l'état via la clé `readied` (`{kind, trigger}` ou `None`). `default_policy` ne se prépare pas (disponible via une politique personnalisée). +- Défense totale (`Action(kind="total_defense")`, action standard) : le + combattant se couvre entièrement et gagne **+4 d'esquive à la CA** jusqu'au + début de son prochain tour (champ runtime `defensive_ac_bonus`, réinitialisé + avec les autres drapeaux de tour). Le kind ne requiert pas de cible. + Simplification documentée : la progression d'Acrobatie (+6 avec 3 rangs) + n'est pas modélisée. `default_policy` ne la choisit pas (disponible via une + politique personnalisée). +- Combat défensif (`Action(kind="fight_defensively")`, action standard) : le + combattant se bat avec prudence — **−4 à ses jets d'attaque et de + confirmation de critique**, et **+2 d'esquive à la CA** jusqu'au début de son + prochain tour (drapeaux runtime `fight_defensively` + `defensive_ac_bonus`). + Simplification documentée : la progression d'Acrobatie (−2 / +3 avec 3 + rangs) n'est pas modélisée. `default_policy` ne le choisit pas (disponible + via une politique personnalisée). - Retour d'exécution des actions : chaque action produit un `ActionResult` explicite (kind, ok, reason) consigné dans le snapshot (`last_turn_results` au tour suivant). Les actions invalides sont rejetées et journalisées au lieu @@ -593,7 +607,11 @@ Règles modélisées : last_turn_results (tuple de {kind, ok, reason} — résultat d'exécution du dernier tour, pour que la policy apprenne les actions rejetées/dégradées), readied ({kind, trigger} de l'action préparée ou None — préparation en - cours, consommée quand le déclencheur la fait tirer). + cours, consommée quand le déclencheur la fait tirer), + defensive_ac_bonus (int — bonus d'esquive à la CA de la défense totale + (+4) ou du combat défensif (+2), réinitialisé au début du tour du + combattant), fight_defensively (bool — combat défensif actif : −4 aux + jets d'attaque et de confirmation jusqu'au prochain tour). - Par paire combattant actif × cible : target_index, distance_ft, weapon_for, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me. diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 2265d22..010587f 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -153,6 +153,9 @@ _IMPROVED_CRITICAL_FEATURE = "Improved Critical" # PF1e: feat doubling the thre _CRIT_RANGE_BASE = 21 # threat range top is 21 - min natural die (20 -> 20-20) _DYING_DC = 10 # PF1e: DC of the per-round stabilization Constitution check _DELAY_INITIATIVE_STEP = 0.5 # PF1e: a delay slots 0.5 below its anchor total +_TOTAL_DEFENSE_AC_BONUS = 4 # PF1e: total defense grants +4 dodge AC +_FIGHT_DEFENSIVELY_AC_BONUS = 2 # PF1e: fighting defensively grants +2 dodge AC +_FIGHT_DEFENSIVELY_ATTACK_PENALTY = -4 # PF1e: and -4 on attacks / crit confirms _SAVE_DESCRIPTOR_CONDITIONS = frozenset({ "fear", "cold", "electricity", "fey", "divination", "poison", "disease", "mind_affecting", @@ -362,6 +365,13 @@ class CombatantState: # fires it. Cleared when the readied action executes (one-shot). readied: Action | None = None readied_trigger: ReadyTrigger | None = None + # Defensive actions (not in the frozen data model): the dodge AC bonus + # 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. + defensive_ac_bonus: int = 0 + fight_defensively: bool = False def usable_attacks(self) -> list[AttackSpec]: return [ @@ -525,6 +535,9 @@ class Action: action that prepares the carrier-field payload (spell / maneuver / attack on ``target_id``) to fire off-turn when ``ready_trigger`` matches a ``ReactionEvent``; the readied action is then consumed. + ``total_defense`` and ``fight_defensively`` are standard defensive actions + granting a temporary dodge AC bonus until the combatant's next turn; + ``fight_defensively`` also takes -4 on its attacks. """ kind: Literal[ @@ -533,6 +546,7 @@ class Action: "maneuver", "channel_energy", "hex", "deed", "spell_combat", "inspire_courage", "retrieve_weapon", "repair_weapon", "activate_power_attack", "delay", "ready", + "total_defense", "fight_defensively", ] target_id: str | None = None spell_name: str | None = None @@ -840,6 +854,8 @@ class CombatEngine: "grappled_with": state.grappled_with, "moved_this_turn": state.moved_this_turn, "power_attack": state.power_attack, + "defensive_ac_bonus": state.defensive_ac_bonus, + "fight_defensively": state.fight_defensively, "name": state.combatant.name, "level": state.combatant.level, "size": state.combatant.size, @@ -1013,6 +1029,8 @@ class CombatEngine: self._apply_fear_aura(state) state.moved_this_turn = False state.power_attack = False + state.defensive_ac_bonus = 0 + state.fight_defensively = False original = self._policy(self, state) actions = original results: list[ActionResult] = [] @@ -1540,9 +1558,12 @@ class CombatEngine: atk_mods.append(StatModifier(target="attack", value=-4)) attack_mod = resolve_modifiers(atk_mods) pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0 + fd_penalty = ( + _FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0 + ) total = ( roll + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt - + fim_penalty + + fim_penalty + fd_penalty ) # Check if target denies DEX to AC (flat-footed) denies_dex = self.has_condition_flag(target, "denies_dex_to_ac") @@ -1568,6 +1589,7 @@ class CombatEngine: else: base_ac = full_ac_base ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac")) + ac += target.defensive_ac_bonus occupied = frozenset( cell for s in self._states @@ -1639,6 +1661,7 @@ class CombatEngine: confirm_total = ( confirm + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt + fim_penalty + + (_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0) ) crit = confirm != _NATURAL_ONE and confirm_total >= ac concealment = self._concealment_chance(target) @@ -2410,6 +2433,8 @@ class CombatEngine: return self._execute_delay(state, target, action) if kind == "ready": return self._execute_ready(state, action) + if kind in ("total_defense", "fight_defensively"): + return self._execute_defensive(state, kind) self._dispatch_action(state, target, action) return ActionResult(kind=kind, ok=True, reason=_REASON_OK) @@ -2481,6 +2506,37 @@ class CombatEngine: ) return ActionResult(kind=kind, ok=True, reason=_REASON_READY) + def _execute_defensive(self, state: CombatantState, kind: str) -> ActionResult: + """Dispatch a target-free defensive standard action to its helper.""" + if kind == "total_defense": + return self._execute_total_defense(state) + return self._execute_fight_defensively(state) + + 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. + """ + 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)" + ) + return ActionResult(kind="total_defense", ok=True, reason=_REASON_OK) + + def _execute_fight_defensively(self, state: CombatantState) -> ActionResult: + """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. + """ + state.fight_defensively = True + state.defensive_ac_bonus += _FIGHT_DEFENSIVELY_AC_BONUS + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"fighting defensively ({_FIGHT_DEFENSIVELY_ATTACK_PENALTY} attack, " + f"+{_FIGHT_DEFENSIVELY_AC_BONUS} AC)" + ) + return ActionResult(kind="fight_defensively", ok=True, reason=_REASON_OK) + def _dispatch_action( self, state: CombatantState, target: CombatantState | None, action: Action ) -> None: diff --git a/tests/test_defensive_actions.py b/tests/test_defensive_actions.py new file mode 100644 index 0000000..4c18e94 --- /dev/null +++ b/tests/test_defensive_actions.py @@ -0,0 +1,226 @@ +"""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