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