"""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 TypeAdapter, 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, 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, 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, "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, "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. "Fey-touched" (Harvie's sheet) and "Pingre" (Nairda's sheet, # French for "miser") are not PF1e features; the SRD has no entry under those # names, so no effect can be attributed to them. _NOOP_FEATS: frozenset[str] = frozenset({ "6. vert - yes mistress", "animal (race)", "drake (race)", "fey-touched", "orange - lazy ass", "pingre", "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 _LEVEL_ADAPTER: TypeAdapter[int] = TypeAdapter(int) def _parse_level(raw_level: Json, path: Path) -> int: """Coerce the JSON ``level`` with the same lax rules Pydantic applies. Feat resolution (Toughness) needs the level before model validation; reading it with a bare ``isinstance(int)`` check disagreed with Pydantic, which also accepts numeric strings ("4") and lossless floats (4.0) -- the feat then silently used level 1 on a level-4 sheet. A value Pydantic would reject raises CombatantLoadError here. """ try: return _LEVEL_ADAPTER.validate_python(raw_level) except ValidationError as exc: msg = f"level must be an integer, got {raw_level!r}" raise CombatantLoadError(path, msg) from exc def _resolve_features( raw_features: list[Json], level: int, path: Path ) -> list[AbilitySpec]: """Resolve a list of feat name strings to AbilitySpec objects. Data contract (accept-string-or-error): every entry must be a non-blank string. A non-string entry (``42``, ``None``, a nested list) or a blank/whitespace-only string is a data error and raises ``CombatantLoadError`` -- nothing is silently dropped, so a feat can never disappear from a sheet without the loader failing loudly. """ specs: list[AbilitySpec] = [] for item in raw_features: if not isinstance(item, str) or not item.strip(): msg = f"features entries must be non-empty feat name strings, got {item!r}" raise CombatantLoadError(path, msg) specs.append(_resolve_feat(item, level)) return specs def _merge_resource_grants( raw_resources: Json, specs: list[AbilitySpec] ) -> dict[str, int]: """Merge the JSON resource pool with per-day grants from resolved features. Traits like Exalted of the Society ("You may channel energy one additional time per day") store their benefit in ``AbilitySpec.resource_grants`` because the engine keeps resource pools (``Combatant.resources`` -> ``CombatantState.resources_left``), not stat modifiers, for per-day uses. """ merged: dict[str, int] = {} if isinstance(raw_resources, dict): merged.update( { key: value for key, value in raw_resources.items() if isinstance(value, int) and not isinstance(value, bool) } ) for spec in specs: for resource_name, amount in spec.resource_grants: merged[resource_name] = merged.get(resource_name, 0) + amount return merged def _parse_spell_slot_keys(raw_slots: dict[str, Json], path: Path) -> dict[int, Json]: """Convert JSON ``spell_slots`` string keys to ints, failing cleanly. JSON object keys are always strings; the ``Combatant.spell_slots`` field is ``dict[int, int]``. A non-numeric key (e.g. ``"one": 4``) is a data error and must surface as a ``CombatantLoadError`` (CLI exit code 2), never as a bare ``ValueError`` traceback escaping the loader. """ parsed: dict[int, Json] = {} for key, value in raw_slots.items(): try: slot_level = int(key) except ValueError as exc: msg = f"spell_slots keys must be integer spell levels, got {key!r}" raise CombatantLoadError(path, msg) from exc parsed[slot_level] = value return parsed 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. The ``alignment`` code (SRD "Alignment") is a plain string of the schema (default ``"N"``), so a JSON ``alignment`` key loads directly too. The ``armor_spell_failure`` percent and the ``arcane_caster`` flag (SRD "Arcane Spells and Armor") are plain fields of the schema (defaults ``0`` and ``false``), so their JSON keys load directly as well. The ``carried_items`` list (APG "Steal" — non-wielded, stealable gear) is a plain ``tuple[str, ...]`` field of the schema (default empty), so a JSON ``carried_items`` key loads directly too. """ 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). A present # but non-list value (e.g. a bare string) used to be popped and dropped # silently, so the combatant lost every feat; it is now a data error. level = _parse_level(data.get("level", 1), path) had_features = "features" in data raw_features = data.pop("features", None) if had_features and not isinstance(raw_features, list): msg = f"features must be a list of feat name strings, got {raw_features!r}" raise CombatantLoadError(path, msg) validate_payload: dict[str, object] = dict(data) if isinstance(raw_features, list): specs = _resolve_features(raw_features, level, path) validate_payload["features"] = specs granted = _merge_resource_grants(data.get("resources"), specs) if granted: validate_payload["resources"] = granted # 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"] = _parse_spell_slot_keys(raw_slots, path) 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