Files
pf1e-simulator/src/pf1e_simulator/effects.py
T
2026-09-04 23:02:20 +02:00

136 lines
5.1 KiB
Python

"""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 Iterable
from pf1e_simulator.conditions import Condition
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",
# Skills (37 PF1e skills)
"acrobatics", "appraise", "bluff", "climb", "craft",
"diplomacy", "disable_device", "disguise", "escape_artist", "fly",
"handle_animal", "heal", "intimidate", "knowledge_arcana",
"knowledge_dungeoneering", "knowledge_engineering", "knowledge_geography",
"knowledge_history", "knowledge_local", "knowledge_nature",
"knowledge_nobility", "knowledge_planes", "knowledge_religion",
"linguistics", "perception", "perform", "profession", "ride",
"sense_motive", "sleight_of_hand", "spellcraft", "stealth",
"survival", "swim", "use_magic_device",
"dex_skills",
]
_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
@dataclass(frozen=True)
class ActiveBuff:
"""A temporary buff with a remaining duration in rounds.
- ``modifiers``: the stat modifiers this buff provides.
- ``rounds_left``: how many more rounds this buff lasts (decremented at
the start of the affected creature's turn). When it reaches 0, the buff
expires and is removed.
- ``source``: a human-readable description of what created this buff
(e.g. "spell: bull's strength", "charge", "class ability: inspire courage").
"""
modifiers: tuple[StatModifier, ...]
rounds_left: int
source: str
@dataclass(frozen=True)
class ActiveCondition:
"""A temporary condition with a remaining duration in rounds.
- ``condition``: the Condition definition (modifiers + behavioral flags).
- ``rounds_left``: how many more rounds this condition lasts (decremented at
the start of the affected creature's turn). When it reaches 0, the condition
expires and is removed.
- ``source``: a human-readable description of what created this condition
(e.g. "spell: hold person", "monster ability: fear aura").
"""
condition: Condition
rounds_left: int
source: str
def resolve_modifiers(modifiers: Iterable[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())