fix(rules): apply maneuver failure consequences

PF1e (SRD Trip): "If your attack fails by 10 or more, you are knocked prone instead" - a trip whose total lands at CMD-10 or below now knocks the attacker prone (1 round). PF1e (SRD Overrun): "If your overrun attempt fails, you stop in the space directly in front of the opponent, or the nearest open space in front of the creature" - a failed overrun from beyond adjacency walks the attacker to the free approach cell nearest the target; an already-adjacent attacker stays put. 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 09:34:57 +02:00
parent 346e300eab
commit 1b60cd049b
3 changed files with 106 additions and 1 deletions
+7 -1
View File
@@ -1027,7 +1027,13 @@ Règles modélisées :
**seulement si le jet dépasse le CMD de 5 ou plus** (conformance R3 — CRB **seulement si le jet dépasse le CMD de 5 ou plus** (conformance R3 — CRB
« Overrun » : « If your attack exceeds your opponent's CMD by 5 or more, « Overrun » : « If your attack exceeds your opponent's CMD by 5 or more,
you move through the target's space and the target is knocked prone ») ; you move through the target's space and the target is knocked prone ») ;
un succès par 0–4 points ne fait que traverser, sans renverser ; dirty trick → un succès par 0–4 points ne fait que traverser, sans renverser ; conformance
L7 (SRD « Overrun ») : une tentative **échouée** arrête l'attaquant « in the
space directly in front of the opponent, or the nearest open space in front
of the creature » — l'attaquant non adjacent est déplacé sur la case libre
la plus proche de la cible sur la ligne d'approche (adjacent : immobile) ;
conformance L7 (SRD « Trip ») : un trip qui « fails by 10 or more » renverse
l'**attaquant** (`prone` 1 round, `total <= CMD - 10`) ; dirty trick →
condition choisie par `Action.trick_condition` (défaut `dazzled` 1 round ; condition choisie par `Action.trick_condition` (défaut `dazzled` 1 round ;
`blinded`/`deafened`/`entangled`/`nauseated` possibles) ; steal → l'arme de `blinded`/`deafened`/`entangled`/`nauseated` possibles) ; steal → l'arme de
mêlée de la cible est volée (exclue de `usable_attacks()`, récupérable via mêlée de la cible est volée (exclue de `usable_attacks()`, récupérable via
+45
View File
@@ -261,6 +261,7 @@ _AID_ANOTHER_BONUS = 2 # PF1e: a successful aid grants +2 (next attack or AC)
_RUN_SPEED_FACTOR = 4 # PF1e: running is a full-round action at 4x speed _RUN_SPEED_FACTOR = 4 # PF1e: running is a full-round action at 4x speed
_OVERRUN_PRONE_MARGIN = 5 # PF1e: overrun knocks prone only on a 5+ CMB margin _OVERRUN_PRONE_MARGIN = 5 # PF1e: overrun knocks prone only on a 5+ CMB margin
_BULL_RUSH_EXTRA_PER_MARGIN = 5 # PF1e: +5 ft push per 5 points above CMD _BULL_RUSH_EXTRA_PER_MARGIN = 5 # PF1e: +5 ft push per 5 points above CMD
_TRIP_RETALIATION_MARGIN = 10 # PF1e: trip failing by 10+ knocks the attacker prone
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({ _SAVE_DESCRIPTOR_CONDITIONS = frozenset({
"fear", "cold", "electricity", "fey", "divination", "poison", "disease", "fear", "cold", "electricity", "fey", "divination", "poison", "disease",
"mind_affecting", "mind_affecting",
@@ -4119,6 +4120,20 @@ class CombatEngine:
) -> None: ) -> None:
if not m.success: if not m.success:
self._maneuver_fail(state, target, "trip", m) self._maneuver_fail(state, target, "trip", m)
# PF1e (SRD Trip): "If your attack fails by 10 or more, you are
# knocked prone instead."
if m.total <= m.target_cmd - _TRIP_RETALIATION_MARGIN:
state.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["prone"],
rounds_left=1,
source="maneuver: trip retaliation",
)
)
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"trip backfire -> attacker prone"
)
return return
target.active_conditions.append( target.active_conditions.append(
ActiveCondition( ActiveCondition(
@@ -4264,6 +4279,7 @@ class CombatEngine:
) -> None: ) -> None:
if not m.success: if not m.success:
self._maneuver_fail(state, target, "overrun", m) self._maneuver_fail(state, target, "overrun", m)
self._overrun_fail_stop(state, target)
return return
# PF1e (CRB "Overrun"): a successful maneuver moves you through the # PF1e (CRB "Overrun"): a successful maneuver moves you through the
# target's square; the target is knocked prone only if the attack # target's square; the target is knocked prone only if the attack
@@ -4293,6 +4309,35 @@ class CombatEngine:
f"CMD {m.target_cmd} -> {outcome}" f"CMD {m.target_cmd} -> {outcome}"
) )
def _overrun_fail_stop(
self, state: CombatantState, target: CombatantState
) -> None:
"""Park a failed overrun on the open approach cell in front of the target.
PF1e (SRD Overrun): "If your overrun attempt fails, you stop in the
space directly in front of the opponent, or the nearest open space
in front of the creature if there are other creatures occupying that
space." An already-adjacent attacker is in front: no move, no log.
"""
ar, ac = state.pos
tr, tc = target.pos
steps = max(abs(tr - ar), abs(tc - ac))
if steps <= 1:
return
dr = 1 if tr - ar > 0 else (-1 if tr - ar < 0 else 0)
dc = 1 if tc - ac > 0 else (-1 if tc - ac < 0 else 0)
for i in range(steps - 1, 0, -1):
cell = (ar + dr * i, ac + dc * i)
if self._cells_free(self._footprint_at(cell, state), state):
start = state.pos
state.pos = cell
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"overrun blocked, stopped ({start[0]},{start[1]})->"
f"({cell[0]},{cell[1]})"
)
return
def _maneuver_dirty_trick( def _maneuver_dirty_trick(
self, self,
state: CombatantState, state: CombatantState,
+54
View File
@@ -194,6 +194,60 @@ def test_bull_rush_natural_20_below_cmd_pushes_one_cell() -> None:
assert b.pos == (1, 3) assert b.pos == (1, 3)
def test_trip_fails_by_10_knocks_attacker_prone() -> None:
# SRD Trip: "If your attack fails by 10 or more, you are knocked prone
# instead." b CMD 30 (bab 20); a total 10+2=12 fails by 18.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b", attack_bonus=20), "monsters", (1, 2))
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)
prone = next(c for c in a.active_conditions if c.condition.name == "prone")
assert prone.source == "maneuver: trip retaliation"
def test_trip_fails_by_less_than_10_attacker_stands() -> None:
# a total 19+2=21 vs CMD 30 fails by 9 — no retaliation.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b", attack_bonus=20), "monsters", (1, 2))
engine = make_engine([1, 19], [a, b])
engine._execute_maneuver(a, b, "trip")
assert not any(c.condition.name == "prone" for c in a.active_conditions)
def test_overrun_fail_stops_in_front_of_target() -> None:
# SRD Overrun: "If your overrun attempt fails, you stop in the space
# directly in front of the opponent." a at (1,0), b at (1,3): the failed
# attempt parks a on (1,2), the cell in front of b. a is not threatened
# from (1,0), so no AoO roll is consumed: the single d20 is the maneuver.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 0))
b = make_state(make_combatant("b"), "monsters", (1, 3))
engine = make_engine([5], [a, b])
engine._execute_maneuver(a, b, "overrun")
assert a.pos == (1, 2)
def test_overrun_fail_nearest_open_when_front_occupied() -> None:
# "...or the nearest open space in front of the creature if there are
# other creatures occupying that space." Blocker on (1,2): a stops (1,1).
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 0))
b = make_state(make_combatant("b"), "monsters", (1, 3))
c = make_state(make_combatant("c"), "monsters", (1, 2))
engine = make_engine([1, 5], [a, b, c])
engine._execute_maneuver(a, b, "overrun")
assert a.pos == (1, 1)
def test_overrun_fail_adjacent_attacker_does_not_move() -> None:
# An already-adjacent attacker is in front of the target: it stays put.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b"), "monsters", (1, 2))
engine = make_engine([1, 5], [a, b])
engine._execute_maneuver(a, b, "overrun")
assert a.pos == (1, 1)
assert not any("overrun blocked" in line for line in engine._transcript)
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).