734 lines
30 KiB
Python
734 lines
30 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_distance_scales_with_margin() -> None:
|
|
# APG Reposition: "For every 5 by which your attack exceeds your
|
|
# opponent's CMD, you can move the target an additional 5 feet."
|
|
# a CMB 2 vs b CMD 12: d20 17 -> total 19, margin 7 -> 2 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, 17], [a, b]) # leading 1: AoO auto-miss
|
|
engine._execute_maneuver(a, b, "reposition", direction=(0, 1))
|
|
assert b.pos == (1, 4)
|
|
|
|
|
|
def test_reposition_stops_at_obstacle() -> None:
|
|
# The extra cell is blocked by c: the walk stops on (1, 3), the last
|
|
# free square ("You cannot move a creature into a square that is
|
|
# occupied by a solid object or obstacle").
|
|
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, 17], [a, b, c])
|
|
engine._execute_maneuver(a, b, "reposition", direction=(0, 1))
|
|
assert b.pos == (1, 3)
|
|
assert c.pos == (1, 4)
|
|
|
|
|
|
def test_drag_distance_scales_with_margin() -> None:
|
|
# APG Drag: "For every 5 by which your attack exceeds your opponent's
|
|
# CMD, you can drag the target back an additional 5 feet." Margin 7 ->
|
|
# two 5-ft swaps along the straight line: b (1,2)->(1,4), a (1,1)->(1,3).
|
|
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, 17], [a, b])
|
|
engine._execute_maneuver(a, b, "drag")
|
|
assert b.pos == (1, 4)
|
|
assert a.pos == (1, 3)
|
|
|
|
|
|
def test_drag_stops_at_obstacle() -> None:
|
|
# Second drag cell (1, 4) is occupied: the drag ends after the first
|
|
# 5 ft ("If there is another creature in the way of your movement, the
|
|
# drag ends adjacent to that creature").
|
|
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, 17], [a, b, c])
|
|
engine._execute_maneuver(a, b, "drag")
|
|
assert b.pos == (1, 3)
|
|
assert a.pos == (1, 2)
|
|
|
|
|
|
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_keeps_penalties() -> None:
|
|
# Enhancement bonuses are NOT among the eight SRD-listed types — ignored.
|
|
# But "Any penalties to a creature's AC also apply to its CMD" (SRD
|
|
# Combat Maneuvers): the untyped -4 penalty rides the CMD.
|
|
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 - 4
|
|
|
|
|
|
def test_cmd_includes_prone_and_grappled_penalties() -> None:
|
|
# A maneuver is a melee attack roll, so the prone -4 melee AC penalty and
|
|
# the grappled -2 AC penalty both apply to CMD (SRD: "Any penalties to a
|
|
# creature's AC also apply to its CMD").
|
|
engine = make_engine([], [])
|
|
prone = make_state(make_combatant("p"), "p", (0, 0))
|
|
prone.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["prone"], rounds_left=1, source="test"
|
|
)
|
|
)
|
|
assert engine._maneuver_cmd(prone) == 12 - 4
|
|
grappled = make_state(make_combatant("g"), "p", (0, 1))
|
|
grappled.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME["grappled"], rounds_left=1, source="test"
|
|
)
|
|
)
|
|
assert engine._maneuver_cmd(grappled) == 12 - 2
|
|
|
|
|
|
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
|
|
|
|
|
|
# --- L8: grapple requires adjacency / pulls the target -----------------------
|
|
|
|
|
|
def test_grapple_from_range_pulls_target_adjacent() -> None:
|
|
# SRD "Grapple": "If you successfully grapple a creature that is not
|
|
# adjacent to you, move that creature to an adjacent open space".
|
|
# Given: a ranged-successful grappler (CMB 20 vs CMD 12) and a target
|
|
# two squares away (no provoked AoO — it does not threaten)
|
|
# When: the grapple succeeds
|
|
# Then: the target is pulled to the nearest open adjacent cell and both
|
|
# gain the grappled condition.
|
|
a = make_state(make_combatant("a", attack_bonus=20), "players", (1, 1))
|
|
b = make_state(make_combatant("b"), "monsters", (1, 3))
|
|
engine = make_engine([5], [a, b])
|
|
engine._execute_maneuver(a, b, "grapple")
|
|
assert b.pos == (0, 2) # nearest open adjacent cell (row,col tie-break)
|
|
assert b.grappled_with == "a"
|
|
assert a.grappled_with == "b"
|
|
assert any("grapple pulls b (1,3)->(0,2)" in line for line in engine._transcript)
|
|
assert any("SUCCESS (grappled)" in line for line in engine._transcript)
|
|
|
|
|
|
def test_grapple_from_range_fails_without_open_adjacent_space() -> None:
|
|
# SRD "Grapple": "(if no space is available, your grapple fails)".
|
|
# Given: grappler in the grid corner, every adjacent cell occupied
|
|
# When: the CMB check succeeds anyway
|
|
# Then: the target is NOT pulled, NOT grappled, and the attempt is logged
|
|
# as failed for lack of space.
|
|
a = make_state(make_combatant("a", attack_bonus=20), "players", (0, 0))
|
|
b = make_state(make_combatant("b"), "monsters", (0, 3))
|
|
blockers = [
|
|
make_state(make_combatant(f"blk{i}"), "monsters", pos)
|
|
for i, pos in enumerate([(0, 1), (1, 0), (1, 1)])
|
|
]
|
|
engine = make_engine([5], [a, b, *blockers])
|
|
engine._execute_maneuver(a, b, "grapple")
|
|
assert b.pos == (0, 3)
|
|
assert b.grappled_with is None
|
|
assert a.grappled_with is None
|
|
assert not any("grappled" in c.condition.name for c in b.active_conditions)
|
|
assert any("FAILED (no adjacent space)" in line for line in engine._transcript)
|
|
|
|
|
|
def test_adjacent_grapple_does_not_move_the_target() -> None:
|
|
# Given: an already-adjacent target (it threatens — the maneuver provokes
|
|
# an AoO, scripted as a natural 1 miss)
|
|
# When: the grapple succeeds
|
|
# Then: no pull happens; positions are untouched.
|
|
a = make_state(make_combatant("a", attack_bonus=20), "players", (1, 1))
|
|
b = make_state(make_combatant("b"), "monsters", (1, 2))
|
|
engine = make_engine([1, 5], [a, b])
|
|
engine._execute_maneuver(a, b, "grapple")
|
|
assert b.pos == (1, 2)
|
|
assert b.grappled_with == "a"
|
|
assert not any("grapple pulls" in line for line in engine._transcript)
|
|
|
|
|
|
# --- Grappled / pinned -2 on combat maneuver checks --------------------------
|
|
|
|
|
|
def _conditioned(a: CombatantState, name: str) -> None:
|
|
a.active_conditions.append(
|
|
ActiveCondition(
|
|
condition=CONDITIONS_BY_NAME[name], rounds_left=1, source="test"
|
|
)
|
|
)
|
|
|
|
|
|
def test_grappled_attacker_takes_minus2_on_trip_cmb() -> None:
|
|
# SRD Conditions - Grappled: "-2 penalty on all attack rolls and combat
|
|
# maneuver checks, except for those made to escape or break a grapple."
|
|
# a CMB 2 - 2 = 0 vs b CMD 12: d20 10 -> total 10 < 12 -> FAILED (the
|
|
# pre-fix engine would have succeeded at 10 + 2 = 12).
|
|
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
|
|
b = make_state(make_combatant("b"), "monsters", (1, 2))
|
|
_conditioned(a, "grappled")
|
|
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)
|
|
assert engine._transcript[-1].endswith("-> FAILED")
|
|
|
|
|
|
def test_pinned_attacker_takes_minus2_on_trip_cmb() -> None:
|
|
# Same -2 applies to a pinned creature (SRD Conditions - Pinned).
|
|
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
|
|
b = make_state(make_combatant("b"), "monsters", (1, 2))
|
|
_conditioned(a, "pinned")
|
|
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)
|
|
assert engine._transcript[-1].endswith("-> FAILED")
|
|
|
|
|
|
def test_grappled_attacker_grapple_check_is_exempt() -> None:
|
|
# The -2 does NOT apply to grapple checks (escape or break a grapple):
|
|
# d20 10 + CMB 2 = 12 >= CMD 12 -> SUCCESS.
|
|
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
|
|
b = make_state(make_combatant("b"), "monsters", (1, 2))
|
|
_conditioned(a, "grappled")
|
|
engine = make_engine([1, 10], [a, b])
|
|
engine._execute_maneuver(a, b, "grapple")
|
|
assert b.grappled_with == "a"
|
|
assert engine._transcript[-1].endswith("SUCCESS (grappled)")
|
|
|
|
|
|
def test_grappled_escape_check_is_exempt() -> None:
|
|
# Escape is handled by _maneuver_escape which never applies the penalty:
|
|
# escape bonus = CMB 2 (no -2), d20 10 -> total 12 >= CMD 12 -> freed.
|
|
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
|
|
b = make_state(make_combatant("b"), "monsters", (1, 2))
|
|
_grappled_by(a, b)
|
|
engine = make_engine([10], [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)
|