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
+54
View File
@@ -194,6 +194,60 @@ def test_bull_rush_natural_20_below_cmd_pushes_one_cell() -> None:
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:
# 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).