97c0431479
Power Attack is the first active feat: a free-action toggle declared at turn start. When state.power_attack is True and the combatant has the POWER_ATTACK feature, melee attacks apply -X to the attack roll and +2X to damage, where X = BAB//4 + 1 (min 1). Ranged attacks are unaffected. The flag is cleared at the start of each turn via _take_turn, like other per-turn state. - abilities.py: POWER_ATTACK constant (AbilitySpec, no passive effects) - combat.py: _has_feat helper, _power_attack_amt, resolve_attack applies penalty to attack total + crit confirm, bonus to damage - tests/test_power_attack.py: 14 tests (amount at BAB 1/4/8/12, flag false, feat missing, attack penalty, damage bonus, ranged unaffected, flag cleared on turn) - pyproject.toml: add SLF001 + RUF059 to test per-file-ignores (white-box testing accesses private helpers, partial tuple unpacking) - README.md: document Power Attack in abilities.py section, 307 tests
150 lines
4.4 KiB
Python
150 lines
4.4 KiB
Python
"""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"),
|
|
),
|
|
)
|
|
|
|
# Power Attack: active feat — declare at turn start, -X atk / +2X dmg on all
|
|
# melee attacks this turn, where X = BAB//4 + 1 (min 1). No passive modifiers;
|
|
# the engine checks for this feat and the ``power_attack`` flag on state.
|
|
POWER_ATTACK = AbilitySpec(
|
|
name="Power Attack",
|
|
category="feat",
|
|
source_detail="feat lv1",
|
|
)
|