feat(combat): add total defense and fighting defensively
This commit is contained in:
@@ -324,6 +324,20 @@ Règles modélisées :
|
|||||||
exécutable. Le snapshot expose l'état via la clé `readied`
|
exécutable. Le snapshot expose l'état via la clé `readied`
|
||||||
(`{kind, trigger}` ou `None`). `default_policy` ne se prépare pas
|
(`{kind, trigger}` ou `None`). `default_policy` ne se prépare pas
|
||||||
(disponible via une politique personnalisée).
|
(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`
|
- Retour d'exécution des actions : chaque action produit un `ActionResult`
|
||||||
explicite (kind, ok, reason) consigné dans le snapshot (`last_turn_results`
|
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
|
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
|
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),
|
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
|
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,
|
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
|
||||||
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
|
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
|
||||||
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me.
|
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me.
|
||||||
|
|||||||
@@ -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)
|
_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
|
_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
|
_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({
|
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({
|
||||||
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
|
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
|
||||||
"mind_affecting",
|
"mind_affecting",
|
||||||
@@ -362,6 +365,13 @@ class CombatantState:
|
|||||||
# fires it. Cleared when the readied action executes (one-shot).
|
# fires it. Cleared when the readied action executes (one-shot).
|
||||||
readied: Action | None = None
|
readied: Action | None = None
|
||||||
readied_trigger: ReadyTrigger | 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]:
|
def usable_attacks(self) -> list[AttackSpec]:
|
||||||
return [
|
return [
|
||||||
@@ -525,6 +535,9 @@ class Action:
|
|||||||
action that prepares the carrier-field payload (spell / maneuver / attack
|
action that prepares the carrier-field payload (spell / maneuver / attack
|
||||||
on ``target_id``) to fire off-turn when ``ready_trigger`` matches a
|
on ``target_id``) to fire off-turn when ``ready_trigger`` matches a
|
||||||
``ReactionEvent``; the readied action is then consumed.
|
``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[
|
kind: Literal[
|
||||||
@@ -533,6 +546,7 @@ class Action:
|
|||||||
"maneuver", "channel_energy", "hex", "deed", "spell_combat",
|
"maneuver", "channel_energy", "hex", "deed", "spell_combat",
|
||||||
"inspire_courage", "retrieve_weapon", "repair_weapon",
|
"inspire_courage", "retrieve_weapon", "repair_weapon",
|
||||||
"activate_power_attack", "delay", "ready",
|
"activate_power_attack", "delay", "ready",
|
||||||
|
"total_defense", "fight_defensively",
|
||||||
]
|
]
|
||||||
target_id: str | None = None
|
target_id: str | None = None
|
||||||
spell_name: str | None = None
|
spell_name: str | None = None
|
||||||
@@ -840,6 +854,8 @@ class CombatEngine:
|
|||||||
"grappled_with": state.grappled_with,
|
"grappled_with": state.grappled_with,
|
||||||
"moved_this_turn": state.moved_this_turn,
|
"moved_this_turn": state.moved_this_turn,
|
||||||
"power_attack": state.power_attack,
|
"power_attack": state.power_attack,
|
||||||
|
"defensive_ac_bonus": state.defensive_ac_bonus,
|
||||||
|
"fight_defensively": state.fight_defensively,
|
||||||
"name": state.combatant.name,
|
"name": state.combatant.name,
|
||||||
"level": state.combatant.level,
|
"level": state.combatant.level,
|
||||||
"size": state.combatant.size,
|
"size": state.combatant.size,
|
||||||
@@ -1013,6 +1029,8 @@ class CombatEngine:
|
|||||||
self._apply_fear_aura(state)
|
self._apply_fear_aura(state)
|
||||||
state.moved_this_turn = False
|
state.moved_this_turn = False
|
||||||
state.power_attack = False
|
state.power_attack = False
|
||||||
|
state.defensive_ac_bonus = 0
|
||||||
|
state.fight_defensively = False
|
||||||
original = self._policy(self, state)
|
original = self._policy(self, state)
|
||||||
actions = original
|
actions = original
|
||||||
results: list[ActionResult] = []
|
results: list[ActionResult] = []
|
||||||
@@ -1540,9 +1558,12 @@ class CombatEngine:
|
|||||||
atk_mods.append(StatModifier(target="attack", value=-4))
|
atk_mods.append(StatModifier(target="attack", value=-4))
|
||||||
attack_mod = resolve_modifiers(atk_mods)
|
attack_mod = resolve_modifiers(atk_mods)
|
||||||
pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0
|
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 = (
|
total = (
|
||||||
roll + base_bonus + penalty + flank + high_ground + attack_mod - pa_amt
|
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)
|
# Check if target denies DEX to AC (flat-footed)
|
||||||
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac")
|
denies_dex = self.has_condition_flag(target, "denies_dex_to_ac")
|
||||||
@@ -1568,6 +1589,7 @@ class CombatEngine:
|
|||||||
else:
|
else:
|
||||||
base_ac = full_ac_base
|
base_ac = full_ac_base
|
||||||
ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac"))
|
ac = base_ac + resolve_modifiers(self._stat_modifiers(target, "ac"))
|
||||||
|
ac += target.defensive_ac_bonus
|
||||||
occupied = frozenset(
|
occupied = frozenset(
|
||||||
cell
|
cell
|
||||||
for s in self._states
|
for s in self._states
|
||||||
@@ -1639,6 +1661,7 @@ class CombatEngine:
|
|||||||
confirm_total = (
|
confirm_total = (
|
||||||
confirm + base_bonus + penalty + flank + high_ground
|
confirm + base_bonus + penalty + flank + high_ground
|
||||||
+ attack_mod - pa_amt + fim_penalty
|
+ attack_mod - pa_amt + fim_penalty
|
||||||
|
+ (_FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0)
|
||||||
)
|
)
|
||||||
crit = confirm != _NATURAL_ONE and confirm_total >= ac
|
crit = confirm != _NATURAL_ONE and confirm_total >= ac
|
||||||
concealment = self._concealment_chance(target)
|
concealment = self._concealment_chance(target)
|
||||||
@@ -2410,6 +2433,8 @@ class CombatEngine:
|
|||||||
return self._execute_delay(state, target, action)
|
return self._execute_delay(state, target, action)
|
||||||
if kind == "ready":
|
if kind == "ready":
|
||||||
return self._execute_ready(state, action)
|
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)
|
self._dispatch_action(state, target, action)
|
||||||
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
|
||||||
|
|
||||||
@@ -2481,6 +2506,37 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
return ActionResult(kind=kind, ok=True, reason=_REASON_READY)
|
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(
|
def _dispatch_action(
|
||||||
self, state: CombatantState, target: CombatantState | None, action: Action
|
self, state: CombatantState, target: CombatantState | None, action: Action
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user