fix(rules): non-stacking types keep best bonus and worst penalty separately (CRB Common Terms - Stacking)
This commit is contained in:
@@ -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())
|
||||
)
|
||||
|
||||
+28
-5
@@ -111,20 +111,42 @@ class TestNonStackingTypes:
|
||||
class TestPenalties:
|
||||
"""Given: penalties (negative values) with non-stacking types
|
||||
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 = [
|
||||
StatModifier("attack", -2, "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 = [
|
||||
StatModifier("attack", 2, "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
|
||||
|
||||
def test_untyped_penalties_stack(self) -> None:
|
||||
@@ -167,7 +189,8 @@ class TestMixedStacking:
|
||||
StatModifier("attack", -1),
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user