Files
pf1e-simulator/tests/test_maneuvers.py
T
ctan 068b49794e fix(rules): stop steal from taking held weapons
PF1e (APG Steal): "Items held in the hands (such as wielded weapons or
wands) also cannot be taken with the steal maneuver—you must use the disarm
combat maneuver instead." The model carries no non-held items, so a
successful steal now takes nothing and logs
"nothing stealable — wielded weapons require disarm". README updated; the
retrieve_weapon tests keep locking the restoration mechanism directly.

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

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

546 lines
22 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 _grappled_by(a: CombatantState, b: CombatantState) -> None:
a.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["grappled"],
rounds_left=1,
source="test",
)
)
a.grappled_with = b.combatant.id
def test_escape_natural_20_auto_succeeds_below_cmd() -> None:
# SRD Combat Maneuvers: "Rolling a natural 20 while attempting a combat
# maneuver is always a success (except when attempting to escape from
# bonds)" — a grapple is not bonds. Escape bonus 2, b CMD 30 (bab 20):
# 20+2=22 < 30, so only the natural-20 rule can succeed this.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b", attack_bonus=20), "monsters", (1, 2))
_grappled_by(a, b)
engine = make_engine([20], [a, b])
engine._maneuver_escape(a, b)
assert a.grappled_with is None
assert not any(c.condition.name == "grappled" for c in a.active_conditions)
def test_escape_natural_1_still_fails_above_cmd() -> None:
# Natural 1 stays an automatic failure even when the total clears CMD:
# escape_artist rank 20 (+3 class) gives bonus 23 vs CMD 12.
a_spec = make_combatant("a", attack_bonus=2).model_copy(
update={"skills": {"escape_artist": SkillRank(rank=20, class_skill=True)}}
)
a = make_state(a_spec, "players", (1, 1))
b = make_state(make_combatant("b"), "monsters", (1, 2))
_grappled_by(a, b)
engine = make_engine([1], [a, b])
engine._maneuver_escape(a, b)
assert a.grappled_with == b.combatant.id
assert any(c.condition.name == "grappled" for c in a.active_conditions)
def test_bull_rush_push_scales_with_margin() -> None:
# SRD Bull Rush: "For every 5 by which your attack exceeds your
# opponent's CMD you can push the target back an additional 5 feet."
# d20=20+2=22 vs CMD 12: margin 10 -> push 1 + 10//5 = 3 cells.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b"), "monsters", (1, 2))
engine = make_engine([1, 20], [a, b])
engine._execute_maneuver(a, b, "bull_rush")
assert b.pos == (1, 5)
def test_bull_rush_push_stops_at_obstacle() -> None:
# Same margin 10 (3 cells) but a creature blocks (1, 4): the push moves
# b two cells and stops.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b"), "monsters", (1, 2))
c = make_state(make_combatant("c"), "monsters", (1, 4))
engine = make_engine([1, 20], [a, b, c])
engine._execute_maneuver(a, b, "bull_rush")
assert b.pos == (1, 3)
def test_bull_rush_natural_20_below_cmd_pushes_one_cell() -> None:
# Natural 20 auto-succeeds even below CMD (bab 20 foe: CMD 30, total 22):
# the margin floors at 0, so only the base 5 ft push applies.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b", attack_bonus=20), "monsters", (1, 2))
engine = make_engine([1, 20], [a, b])
engine._execute_maneuver(a, b, "bull_rush")
assert b.pos == (1, 3)
def test_trip_fails_by_10_knocks_attacker_prone() -> None:
# SRD Trip: "If your attack fails by 10 or more, you are knocked prone
# instead." b CMD 30 (bab 20); a total 10+2=12 fails by 18.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b", attack_bonus=20), "monsters", (1, 2))
engine = make_engine([1, 10], [a, b])
engine._execute_maneuver(a, b, "trip")
assert not any(c.condition.name == "prone" for c in b.active_conditions)
prone = next(c for c in a.active_conditions if c.condition.name == "prone")
assert prone.source == "maneuver: trip retaliation"
def test_trip_fails_by_less_than_10_attacker_stands() -> None:
# a total 19+2=21 vs CMD 30 fails by 9 — no retaliation.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b", attack_bonus=20), "monsters", (1, 2))
engine = make_engine([1, 19], [a, b])
engine._execute_maneuver(a, b, "trip")
assert not any(c.condition.name == "prone" for c in a.active_conditions)
def test_overrun_fail_stops_in_front_of_target() -> None:
# SRD Overrun: "If your overrun attempt fails, you stop in the space
# directly in front of the opponent." a at (1,0), b at (1,3): the failed
# attempt parks a on (1,2), the cell in front of b. a is not threatened
# from (1,0), so no AoO roll is consumed: the single d20 is the maneuver.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 0))
b = make_state(make_combatant("b"), "monsters", (1, 3))
engine = make_engine([5], [a, b])
engine._execute_maneuver(a, b, "overrun")
assert a.pos == (1, 2)
def test_overrun_fail_nearest_open_when_front_occupied() -> None:
# "...or the nearest open space in front of the creature if there are
# other creatures occupying that space." Blocker on (1,2): a stops (1,1).
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 0))
b = make_state(make_combatant("b"), "monsters", (1, 3))
c = make_state(make_combatant("c"), "monsters", (1, 2))
engine = make_engine([1, 5], [a, b, c])
engine._execute_maneuver(a, b, "overrun")
assert a.pos == (1, 1)
def test_overrun_fail_adjacent_attacker_does_not_move() -> None:
# An already-adjacent attacker is in front of the target: it stays put.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b"), "monsters", (1, 2))
engine = make_engine([1, 5], [a, b])
engine._execute_maneuver(a, b, "overrun")
assert a.pos == (1, 1)
assert not any("overrun blocked" in line for line in engine._transcript)
def test_maneuver_rejects_target_two_sizes_larger() -> None:
# SRD Trip/Bull Rush/Overrun + APG Reposition/Drag: "You can only
# [affect/reposition/drag] an opponent who is no more than one size
# category larger than you." Medium vs Huge: the attempt is illegal — no
# d20 consumed, no effect, logged rejection.
for maneuver in ("trip", "bull_rush", "overrun", "reposition", "drag"):
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
huge = make_state(_resized(make_combatant("h"), "Huge", 10, 10), "monsters", (1, 2))
engine = make_engine([1, 12], [a, huge])
engine._execute_maneuver(a, huge, maneuver)
assert len(engine._rng._queue) == 2
assert not any(c.condition.name == "prone" for c in huge.active_conditions)
assert huge.pos == (1, 2) # reposition/drag must not move the target
assert a.pos == (1, 1) # drag must not displace the attacker either
assert f"{maneuver} vs h rejected (target too large)" in engine._transcript[-1]
def test_maneuver_allows_target_one_size_larger() -> None:
# Medium vs Large (one category) stays legal; the roll happens.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
large = make_state(_resized(make_combatant("l"), "Large", 10, 10), "monsters", (1, 2))
engine = make_engine([1, 12], [a, large])
engine._execute_maneuver(a, large, "trip")
assert not engine._rng._queue
assert any(c.condition.name == "prone" for c in large.active_conditions)
def test_unrestricted_maneuver_ignores_size_limit() -> None:
# The size cap is trip/bull_rush/overrun only: disarm works on Huge.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
huge = make_state(_resized(make_combatant("h"), "Huge", 10, 10), "monsters", (1, 2))
engine = make_engine([1, 12], [a, huge])
engine._execute_maneuver(a, huge, "disarm")
assert not engine._rng._queue
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_refuses_wielded_weapon() -> None:
# APG Steal: "Items held in the hands (such as wielded weapons or wands)
# also cannot be taken with the steal maneuver—you must use the disarm
# combat maneuver instead." The target's sword is wielded: the success
# steals nothing.
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, "steal")
assert not b.weapon_stolen
assert engine.weapon_for(b, a) is not None # the sword stays usable
assert "nothing stealable — wielded weapons require disarm" in engine._transcript[-1]
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 stealable" 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 test_tiny_or_smaller_cmd_uses_strength_and_dexterity() -> None:
# SRD Combat Maneuvers: "CMD = 10 + Base attack bonus + Strength modifier
# + Dexterity modifier + special size modifier" — the Tiny-or-smaller
# Dex-for-Str substitution is CMB-only. Tiny: str -1, dex +4, bab 2,
# size -2 -> 10 + 2 - 1 + 4 - 2 = 13 (not 10 + 2 + 4 - 2 + 4 = 18).
engine = make_engine([], [])
for size in ("Tiny", "Diminutive", "Fine"):
tiny = make_state(_resized(make_combatant("t"), size, 8, 18), "p", (0, 0))
expected = 10 + 2 - 1 + 4 + _SIZE_MODIFIERS[size.lower()]
assert engine._maneuver_cmd(tiny) == expected
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