"""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.combat import CombatantState, CombatEngine from pf1e_simulator.grid import Grid from pf1e_simulator.loaders.combatant import ( _KNOWN_FEATS, _NOOP_FEATS, _resolve_feat, load_combatant, ) from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.rng import ScriptedRng 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_weapon_focus_applies_to_real_tammara_pistols() -> None: # The feat filter "Pistol" must match tammara's real weapon names # ("Braises d'Automne (pistolet ?)", "Adieux du Printemps # (pistolet, masterwork)") via case-insensitive substring matching, # and must NOT match her rapier or alchemist's fire. tammara = load_combatant(Path("data/pcs/tammara.json")) state = CombatantState( combatant=tammara, side="players", pos=(0, 0), hp=tammara.hp_max ) legend = {".": TerrainType(type="floor", move_cost=1)} grid = Grid.from_spec(MapSpec(name="t", terrain=tuple([".." for _ in range(2)]), legend=legend)) engine = CombatEngine(ScriptedRng([]), grid, [state]) def attack_total(weapon_name: str | None) -> int: mods = engine._stat_modifiers(state, "attack", weapon_name=weapon_name) return sum(m.value for m in mods if m.weapon_filter is not None) assert attack_total("Braises d'Automne (pistolet ?)") == 1 assert attack_total("Adieux du Printemps (pistolet, masterwork)") == 1 assert attack_total("Rapier (masterwork)") == 0 assert attack_total("Alchemist's Fire") == 0 assert attack_total(None) == 0 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) def test_toughness_is_plus3_below_four_hd_and_plus_hd_above() -> None: # CRB Toughness: "+3 hit points. If the character has 4 or more Hit # Dice, she gains +1 hit point per Hit Die." def hp_value(level: int) -> int: spec = _resolve_feat("Toughness", level) values = [e.value for e in spec.effects if e.target == "hp"] assert len(values) == 1 return values[0] assert hp_value(1) == 3 assert hp_value(2) == 3 assert hp_value(3) == 3 assert hp_value(4) == 4 # ── 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()