From d0833c7a2c8757aea426e55b54838bc94992be10 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:56:00 +0200 Subject: [PATCH] feat(data): wire all 55 PC feats --- src/pf1e_simulator/loaders/combatant.py | 115 +++++++++++++-- tests/test_feats.py | 184 ++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 8 deletions(-) create mode 100644 tests/test_feats.py diff --git a/src/pf1e_simulator/loaders/combatant.py b/src/pf1e_simulator/loaders/combatant.py index e01aca9..22b46e9 100644 --- a/src/pf1e_simulator/loaders/combatant.py +++ b/src/pf1e_simulator/loaders/combatant.py @@ -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") diff --git a/tests/test_feats.py b/tests/test_feats.py new file mode 100644 index 0000000..4040f01 --- /dev/null +++ b/tests/test_feats.py @@ -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()