"""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 ActiveCondition 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