fix(rules): trait and racial are non-stacking bonus types (APG Traits, CRB Combining Magic Effects)
This commit is contained in:
@@ -1,10 +1,14 @@
|
|||||||
"""Effect system: bonus types, stat modifiers, and PF1e stacking rules.
|
"""Effect system: bonus types, stat modifiers, and PF1e stacking rules.
|
||||||
|
|
||||||
PF1e bonus types determine whether multiple bonuses to the same stat stack:
|
PF1e bonus types determine whether multiple bonuses to the same stat stack:
|
||||||
- Stacking types (dodge, racial, trait, untyped/"") all sum together.
|
- Stacking types (dodge, untyped/"") all sum together (UM Table 2-7: "dodge
|
||||||
|
bonuses always stack"; CRB: "A bonus that doesn't have a type stacks with
|
||||||
|
any bonus").
|
||||||
- Non-stacking types (morale, sacred, profane, competence, insight, luck,
|
- Non-stacking types (morale, sacred, profane, competence, insight, luck,
|
||||||
enhancement, deflection, natural, shield, armor) keep only the highest
|
racial, trait, enhancement, deflection, natural, shield, armor,
|
||||||
value per type.
|
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
|
- Penalties follow the same rules: same-type penalties don't stack (the
|
||||||
least severe — i.e. the max value — is kept).
|
least severe — i.e. the max value — is kept).
|
||||||
|
|
||||||
@@ -32,8 +36,8 @@ BonusType = Literal[
|
|||||||
"competence", # no stack
|
"competence", # no stack
|
||||||
"insight", # no stack
|
"insight", # no stack
|
||||||
"luck", # no stack
|
"luck", # no stack
|
||||||
"racial", # stacks with itself
|
"racial", # no stack (CRB "Combining Magic Effects": same type)
|
||||||
"trait", # stacks with itself
|
"trait", # no stack (APG "Traits": "Trait bonuses do not stack")
|
||||||
"enhancement", # no stack
|
"enhancement", # no stack
|
||||||
"deflection", # no stack
|
"deflection", # no stack
|
||||||
"natural", # no stack
|
"natural", # no stack
|
||||||
@@ -59,7 +63,7 @@ StatTarget = Literal[
|
|||||||
"dex_skills",
|
"dex_skills",
|
||||||
]
|
]
|
||||||
|
|
||||||
_STACKING_TYPES: frozenset[str] = frozenset({"", "dodge", "racial", "trait"})
|
_STACKING_TYPES: frozenset[str] = frozenset({"", "dodge"})
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -118,10 +122,11 @@ 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, racial, trait, untyped) all sum together.
|
Stacking bonus types (dodge, untyped) all sum together. Non-stacking
|
||||||
Non-stacking types keep only the highest value per type (max is used,
|
types (including racial and trait) keep only the highest value per type
|
||||||
which correctly handles both bonuses and penalties: for bonuses the
|
(max is used, which correctly handles both bonuses and penalties: for
|
||||||
highest is most beneficial, for penalties the highest is least severe).
|
bonuses the highest is most beneficial, for penalties the highest is
|
||||||
|
least severe).
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
+19
-16
@@ -19,20 +19,6 @@ class TestStackingTypes:
|
|||||||
]
|
]
|
||||||
assert resolve_modifiers(mods) == 3
|
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:
|
def test_two_untyped_bonus_stack(self) -> None:
|
||||||
mods = [
|
mods = [
|
||||||
StatModifier("attack", 1),
|
StatModifier("attack", 1),
|
||||||
@@ -43,8 +29,8 @@ class TestStackingTypes:
|
|||||||
def test_mixed_stacking_types_all_sum(self) -> None:
|
def test_mixed_stacking_types_all_sum(self) -> None:
|
||||||
mods = [
|
mods = [
|
||||||
StatModifier("ac", 1, "dodge"),
|
StatModifier("ac", 1, "dodge"),
|
||||||
StatModifier("ac", 2, "racial"),
|
StatModifier("ac", 2, "dodge"),
|
||||||
StatModifier("ac", 1, "trait"),
|
StatModifier("ac", 1),
|
||||||
StatModifier("ac", 1),
|
StatModifier("ac", 1),
|
||||||
]
|
]
|
||||||
assert resolve_modifiers(mods) == 5
|
assert resolve_modifiers(mods) == 5
|
||||||
@@ -101,6 +87,23 @@ class TestNonStackingTypes:
|
|||||||
]
|
]
|
||||||
assert resolve_modifiers(mods) == 2
|
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 ─────────────────────────────────────────────────────────────────
|
# ── Penalties ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user