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