"""Tests for the effect system: StatModifier and PF1e bonus stacking rules.""" from __future__ import annotations from pf1e_simulator.effects import StatModifier, resolve_modifiers # ── Stacking types: sum together ────────────────────────────────────────────── class TestStackingTypes: """Given: modifiers with stacking bonus types When: resolve_modifiers is called Then: values sum together.""" def test_two_dodge_bonus_stack(self) -> None: mods = [ StatModifier("ac", 1, "dodge"), StatModifier("ac", 2, "dodge"), ] assert resolve_modifiers(mods) == 3 def test_two_racial_bonus_stack(self) -> None: mods = [ StatModifier("save", 1, "racial"), StatModifier("save", 1, "racial"), ] assert resolve_modifiers(mods) == 2 def test_two_trait_bonus_stack(self) -> None: mods = [ StatModifier("attack", 1, "trait"), StatModifier("attack", 1, "trait"), ] assert resolve_modifiers(mods) == 2 def test_two_untyped_bonus_stack(self) -> None: mods = [ StatModifier("attack", 1), StatModifier("attack", 2), ] assert resolve_modifiers(mods) == 3 def test_mixed_stacking_types_all_sum(self) -> None: mods = [ StatModifier("ac", 1, "dodge"), StatModifier("ac", 2, "racial"), StatModifier("ac", 1, "trait"), StatModifier("ac", 1), ] assert resolve_modifiers(mods) == 5 def test_negative_untyped_stack(self) -> None: mods = [ StatModifier("attack", -1), StatModifier("attack", -2), ] assert resolve_modifiers(mods) == -3 # ── Non-stacking types: keep highest ────────────────────────────────────────── class TestNonStackingTypes: """Given: modifiers with non-stacking bonus types When: resolve_modifiers is called Then: only the highest value per type is kept.""" def test_two_morale_keep_highest(self) -> None: mods = [ StatModifier("attack", 2, "morale"), StatModifier("attack", 1, "morale"), ] assert resolve_modifiers(mods) == 2 def test_two_sacred_keep_highest(self) -> None: mods = [ StatModifier("ac", 2, "sacred"), StatModifier("ac", 3, "sacred"), ] assert resolve_modifiers(mods) == 3 def test_two_enhancement_keep_highest(self) -> None: mods = [ StatModifier("ac", 2, "enhancement"), StatModifier("ac", 4, "enhancement"), ] assert resolve_modifiers(mods) == 4 def test_different_non_stacking_types_each_apply(self) -> None: mods = [ StatModifier("ac", 2, "morale"), StatModifier("ac", 1, "sacred"), StatModifier("ac", 3, "enhancement"), ] assert resolve_modifiers(mods) == 6 def test_circumstance_does_not_stack(self) -> None: mods = [ StatModifier("attack", 2, "circumstance"), StatModifier("attack", 1, "circumstance"), ] assert resolve_modifiers(mods) == 2 # ── Penalties ───────────────────────────────────────────────────────────────── class TestPenalties: """Given: penalties (negative values) with non-stacking types When: resolve_modifiers is called Then: least severe penalty is kept (max value).""" def test_same_type_penalties_keep_least_severe(self) -> None: mods = [ StatModifier("attack", -2, "morale"), StatModifier("attack", -1, "morale"), ] assert resolve_modifiers(mods) == -1 def test_same_type_bonus_and_penalty_keep_most_beneficial(self) -> None: mods = [ StatModifier("attack", 2, "morale"), StatModifier("attack", -1, "morale"), ] assert resolve_modifiers(mods) == 2 def test_untyped_penalties_stack(self) -> None: mods = [ StatModifier("attack", -1), StatModifier("attack", -2), ] assert resolve_modifiers(mods) == -3 # ── Mixed: stacking + non-stacking ──────────────────────────────────────────── class TestMixedStacking: """Given: a mix of stacking and non-stacking modifiers When: resolve_modifiers is called Then: stacking types sum, non-stacking types keep highest, totals combine.""" def test_dodge_plus_morale_plus_untyped(self) -> None: mods = [ StatModifier("ac", 1, "dodge"), StatModifier("ac", 2, "morale"), StatModifier("ac", 1), ] assert resolve_modifiers(mods) == 4 def test_two_morale_plus_dodge_plus_racial(self) -> None: mods = [ StatModifier("ac", 2, "morale"), StatModifier("ac", 3, "morale"), StatModifier("ac", 1, "dodge"), StatModifier("ac", 1, "racial"), ] assert resolve_modifiers(mods) == 5 # 3 (max morale) + 1 (dodge) + 1 (racial) def test_penalties_and_bonus_mixed(self) -> None: mods = [ StatModifier("attack", 2, "morale"), StatModifier("attack", -1, "morale"), StatModifier("attack", -1), StatModifier("attack", 1, "dodge"), ] assert resolve_modifiers(mods) == 2 # 2 (max morale) + -1 (untyped) + 1 (dodge) # ── Edge cases ──────────────────────────────────────────────────────────────── class TestEdgeCases: """Given: edge-case modifier lists When: resolve_modifiers is called Then: returns the correct total.""" def test_empty_list_returns_zero(self) -> None: assert resolve_modifiers([]) == 0 def test_single_modifier(self) -> None: assert resolve_modifiers([StatModifier("ac", 4, "dodge")]) == 4 def test_single_negative_modifier(self) -> None: assert resolve_modifiers([StatModifier("attack", -3, "morale")]) == -3 def test_all_zero_values(self) -> None: mods = [ StatModifier("ac", 0, "dodge"), StatModifier("ac", 0, "morale"), ] assert resolve_modifiers(mods) == 0 def test_resolve_does_not_filter_by_target(self) -> None: """resolve_modifiers treats all modifiers together regardless of target. The caller must filter by target before calling — the function only applies bonus-type stacking rules, not target grouping. """ mods = [ StatModifier("attack", 2, "morale"), StatModifier("ac", 3, "morale"), ] assert resolve_modifiers(mods) == 3 # same non-stacking type → max(2, 3)