fix(rules): restrict maneuvers to targets within one size category

PF1e (SRD Trip / Bull Rush / Overrun): "You can only [affect] an opponent who is no more than one size category larger than you." _execute_maneuver now rejects trip, bull_rush and overrun against a target two or more size categories larger: no roll, no provoked AoO, logged rejection. A _SIZE_ORDER ranking (Fine..Colossal, unknown -> Medium) backs the check; other maneuvers keep no size cap. 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:40:50 +02:00
parent 1b60cd049b
commit 49aa79d612
3 changed files with 70 additions and 0 deletions
+6
View File
@@ -1007,6 +1007,12 @@ Règles modélisées :
contact pris de dos ne ajoute pas son bonus de Dextérité (« A flat-footed contact pris de dos ne ajoute pas son bonus de Dextérité (« A flat-footed
creature does not add its Dexterity bonus to its CMD » — la pénalité de creature does not add its Dexterity bonus to its CMD » — la pénalité de
Dextérité, qui n'est pas un bonus, reste appliquée). Dextérité, qui n'est pas un bonus, reste appliquée).
(SRD « Trip » / « Bull Rush » / « Overrun ») : « You can only [affect] an
opponent who is no more than one size category larger than you » — conformance
L8 : trip, bull_rush et overrun contre une cible de **deux catégories de
taille ou plus au-dessus** de l'attaquant sont illégaux (tentative refusée,
journalisée `rejected (target too large)`, aucun jet, pas d'AoO provoquée) ;
les autres manœuvres (disarm, sunder, grapple…) n'ont pas de limite de taille.
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la
cible (direction sélectionnable via `Action.direction`) ; conformance L6 cible (direction sélectionnable via `Action.direction`) ; conformance L6
(SRD « Bull Rush ») : la poussée vaut 5 ft plus « For every 5 by which (SRD « Bull Rush ») : la poussée vaut 5 ft plus « For every 5 by which
+31
View File
@@ -262,6 +262,7 @@ _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 _TRIP_RETALIATION_MARGIN = 10 # PF1e: trip failing by 10+ knocks the attacker prone
_MANEUVER_MAX_SIZE_DIFF = 2 # PF1e: 2+ size categories larger = unmaneuverable
_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",
@@ -277,6 +278,24 @@ _SIZE_MODIFIERS: dict[str, int] = {
# modifier in place of their Strength modifier to determine their CMB. # modifier in place of their Strength modifier to determine their CMB.
_TINY_OR_SMALLER_SIZES: frozenset[str] = frozenset({"tiny", "diminutive", "fine"}) _TINY_OR_SMALLER_SIZES: frozenset[str] = frozenset({"tiny", "diminutive", "fine"})
# PF1e size order, smallest first (SRD "Size").
_SIZE_ORDER: tuple[str, ...] = (
"fine", "diminutive", "tiny", "small", "medium", "large", "huge",
"gargantuan", "colossal",
)
# PF1e (SRD Trip / Bull Rush / Overrun): "You can only [affect] an opponent
# who is no more than one size category larger than you."
_SIZE_RESTRICTED_MANEUVERS: frozenset[str] = frozenset({"trip", "bull_rush", "overrun"})
def _size_rank(size: str) -> int:
"""Index in the PF1e size order; unknown sizes rank as Medium."""
try:
return _SIZE_ORDER.index(size.lower())
except ValueError:
return _SIZE_ORDER.index("medium")
# PF1e natural reach for tall creatures (simplification: no long/quadruped # PF1e natural reach for tall creatures (simplification: no long/quadruped
# distinction). Unknown sizes fall back to 5 ft (the historical behavior). # distinction). Unknown sizes fall back to 5 ft (the historical behavior).
_SIZE_NATURAL_REACH_FT: dict[str, int] = { _SIZE_NATURAL_REACH_FT: dict[str, int] = {
@@ -3947,6 +3966,18 @@ class CombatEngine:
"overrun", "dirty_trick", "steal", "reposition", "drag", "overrun", "dirty_trick", "steal", "reposition", "drag",
): ):
return return
# PF1e (SRD Trip / Bull Rush / Overrun): "You can only [affect] an
# opponent who is no more than one size category larger than you."
# The attempt is illegal outright: no roll, no provoked AoO.
if maneuver in _SIZE_RESTRICTED_MANEUVERS and (
_size_rank(target.combatant.size) - _size_rank(state.combatant.size)
>= _MANEUVER_MAX_SIZE_DIFF
):
self._log(
f"round {self._current_round} {state.combatant.id}: {maneuver} vs "
f"{target.combatant.id} rejected (target too large)"
)
return
# PF1e (CRB, Combat Maneuver): attempting the maneuver provokes an # PF1e (CRB, Combat Maneuver): attempting the maneuver provokes an
# attack of opportunity from the target — resolved BEFORE the CMB # attack of opportunity from the target — resolved BEFORE the CMB
+33
View File
@@ -248,6 +248,39 @@ def test_overrun_fail_adjacent_attacker_does_not_move() -> None:
assert not any("overrun blocked" in line for line in engine._transcript) assert not any("overrun blocked" in line for line in engine._transcript)
def test_maneuver_rejects_target_two_sizes_larger() -> None:
# SRD Trip/Bull Rush/Overrun: "You can only [affect] an opponent who is
# no more than one size category larger than you." Medium vs Huge: the
# attempt is illegal — no d20 consumed, no effect, logged rejection.
for maneuver in ("trip", "bull_rush", "overrun"):
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
huge = make_state(_resized(make_combatant("h"), "Huge", 10, 10), "monsters", (1, 2))
engine = make_engine([1, 12], [a, huge])
engine._execute_maneuver(a, huge, maneuver)
assert len(engine._rng._queue) == 2
assert not any(c.condition.name == "prone" for c in huge.active_conditions)
assert f"{maneuver} vs h rejected (target too large)" in engine._transcript[-1]
def test_maneuver_allows_target_one_size_larger() -> None:
# Medium vs Large (one category) stays legal; the roll happens.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
large = make_state(_resized(make_combatant("l"), "Large", 10, 10), "monsters", (1, 2))
engine = make_engine([1, 12], [a, large])
engine._execute_maneuver(a, large, "trip")
assert not engine._rng._queue
assert any(c.condition.name == "prone" for c in large.active_conditions)
def test_unrestricted_maneuver_ignores_size_limit() -> None:
# The size cap is trip/bull_rush/overrun only: disarm works on Huge.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
huge = make_state(_resized(make_combatant("h"), "Huge", 10, 10), "monsters", (1, 2))
engine = make_engine([1, 12], [a, huge])
engine._execute_maneuver(a, huge, "disarm")
assert not engine._rng._queue
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).