fix(rules): use Dexterity for the CMB of Tiny and smaller creatures

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." _maneuver_cmb now swaps Str for Dex when the size is Tiny, Diminutive or Fine. True 0-reach and co-occupancy for Tiny-or-smaller stay unmodelled (5-ft grid abstraction), documented in the README.

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:44:44 +02:00
parent 9d583a04c0
commit 47010c6e00
3 changed files with 56 additions and 7 deletions
+11 -5
View File
@@ -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,
+13 -1
View File
@@ -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:
+32 -1
View File
@@ -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