diff --git a/README.md b/README.md index 750679d..4d1b547 100644 --- a/README.md +++ b/README.md @@ -1007,6 +1007,12 @@ Règles modélisées : 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 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 cible (direction sélectionnable via `Action.direction`) ; conformance L6 (SRD « Bull Rush ») : la poussée vaut 5 ft plus « For every 5 by which diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index de621e6..edca3ec 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 _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 +_MANEUVER_MAX_SIZE_DIFF = 2 # PF1e: 2+ size categories larger = unmaneuverable _SAVE_DESCRIPTOR_CONDITIONS = frozenset({ "fear", "cold", "electricity", "fey", "divination", "poison", "disease", "mind_affecting", @@ -277,6 +278,24 @@ _SIZE_MODIFIERS: dict[str, int] = { # modifier in place of their Strength modifier to determine their CMB. _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 # distinction). Unknown sizes fall back to 5 ft (the historical behavior). _SIZE_NATURAL_REACH_FT: dict[str, int] = { @@ -3947,6 +3966,18 @@ class CombatEngine: "overrun", "dirty_trick", "steal", "reposition", "drag", ): 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 # attack of opportunity from the target — resolved BEFORE the CMB diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index c03a74f..66e8aee 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -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) +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: # 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).