"""Tests for the ``trick_condition`` field on the dirty trick maneuver. L6 of the ``llm-policy-contract`` plan: ``Action`` gains a ``trick_condition`` field that lets a policy choose which condition a successful dirty trick applies (blinded, dazzled, deafened, entangled) instead of always applying ``dazzled``. APG Dirty Trick limits the penalty to those conditions — nauseated is NOT on the RAW list and is not a valid choice. The condition lasts 1 round plus 1 more per 5 by which the attack exceeds the target's CMD. When ``trick_condition`` is ``None`` the previous behavior (``dazzled``) is kept bit-identical. """ from __future__ import annotations from pf1e_simulator.combat import Action, CombatantState, CombatEngine from pf1e_simulator.grid import Grid from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.models import ( AbilityScores, ACProfile, AttackSpec, Combatant, DamageComponent, Saves, ) from pf1e_simulator.rng import ScriptedRng, SeededRng def make_combatant(cid: str, *, attack_bonus: int = 2) -> Combatant: attack = AttackSpec( id=f"{cid}-w", name="sword", 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): the scripted queues carry a leading natural 1 # so the provoking attack always misses. return CombatEngine(ScriptedRng(queue), make_grid(), states) def _condition_names(state: CombatantState) -> list[str]: return [ac.condition.name for ac in state.active_conditions] # --- Test 1: explicit trick_condition applies that condition ----------------- def test_dirty_trick_blinded() -> None: a_spec = make_combatant("a") 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", trick_condition="blinded") assert _condition_names(b) == ["blinded"] # --- Test 2: trick_condition=None keeps historical dazzled ------------------- def test_dirty_trick_default_dazzled() -> None: a_spec = make_combatant("a") 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") assert _condition_names(b) == ["dazzled"] # --- Test 3: each other valid condition -------------------------------------- def test_dirty_trick_deafened() -> None: a_spec = make_combatant("a") 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", trick_condition="deafened") assert _condition_names(b) == ["deafened"] def test_dirty_trick_entangled() -> None: a_spec = make_combatant("a") 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", trick_condition="entangled") assert _condition_names(b) == ["entangled"] def test_dirty_trick_duration_scales_with_margin() -> None: # APG Dirty Trick: "This condition lasts for 1 round. For every 5 by # which your attack exceeds your opponent's CMD, the penalty lasts 1 # additional round." b CMD 12, a CMB 2: d20 17 -> total 19, margin 7 # -> 2 rounds; d20 20 -> total 22, margin 10 -> 3 rounds. for roll, expected_rounds in ((17, 2), (20, 3)): a = make_state(make_combatant("a"), "players", (1, 1)) b = make_state(make_combatant("b"), "monsters", (1, 2)) engine = make_engine([1, roll], [a, b]) # leading 1: AoO auto-miss engine._execute_maneuver(a, b, "dirty_trick") assert b.active_conditions[0].rounds_left == expected_rounds assert f"dazzled {expected_rounds}r" in engine._transcript[-1] def test_dirty_trick_small_margin_stays_one_round() -> None: # Margin 0-4 (d20 10-14 -> total 12-16 vs CMD 12): exactly 1 round. a = make_state(make_combatant("a"), "players", (1, 1)) b = make_state(make_combatant("b"), "monsters", (1, 2)) engine = make_engine([1, 13], [a, b]) engine._execute_maneuver(a, b, "dirty_trick") assert b.active_conditions[0].rounds_left == 1 # --- Test 4: failed maneuver applies no condition ---------------------------- def test_dirty_trick_failure_no_condition() -> None: a_spec = make_combatant("a") 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]) # AoO miss, then roll 1 -> automatic failure engine._execute_maneuver(a, b, "dirty_trick", trick_condition="blinded") assert _condition_names(b) == [] # --- Test 5: determinism ----------------------------------------------------- def _run_with_trick_condition(trick_condition: str | None) -> tuple[str, ...]: a_spec = make_combatant("a", attack_bonus=5) b_spec = make_combatant("b") a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: if state.combatant.id == "a": return ( Action( kind="maneuver", target_id="b", maneuver="dirty_trick", trick_condition=trick_condition, ), ) return (Action(kind="wait"),) engine = CombatEngine(SeededRng(42), make_grid(), [a, b], policy=policy) return engine.run().transcript def test_determinism_same_seed_same_transcript() -> None: assert _run_with_trick_condition("blinded") == _run_with_trick_condition("blinded") assert _run_with_trick_condition(None) == _run_with_trick_condition(None) def test_trick_condition_changes_transcript() -> None: assert _run_with_trick_condition("blinded") != _run_with_trick_condition(None)