From faa8a72a86bd039bad9e4b53d738eaefd9c628bb Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 12 Sep 2026 08:58:11 +0200 Subject: [PATCH] fix(rules): let a natural 20 auto-succeed on escape PF1e (SRD Combat Maneuvers): "Rolling a natural 20 while attempting a combat maneuver is always a success (except when attempting to escape from bonds)" - escaping a grapple is a combat maneuver, not bonds. _maneuver_escape now auto-succeeds on a natural 20 below CMD (the natural 1 auto-failure is unchanged). README updated. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 10 ++++++--- src/pf1e_simulator/combat.py | 6 +++++- tests/test_maneuvers.py | 40 ++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2b86255..a85711c 100644 --- a/README.md +++ b/README.md @@ -1000,9 +1000,13 @@ Règles modélisées : Dextérité, qui n'est pas un bonus, reste appliquée). trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la cible (direction sélectionnable via `Action.direction`) ; - grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la - compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative, - sans boucle multi-tour. Un sunder endommage l'**arme**, pas les PV : `str_mod + 2` + grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la + compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative, + sans boucle multi-tour. Conformance L4 (SRD « Combat Maneuvers ») : le 20 + naturel réussit **toujours** à l'escape d'un grapple (« Rolling a natural + 20 while attempting a combat maneuver is always a success (except when + attempting to escape from bonds) » — un grapple n'est pas un lien), le 1 + naturel échoue toujours. Un sunder endommage l'**arme**, pas les PV : `str_mod + 2` de dégâts à l'arme, qui se brise à 10 d'usure cumulée (`_WEAPON_DURABILITY`) → `disarmed` ; une arme brisée est exclue de `usable_attacks()` et réparable via `Action(kind="repair_weapon")`. overrun → diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 4c9c7f7..824de3a 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -4384,7 +4384,11 @@ class CombatEngine: target_cmd = self._maneuver_cmd(target) roll = self._rng.d20() total = roll + escape_bonus - success = roll != 1 and total >= target_cmd + # PF1e (SRD Combat Maneuvers): "Rolling a natural 20 while attempting + # a combat maneuver is always a success (except when attempting to + # escape from bonds)" — escaping a grapple is a combat maneuver, not + # bonds, so the natural 20 auto-succeeds here too. + success = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= target_cmd) if success: target.active_conditions = [ ac for ac in target.active_conditions if ac.condition.name != "grappled" diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index eceefa4..881c7b5 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -122,6 +122,46 @@ def test_escape_from_grapple_consumes_single_d20() -> 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_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).