feat(data): wire all 55 PC feats
This commit is contained in:
@@ -8,13 +8,14 @@ as-is.
|
|||||||
|
|
||||||
Feat names are matched case-insensitively. Known feats (Power Attack,
|
Feat names are matched case-insensitively. Known feats (Power Attack,
|
||||||
Iron Will, Weapon Focus, Toughness, etc.) map to their ``AbilitySpec``
|
Iron Will, Weapon Focus, Toughness, etc.) map to their ``AbilitySpec``
|
||||||
constants. Unknown feat names produce a minimal ``AbilitySpec`` with no
|
constants. Unmatched feat names produce a traced no-op (debug log +
|
||||||
mechanical effects — the data is preserved for future implementation.
|
minimal ``AbilitySpec``) so no name silently disappears.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@@ -26,20 +27,66 @@ if TYPE_CHECKING:
|
|||||||
from pf1e_simulator.loaders.foundry import Json
|
from pf1e_simulator.loaders.foundry import Json
|
||||||
|
|
||||||
from pf1e_simulator.abilities import (
|
from pf1e_simulator.abilities import (
|
||||||
|
ADOPTED,
|
||||||
ALERTNESS,
|
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,
|
DODGE,
|
||||||
|
ESCHEW_MATERIALS,
|
||||||
|
EXALTED_SOCIETY,
|
||||||
|
EXTRA_HEX,
|
||||||
|
EXTREMELY_FASHIONABLE,
|
||||||
|
FATES_FAVORED,
|
||||||
|
FEY_TOUCHED,
|
||||||
|
FOCUSED_STUDY,
|
||||||
|
FORTIFIED_DRINKER,
|
||||||
|
FRIENDLY_FACE,
|
||||||
|
GIFTED_ADEPT,
|
||||||
GREAT_FORTITUDE,
|
GREAT_FORTITUDE,
|
||||||
|
GUNSMITHING,
|
||||||
|
HALF_FORGOTTEN_SECRETS,
|
||||||
|
HARMONIC_SPELL,
|
||||||
IRON_WILL,
|
IRON_WILL,
|
||||||
|
LARGER_THAN_LIFE,
|
||||||
LIGHTNING_REFLEXES,
|
LIGHTNING_REFLEXES,
|
||||||
|
LIKE_THE_WIND,
|
||||||
|
LONELY,
|
||||||
|
MAW_OR_CLAW,
|
||||||
|
MOUNTED_COMBAT,
|
||||||
|
PINGRE,
|
||||||
POINT_BLANK_SHOT,
|
POINT_BLANK_SHOT,
|
||||||
POWER_ATTACK,
|
POWER_ATTACK,
|
||||||
PRECISE_SHOT,
|
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,
|
AbilitySpec,
|
||||||
toughness,
|
toughness,
|
||||||
weapon_focus,
|
weapon_focus,
|
||||||
)
|
)
|
||||||
from pf1e_simulator.models import Combatant
|
from pf1e_simulator.models import Combatant
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── Feat lookup ────────────────────────────────────────────────────────────────
|
# ── Feat lookup ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_WEAPON_FOCUS_RE = re.compile(r"^Weapon Focus \((.+)\)$", re.IGNORECASE)
|
_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,
|
"point-blank shot": POINT_BLANK_SHOT,
|
||||||
"precise shot": PRECISE_SHOT,
|
"precise shot": PRECISE_SHOT,
|
||||||
"dodge": DODGE,
|
"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:
|
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()
|
stripped = name.strip()
|
||||||
lower = stripped.lower()
|
lower = stripped.lower()
|
||||||
if lower in _KNOWN_FEATS:
|
if lower in _KNOWN_FEATS:
|
||||||
@@ -72,6 +167,10 @@ def _resolve_feat(name: str, level: int) -> AbilitySpec:
|
|||||||
wf_match = _WEAPON_FOCUS_RE.match(stripped)
|
wf_match = _WEAPON_FOCUS_RE.match(stripped)
|
||||||
if wf_match:
|
if wf_match:
|
||||||
return weapon_focus(wf_match.group(1))
|
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")
|
return AbilitySpec(name=stripped, category="feat")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Feat resolution for the 8 PC data files.
|
||||||
|
|
||||||
|
Every feature name string in each ``data/pcs/*.json`` file must resolve to a
|
||||||
|
real ``AbilitySpec`` (real effects where modeled, otherwise an attested traced
|
||||||
|
no-op) — never a silently empty spec. This test proves no feat in the 8 PCs is
|
||||||
|
silently unimplemented.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pf1e_simulator.abilities import (
|
||||||
|
COMBAT_REFLEXES,
|
||||||
|
MOUNTED_COMBAT,
|
||||||
|
POINT_BLANK_SHOT,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.loaders.combatant import (
|
||||||
|
_KNOWN_FEATS,
|
||||||
|
_NOOP_FEATS,
|
||||||
|
_resolve_feat,
|
||||||
|
)
|
||||||
|
|
||||||
|
PC_FILES = [
|
||||||
|
"data/pcs/esha.json",
|
||||||
|
"data/pcs/harvie.json",
|
||||||
|
"data/pcs/ierlieth.json",
|
||||||
|
"data/pcs/jeanne.json",
|
||||||
|
"data/pcs/misty.json",
|
||||||
|
"data/pcs/nairda.json",
|
||||||
|
"data/pcs/oni.json",
|
||||||
|
"data/pcs/tammara.json",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _pcs_features() -> set[str]:
|
||||||
|
names: set[str] = set()
|
||||||
|
for path in PC_FILES:
|
||||||
|
with Path(path).open(encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
for feat in data.get("features", []):
|
||||||
|
if isinstance(feat, str) and feat.strip():
|
||||||
|
names.add(feat.strip())
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _all_feature_names() -> list[tuple[str, str, int]]:
|
||||||
|
rows: list[tuple[str, str, int]] = []
|
||||||
|
for path in PC_FILES:
|
||||||
|
with Path(path).open(encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
for feat in data.get("features", []):
|
||||||
|
if isinstance(feat, str) and feat.strip():
|
||||||
|
level = data.get("level", 1)
|
||||||
|
rows.append((path, feat.strip(), level if isinstance(level, int) else 1))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_55_unique_feature_names_across_8_pcs() -> None:
|
||||||
|
assert len(_pcs_features()) == 55
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_feat_in_8_pcs_is_silently_unresolved() -> None:
|
||||||
|
"""Every feature either maps to a known spec or is an attested traced-no-op.
|
||||||
|
|
||||||
|
This is the core acceptance: no feat in the 8 JSONs is silently
|
||||||
|
unimplemented (an unmatched name would be a silent empty spec).
|
||||||
|
"""
|
||||||
|
unresolved = []
|
||||||
|
for path, name, level in _all_feature_names():
|
||||||
|
spec = _resolve_feat(name, level)
|
||||||
|
lower = name.lower()
|
||||||
|
if lower in _KNOWN_FEATS:
|
||||||
|
expected = _KNOWN_FEATS[lower].name
|
||||||
|
elif lower == "toughness":
|
||||||
|
expected = "Toughness"
|
||||||
|
elif lower.startswith("weapon focus (") or lower in _NOOP_FEATS:
|
||||||
|
expected = name
|
||||||
|
else:
|
||||||
|
unresolved.append(name)
|
||||||
|
continue
|
||||||
|
assert spec.name == expected, f"{path}: {name!r} -> {spec.name!r} != {expected!r}"
|
||||||
|
assert unresolved == [], (
|
||||||
|
f"{len(unresolved)} feats silently unresolved: {sorted(set(unresolved))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spot_check_real_effects() -> None:
|
||||||
|
assert _resolve_feat("Point-Blank Shot", 1) is POINT_BLANK_SHOT
|
||||||
|
assert POINT_BLANK_SHOT.effects != ()
|
||||||
|
assert _resolve_feat("Combat Reflexes", 1) is COMBAT_REFLEXES
|
||||||
|
assert _resolve_feat("Mounted Combat", 1) is MOUNTED_COMBAT
|
||||||
|
assert MOUNTED_COMBAT.effects != ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_spot_check_weapon_focus_pistol() -> None:
|
||||||
|
spec = _resolve_feat("Weapon Focus (Pistol)", 1)
|
||||||
|
assert spec.name == "Weapon Focus (Pistol)"
|
||||||
|
assert any("Pistol" in str(e.weapon_filter) for e in spec.effects)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spot_check_toughness_level_tied() -> None:
|
||||||
|
spec = _resolve_feat("Toughness", 5)
|
||||||
|
assert spec.name == "Toughness"
|
||||||
|
assert any(e.value == 5 for e in spec.effects)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Expected mapping table (name -> resolved display name or traced-no-op) ─────
|
||||||
|
|
||||||
|
_EXPECTED_RESOLUTION: dict[str, str] = {
|
||||||
|
"6. Vert - Yes Mistress": "6. Vert - Yes Mistress",
|
||||||
|
"Adopted": "Adopted",
|
||||||
|
"Alertness": "Alertness",
|
||||||
|
"Ambitious": "Ambitious",
|
||||||
|
"Animal (race)": "Animal (race)",
|
||||||
|
"Armor Proficiency (Light)": "Armor Proficiency (Light)",
|
||||||
|
"Armor Proficiency (Medium)": "Armor Proficiency (Medium)",
|
||||||
|
"Axe to Grind (combat)": "Axe to Grind",
|
||||||
|
"Bitter": "Bitter",
|
||||||
|
"Born Damned (race: tiefling)": "Born Damned (race: tiefling)",
|
||||||
|
"Breeze-Kissed": "Breeze-Kissed",
|
||||||
|
"Brew Potion": "Brew Potion",
|
||||||
|
"Combat Reflexes": "Combat Reflexes",
|
||||||
|
"Craft Wondrous Item": "Craft Wondrous Item",
|
||||||
|
"Dazzling Display": "Dazzling Display",
|
||||||
|
"Drake (race)": "Drake (race)",
|
||||||
|
"Eschew Materials": "Eschew Materials",
|
||||||
|
"Exalted of the Society (Cleric, Pathfinder Society)": (
|
||||||
|
"Exalted of the Society (Cleric, Pathfinder Society)"
|
||||||
|
),
|
||||||
|
"Extra Hex": "Extra Hex",
|
||||||
|
"Extremely Fashionable": "Extremely Fashionable",
|
||||||
|
"Fate's Favored": "Fate's Favored",
|
||||||
|
"Fey-touched": "Fey-touched",
|
||||||
|
"Focused Study (trait racial humain — remplace le bonus feat)": "Focused Study",
|
||||||
|
"Fortified Drinker (Cayden Cailean)": "Fortified Drinker (Cayden Cailean)",
|
||||||
|
"Friendly Face": "Friendly Face",
|
||||||
|
"Gifted Adept (dispel magic; magic)": "Gifted Adept (dispel magic; magic)",
|
||||||
|
"Gunsmithing": "Gunsmithing",
|
||||||
|
"Half-Forgotten Secrets (Dhampir; Ajibachana)": "Half-Forgotten Secrets (Dhampir; Ajibachana)",
|
||||||
|
"Harmonic Spell": "Harmonic Spell",
|
||||||
|
"Larger Than Life": "Larger Than Life",
|
||||||
|
"Like the Wind": "Like the Wind",
|
||||||
|
"Lonely": "Lonely",
|
||||||
|
"Maw or Claw (Bite) (tiefling daemon-spawn)": "Maw or Claw (Bite) (tiefling daemon-spawn)",
|
||||||
|
"Mounted Combat": "Mounted Combat",
|
||||||
|
"Orange - Lazy Ass": "Orange - Lazy Ass",
|
||||||
|
"Pingre": "Pingre",
|
||||||
|
"Point-Blank Shot": "Point-Blank Shot",
|
||||||
|
"Power Attack": "Power Attack",
|
||||||
|
"Precise Shot": "Precise Shot",
|
||||||
|
"Prehensile Tail (tiefling daemon-spawn)": "Prehensile Tail (tiefling daemon-spawn)",
|
||||||
|
"Rapid Reload (Pistol)": "Rapid Reload (Pistol)",
|
||||||
|
"Sacred Summons": "Sacred Summons",
|
||||||
|
"Scaled Skin (electricity) (tiefling daemon-spawn)": (
|
||||||
|
"Scaled Skin (electricity) (tiefling daemon-spawn)"
|
||||||
|
),
|
||||||
|
"Selective Channeling": "Selective Channeling",
|
||||||
|
"Skill Focus (Perception)": "Skill Focus (Perception)",
|
||||||
|
"Spell Vulnerability (divination; drawback)": "Spell Vulnerability (divination; drawback)",
|
||||||
|
"Stealth Synergy": "Stealth Synergy",
|
||||||
|
"Summon Good Monster": "Summon Good Monster",
|
||||||
|
"Thoroughbred": "Thoroughbred",
|
||||||
|
"Thoroughbred (Human; Keleshite)": "Thoroughbred",
|
||||||
|
"Toughness": "Toughness",
|
||||||
|
"Vagabond Child (Urban)": "Vagabond Child",
|
||||||
|
"Weapon Finesse": "Weapon Finesse",
|
||||||
|
"Weapon Focus (Pistol)": "Weapon Focus (Pistol)",
|
||||||
|
"Whispering Wind": "Whispering Wind",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_pc_feature_resolves_to_expected_spec() -> None:
|
||||||
|
"""The full 55-name mapping table is satisfied by the loader."""
|
||||||
|
for name, expected_name in _EXPECTED_RESOLUTION.items():
|
||||||
|
spec = _resolve_feat(name, 1)
|
||||||
|
assert spec.name == expected_name, (
|
||||||
|
f"{name!r} resolved to {spec.name!r}, expected {expected_name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mapping_table_covers_exactly_the_8_pc_feats() -> None:
|
||||||
|
assert set(_EXPECTED_RESOLUTION) == _pcs_features()
|
||||||
Reference in New Issue
Block a user