feat(data): wire all 55 PC feats

This commit is contained in:
2026-09-04 22:56:00 +02:00
parent 5353018c80
commit d0833c7a2c
2 changed files with 291 additions and 8 deletions
+107 -8
View File
@@ -8,13 +8,14 @@ as-is.
Feat names are matched case-insensitively. Known feats (Power Attack,
Iron Will, Weapon Focus, Toughness, etc.) map to their ``AbilitySpec``
constants. Unknown feat names produce a minimal ``AbilitySpec`` with no
mechanical effects — the data is preserved for future implementation.
constants. Unmatched feat names produce a traced no-op (debug log +
minimal ``AbilitySpec``) so no name silently disappears.
"""
from __future__ import annotations
import json
import logging
import re
from typing import TYPE_CHECKING
@@ -26,20 +27,66 @@ if TYPE_CHECKING:
from pf1e_simulator.loaders.foundry import Json
from pf1e_simulator.abilities import (
ADOPTED,
ALERTNESS,
AMBITIOUS,
ARMOR_PROFICIENCY_LIGHT,
ARMOR_PROFICIENCY_MEDIUM,
AXE_TO_GRIND,
BITTER,
BORN_DAMNED,
BREEZE_KISSED,
BREW_POTION,
COMBAT_REFLEXES,
CRAFT_WONDROUS_ITEM,
DAZZLING_DISPLAY,
DODGE,
ESCHEW_MATERIALS,
EXALTED_SOCIETY,
EXTRA_HEX,
EXTREMELY_FASHIONABLE,
FATES_FAVORED,
FEY_TOUCHED,
FOCUSED_STUDY,
FORTIFIED_DRINKER,
FRIENDLY_FACE,
GIFTED_ADEPT,
GREAT_FORTITUDE,
GUNSMITHING,
HALF_FORGOTTEN_SECRETS,
HARMONIC_SPELL,
IRON_WILL,
LARGER_THAN_LIFE,
LIGHTNING_REFLEXES,
LIKE_THE_WIND,
LONELY,
MAW_OR_CLAW,
MOUNTED_COMBAT,
PINGRE,
POINT_BLANK_SHOT,
POWER_ATTACK,
PRECISE_SHOT,
PREHENSILE_TAIL,
RAPID_RELOAD,
SACRED_SUMS,
SCALED_SKIN_ELECTRICITY,
SELECTIVE_CHANNELING,
SKILL_FOCUS_PERCEPTION,
SPELL_VULNERABILITY_DIVINATION,
STEALTH_SYNERGY,
SUMMON_GOOD_MONSTER,
THOROUGHBRED,
VAGABOND_CHILD,
WEAPON_FINESSE,
WHISPERING_WIND,
AbilitySpec,
toughness,
weapon_focus,
)
from pf1e_simulator.models import Combatant
logger = logging.getLogger(__name__)
# ── Feat lookup ────────────────────────────────────────────────────────────────
_WEAPON_FOCUS_RE = re.compile(r"^Weapon Focus \((.+)\)$", re.IGNORECASE)
@@ -53,16 +100,64 @@ _KNOWN_FEATS: dict[str, AbilitySpec] = {
"point-blank shot": POINT_BLANK_SHOT,
"precise shot": PRECISE_SHOT,
"dodge": DODGE,
"mounted combat": MOUNTED_COMBAT,
"stealth synergy": STEALTH_SYNERGY,
"adopted": ADOPTED,
"thoroughbred": THOROUGHBRED,
"thoroughbred (human; keleshite)": THOROUGHBRED,
"vagabond child (urban)": VAGABOND_CHILD,
"whispering wind": WHISPERING_WIND,
"breeze-kissed": BREEZE_KISSED,
"like the wind": LIKE_THE_WIND,
"friendly face": FRIENDLY_FACE,
"fey-touched": FEY_TOUCHED,
"combat reflexes": COMBAT_REFLEXES,
"armor proficiency (light)": ARMOR_PROFICIENCY_LIGHT,
"armor proficiency (medium)": ARMOR_PROFICIENCY_MEDIUM,
"harmonic spell": HARMONIC_SPELL,
"weapon finesse": WEAPON_FINESSE,
"axe to grind (combat)": AXE_TO_GRIND,
"spell vulnerability (divination; drawback)": SPELL_VULNERABILITY_DIVINATION,
"born damned (race: tiefling)": BORN_DAMNED,
"gifted adept (dispel magic; magic)": GIFTED_ADEPT,
"prehensile tail (tiefling daemon-spawn)": PREHENSILE_TAIL,
"scaled skin (electricity) (tiefling daemon-spawn)": SCALED_SKIN_ELECTRICITY,
"maw or claw (bite) (tiefling daemon-spawn)": MAW_OR_CLAW,
"brew potion": BREW_POTION,
"craft wondrous item": CRAFT_WONDROUS_ITEM,
"extra hex": EXTRA_HEX,
"eschew materials": ESCHEW_MATERIALS,
"half-forgotten secrets (dhampir; ajibachana)": HALF_FORGOTTEN_SECRETS,
"pingre": PINGRE,
"summon good monster": SUMMON_GOOD_MONSTER,
"sacred summons": SACRED_SUMS,
"selective channeling": SELECTIVE_CHANNELING,
"extremely fashionable": EXTREMELY_FASHIONABLE,
"exalted of the society (cleric, pathfinder society)": EXALTED_SOCIETY,
"bitter": BITTER,
"ambitious": AMBITIOUS,
"rapid reload (pistol)": RAPID_RELOAD,
"gunsmithing": GUNSMITHING,
"skill focus (perception)": SKILL_FOCUS_PERCEPTION,
"dazzling display": DAZZLING_DISPLAY,
"fortified drinker (cayden cailean)": FORTIFIED_DRINKER,
"fate's favored": FATES_FAVORED,
"lonely": LONELY,
"larger than life": LARGER_THAN_LIFE,
"focused study (trait racial humain \u2014 remplace le bonus feat)": FOCUSED_STUDY,
}
# Feat names with no combat-mechanical definition (flavor / race descriptors / jokes).
# These are resolved to traced no-ops so the loader never silently drops a name.
_NOOP_FEATS: frozenset[str] = frozenset({
"6. vert - yes mistress",
"animal (race)",
"drake (race)",
"orange - lazy ass",
})
def _resolve_feat(name: str, level: int) -> AbilitySpec:
"""Resolve a feat name string to an AbilitySpec.
Known feats → their constant. Toughness → factory with level.
Weapon Focus (X) → factory with weapon name. Unknown feats → minimal
AbilitySpec with no effects (data preserved for future implementation).
"""
stripped = name.strip()
lower = stripped.lower()
if lower in _KNOWN_FEATS:
@@ -72,6 +167,10 @@ def _resolve_feat(name: str, level: int) -> AbilitySpec:
wf_match = _WEAPON_FOCUS_RE.match(stripped)
if wf_match:
return weapon_focus(wf_match.group(1))
if lower in _NOOP_FEATS:
logger.debug("feat traced-noop: %r", stripped)
return AbilitySpec(name=stripped, category="feat")
logger.debug("feat unmatched (no definition): %r", stripped)
return AbilitySpec(name=stripped, category="feat")