247 lines
8.6 KiB
Python
247 lines
8.6 KiB
Python
"""Tests for the effect system: StatModifier and PF1e bonus stacking rules."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pf1e_simulator.effects import BonusType, 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_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, "dodge"),
|
|
StatModifier("ac", 1),
|
|
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
|
|
|
|
def test_two_racial_bonus_do_not_stack(self) -> None:
|
|
# CRB "Combining Magic Effects": "two bonuses of the same type don't
|
|
# stack" — racial is a named type.
|
|
mods = [
|
|
StatModifier("save", 1, "racial"),
|
|
StatModifier("save", 2, "racial"),
|
|
]
|
|
assert resolve_modifiers(mods) == 2
|
|
|
|
def test_two_trait_bonus_do_not_stack(self) -> None:
|
|
# APG "Traits": "Trait bonuses do not stack".
|
|
mods = [
|
|
StatModifier("attack", 1, "trait"),
|
|
StatModifier("attack", 2, "trait"),
|
|
]
|
|
assert resolve_modifiers(mods) == 2
|
|
|
|
|
|
# ── Penalties ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPenalties:
|
|
"""Given: penalties (negative values) with non-stacking types
|
|
When: resolve_modifiers is called
|
|
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_most_severe(self) -> None:
|
|
mods = [
|
|
StatModifier("attack", -2, "morale"),
|
|
StatModifier("attack", -1, "morale"),
|
|
]
|
|
assert resolve_modifiers(mods) == -2
|
|
|
|
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:
|
|
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"),
|
|
]
|
|
# 2 (max morale bonus) + -1 (morale penalty) + -1 (untyped) + 1 (dodge)
|
|
assert resolve_modifiers(mods) == 1
|
|
|
|
|
|
# ── 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)
|
|
|
|
def test_um_table_2_7_bonus_types_parse_and_do_not_stack(self) -> None:
|
|
# SRD "Ultimate Magic" Table 2-7 types: alchemical, inherent,
|
|
# resistance, size — accepted by StatModifier, non-stacking.
|
|
new_types: tuple[BonusType, ...] = (
|
|
"alchemical",
|
|
"inherent",
|
|
"resistance",
|
|
"size",
|
|
)
|
|
for bonus_type in new_types:
|
|
mods = [
|
|
StatModifier("ac", 2, bonus_type),
|
|
StatModifier("ac", 4, bonus_type),
|
|
]
|
|
assert resolve_modifiers(mods) == 4, bonus_type
|