265 lines
9.0 KiB
Python
265 lines
9.0 KiB
Python
"""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
|