From fa63829385addf26c8e775bf246715e6d0caf25c Mon Sep 17 00:00:00 2001 From: ctan Date: Wed, 16 Sep 2026 21:37:03 +0200 Subject: [PATCH] fix(rules): trait and racial are non-stacking bonus types (APG Traits, CRB Combining Magic Effects) --- src/pf1e_simulator/effects.py | 25 +++++++++++++++---------- tests/test_effects.py | 35 +++++++++++++++++++---------------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/pf1e_simulator/effects.py b/src/pf1e_simulator/effects.py index 0990a25..dbc58f7 100644 --- a/src/pf1e_simulator/effects.py +++ b/src/pf1e_simulator/effects.py @@ -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. diff --git a/tests/test_effects.py b/tests/test_effects.py index 0cac811..7780c29 100644 --- a/tests/test_effects.py +++ b/tests/test_effects.py @@ -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 ─────────────────────────────────────────────────────────────────