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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-12 08:58:11 +02:00
parent c96579a485
commit faa8a72a86
3 changed files with 52 additions and 4 deletions
+5 -1
View File
@@ -1002,7 +1002,11 @@ Règles modélisées :
cible (direction sélectionnable via `Action.direction`) ; cible (direction sélectionnable via `Action.direction`) ;
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative, 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` 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`) → 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 `disarmed` ; une arme brisée est exclue de `usable_attacks()` et réparable via
`Action(kind="repair_weapon")`. overrun → `Action(kind="repair_weapon")`. overrun →
+5 -1
View File
@@ -4384,7 +4384,11 @@ class CombatEngine:
target_cmd = self._maneuver_cmd(target) target_cmd = self._maneuver_cmd(target)
roll = self._rng.d20() roll = self._rng.d20()
total = roll + escape_bonus 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: if success:
target.active_conditions = [ target.active_conditions = [
ac for ac in target.active_conditions if ac.condition.name != "grappled" ac for ac in target.active_conditions if ac.condition.name != "grappled"
+40
View File
@@ -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) 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: def test_overrun_knocks_prone_and_moves_through() -> None:
# d20=15+2=17 vs CMD 12: margin 5 -> prone AND moved through (CRB # 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). # "Overrun": prone only when the attack exceeds CMD by 5 or more).