fix(rules): trait and racial are non-stacking bonus types (APG Traits, CRB Combining Magic Effects)

This commit is contained in:
2026-09-16 21:37:03 +02:00
parent cc9fba3b52
commit fa63829385
2 changed files with 34 additions and 26 deletions
+15 -10
View File
@@ -1,10 +1,14 @@
"""Effect system: bonus types, stat modifiers, and PF1e stacking rules.
PF1e bonus types determine whether multiple bonuses to the same stat stack:
- Stacking types (dodge, racial, trait, untyped/"") all sum together.
- Stacking types (dodge, untyped/"") all sum together (UM Table 2-7: "dodge
bonuses always stack"; CRB: "A bonus that doesn't have a type stacks with
any bonus").
- Non-stacking types (morale, sacred, profane, competence, insight, luck,
enhancement, deflection, natural, shield, armor) keep only the highest
value per type.
racial, trait, enhancement, deflection, natural, shield, armor,
circumstance) keep only the highest value per type (CRB "Combining Magic
Effects": "two bonuses of the same type don't stack even if they come from
different spells"; APG "Traits": "Trait bonuses do not stack").
- Penalties follow the same rules: same-type penalties don't stack (the
least severe — i.e. the max value — is kept).
@@ -32,8 +36,8 @@ BonusType = Literal[
"competence", # no stack
"insight", # no stack
"luck", # no stack
"racial", # stacks with itself
"trait", # stacks with itself
"racial", # no stack (CRB "Combining Magic Effects": same type)
"trait", # no stack (APG "Traits": "Trait bonuses do not stack")
"enhancement", # no stack
"deflection", # no stack
"natural", # no stack
@@ -59,7 +63,7 @@ StatTarget = Literal[
"dex_skills",
]
_STACKING_TYPES: frozenset[str] = frozenset({"", "dodge", "racial", "trait"})
_STACKING_TYPES: frozenset[str] = frozenset({"", "dodge"})
@dataclass(frozen=True)
@@ -118,10 +122,11 @@ class ActiveCondition:
def resolve_modifiers(modifiers: Iterable[StatModifier]) -> int:
"""Sum stat modifiers applying PF1e stacking rules.
Stacking bonus types (dodge, racial, trait, untyped) all sum together.
Non-stacking types keep only the highest value per type (max is used,
which correctly handles both bonuses and penalties: for bonuses the
highest is most beneficial, for penalties the highest is least severe).
Stacking bonus types (dodge, untyped) all sum together. Non-stacking
types (including racial and trait) keep only the highest value per type
(max is used, which correctly handles both bonuses and penalties: for
bonuses the highest is most beneficial, for penalties the highest is
least severe).
The caller is responsible for filtering by condition and weapon before
calling — this function performs pure stacking math.
+19 -16
View File
@@ -19,20 +19,6 @@ class TestStackingTypes:
]
assert resolve_modifiers(mods) == 3
def test_two_racial_bonus_stack(self) -> None:
mods = [
StatModifier("save", 1, "racial"),
StatModifier("save", 1, "racial"),
]
assert resolve_modifiers(mods) == 2
def test_two_trait_bonus_stack(self) -> None:
mods = [
StatModifier("attack", 1, "trait"),
StatModifier("attack", 1, "trait"),
]
assert resolve_modifiers(mods) == 2
def test_two_untyped_bonus_stack(self) -> None:
mods = [
StatModifier("attack", 1),
@@ -43,8 +29,8 @@ class TestStackingTypes:
def test_mixed_stacking_types_all_sum(self) -> None:
mods = [
StatModifier("ac", 1, "dodge"),
StatModifier("ac", 2, "racial"),
StatModifier("ac", 1, "trait"),
StatModifier("ac", 2, "dodge"),
StatModifier("ac", 1),
StatModifier("ac", 1),
]
assert resolve_modifiers(mods) == 5
@@ -101,6 +87,23 @@ class TestNonStackingTypes:
]
assert resolve_modifiers(mods) == 2
def test_two_racial_bonus_do_not_stack(self) -> None:
# CRB "Combining Magic Effects": "two bonuses of the same type don't
# stack" — racial is a named type.
mods = [
StatModifier("save", 1, "racial"),
StatModifier("save", 2, "racial"),
]
assert resolve_modifiers(mods) == 2
def test_two_trait_bonus_do_not_stack(self) -> None:
# APG "Traits": "Trait bonuses do not stack".
mods = [
StatModifier("attack", 1, "trait"),
StatModifier("attack", 2, "trait"),
]
assert resolve_modifiers(mods) == 2
# ── Penalties ─────────────────────────────────────────────────────────────────