"""Unified loader for combatant JSON files (PCs and monsters). Both player characters (``data/pcs/*.json``) and monsters (``data/monsters/*.json``) share the same ``Combatant`` Pydantic schema. This loader resolves ``features`` (list of feat name strings) to ``AbilitySpec`` objects via a lookup table and passes ``spells`` through as-is. Feat names are matched case-insensitively. Known feats (Power Attack, Iron Will, Weapon Focus, Toughness, etc.) map to their ``AbilitySpec`` 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 from pydantic import ValidationError if TYPE_CHECKING: from pathlib import Path 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) _KNOWN_FEATS: dict[str, AbilitySpec] = { "power attack": POWER_ATTACK, "iron will": IRON_WILL, "great fortitude": GREAT_FORTITUDE, "lightning reflexes": LIGHTNING_REFLEXES, "alertness": ALERTNESS, "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", "plot armor", }) def _resolve_feat(name: str, level: int) -> AbilitySpec: stripped = name.strip() lower = stripped.lower() if lower in _KNOWN_FEATS: return _KNOWN_FEATS[lower] if lower == "toughness": return toughness(level) 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") # ── Loader ───────────────────────────────────────────────────────────────────── class CombatantLoadError(Exception): """Raised when a combatant JSON file cannot be loaded.""" def __init__(self, path: Path, reason: str) -> None: super().__init__(f"{path}: {reason}") self.path = path self.reason = reason def _resolve_features(raw_features: list[Json], level: int) -> list[AbilitySpec]: """Resolve a list of feat name strings to AbilitySpec objects.""" return [ _resolve_feat(item, level) for item in raw_features if isinstance(item, str) and item.strip() ] def _auto_attack_ids(data: dict[str, Json], monster_id: str) -> None: """Auto-assign attack ids from file stem if missing.""" attacks = data.get("attacks") if isinstance(attacks, list): for index, attack in enumerate(attacks): if isinstance(attack, dict) and "id" not in attack: attack["id"] = f"{monster_id}:{index}" def load_combatant(path: Path) -> Combatant: """Load a combatant JSON file (PC or monster) into a ``Combatant``. ``features`` in the JSON are a list of feat name strings resolved via ``_resolve_feat``. ``spells`` are passed through as-is (list of strings). The ``aware`` flag (SRD "Surprise") is a plain boolean of the ``Combatant`` schema (default ``true``), so it parses directly here. """ try: raw = path.read_text(encoding="utf-8") except OSError as exc: msg = f"cannot read file: {exc}" raise CombatantLoadError(path, msg) from exc try: data: Json = json.loads(raw) except json.JSONDecodeError as exc: msg = f"invalid JSON: {exc}" raise CombatantLoadError(path, msg) from exc if not isinstance(data, dict): msg = "combatant file must contain a JSON object" raise CombatantLoadError(path, msg) # Auto-assign monster/PC id from file stem if missing monster_id = data.get("id") if not isinstance(monster_id, str) or not monster_id: monster_id = path.stem data["id"] = monster_id _auto_attack_ids(data, monster_id) # Resolve features (list of strings → list of AbilitySpec) raw_level = data.get("level") level = raw_level if isinstance(raw_level, int) and not isinstance(raw_level, bool) else 1 raw_features = data.pop("features", None) validate_payload: dict[str, object] = dict(data) if isinstance(raw_features, list): validate_payload["features"] = _resolve_features(raw_features, level) # spells stays as list[str] — Pydantic validates it directly raw_slots = data.pop("spell_slots", None) if isinstance(raw_slots, dict): validate_payload["spell_slots"] = {int(k): v for k, v in raw_slots.items()} try: return Combatant.model_validate(validate_payload) except ValidationError as exc: raise CombatantLoadError(path, str(exc)) from exc # ── Backward-compatible alias ────────────────────────────────────────────────── def load_monster(path: Path) -> Combatant: """Alias for ``load_combatant`` (backward compatibility).""" return load_combatant(path) MonsterLoadError = CombatantLoadError