refactor(loaders): unify combatant loader, migrate PC features/spells
Rename monster.py → combatant.py with _KNOWN_FEATS lookup table that resolves feat name strings to AbilitySpec constants (Power Attack, Iron Will, Toughness, Weapon Focus, etc.). Unknown feats produce minimal AbilitySpec (data preserved for future implementation). Migrate all 8 PC JSONs (data/pcs/) with complete feat/spell lists extracted from Foundry sheets — including traits, racial abilities, and all spells. Monsters (goblin, orc) unchanged (no feats/spells). Update all imports, add backward-compatible aliases (load_monster, MonsterLoadError), update CLI to use load_combatant. Gate: 377 tests, ruff clean, basedpyright clean.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""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. Unknown feat names produce a minimal ``AbilitySpec`` with no
|
||||
mechanical effects — the data is preserved for future implementation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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 (
|
||||
ALERTNESS,
|
||||
DODGE,
|
||||
GREAT_FORTITUDE,
|
||||
IRON_WILL,
|
||||
LIGHTNING_REFLEXES,
|
||||
POINT_BLANK_SHOT,
|
||||
POWER_ATTACK,
|
||||
PRECISE_SHOT,
|
||||
AbilitySpec,
|
||||
toughness,
|
||||
weapon_focus,
|
||||
)
|
||||
from pf1e_simulator.models import Combatant
|
||||
|
||||
# ── 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,
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
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))
|
||||
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).
|
||||
"""
|
||||
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)
|
||||
level = data.get("level", 1) if isinstance(data.get("level"), int) else 1
|
||||
raw_features = data.pop("features", None)
|
||||
if isinstance(raw_features, list):
|
||||
# Type ignore: we assign AbilitySpec list into a Json dict before Pydantic validation
|
||||
data["features"] = _resolve_features(raw_features, level) # type: ignore[assignment]
|
||||
|
||||
# spells stays as list[str] — Pydantic validates it directly
|
||||
|
||||
try:
|
||||
return Combatant.model_validate(data)
|
||||
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
|
||||
Reference in New Issue
Block a user