9adc87c9e1
Feat resolution read level with an isinstance(int) check and fell back to 1, while Pydantic accepts numeric strings and lossless floats: a level-4 sheet authored as "4" got Toughness for level 1. _parse_level now runs the value through TypeAdapter(int) (same lax coercion as the model) and raises CombatantLoadError for values the model would reject.
357 lines
13 KiB
Python
357 lines
13 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 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
|