feat(effects): add effect system with PF1e bonus stacking rules
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user