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 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 Effects": "two bonuses of the same type don't stack even if they come from
different spells"; APG "Traits": "Trait bonuses do not stack"). different spells"; APG "Traits": "Trait bonuses do not stack").
- Penalties follow the same rules: same-type penalties don't stack (the - Penalties: for a non-stacking type the WORST penalty applies (same-type
least severe — i.e. the max value — is kept). penalties don't stack), and a same-type bonus and penalty apply together.
This module is the foundation for spells, conditions, feats, class abilities, This module is the foundation for spells, conditions, feats, class abilities,
and racial traits — all of which produce StatModifier instances that the and racial traits — all of which produce StatModifier instances that the
@@ -122,22 +122,33 @@ 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, untyped) all sum together. Non-stacking Stacking bonus types (dodge, untyped) all sum together. For each
types (including racial and trait) keep only the highest value per type non-stacking type the best BONUS (max, floor 0) and the worst PENALTY
(max is used, which correctly handles both bonuses and penalties: for (min) are kept separately and then summed — same-type bonuses don't
bonuses the highest is most beneficial, for penalties the highest is stack (highest applies), same-type penalties don't stack (worst
least severe). 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 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.
""" """
stacking_total = 0 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: for mod in modifiers:
if mod.bonus_type in _STACKING_TYPES: if mod.bonus_type in _STACKING_TYPES:
stacking_total += mod.value stacking_total += mod.value
else: elif mod.value >= 0:
prev = best_by_type.get(mod.bonus_type) prev = best_bonus_by_type.get(mod.bonus_type)
if prev is None or mod.value > prev: if prev is None or mod.value > prev:
best_by_type[mod.bonus_type] = mod.value best_bonus_by_type[mod.bonus_type] = mod.value
return stacking_total + sum(best_by_type.values()) 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())
)
+28 -5
View File
@@ -111,20 +111,42 @@ class TestNonStackingTypes:
class TestPenalties: class TestPenalties:
"""Given: penalties (negative values) with non-stacking types """Given: penalties (negative values) with non-stacking types
When: resolve_modifiers is called When: resolve_modifiers is called
Then: least severe penalty is kept (max value).""" Then: the worst penalty per type is kept, and a same-type bonus and
penalty apply together (CRB "Common Terms — Stacking")."""
def test_same_type_penalties_keep_least_severe(self) -> None: def test_same_type_penalties_keep_most_severe(self) -> None:
mods = [ mods = [
StatModifier("attack", -2, "morale"), StatModifier("attack", -2, "morale"),
StatModifier("attack", -1, "morale"), StatModifier("attack", -1, "morale"),
] ]
assert resolve_modifiers(mods) == -1 assert resolve_modifiers(mods) == -2
def test_same_type_bonus_and_penalty_keep_most_beneficial(self) -> None: def test_morale_minus2_and_minus4_keep_worst(self) -> None:
mods = [
StatModifier("attack", -2, "morale"),
StatModifier("attack", -4, "morale"),
]
assert resolve_modifiers(mods) == -4
def test_same_type_bonus_and_penalty_both_apply(self) -> None:
mods = [ mods = [
StatModifier("attack", 2, "morale"), StatModifier("attack", 2, "morale"),
StatModifier("attack", -1, "morale"), StatModifier("attack", -1, "morale"),
] ]
assert resolve_modifiers(mods) == 1
def test_morale_plus2_and_minus2_cancel(self) -> None:
mods = [
StatModifier("attack", 2, "morale"),
StatModifier("attack", -2, "morale"),
]
assert resolve_modifiers(mods) == 0
def test_morale_plus2_and_plus1_keep_highest(self) -> None:
mods = [
StatModifier("attack", 2, "morale"),
StatModifier("attack", 1, "morale"),
]
assert resolve_modifiers(mods) == 2 assert resolve_modifiers(mods) == 2
def test_untyped_penalties_stack(self) -> None: def test_untyped_penalties_stack(self) -> None:
@@ -167,7 +189,8 @@ class TestMixedStacking:
StatModifier("attack", -1), StatModifier("attack", -1),
StatModifier("attack", 1, "dodge"), StatModifier("attack", 1, "dodge"),
] ]
assert resolve_modifiers(mods) == 2 # 2 (max morale) + -1 (untyped) + 1 (dodge) # 2 (max morale bonus) + -1 (morale penalty) + -1 (untyped) + 1 (dodge)
assert resolve_modifiers(mods) == 1
# ── Edge cases ──────────────────────────────────────────────────────────────── # ── Edge cases ────────────────────────────────────────────────────────────────