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 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 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 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 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 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 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). pénalisé (un test de compétence n'est pas un jet de manœuvre). Conformance
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la 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`) ; 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
compétence `escape_artist` ou CMB, + un d20) contre CMD — une seule tentative, 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, "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 # PF1e natural reach for tall creatures (simplification: no long/quadruped
# distinction). Unknown sizes fall back to 5 ft (the historical behavior). # distinction). Unknown sizes fall back to 5 ft (the historical behavior).
_SIZE_NATURAL_REACH_FT: dict[str, int] = { _SIZE_NATURAL_REACH_FT: dict[str, int] = {
@@ -4033,9 +4037,17 @@ class CombatEngine:
) )
def _maneuver_cmb(self, state: CombatantState) -> int: 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 ( return (
state.combatant.cmb 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: 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 __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.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.effects import ActiveCondition from pf1e_simulator.effects import ActiveCondition
from pf1e_simulator.grid import Grid from pf1e_simulator.grid import Grid
@@ -269,3 +269,34 @@ def test_drag_no_space() -> None:
assert b.pos == (1, 2) assert b.pos == (1, 2)
assert a.pos == (1, 1) assert a.pos == (1, 1)
assert "no space" in engine._transcript[-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