feat(abilities): add passive feats system (Weapon Focus, Iron Will, Dodge, Point-Blank Shot)

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent a32444f804
commit ba1c3c2117
5 changed files with 439 additions and 9 deletions
+143
View File
@@ -0,0 +1,143 @@
"""PF1e abilities: feats, class features, racial traits, character traits.
All four categories share the same mechanical representation via ``AbilitySpec``.
What differs is the ``category`` (for stacking and display) and ``source_detail``.
This step defines passive feats — permanent stat modifiers that the engine
collects alongside effects and conditions via ``_stat_modifiers``. Active feats
(Power Attack, Rage) and conditional feats (Sneak Attack) will be added in
later steps.
Sources: PRPG Core Rulebook, Feats chapter.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from pf1e_simulator.effects import StatModifier
Category = Literal["feat", "class", "racial", "trait"]
@dataclass(frozen=True)
class AbilitySpec:
"""One feat, class feature, racial trait, or character trait.
``effects`` are the StatModifiers this ability grants. For passive feats,
these are always-on modifiers. For conditional feats, ``condition`` on
each StatModifier gates applicability (evaluated by the engine).
"""
name: str
category: Category
source_detail: str = ""
effects: tuple[StatModifier, ...] = ()
# ── Passive combat feats ─────────────────────────────────────────────────────
# Weapon Focus: +1 attack with a specific weapon (untyped, CRB)
def weapon_focus(weapon_name: str) -> AbilitySpec:
return AbilitySpec(
name=f"Weapon Focus ({weapon_name})",
category="feat",
source_detail="feat lv1",
effects=(
StatModifier(target="attack", value=1, weapon_filter=weapon_name),
),
)
# Toughness: +1 HP per Hit Die (approximated as +level HP, untyped, CRB)
# For simplicity at this stage, we model it as a flat HP bonus.
# The engine doesn't yet compute per-HD bonuses, so the caller sets the value.
def toughness(level: int) -> AbilitySpec:
return AbilitySpec(
name="Toughness",
category="feat",
source_detail="feat lv1",
effects=(
StatModifier(target="hp", value=level),
),
)
# Iron Will: +2 will saves (untyped, CRB)
IRON_WILL = AbilitySpec(
name="Iron Will",
category="feat",
source_detail="feat lv1",
effects=(
StatModifier(target="will", value=2),
),
)
# Great Fortitude: +2 fortitude saves (untyped, CRB)
GREAT_FORTITUDE = AbilitySpec(
name="Great Fortitude",
category="feat",
source_detail="feat lv1",
effects=(
StatModifier(target="fort", value=2),
),
)
# Lightning Reflexes: +2 reflex saves (untyped, CRB)
LIGHTNING_REFLEXES = AbilitySpec(
name="Lightning Reflexes",
category="feat",
source_detail="feat lv1",
effects=(
StatModifier(target="ref", value=2),
),
)
# Alertness: +2 Perception and Sense Motive (untyped, CRB)
# Modeled as +2 will (Wisdom-based) since skills aren't implemented
ALERTNESS = AbilitySpec(
name="Alertness",
category="feat",
source_detail="feat lv1",
effects=(
StatModifier(target="will", value=2),
),
)
# Point-Blank Shot: +1 attack and +1 damage with ranged weapons within 30 ft.
# Untyped, conditional (CRB).
POINT_BLANK_SHOT = AbilitySpec(
name="Point-Blank Shot",
category="feat",
source_detail="feat lv1",
effects=(
StatModifier(
target="attack", value=1, condition="target_within_30ft_ranged"
),
StatModifier(
target="damage", value=1, condition="target_within_30ft_ranged"
),
),
)
# Precise Shot: no -4 penalty for shooting into melee (CRB)
# This is a behavioral flag, not a stat modifier — it will be handled
# as a SpecialBehavior in a later step. Defined here for completeness.
PRECISE_SHOT = AbilitySpec(
name="Precise Shot",
category="feat",
source_detail="feat lv1",
)
# Dodge: +1 dodge bonus to AC (dodge type, stacks, CRB)
DODGE = AbilitySpec(
name="Dodge",
category="feat",
source_detail="feat lv1",
effects=(
StatModifier(target="ac", value=1, bonus_type="dodge"),
),
)
# Skill Focus: +3 to a skill (not yet modeled — skills deferred)
# Defined as a placeholder for future expansion.