259 lines
9.4 KiB
Python
259 lines
9.4 KiB
Python
"""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)
|