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. """Effect system: bonus types, stat modifiers, and PF1e stacking rules.
PF1e bonus types determine whether multiple bonuses to the same stat stack: 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, - Non-stacking types (morale, sacred, profane, competence, insight, luck,
enhancement, deflection, natural, shield, armor) keep only the highest racial, trait, enhancement, deflection, natural, shield, armor,
value per type. 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 - Penalties follow the same rules: same-type penalties don't stack (the
least severe — i.e. the max value — is kept). least severe — i.e. the max value — is kept).
@@ -32,8 +36,8 @@ BonusType = Literal[
"competence", # no stack "competence", # no stack
"insight", # no stack "insight", # no stack
"luck", # no stack "luck", # no stack
"racial", # stacks with itself "racial", # no stack (CRB "Combining Magic Effects": same type)
"trait", # stacks with itself "trait", # no stack (APG "Traits": "Trait bonuses do not stack")
"enhancement", # no stack "enhancement", # no stack
"deflection", # no stack "deflection", # no stack
"natural", # no stack "natural", # no stack
@@ -59,7 +63,7 @@ StatTarget = Literal[
"dex_skills", "dex_skills",
] ]
_STACKING_TYPES: frozenset[str] = frozenset({"", "dodge", "racial", "trait"}) _STACKING_TYPES: frozenset[str] = frozenset({"", "dodge"})
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -118,10 +122,11 @@ class ActiveCondition:
def resolve_modifiers(modifiers: Iterable[StatModifier]) -> int: def resolve_modifiers(modifiers: Iterable[StatModifier]) -> int:
"""Sum stat modifiers applying PF1e stacking rules. """Sum stat modifiers applying PF1e stacking rules.
Stacking bonus types (dodge, racial, trait, untyped) all sum together. Stacking bonus types (dodge, untyped) all sum together. Non-stacking
Non-stacking types keep only the highest value per type (max is used, types (including racial and trait) keep only the highest value per type
which correctly handles both bonuses and penalties: for bonuses the (max is used, which correctly handles both bonuses and penalties: for
highest is most beneficial, for penalties the highest is least severe). bonuses the highest is most beneficial, for penalties the highest is
least severe).
The caller is responsible for filtering by condition and weapon before The caller is responsible for filtering by condition and weapon before
calling — this function performs pure stacking math. calling — this function performs pure stacking math.
+19 -16
View File
@@ -19,20 +19,6 @@ class TestStackingTypes:
] ]
assert resolve_modifiers(mods) == 3 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: def test_two_untyped_bonus_stack(self) -> None:
mods = [ mods = [
StatModifier("attack", 1), StatModifier("attack", 1),
@@ -43,8 +29,8 @@ class TestStackingTypes:
def test_mixed_stacking_types_all_sum(self) -> None: def test_mixed_stacking_types_all_sum(self) -> None:
mods = [ mods = [
StatModifier("ac", 1, "dodge"), StatModifier("ac", 1, "dodge"),
StatModifier("ac", 2, "racial"), StatModifier("ac", 2, "dodge"),
StatModifier("ac", 1, "trait"), StatModifier("ac", 1),
StatModifier("ac", 1), StatModifier("ac", 1),
] ]
assert resolve_modifiers(mods) == 5 assert resolve_modifiers(mods) == 5
@@ -101,6 +87,23 @@ class TestNonStackingTypes:
] ]
assert resolve_modifiers(mods) == 2 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 ───────────────────────────────────────────────────────────────── # ── Penalties ─────────────────────────────────────────────────────────────────