diff --git a/src/pf1e_simulator/effects.py b/src/pf1e_simulator/effects.py index dbc58f7..ba05f05 100644 --- a/src/pf1e_simulator/effects.py +++ b/src/pf1e_simulator/effects.py @@ -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()) + ) diff --git a/tests/test_effects.py b/tests/test_effects.py index 7780c29..d5b8e71 100644 --- a/tests/test_effects.py +++ b/tests/test_effects.py @@ -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 ────────────────────────────────────────────────────────────────