From 3a5948c35cd0e7b4665879eef5667e2fa1384332 Mon Sep 17 00:00:00 2001 From: ctan Date: Wed, 16 Sep 2026 20:25:34 +0200 Subject: [PATCH] fix(rules): CMD never adds Dex on top of a Tiny Dex-substituted pre-baked CMB --- src/pf1e_simulator/combat.py | 22 +++++++++++++++++----- tests/test_maneuvers.py | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 9e9d15b..505aca7 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -4782,11 +4782,23 @@ class CombatEngine: # Strength modifier + Dexterity modifier + special size modifier" — # the Tiny-or-smaller Dex-for-Str substitution is CMB-only, so the # ability component is computed here instead of via _maneuver_cmb. - target_base = target.combatant.cmb or ( - target.combatant.bab - + target.combatant.abilities.str_mod - + self._size_mod(target) - ) + # A pre-baked CMB on a Tiny-or-smaller creature ALREADY carries that + # substitution (SRD: they "use their Dexterity modifier in place of + # their Strength modifier to determine their CMB"), so it must never + # seed the CMD base — the extra Dexterity below would be double- + # counted and Strength lost. Use the raw CMD formula for those sizes. + if target.combatant.size.lower() in _TINY_OR_SMALLER_SIZES: + target_base = ( + target.combatant.bab + + target.combatant.abilities.str_mod + + self._size_mod(target) + ) + else: + target_base = target.combatant.cmb or ( + target.combatant.bab + + target.combatant.abilities.str_mod + + self._size_mod(target) + ) # PF1e (SRD Combat Maneuver Defense): "A creature can also add any # circumstance, deflection, dodge, insight, luck, morale, profane, # and sacred bonuses to AC to its CMD. [...] A flat-footed creature diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index adae848..5343e3b 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -622,6 +622,27 @@ def test_tiny_or_smaller_cmd_uses_strength_and_dexterity() -> None: assert engine._maneuver_cmd(tiny) == expected +def test_cmd_never_double_counts_tiny_dex_substituted_cmb() -> None: + # Latent bug guard: a Tiny creature with a PRE-BAKED cmb (already + # Dex-substituted per SRD CMB) must not get the Dexterity modifier added + # on top of it. Tiny: str -1, dex +4, bab 2, size -2, cmb pre-baked to + # 2 + 4 - 2 = 4. Correct CMD = 10 + 2 - 1 + 4 - 2 = 13; the buggy path + # (10 + cmb + dex) would give 10 + 4 + 4 = 18. + engine = make_engine([], []) + tiny = make_state( + _resized(make_combatant("t"), "Tiny", 8, 18).model_copy(update={"cmb": 4}), + "p", + (0, 0), + ) + assert engine._maneuver_cmd(tiny) == 13 + # A Medium creature's pre-baked cmb (Str-based) keeps the old behavior: + # CMD = 10 + cmb + dex = 10 + 8 + 0 = 18 (thrune_agent-like shape). + medium = make_state( + make_combatant("m").model_copy(update={"cmb": 8}), "p", (0, 1) + ) + assert engine._maneuver_cmd(medium) == 18 + + def _buffed(spec: Combatant, mods: tuple[StatModifier, ...]) -> CombatantState: state = make_state(spec, "p", (0, 0)) state.active_buffs.append(