feat(effects): add effect system with PF1e bonus stacking rules

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 94521394ef
commit 8ac6f9eabe
3 changed files with 308 additions and 7 deletions
+88
View File
@@ -0,0 +1,88 @@
"""Effect system: bonus types, stat modifiers, and PF1e stacking rules.
PF1e bonus types determine whether multiple bonuses to the same stat stack:
- Stacking types (dodge, racial, trait, untyped/"") all sum together.
- Non-stacking types (morale, sacred, profane, competence, insight, luck,
enhancement, deflection, natural) keep only the highest value per type.
- Penalties follow the same rules: same-type penalties don't stack (the
least severe — i.e. the max value — is kept).
This module is the foundation for spells, conditions, feats, class abilities,
and racial traits — all of which produce StatModifier instances that the
engine collects and sums via resolve_modifiers.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
from collections.abc import Sequence
BonusType = Literal[
"", # untyped — stacks with everything
"dodge", # stacks with itself
"morale", # no stack
"sacred", # no stack
"profane", # no stack
"competence", # no stack
"insight", # no stack
"luck", # no stack
"racial", # stacks with itself
"trait", # stacks with itself
"enhancement", # no stack
"deflection", # no stack
"natural", # no stack
"circumstance", # no stack (simulator can't distinguish circumstances)
]
StatTarget = Literal[
"attack", "damage", "ac", "touch_ac", "flat_footed_ac",
"fort", "ref", "will", "initiative",
"cmb", "cmd", "speed_ft", "hp", "concentration", "cl",
]
_STACKING_TYPES: frozenset[str] = frozenset({"", "dodge", "racial", "trait"})
@dataclass(frozen=True)
class StatModifier:
"""+X to a stat, optionally with a bonus type for stacking and a condition.
- ``bonus_type`` defaults to "" (untyped) which stacks with everything.
- ``condition`` is None for always-active modifiers; otherwise it names a
runtime condition (e.g. "target_within_30ft", "flanking") that the engine
evaluates before including this modifier.
- ``weapon_filter`` restricts the modifier to a specific weapon by name
(e.g. "Pistol" for Weapon Focus (Pistol)).
"""
target: StatTarget
value: int
bonus_type: BonusType = ""
condition: str | None = None
weapon_filter: str | None = None
def resolve_modifiers(modifiers: Sequence[StatModifier]) -> int:
"""Sum stat modifiers applying PF1e stacking rules.
Stacking bonus types (dodge, racial, trait, untyped) all sum together.
Non-stacking types 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).
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] = {}
for mod in modifiers:
if mod.bonus_type in _STACKING_TYPES:
stacking_total += mod.value
else:
prev = best_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())