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:
@@ -1027,7 +1027,13 @@ Règles modélisées :
|
||||
**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,
|
||||
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 ;
|
||||
`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
|
||||
|
||||
@@ -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
|
||||
_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
|
||||
_TRIP_RETALIATION_MARGIN = 10 # PF1e: trip failing by 10+ knocks the attacker prone
|
||||
_SAVE_DESCRIPTOR_CONDITIONS = frozenset({
|
||||
"fear", "cold", "electricity", "fey", "divination", "poison", "disease",
|
||||
"mind_affecting",
|
||||
@@ -4119,6 +4120,20 @@ class CombatEngine:
|
||||
) -> None:
|
||||
if not m.success:
|
||||
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
|
||||
target.active_conditions.append(
|
||||
ActiveCondition(
|
||||
@@ -4264,6 +4279,7 @@ class CombatEngine:
|
||||
) -> None:
|
||||
if not m.success:
|
||||
self._maneuver_fail(state, target, "overrun", m)
|
||||
self._overrun_fail_stop(state, target)
|
||||
return
|
||||
# PF1e (CRB "Overrun"): a successful maneuver moves you through the
|
||||
# target's square; the target is knocked prone only if the attack
|
||||
@@ -4293,6 +4309,35 @@ class CombatEngine:
|
||||
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(
|
||||
self,
|
||||
state: CombatantState,
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user