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:
@@ -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 →
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user