Files
pf1e-simulator/src/pf1e_simulator/loaders/combatant.py
T
ctan 9adc87c9e1 fix(loaders): coerce combatant level with Pydantic-consistent rules
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.
2026-09-20 17:05:36 +02:00

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