"""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", ] _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())