fix(rules): trait and racial are non-stacking bonus types (APG Traits, CRB Combining Magic Effects)
This commit is contained in:
@@ -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
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user