diff --git a/README.md b/README.md index ea3cea1..e480640 100644 --- a/README.md +++ b/README.md @@ -976,11 +976,17 @@ Règles modélisées : toujours) ; si l'AoO fait passer l'attaquant sous 0 pv, la manœuvre est perdue. L'AoO de manœuvre consomme le budget d'AoO du round de la cible (Combat Reflexes inclus). Conformance B16 (CRB « Power Attack ») : - la pénalité −X de Power Attack actif s'applique **au jet de CMB** de la - manœuvre (X = BAB//4 + 1) — la pénalité couvre « les jets d'attaque de mêlée - et les jets de manœuvres de combat » ; l'escape par compétence n'est pas - pénalisé (un test de compétence n'est pas un jet de manœuvre). - trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la + la pénalité −X de Power Attack actif s'applique **au jet de CMB** de la + manœuvre (X = BAB//4 + 1) — la pénalité couvre « les jets d'attaque de mêlée + et les jets de manœuvres de combat » ; l'escape par compétence n'est pas + pénalisé (un test de compétence n'est pas un jet de manœuvre). Conformance + L1 (SRD « Combat Maneuvers ») : « Creatures that are size Tiny or smaller + use their Dexterity modifier in place of their Strength modifier to + determine their CMB » — Tiny/Diminutive/Fine calculent donc leur CMB avec + le modificateur de Dextérité à la place de celui de Force ; lacune + documentée : l'allonge nulle et la co-occupation des créatures Tiny et + plus petites ne sont pas modélisées (abstraction à la grille de 5 ft). + trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la cible (direction sélectionnable via `Action.direction`) ; grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative, diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 4e58a84..914320c 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -271,6 +271,10 @@ _SIZE_MODIFIERS: dict[str, int] = { "medium": 0, "large": 1, "huge": 2, "gargantuan": 4, "colossal": 8, } +# PF1e (SRD Combat Maneuvers): creatures Tiny or smaller use their Dexterity +# modifier in place of their Strength modifier to determine their CMB. +_TINY_OR_SMALLER_SIZES: frozenset[str] = frozenset({"tiny", "diminutive", "fine"}) + # 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] = { @@ -4033,9 +4037,17 @@ class CombatEngine: ) def _maneuver_cmb(self, state: CombatantState) -> int: + # PF1e (SRD Combat Maneuvers): "Creatures that are size Tiny or + # smaller use their Dexterity modifier in place of their Strength + # modifier to determine their CMB." + ability_mod = ( + state.combatant.abilities.dex_mod + if state.combatant.size.lower() in _TINY_OR_SMALLER_SIZES + else state.combatant.abilities.str_mod + ) return ( state.combatant.cmb - or (state.combatant.bab + state.combatant.abilities.str_mod + self._size_mod(state)) + or (state.combatant.bab + ability_mod + self._size_mod(state)) ) def _maneuver_cmd(self, target: CombatantState) -> int: diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index bac1298..a13bcaf 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -7,7 +7,7 @@ it (effectively disarmed). See the plan task-12. from __future__ import annotations -from pf1e_simulator.combat import CombatantState, CombatEngine +from pf1e_simulator.combat import _SIZE_MODIFIERS, CombatantState, CombatEngine from pf1e_simulator.conditions import CONDITIONS_BY_NAME from pf1e_simulator.effects import ActiveCondition from pf1e_simulator.grid import Grid @@ -269,3 +269,34 @@ def test_drag_no_space() -> None: assert b.pos == (1, 2) assert a.pos == (1, 1) assert "no space" in engine._transcript[-1] + + +def _resized(spec: Combatant, size: str, str_score: int, dex_score: int) -> Combatant: + return spec.model_copy( + update={ + "size": size, + "abilities": spec.abilities.model_copy( + update={"str_score": str_score, "dex_score": dex_score} + ), + } + ) + + +def test_tiny_or_smaller_cmb_uses_dexterity() -> None: + # SRD Combat Maneuvers: "Creatures that are size Tiny or smaller use + # their Dexterity modifier in place of their Strength modifier to + # determine their CMB." Tiny: str -1, dex +4, bab 2, size -2 -> 2+4-2. + engine = make_engine([], []) + for size in ("Tiny", "Diminutive", "Fine"): + tiny = make_state(_resized(make_combatant("t"), size, 8, 18), "p", (0, 0)) + expected = 2 + 4 + _SIZE_MODIFIERS[size.lower()] + assert engine._maneuver_cmb(tiny) == expected + + +def test_small_or_larger_cmb_uses_strength() -> None: + # The Dex substitution stops at Tiny: Small and larger use Strength. + engine = make_engine([], []) + small = make_state(_resized(make_combatant("s"), "Small", 8, 18), "p", (0, 0)) + medium = make_state(_resized(make_combatant("m"), "Medium", 8, 18), "p", (0, 1)) + assert engine._maneuver_cmb(small) == 2 - 1 - 1 # bab + str + size + assert engine._maneuver_cmb(medium) == 2 - 1 + 0 # bab + str + size