"""Tests for combat maneuvers, focused on the sunder result. A successful sunder damages the defender's WEAPON as an OBJECT (SRD Sunder / CRB "Smashing an Object"): the attacker's weapon damage, less the object's Hardness, is subtracted from its hit points. At half hp or less the weapon gains the broken condition (still usable at -2 attack/damage, crits only on a natural 20 at x2 - CRB "Broken"); at 0 hp or less it is destroyed and unusable. """ 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_object_combatant( cid: str, *, hardness: int, hit_points: int, attack_bonus: int = 2 ) -> Combatant: """Combatant whose sword carries authored object stats (Table 7-12 overrides) so sunder outcomes are scripted exactly.""" attack = AttackSpec( id=f"{cid}-w", name="sword", kind="melee", attack_bonus=attack_bonus, damage=[DamageComponent(formula="1d4", types=["slashing"])], hardness=hardness, hit_points=hit_points, ) base = make_combatant(cid, attack_bonus=attack_bonus) return base.model_copy(update={"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_object_stats_default_from_table_7_12() -> None: # CRB Table 7-12 defaults by weapon class (Medium, unenhanced): steel # blade 10/5 (one-handed) and 10/10 (two-handed); wooden hafted 5/5 and # 5/10; projectile weapon 5/5. Enhancement: +2 hardness and +10 hp per # +1 (footnotes 1 and 3). def _spec(**kw: object) -> AttackSpec: base: dict[str, object] = { "id": "w", "name": "sword", "kind": "melee", "attack_bonus": 0, "damage": [DamageComponent(formula="1d8", types=["slashing"])], } base.update(kw) return AttackSpec(**base) assert _spec().object_stats() == (10, 5) assert _spec(two_handed=True).object_stats() == (10, 10) assert _spec(name="waraxe").object_stats() == (5, 5) assert _spec(name="waraxe", two_handed=True).object_stats() == (5, 10) assert _spec(name="short bow", kind="ranged").object_stats() == (5, 5) assert _spec(enhancement=2).object_stats() == (14, 25) # Authored overrides replace the class default. assert _spec(hardness=0, hit_points=4).object_stats() == (0, 4) def test_sunder_damages_weapon_not_hp() -> None: # SRD Sunder / CRB "Smashing an Object": the sunder deals the attacker's # weapon damage (1d4 scripted to 4), reduced by the object's Hardness # (authored 2) -> 2 damage to the weapon's hit points, never to the # defender's HP. a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1)) b = make_state( make_object_combatant("b", hardness=2, hit_points=10), "monsters", (1, 2) ) hp_before = b.hp engine = make_engine([1, 12, 4], [a, b]) # AoO miss, CMB 12, 1d4 -> 4 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 assert "b-w" not in b.weapon_destroyed def test_sunder_hardness_absorbs_small_damage() -> None: # A plain steel blade is hardness 10 (Table 7-12): a 1d4 (max 4) sunder # cannot scratch it — 0 damage past hardness. 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, 12, 4], [a, b]) engine._execute_maneuver(a, b, "sunder") assert b.weapon_damage["b-w"] == 0 assert "b-w" not in b.weapon_broken def test_sunder_breaks_weapon_at_half_hit_points() -> None: # SRD Sunder: "If an object has equal to or less than half its total hit # points remaining, it gains the broken condition." hardness 0, 10 hp; # 1d4+3 scripted to 4+3=7 -> 3 hp left <= 5 -> broken. The broken weapon # STAYS usable (CRB "Broken"). a_spec = make_combatant("a", attack_bonus=2).model_copy( update={"attacks": [ AttackSpec( id="a-w", name="sword", kind="melee", attack_bonus=2, damage=[DamageComponent(formula="1d4", types=["slashing"])], damage_bonus=3, ) ]} ) a = make_state(a_spec, "players", (1, 1)) b = make_state( make_object_combatant("b", hardness=0, hit_points=10), "monsters", (1, 2) ) engine = make_engine([1, 12, 4], [a, b]) engine._execute_maneuver(a, b, "sunder") assert "b-w" in b.weapon_broken assert "b-w" not in b.weapon_destroyed assert [w.id for w in b.usable_attacks()] == ["b-w"] assert engine.weapon_for(b, a) is not None assert "broken" in engine._transcript[-1] def test_sunder_destroys_weapon_at_zero_hit_points() -> None: # CRB "Smashing an Object": "When an object's hit points reach 0, it's # ruined." Two sunders of 7 (hardness 0, 10 hp): broken after the first, # destroyed after the second — then unusable. a_spec = make_combatant("a", attack_bonus=2).model_copy( update={"attacks": [ AttackSpec( id="a-w", name="sword", kind="melee", attack_bonus=2, damage=[DamageComponent(formula="1d4", types=["slashing"])], damage_bonus=3, ) ]} ) a = make_state(a_spec, "players", (1, 1)) b = make_state( make_object_combatant("b", hardness=0, hit_points=10), "monsters", (1, 2) ) engine = make_engine([1, 12, 4, 12, 4], [a, b]) engine._execute_maneuver(a, b, "sunder") # first: AoO provokes (nat 1) assert "b-w" in b.weapon_broken engine._execute_maneuver(a, b, "sunder") # b's AoO allowance is spent assert "b-w" in b.weapon_destroyed assert "b-w" not in b.weapon_broken assert [w.id for w in b.usable_attacks()] == [] assert engine.weapon_for(b, a) is None assert "weapon destroyed" in engine._transcript[-1] def test_broken_weapon_still_attacks_at_minus_two() -> None: # CRB "Broken": "any attacks made with the item suffer a -2 penalty on # attack and damage rolls." b = make_state(make_combatant("b"), "monsters", (1, 2)) a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1)) engine = make_engine([10], [a, b]) result = engine.resolve_attack(a, b, a.combatant.attacks[0]) assert result.total == 10 + 2 # intact baseline b.weapon_broken.add("b-w") engine2 = make_engine([10], [a, b]) result2 = engine2.resolve_attack(b, a, b.combatant.attacks[0]) assert result2.total == 10 + 2 - 2 assert result2.hit is False # 10 vs AC 13: the -2 turns the hit into a miss def test_broken_weapon_crits_only_on_natural_twenty_at_x2() -> None: # CRB "Broken": "Such weapons only score a critical hit on a natural 20 # and only deal x2 damage on a confirmed critical hit." The sword # threatens 19-20 at x3 when intact. sharp = AttackSpec( id="c-w", name="sword", kind="melee", attack_bonus=2, damage=[DamageComponent(formula="1d4", types=["slashing"])], crit_range=19, crit_mult=3, ) c = make_state( make_combatant("c").model_copy(update={"attacks": [sharp]}), "players", (1, 1) ) d = make_state(make_combatant("d"), "monsters", (1, 2)) c.weapon_broken.add("c-w") # Natural 19: no threat at all with a broken weapon (no confirm roll). engine = make_engine([19, 4], [c, d]) result = engine.resolve_attack(c, d, sharp) assert result.crit is False # Natural 20: threatens, auto-confirms (natural 20), x2 (not x3): # (1d4=4 - 2 broken) * 2 = 4. engine = make_engine([20, 20, 4], [c, d]) result = engine.resolve_attack(c, d, sharp) assert result.crit is True assert result.damage == 4 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 # --- SRD Disarm: the target DROPS the weapon (no synthetic -4 penalty) -------- def test_disarm_success_drops_target_weapon() -> None: # SRD Disarm: "If your attack is successful, your target drops one item # it is carrying." The dropped weapon is unusable until retrieved; the # old synthetic "disarmed" -4 attack condition is gone. 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, 12], [a, b]) engine._execute_maneuver(a, b, "disarm") assert "b-w" in b.weapon_dropped assert [w.id for w in b.usable_attacks()] == [] assert engine.weapon_for(b, a) is None assert not any(c.condition.name == "disarmed" for c in b.active_conditions) assert "SUCCESS (sword dropped)" in engine._transcript[-1] def test_retrieve_weapon_picks_dropped_weapon_back_up() -> None: # Picking the dropped weapon back up is a move action (retrieve_weapon); # once restored, the weapon is usable again. 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, 12], [a, b]) engine._execute_maneuver(a, b, "disarm") assert "b-w" in b.weapon_dropped engine._retrieve_weapon(b, "b-w") assert "b-w" not in b.weapon_dropped assert [w.id for w in b.usable_attacks()] == ["b-w"] assert "retrieve b-w (restored)" in engine._transcript[-1] def test_retrieve_weapon_still_restores_stolen() -> None: # The steal path keeps working: retrieve removes the weapon from # weapon_stolen. b = make_state(make_combatant("b"), "monsters", (1, 2)) b.weapon_stolen.add("b-w") engine = make_engine([], [b]) engine._retrieve_weapon(b, "b-w") assert not b.weapon_stolen assert [w.id for w in b.usable_attacks()] == ["b-w"] def test_disarm_fail_by_10_drops_attacker_weapon() -> None: # SRD Disarm: "If your attack fails by 10 or more, you drop the weapon # that you were using to attempt the disarm." a total 2+2=4 vs CMD 14 # fails by 10 -> a drops its sword; b's weapon is untouched. 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, 2], [a, b]) engine._execute_maneuver(a, b, "disarm") assert "a-w" in a.weapon_dropped assert not b.weapon_dropped assert any("disarm backfire -> drops sword" in line for line in engine._transcript) # --- SRD Combat Maneuvers: auto-success against an incapacitated target ------- def test_maneuver_auto_succeeds_vs_helpless_target() -> None: # SRD Combat Maneuvers: "If your target is immobilized, unconscious, or # otherwise incapacitated, your maneuver automatically succeeds (treat # as if you rolled a natural 20 on the attack roll)." b (bab 20) has CMD # 30: a natural 1 would normally auto-fail, but the helpless target # forces the natural-20 treatment -> trip succeeds. a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1)) b = make_state(make_combatant("b", attack_bonus=20), "monsters", (1, 2)) _conditioned(b, "paralyzed") engine = make_engine([1, 1], [a, b]) engine._execute_maneuver(a, b, "trip") assert any(c.condition.name == "prone" for c in b.active_conditions) assert "d20=20" in engine._transcript[-1] def test_maneuver_auto_succeeds_vs_asleep_target_with_margin() -> None: # The natural-20 treatment feeds the margin outcomes: a CMB 2 vs CMD 12, # roll forced to 20 -> total 22, margin 10 -> bull rush pushes 3 cells. a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1)) b = make_state(make_combatant("b"), "monsters", (1, 2)) _conditioned(b, "asleep") engine = make_engine([1, 5], [a, b]) engine._execute_maneuver(a, b, "bull_rush") assert b.pos == (1, 5) def test_maneuver_normal_roll_still_fails_vs_awake_target() -> None: # Control: the same natural 1 against a non-helpless target auto-fails. 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, 1], [a, b]) engine._execute_maneuver(a, b, "trip") assert not any(c.condition.name == "prone" for c in b.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_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_steal_takes_one_carried_item() -> None: # APG Steal: "If your attack is successful, you may take one item from # your opponent" — one non-wielded, non-closely-worn carried item moves # from the victim's list to the thief's (the engine takes the FIRST # carried item; the RAW item choice has no action slot). The wielded # sword stays with the victim. a_spec = make_combatant("a", attack_bonus=2) b_spec = make_combatant("b").model_copy( update={"carried_items": ("potion of cure light wounds", "thieves' tools")} ) 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 b.carried_items == ["thieves' tools"] assert a.carried_items == ["potion of cure light wounds"] assert "took potion of cure light wounds" in engine._transcript[-1] assert engine.weapon_for(b, a) is not None # the sword is untouched # The observation snapshot exposes the transfer. assert engine._combatant_snapshot(a)["carried_items"] == ( "potion of cure light wounds", ) assert engine._combatant_snapshot(b)["carried_items"] == ("thieves' tools",) def test_disarmed_actor_recovers_weapon_with_a_move_action() -> None: # SRD Disarm + "Picking up an item": the dropped sword lies in the # victim's own square; retrieve_weapon (a move action) restores it and # the weapon is usable again for a real attack. 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, 12], [a, b]) engine._execute_maneuver(a, b, "disarm") assert b.weapon_dropped == {"b-w"} assert engine.weapon_for(b, a) is None engine._retrieve_weapon(b, "b-w") assert not b.weapon_dropped assert engine.weapon_for(b, a) is not None assert "retrieve b-w (restored)" in engine._transcript[-1] def test_unarmed_disarm_success_auto_picks_up_the_weapon() -> None: # SRD Disarm: "If you successfully disarm your opponent without using a # weapon, you may automatically pick up the item dropped." a is unarmed # (no melee weapon at all): the dropped sword lands in a's carried_items # (held, not wielded) with no retrieve move action spent. a_spec = make_combatant("a", attack_bonus=2).model_copy(update={"attacks": []}) 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, "disarm") assert b.weapon_dropped == {"b-w"} assert a.carried_items == ["sword"] assert "unarmed disarm pickup (sword taken)" in engine._transcript[-1] def test_armed_disarm_success_does_not_pick_up() -> None: # The automatic pickup is reserved to unarmed disarms: an armed success # leaves the weapon on the ground (victim's weapon_dropped only). 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, 12], [a, b]) engine._execute_maneuver(a, b, "disarm") assert b.weapon_dropped == {"b-w"} assert a.carried_items == [] assert engine._transcript[-1].endswith("SUCCESS (sword dropped)") 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 test_cmd_never_double_counts_tiny_dex_substituted_cmb() -> None: # Latent bug guard: a Tiny creature with a PRE-BAKED cmb (already # Dex-substituted per SRD CMB) must not get the Dexterity modifier added # on top of it. Tiny: str -1, dex +4, bab 2, size -2, cmb pre-baked to # 2 + 4 - 2 = 4. Correct CMD = 10 + 2 - 1 + 4 - 2 = 13; the buggy path # (10 + cmb + dex) would give 10 + 4 + 4 = 18. engine = make_engine([], []) tiny = make_state( _resized(make_combatant("t"), "Tiny", 8, 18).model_copy(update={"cmb": 4}), "p", (0, 0), ) assert engine._maneuver_cmd(tiny) == 13 # A Medium creature's pre-baked cmb (Str-based) keeps the old behavior: # CMD = 10 + cmb + dex = 10 + 8 + 0 = 18 (thrune_agent-like shape). medium = make_state( make_combatant("m").model_copy(update={"cmb": 8}), "p", (0, 1) ) assert engine._maneuver_cmd(medium) == 18 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_cannot_attempt_maneuvers() -> None: # SRD Conditions - Pinned: a pinned creature is tightly bound and can # only attempt to escape — the trip is refused outright (cant_attack), # so the -2 CMB never even rolls. 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 "wait (cannot attack)" in engine._transcript[-1] 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)