98b61c6cf8
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>
369 lines
13 KiB
Python
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
|