Files
pf1e-simulator/tests/test_maneuvers.py
T
ctan 98b61c6cf8 fix(rules): add AC bonuses and flat-footed Dexterity to CMD
PF1e (SRD Combat Maneuver Defense): "A creature can also add any circumstance, deflection, dodge, insight, luck, morale, profane, and sacred bonuses to AC to its CMD [...] A flat-footed creature does not add its Dexterity bonus to its CMD." _maneuver_cmd now sums the target's eligible AC bonus modifiers (plus the dodge/circumstance runtime bonuses from total defense, fight defensively and aid another) and drops the Dexterity bonus (keeping any penalty) while the target denies its Dexterity to AC. README updated.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-12 08:49:46 +02:00

369 lines
13 KiB
Python

"""Tests for combat maneuvers, focused on the sunder result.
A successful sunder damages the defender's WEAPON, never its HP. Repeated
sunders can destroy the weapon, after which the defender can no longer use
it (effectively disarmed). See the plan task-12.
"""
from __future__ import annotations
from pf1e_simulator.combat import _SIZE_MODIFIERS, CombatantState, CombatEngine
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.effects import ActiveBuff, ActiveCondition, StatModifier
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
SkillRank,
)
from pf1e_simulator.rng import ScriptedRng
def make_combatant(
cid: str, *, attack_bonus: int = 2, weapon_name: str = "sword"
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name=weapon_name,
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula="1d4", types=["slashing"])],
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=6,
ac=ACProfile(total=13, touch=13, flat_footed=13),
bab=attack_bonus,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[attack],
)
def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
def make_grid() -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
return Grid.from_spec(spec)
def make_engine(
queue: list[int], states: list[CombatantState]
) -> CombatEngine:
# A maneuver provokes an AoO from the (threatening) target BEFORE the CMB
# check (B13 conformance): scripted queues carry a leading natural 1 so
# the provoking attack always misses.
return CombatEngine(ScriptedRng(queue), make_grid(), states)
def test_sunder_damages_weapon_not_hp() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
hp_before = b.hp
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "sunder")
assert b.hp == hp_before
assert b.weapon_damage["b-w"] == 2
assert "b-w" not in b.weapon_broken
def test_sunder_destroys_weapon_and_disarms() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
# Only the first sunder provokes: b's per-round AoO allowance is spent.
engine = make_engine([1, 12, 12, 12, 12, 12], [a, b])
for _ in range(5):
engine._execute_maneuver(a, b, "sunder")
assert b.hp == 6
assert "b-w" in b.weapon_broken
assert engine.weapon_for(b, a) is None
assert any(c.condition.name == "disarmed" for c in b.active_conditions)
def test_escape_from_grapple_consumes_single_d20() -> None:
a_spec = make_combatant("a", attack_bonus=2).model_copy(
update={"skills": {"escape_artist": SkillRank(rank=10)}}
)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
a.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["grappled"],
rounds_left=1,
source="test",
)
)
a.grappled_with = b_spec.id
engine = make_engine([20], [a, b])
engine._maneuver_escape(a, b)
assert not engine._rng._queue
assert a.grappled_with is None
assert not any(c.condition.name == "grappled" for c in a.active_conditions)
def test_overrun_knocks_prone_and_moves_through() -> None:
# d20=15+2=17 vs CMD 12: margin 5 -> prone AND moved through (CRB
# "Overrun": prone only when the attack exceeds CMD by 5 or more).
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([1, 15], [a, b])
engine._execute_maneuver(a, b, "overrun")
prone = next(c for c in b.active_conditions if c.condition.name == "prone")
assert prone.rounds_left == 1
assert prone.source == "maneuver: overrun"
assert a.pos == (1, 3)
def test_overrun_small_margin_moves_through_without_prone() -> None:
# d20=14+2=16 vs CMD 12: margin 4 -> plain success: moved through,
# NOT prone (CRB "Overrun").
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([1, 14], [a, b])
engine._execute_maneuver(a, b, "overrun")
assert not any(c.condition.name == "prone" for c in b.active_conditions)
assert a.pos == (1, 3)
assert "SUCCESS (moved through)" in engine._transcript[-1]
assert "prone" not in engine._transcript[-1]
def test_overrun_prone_but_no_space() -> None:
# d20=15+2=17 vs CMD 12: margin 5 -> prone; the beyond cell is occupied,
# so no move-through.
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
c_spec = make_combatant("c")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3))
engine = make_engine([1, 15], [a, b, c])
engine._execute_maneuver(a, b, "overrun")
assert any(cond.condition.name == "prone" for cond in b.active_conditions)
assert a.pos == (1, 1)
assert "SUCCESS (prone)" in engine._transcript[-1]
assert "moved through" not in engine._transcript[-1]
def test_dirty_trick_dazzles() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "dirty_trick")
dazzled = next(c for c in b.active_conditions if c.condition.name == "dazzled")
assert dazzled.rounds_left == 1
assert dazzled.source == "maneuver: dirty trick"
def test_dirty_trick_fails_on_natural_1() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([1, 1], [a, b])
engine._execute_maneuver(a, b, "dirty_trick")
assert not any(c.condition.name == "dazzled" for c in b.active_conditions)
assert not any(c.condition.name == "prone" for c in b.active_conditions)
assert engine._transcript[-1].endswith("-> FAILED")
def test_steal_takes_melee_weapon() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
hp_before = b.hp
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "steal")
assert "b-w" in b.weapon_stolen
assert engine.weapon_for(b, a) is None
assert b.hp == hp_before
def test_steal_nothing_to_steal() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b").model_copy(update={"attacks": []})
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([12], [a, b])
engine._execute_maneuver(a, b, "steal")
assert not b.weapon_stolen
assert "nothing to steal" in engine._transcript[-1]
def test_reposition_moves_to_first_free_neighbor() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
c_spec = make_combatant("c")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3))
engine = make_engine([1, 12], [a, b, c])
engine._execute_maneuver(a, b, "reposition")
assert b.pos == (0, 1)
assert c.pos == (1, 3)
def test_reposition_no_space() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
blocker_positions = [(0, 0), (0, 1), (0, 2), (1, 0), (1, 2), (2, 0), (2, 2)]
blockers = [
make_state(make_combatant(f"c{idx}"), "monsters", pos)
for idx, pos in enumerate(blocker_positions)
]
a = make_state(a_spec, "players", (2, 1))
b = make_state(b_spec, "monsters", (1, 1))
engine = make_engine([1, 12], [a, b, *blockers])
engine._execute_maneuver(a, b, "reposition")
assert b.pos == (1, 1)
assert "no space" in engine._transcript[-1]
def test_drag_moves_pair_together() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "drag")
assert b.pos == (1, 3)
assert a.pos == (1, 2)
def test_drag_no_space() -> None:
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b")
c_spec = make_combatant("c")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
c = make_state(c_spec, "monsters", (1, 3))
engine = make_engine([1, 12], [a, b, c])
engine._execute_maneuver(a, b, "drag")
assert b.pos == (1, 2)
assert a.pos == (1, 1)
assert "no space" in engine._transcript[-1]
def _resized(spec: Combatant, size: str, str_score: int, dex_score: int) -> Combatant:
return spec.model_copy(
update={
"size": size,
"abilities": spec.abilities.model_copy(
update={"str_score": str_score, "dex_score": dex_score}
),
}
)
def test_tiny_or_smaller_cmb_uses_dexterity() -> None:
# SRD Combat Maneuvers: "Creatures that are size Tiny or smaller use
# their Dexterity modifier in place of their Strength modifier to
# determine their CMB." Tiny: str -1, dex +4, bab 2, size -2 -> 2+4-2.
engine = make_engine([], [])
for size in ("Tiny", "Diminutive", "Fine"):
tiny = make_state(_resized(make_combatant("t"), size, 8, 18), "p", (0, 0))
expected = 2 + 4 + _SIZE_MODIFIERS[size.lower()]
assert engine._maneuver_cmb(tiny) == expected
def test_small_or_larger_cmb_uses_strength() -> None:
# The Dex substitution stops at Tiny: Small and larger use Strength.
engine = make_engine([], [])
small = make_state(_resized(make_combatant("s"), "Small", 8, 18), "p", (0, 0))
medium = make_state(_resized(make_combatant("m"), "Medium", 8, 18), "p", (0, 1))
assert engine._maneuver_cmb(small) == 2 - 1 - 1 # bab + str + size
assert engine._maneuver_cmb(medium) == 2 - 1 + 0 # bab + str + size
def _buffed(spec: Combatant, mods: tuple[StatModifier, ...]) -> CombatantState:
state = make_state(spec, "p", (0, 0))
state.active_buffs.append(
ActiveBuff(modifiers=mods, rounds_left=1, source="test")
)
return state
def test_cmd_adds_eligible_ac_bonus_types() -> None:
# SRD CMD: "A creature can also add any circumstance, deflection, dodge,
# insight, luck, morale, profane, and sacred bonuses to AC to its CMD."
# Medium str 10 dex 10 bab 2: base CMD 10 + (2+0+0) + 0 = 12.
engine = make_engine([], [])
base = make_state(make_combatant("b"), "p", (0, 0))
assert engine._maneuver_cmd(base) == 12
buffed = _buffed(
make_combatant("b"),
(
StatModifier(target="ac", value=4, bonus_type="dodge"),
StatModifier(target="ac", value=2, bonus_type="morale"),
),
)
assert engine._maneuver_cmd(buffed) == 12 + 4 + 2
def test_cmd_ignores_non_ac_bonus_types() -> None:
# Enhancement/natural armor bonuses and untyped penalties are NOT added
# to CMD (only the eight SRD-listed AC bonus types are).
engine = make_engine([], [])
state = _buffed(
make_combatant("b"),
(
StatModifier(target="ac", value=2, bonus_type="enhancement"),
StatModifier(target="ac", value=-4),
),
)
assert engine._maneuver_cmd(state) == 12
def test_cmd_flat_footed_drops_dex_bonus_keeps_penalty() -> None:
# SRD CMD: "A flat-footed creature does not add its Dexterity bonus to
# its CMD." A Dex penalty is not a bonus, so it still applies.
engine = make_engine([], [])
dexy = _resized(make_combatant("d"), "Medium", 10, 18) # dex +4
flat = make_state(dexy, "p", (0, 0))
flat.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["flat_footed"],
rounds_left=1,
source="test",
)
)
assert engine._maneuver_cmd(make_state(dexy, "p", (0, 1))) == 10 + 2 + 4
assert engine._maneuver_cmd(flat) == 10 + 2 # Dex bonus gone
clumsy = _resized(make_combatant("c"), "Medium", 10, 6) # dex -2
clumsy_flat = make_state(clumsy, "p", (0, 2))
clumsy_flat.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["flat_footed"],
rounds_left=1,
source="test",
)
)
assert engine._maneuver_cmd(clumsy_flat) == 10 + 2 - 2 # penalty kept