From 5353018c80c31edcad720b39fdf38cd960f761c2 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:55:59 +0200 Subject: [PATCH] fix(combat): single roll to escape grapple --- src/pf1e_simulator/combat.py | 20 ++++++++++++-------- tests/test_maneuvers.py | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 8cd7f14..66fc9f4 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -703,15 +703,22 @@ class CombatEngine: def resolve_skill( self, state: CombatantState, skill: str, dc: int ) -> SaveResult: - """Roll a skill check against ``dc``. + """Roll a skill check against ``dc``.""" - """ + roll = self._rng.d20() + bonus = self._skill_static_bonus(state, skill) + total = roll + bonus + # Natural 1/20 are not auto-fail/success for skills + success = total >= dc + return SaveResult(success=success, roll=roll, total=total, dc=dc) + + def _skill_static_bonus(self, state: CombatantState, skill: str) -> int: + """Return a skill's static bonus (no d20). Shared by resolve_skill.""" skill_spec = PREDEFINED_SKILLS.get(skill) if skill_spec is None: msg = f"Unknown skill: {skill}" raise ValueError(msg) - roll = self._rng.d20() skill_rank = state.combatant.skills.get(skill, None) rank = skill_rank.rank if skill_rank else 0 # Class skill bonus @@ -728,10 +735,7 @@ class CombatEngine: ability_mod = (ability_score - 10) // 2 # Effect/condition modifiers mod_bonus = resolve_modifiers(self._stat_modifiers(state, skill)) - total = roll + rank + class_bonus + ability_mod + armor_penalty + mod_bonus - # Natural 1/20 are not auto-fail/success for skills - success = total >= dc - return SaveResult(success=success, roll=roll, total=total, dc=dc) + return rank + class_bonus + ability_mod + armor_penalty + mod_bonus def _roll_initiative(self) -> list[CombatantState]: rolls: dict[str, tuple[int, int]] = {} @@ -1304,7 +1308,7 @@ class CombatEngine: ) -> None: escape_cmb = self._maneuver_cmb(state) escape_skill = ( - self.resolve_skill(state, "escape_artist", 0).total + self._skill_static_bonus(state, "escape_artist") if hasattr(state.combatant, "skills") and "escape_artist" in state.combatant.skills else 0 ) diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index ed47b4b..01efaec 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -8,6 +8,8 @@ it (effectively disarmed). See the plan task-12. from __future__ import annotations from pf1e_simulator.combat import 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 ( @@ -17,6 +19,7 @@ from pf1e_simulator.models import ( Combatant, DamageComponent, Saves, + SkillRank, ) from pf1e_simulator.rng import ScriptedRng @@ -91,3 +94,25 @@ def test_sunder_destroys_weapon_and_disarms() -> None: 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)