From 98b61c6cf8259641d4f4806fa4c8e4a2a9f4f121 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 12 Sep 2026 08:49:46 +0200 Subject: [PATCH] fix(rules): add AC bonuses and flat-footed Dexterity to CMD 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 does not add its Dexterity bonus to its CMD." _maneuver_cmd now sums the target's eligible AC bonus modifiers (plus the dodge/circumstance runtime bonuses from total defense, fight defensively and aid another) and drops the Dexterity bonus (keeping any penalty) while the target denies its Dexterity to AC. README updated. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 8 +++++ src/pf1e_simulator/combat.py | 32 ++++++++++++++++- tests/test_maneuvers.py | 68 +++++++++++++++++++++++++++++++++++- 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e480640..de51b36 100644 --- a/README.md +++ b/README.md @@ -986,6 +986,14 @@ Règles modélisées : 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). + Conformance L2 (SRD « Combat Maneuver Defense ») : le CMD calculé ajoute + les bonus de CA des huit types autorisés (« A creature can also add any + circumstance, deflection, dodge, insight, luck, morale, profane, and + sacred bonuses to AC to its CMD » — bonus issus des buffs/conditions/ + dons, défense totale et combat défensif inclus) et une créature au + 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). 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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 914320c..a4bb145 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -303,6 +303,15 @@ _NEIGHBOR_OFFSETS: tuple[tuple[int, int], ...] = ( ) +# PF1e (SRD Combat Maneuver Defense): the AC bonus types a creature may add +# to its CMD. Penalties and other bonus types (armor, shield, natural, +# enhancement, competence, untyped) stay out of the CMD. +_CMD_AC_BONUS_TYPES: frozenset[str] = frozenset({ + "circumstance", "deflection", "dodge", "insight", "luck", "morale", + "profane", "sacred", +}) + + def _save_label(result: SaveResult | None) -> str: if result is None: return "NO SAVE" @@ -4051,8 +4060,29 @@ class CombatEngine: ) def _maneuver_cmd(self, target: CombatantState) -> int: + if target.combatant.cmd: + return target.combatant.cmd target_cmb = self._maneuver_cmb(target) - return target.combatant.cmd or (10 + target_cmb + target.combatant.abilities.dex_mod) + # 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 + # does not add its Dexterity bonus to its CMD." A Dexterity penalty + # is not a bonus, so it still applies when flat-footed. + dex_mod = target.combatant.abilities.dex_mod + if self.has_condition_flag(target, "denies_dex_to_ac"): + dex_mod = min(0, dex_mod) + ac_bonuses = resolve_modifiers( + m + for m in self._stat_modifiers(target, "ac") + if m.bonus_type in _CMD_AC_BONUS_TYPES + ) + # defensive_ac_bonus (total defense / fight defensively) is a dodge + # bonus and aid_ac_bonus a circumstance bonus to AC — both types the + # SRD lets a creature add to its CMD. + return ( + 10 + target_cmb + dex_mod + ac_bonuses + + target.defensive_ac_bonus + target.aid_ac_bonus + ) def _maneuver_trip( self, state: CombatantState, target: CombatantState, m: _ManeuverRoll diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index a13bcaf..eceefa4 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -9,7 +9,7 @@ from __future__ import annotations 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.effects import ActiveBuff, ActiveCondition, StatModifier from pf1e_simulator.grid import Grid from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.models import ( @@ -300,3 +300,69 @@ def test_small_or_larger_cmb_uses_strength() -> None: 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 + + +def _buffed(spec: Combatant, mods: tuple[StatModifier, ...]) -> CombatantState: + state = make_state(spec, "p", (0, 0)) + state.active_buffs.append( + ActiveBuff(modifiers=mods, rounds_left=1, source="test") + ) + return state + + +def test_cmd_adds_eligible_ac_bonus_types() -> None: + # SRD CMD: "A creature can also add any circumstance, deflection, dodge, + # insight, luck, morale, profane, and sacred bonuses to AC to its CMD." + # Medium str 10 dex 10 bab 2: base CMD 10 + (2+0+0) + 0 = 12. + engine = make_engine([], []) + base = make_state(make_combatant("b"), "p", (0, 0)) + assert engine._maneuver_cmd(base) == 12 + buffed = _buffed( + make_combatant("b"), + ( + StatModifier(target="ac", value=4, bonus_type="dodge"), + StatModifier(target="ac", value=2, bonus_type="morale"), + ), + ) + assert engine._maneuver_cmd(buffed) == 12 + 4 + 2 + + +def test_cmd_ignores_non_ac_bonus_types() -> None: + # Enhancement/natural armor bonuses and untyped penalties are NOT added + # to CMD (only the eight SRD-listed AC bonus types are). + engine = make_engine([], []) + state = _buffed( + make_combatant("b"), + ( + StatModifier(target="ac", value=2, bonus_type="enhancement"), + StatModifier(target="ac", value=-4), + ), + ) + assert engine._maneuver_cmd(state) == 12 + + +def test_cmd_flat_footed_drops_dex_bonus_keeps_penalty() -> None: + # SRD CMD: "A flat-footed creature does not add its Dexterity bonus to + # its CMD." A Dex penalty is not a bonus, so it still applies. + engine = make_engine([], []) + dexy = _resized(make_combatant("d"), "Medium", 10, 18) # dex +4 + flat = make_state(dexy, "p", (0, 0)) + flat.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["flat_footed"], + rounds_left=1, + source="test", + ) + ) + assert engine._maneuver_cmd(make_state(dexy, "p", (0, 1))) == 10 + 2 + 4 + assert engine._maneuver_cmd(flat) == 10 + 2 # Dex bonus gone + clumsy = _resized(make_combatant("c"), "Medium", 10, 6) # dex -2 + clumsy_flat = make_state(clumsy, "p", (0, 2)) + clumsy_flat.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["flat_footed"], + rounds_left=1, + source="test", + ) + ) + assert engine._maneuver_cmd(clumsy_flat) == 10 + 2 - 2 # penalty kept