diff --git a/src/pf1e_simulator/conditions.py b/src/pf1e_simulator/conditions.py index ec973a8..fe41adc 100644 --- a/src/pf1e_simulator/conditions.py +++ b/src/pf1e_simulator/conditions.py @@ -266,6 +266,12 @@ GRAPPLED = Condition( # Pinned: -4 Dex, -2 attack, no movement, can't cast, -4 AC vs melee (CRB) +# SRD "Conditions—Pinned": a pinned creature is tightly bound and can only +# attempt to escape (verbal/mental actions + the escape check) — it cannot +# attack, so cant_attack applies (the engine's move-only filter then blocks +# attacks, maneuvers and casts). Documented gap: the engine never APPLIES +# pinned (no grapple-maintain loop), so this definition is exercised only +# when the condition is injected directly. PINNED = Condition( name="pinned", modifiers=( @@ -276,6 +282,7 @@ PINNED = Condition( ), cant_move=True, cant_charge=True, + cant_attack=True, cant_aoo=True, denies_dex_to_ac=True, ) diff --git a/tests/test_conditions_effects.py b/tests/test_conditions_effects.py index 73395a4..dbd1c71 100644 --- a/tests/test_conditions_effects.py +++ b/tests/test_conditions_effects.py @@ -13,6 +13,7 @@ from pf1e_simulator.conditions import ( FATIGUED, NAUSEATED, PARALYZED, + PINNED, PRONE, STAGGERED, ) @@ -326,6 +327,30 @@ class TestNauseatedActionEconomy: ) +class TestPinnedCannotAttack: + """Given: a pinned combatant (SRD "Conditions—Pinned": tightly bound, + can only attempt to escape — it cannot attack) + When: _take_turn is called + Then: no attack is resolved.""" + + def test_pinned_flag(self) -> None: + assert PINNED.cant_attack is True + + def test_pinned_no_attack(self) -> None: + hero = _make_hero(bab=6) + foe = _make_foe() + engine, h, f = _make_engine(hero, foe, [10, 10, 4]) + h.active_conditions.append( + ActiveCondition(condition=PINNED, rounds_left=5, source="test") + ) + engine._roll_initiative() + engine._take_turn(h) + for entry in engine._transcript: + assert "sword vs foe" not in entry, ( + f"pinned combatant should not attack: {entry}" + ) + + # ── Test 5: paralyzed → no actions + effective DEX 0 (-4 AC) ───────────────── diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index afeac05..50baaf1 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -698,15 +698,17 @@ def test_grappled_attacker_takes_minus2_on_trip_cmb() -> None: 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). +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 engine._transcript[-1].endswith("-> FAILED") + assert "wait (cannot attack)" in engine._transcript[-1] def test_grappled_attacker_grapple_check_is_exempt() -> None: