fix(rules): non-stacking types keep best bonus and worst penalty separately (CRB Common Terms - Stacking)

This commit is contained in:
2026-09-16 21:40:30 +02:00
parent fa63829385
commit 1c2663d21c
2 changed files with 51 additions and 17 deletions
+23 -12
View File
@@ -9,8 +9,8 @@ PF1e bonus types determine whether multiple bonuses to the same stat stack:
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).
- Penalties: for a non-stacking type the WORST penalty applies (same-type
penalties don't stack), and a same-type bonus and penalty apply together.
This module is the foundation for spells, conditions, feats, class abilities,
and racial traits — all of which produce StatModifier instances that the
@@ -122,22 +122,33 @@ class ActiveCondition:
def resolve_modifiers(modifiers: Iterable[StatModifier]) -> int:
"""Sum stat modifiers applying PF1e stacking rules.
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).
Stacking bonus types (dodge, untyped) all sum together. For each
non-stacking type the best BONUS (max, floor 0) and the worst PENALTY
(min) are kept separately and then summed — same-type bonuses don't
stack (highest applies), same-type penalties don't stack (worst
applies), and a bonus and a penalty of the same type apply together
(CRB "Common Terms — Stacking": "Penalties and bonuses generally stack
with one another").
The caller is responsible for filtering by condition and weapon before
calling — this function performs pure stacking math.
"""
stacking_total = 0
best_by_type: dict[str, int] = {}
best_bonus_by_type: dict[str, int] = {}
worst_penalty_by_type: dict[str, int] = {}
for mod in modifiers:
if mod.bonus_type in _STACKING_TYPES:
stacking_total += mod.value
else:
prev = best_by_type.get(mod.bonus_type)
elif mod.value >= 0:
prev = best_bonus_by_type.get(mod.bonus_type)
if prev is None or mod.value > prev:
best_by_type[mod.bonus_type] = mod.value
return stacking_total + sum(best_by_type.values())
best_bonus_by_type[mod.bonus_type] = mod.value
else:
prev = worst_penalty_by_type.get(mod.bonus_type)
if prev is None or mod.value < prev:
worst_penalty_by_type[mod.bonus_type] = mod.value
return (
stacking_total
+ sum(best_bonus_by_type.values())
+ sum(worst_penalty_by_type.values())
)