feat(abilities): add passive feats system (Weapon Focus, Iron Will, Dodge, Point-Blank Shot)
This commit is contained in:
@@ -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.
|
||||
@@ -357,11 +357,25 @@ class CombatEngine:
|
||||
return _FLANK_BONUS
|
||||
return 0
|
||||
|
||||
def _stat_modifiers(self, state: CombatantState, target: str) -> list[StatModifier]:
|
||||
"""Collect all StatModifiers for ``target`` from effects + conditions."""
|
||||
def _stat_modifiers(
|
||||
self, state: CombatantState, target: str, *, weapon_name: str | None = None
|
||||
) -> list[StatModifier]:
|
||||
"""Collect all StatModifiers for ``target`` from effects, conditions, and features.
|
||||
|
||||
``weapon_name`` filters out modifiers with a ``weapon_filter`` that
|
||||
doesn't match (e.g. Weapon Focus (Pistol) only applies to "Pistol").
|
||||
Modifiers without a ``weapon_filter`` are always included.
|
||||
"""
|
||||
mods = [e for e in state.effects if e.target == target]
|
||||
for cond in state.conditions:
|
||||
mods.extend(m for m in cond.modifiers if m.target == target)
|
||||
for feat in state.combatant.features:
|
||||
for m in feat.effects:
|
||||
if m.target != target:
|
||||
continue
|
||||
if m.weapon_filter is not None and m.weapon_filter != weapon_name:
|
||||
continue
|
||||
mods.append(m)
|
||||
return mods
|
||||
|
||||
def resolve_attack(
|
||||
@@ -378,7 +392,7 @@ class CombatEngine:
|
||||
penalty = self.range_penalty(weapon, dist_ft)
|
||||
flank = self._flanking_bonus(attacker, target) if weapon.kind == "melee" else 0
|
||||
base_bonus = bonus_override if bonus_override is not None else weapon.attack_bonus
|
||||
atk_mods = self._stat_modifiers(attacker, "attack")
|
||||
atk_mods = self._stat_modifiers(attacker, "attack", weapon_name=weapon.name)
|
||||
attack_mod = resolve_modifiers(atk_mods)
|
||||
total = roll + base_bonus + penalty + flank + attack_mod
|
||||
ac = target.combatant.ac.total + resolve_modifiers(self._stat_modifiers(target, "ac"))
|
||||
@@ -401,7 +415,7 @@ class CombatEngine:
|
||||
confirm = self._rng.d20()
|
||||
confirm_total = confirm + base_bonus + penalty + flank + attack_mod
|
||||
crit = confirm != _NATURAL_ONE and confirm_total >= ac
|
||||
dmg_mods = self._stat_modifiers(attacker, "damage")
|
||||
dmg_mods = self._stat_modifiers(attacker, "damage", weapon_name=weapon.name)
|
||||
damage = sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
|
||||
damage += resolve_modifiers(dmg_mods)
|
||||
if crit:
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from pf1e_simulator.abilities import AbilitySpec # noqa: TC001
|
||||
from pf1e_simulator.dice import DiceExpr, DiceParseError, parse_dice
|
||||
|
||||
|
||||
@@ -110,3 +111,4 @@ class Combatant(_FrozenModel):
|
||||
xp: int | None = None
|
||||
source: str = ""
|
||||
notes: str = ""
|
||||
features: list[AbilitySpec] = []
|
||||
|
||||
Reference in New Issue
Block a user