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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-12 08:49:46 +02:00
parent 47010c6e00
commit 98b61c6cf8
3 changed files with 106 additions and 2 deletions
+8
View File
@@ -986,6 +986,14 @@ Règles modélisées :
le modificateur de Dextérité à la place de celui de Force ; lacune 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 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). 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 trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la
cible (direction sélectionnable via `Action.direction`) ; cible (direction sélectionnable via `Action.direction`) ;
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
+31 -1
View File
@@ -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: def _save_label(result: SaveResult | None) -> str:
if result is None: if result is None:
return "NO SAVE" return "NO SAVE"
@@ -4051,8 +4060,29 @@ class CombatEngine:
) )
def _maneuver_cmd(self, target: CombatantState) -> int: def _maneuver_cmd(self, target: CombatantState) -> int:
if target.combatant.cmd:
return target.combatant.cmd
target_cmb = self._maneuver_cmb(target) 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( def _maneuver_trip(
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
+67 -1
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from pf1e_simulator.combat import _SIZE_MODIFIERS, CombatantState, CombatEngine from pf1e_simulator.combat import _SIZE_MODIFIERS, CombatantState, CombatEngine
from pf1e_simulator.conditions import CONDITIONS_BY_NAME 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.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import ( 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)) 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(small) == 2 - 1 - 1 # bab + str + size
assert engine._maneuver_cmb(medium) == 2 - 1 + 0 # 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