135 lines
5.1 KiB
Python
135 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",
|
|
]
|
|
|
|
_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())
|