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.