"""Loader for Foundry VTT pf1-sheet/v1 character exports. Sheets are trusted but loosely typed JSON: every value is checked as it is pulled out. Unusable attack entries are skipped with a recorded reason; broken core stats abort the load with a typed error. """ from __future__ import annotations import json import re from typing import TYPE_CHECKING, Literal if TYPE_CHECKING: from pathlib import Path from pydantic import BaseModel, ConfigDict from pf1e_simulator.abilities import ( ALERTNESS, DODGE, GREAT_FORTITUDE, IRON_WILL, LIGHTNING_REFLEXES, POINT_BLANK_SHOT, POWER_ATTACK, PRECISE_SHOT, AbilitySpec, weapon_focus, ) from pf1e_simulator.dice import DiceParseError, parse_dice from pf1e_simulator.models import ( AbilityScores, ACProfile, AttackSpec, Combatant, DamageComponent, Saves, SkillRank, ) type Json = dict[str, Json] | list[Json] | str | int | float | bool | None _ACTION_KINDS: dict[str, Literal["melee", "ranged", "touch"]] = { "mwak": "melee", "rwak": "ranged", "twak": "touch", } _SIZE_ROLL_RE = re.compile(r"sizeRoll\(\s*(\d+)\s*,\s*(\d+)\s*,\s*@size\s*\)") _COUNT_PREFIX_RE = re.compile(r"^(\d+)x\s") _WEAPON_FOCUS_RE = re.compile(r"^Weapon Focus \((.+)\)$", re.IGNORECASE) _FEAT_MAP: dict[str, AbilitySpec] = { "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, "power attack": POWER_ATTACK, } def _match_feat(name: str) -> AbilitySpec | None: lower = name.strip().lower() if lower in _FEAT_MAP: return _FEAT_MAP[lower] wf_match = _WEAPON_FOCUS_RE.match(name.strip()) if wf_match: return weapon_focus(wf_match.group(1)) return None def _extract_features(feats_section: Json) -> list[AbilitySpec]: if not isinstance(feats_section, dict): return [] features: list[AbilitySpec] = [] seen: set[str] = set() for key in ("feats", "traits", "racial"): items = feats_section.get(key) if not isinstance(items, list): continue for item in items: if not isinstance(item, str): continue spec = _match_feat(item) if spec is not None and spec.name not in seen: features.append(spec) seen.add(spec.name) return features def _extract_spells(spellcasting: Json) -> list[str]: if not isinstance(spellcasting, dict): return [] spells = spellcasting.get("spells") if not isinstance(spells, list): return [] names: list[str] = [] seen: set[str] = set() for entry in spells: if not isinstance(entry, dict): continue name = entry.get("name") if isinstance(name, str) and name and name not in seen: names.append(name) seen.add(name) return names def _extract_skills(skills_section: Json) -> dict[str, SkillRank]: """Extract skills from Foundry sheet skills section.""" if not isinstance(skills_section, dict): return {} skills: dict[str, SkillRank] = {} for name, data in skills_section.items(): if not isinstance(data, dict): continue key = name.lower().replace(" ", "_").replace("(", "").replace(")", "") # Handle special cases key = key.replace("knowledge_", "knowledge_") rank = data.get("rank", 0) class_skill = data.get("classSkill", False) armor_penalty = data.get("armorPenalty", 0) if isinstance(armor_penalty, bool): armor_penalty = 0 if isinstance(rank, bool): rank = 0 skills[key] = SkillRank( rank=int(rank) if isinstance(rank, (int, float)) else 0, class_skill=bool(class_skill), armor_penalty=int(armor_penalty) if isinstance(armor_penalty, (int, float)) else 0, ) return skills class SheetLoadError(Exception): """Raised when a sheet cannot be loaded; carries path and reason.""" def __init__(self, path: Path, reason: str) -> None: super().__init__(f"{path}: {reason}") self.path = path self.reason = reason class _SkipAttackError(Exception): """Internal control flow: one attack entry is unusable, the sheet is fine.""" class SkippedEntry(BaseModel): model_config = ConfigDict(frozen=True) weapon: str reason: str class LoadReport(BaseModel): model_config = ConfigDict(frozen=True) path: str kept: int skipped: tuple[SkippedEntry, ...] class _Reader: """Typed navigation over the raw sheet JSON, failing with SheetLoadError.""" def __init__(self, path: Path) -> None: self._path = path def as_dict(self, value: Json, what: str) -> dict[str, Json]: if not isinstance(value, dict): msg = f"{what} must be an object, got {type(value).__name__}" raise SheetLoadError(self._path, msg) return value def req_int(self, obj: dict[str, Json], key: str, what: str) -> int: value = obj.get(key) if isinstance(value, bool) or not isinstance(value, int): msg = f"{what} must be an integer, got {value!r}" raise SheetLoadError(self._path, msg) return value def req_str(self, obj: dict[str, Json], key: str, what: str) -> str: value = obj.get(key) if not isinstance(value, str) or not value: msg = f"{what} must be a non-empty string, got {value!r}" raise SheetLoadError(self._path, msg) return value def ability(self, scores: dict[str, Json], key: str) -> int: block = self.as_dict(scores.get(key), f"abilities.{key}") return self.req_int(block, "value", f"abilities.{key}.value") def save_total(self, saves: dict[str, Json], key: str) -> int: block = self.as_dict(saves.get(key), f"savingThrows.{key}") return self.req_int(block, "total", f"savingThrows.{key}.total") def speed(self, value: Json, what: str) -> int | None: """Speeds are a plain int (0 = no such speed) or a {base, total} dict.""" if value is None: return None if isinstance(value, bool): msg = f"{what} must be a speed, got {value!r}" raise SheetLoadError(self._path, msg) if isinstance(value, int): return value if value > 0 else None if isinstance(value, dict): candidate = _opt_int(value.get("total")) if candidate is None: candidate = _opt_int(value.get("base")) return candidate if candidate is None or candidate > 0 else None msg = f"{what} must be a speed, got {type(value).__name__}" raise SheetLoadError(self._path, msg) def _opt_int(value: Json) -> int | None: if isinstance(value, bool): return None return value if isinstance(value, int) else None def _parse_damage_components( raw: Json, weapon: str, reader: _Reader ) -> list[DamageComponent]: if not isinstance(raw, list) or not raw: msg = "no damage entries" raise _SkipAttackError(msg) components: list[DamageComponent] = [] for item in raw: entry = reader.as_dict(item, f"damage component of {weapon}") formula = entry.get("formula") if not isinstance(formula, str) or not formula.strip(): continue # Foundry encodes size-dependent dice as sizeRoll(n, d, @size); every # sheet in this campaign is size-normalized already, so n d d is exact. normalized = _SIZE_ROLL_RE.sub(r"\1d\2", formula.strip()) try: expr = parse_dice(normalized) except DiceParseError: msg = f"unparseable damage formula {formula!r}" raise _SkipAttackError(msg) from None raw_types = entry.get("types") types = [t for t in raw_types if isinstance(t, str)] if isinstance(raw_types, list) else [] components.append(DamageComponent(formula=expr, types=types or ["untyped"])) if not components: msg = "no parseable damage formula" raise _SkipAttackError(msg) return components def _parse_attack(entry: Json, index: int, sheet_id: str, reader: _Reader) -> AttackSpec: data = reader.as_dict(entry, "attack entry") weapon = data.get("weapon") name = weapon.strip() if isinstance(weapon, str) else "" if not name: msg = "empty weapon name" raise _SkipAttackError(msg) action = data.get("actionType") kind = _ACTION_KINDS.get(action) if isinstance(action, str) else None if kind is None: msg = f"actionType {action!r} is not an attack" raise _SkipAttackError(msg) attack_bonus = data.get("attackBonus") if isinstance(attack_bonus, bool) or not isinstance(attack_bonus, int): msg = "attackBonus missing" raise _SkipAttackError(msg) components = _parse_damage_components(data.get("damage"), name, reader) # "2x Talons" count prefixes live in attackName, never in weapon. count = 1 attack_name = data.get("attackName") if isinstance(attack_name, str): match = _COUNT_PREFIX_RE.match(attack_name.strip()) if match: count = int(match.group(1)) reach_ft = 5 range_increment: int | None = None raw_range = data.get("range") if isinstance(raw_range, dict): units = raw_range.get("units") raw_value = raw_range.get("value") value = ( int(raw_value) if isinstance(raw_value, str) and raw_value.isdigit() else _opt_int(raw_value) or 0 ) if units == "reach": reach_ft = 10 elif units == "ft": range_increment = value return AttackSpec( id=f"{sheet_id}:{index}", name=name, kind=kind, attack_bonus=attack_bonus, damage=components, damage_bonus=_opt_int(data.get("damageBonus")) or 0, crit_range=_opt_int(data.get("critRange")) or 20, crit_mult=_opt_int(data.get("critMult")) or 2, reach_ft=reach_ft, range_increment_ft=range_increment, count=count, ) def load_sheet_detailed(path: Path) -> tuple[Combatant, LoadReport]: """Load a Foundry pf1-sheet/v1 export into a Combatant plus a skip report.""" try: raw_text = path.read_text(encoding="utf-8") except OSError as exc: msg = f"cannot read file: {exc}" raise SheetLoadError(path, msg) from exc try: data: Json = json.loads(raw_text) except json.JSONDecodeError as exc: msg = f"invalid JSON: {exc}" raise SheetLoadError(path, msg) from exc reader = _Reader(path) root = reader.as_dict(data, "sheet root") meta = reader.as_dict(root.get("meta"), "meta") identity = reader.as_dict(root.get("identity"), "identity") combat = reader.as_dict(root.get("combat"), "combat") abilities_raw = reader.as_dict(root.get("abilities"), "abilities") saves_raw = reader.as_dict(combat.get("savingThrows"), "combat.savingThrows") speed_raw = reader.as_dict(combat.get("speed"), "combat.speed") ac_raw = reader.as_dict(combat.get("ac"), "combat.ac") hp_raw = reader.as_dict(combat.get("hp"), "combat.hp") skills_raw = root.get("skills") skills = _extract_skills(skills_raw) sheet_id = reader.req_str(meta, "id", "meta.id") land_speed = reader.speed(speed_raw.get("land"), "combat.speed.land") if land_speed is None: msg = "combat.speed.land is required" raise SheetLoadError(path, msg) kept: list[AttackSpec] = [] skipped: list[SkippedEntry] = [] attacks_value = root.get("attacks") for entry in attacks_value if isinstance(attacks_value, list) else []: weapon_name = entry.get("weapon") if isinstance(entry, dict) else None label = weapon_name if isinstance(weapon_name, str) and weapon_name else "?" try: kept.append(_parse_attack(entry, len(kept), sheet_id, reader)) except _SkipAttackError as skip: skipped.append(SkippedEntry(weapon=label, reason=str(skip))) size_value = combat.get("size") combatant = Combatant( id=sheet_id, name=reader.req_str(meta, "name", "meta.name"), level=reader.req_int(identity, "level", "identity.level"), size=size_value if isinstance(size_value, str) and size_value else "Medium", abilities=AbilityScores( str_score=reader.ability(abilities_raw, "str"), dex_score=reader.ability(abilities_raw, "dex"), con_score=reader.ability(abilities_raw, "con"), int_score=reader.ability(abilities_raw, "int"), wis_score=reader.ability(abilities_raw, "wis"), cha_score=reader.ability(abilities_raw, "cha"), ), hp_max=reader.req_int(hp_raw, "max", "combat.hp.max"), ac=ACProfile( total=reader.req_int(ac_raw, "total", "combat.ac.total"), touch=reader.req_int(ac_raw, "touch", "combat.ac.touch"), flat_footed=reader.req_int(ac_raw, "flatFooted", "combat.ac.flatFooted"), ), bab=reader.req_int(combat, "bab", "combat.bab"), initiative_mod=reader.req_int(combat, "initiative", "combat.initiative"), speed_land_ft=land_speed, speed_fly_ft=reader.speed(speed_raw.get("fly"), "combat.speed.fly"), saves=Saves( fort=reader.save_total(saves_raw, "fort"), ref=reader.save_total(saves_raw, "ref"), will=reader.save_total(saves_raw, "will"), ), attacks=kept, features=_extract_features(root.get("feats")), spells=_extract_spells(root.get("spellcasting")), skills=skills, ) report = LoadReport(path=str(path), kept=len(kept), skipped=tuple(skipped)) return combatant, report def load_sheet(path: Path) -> Combatant: """Load a Foundry sheet, discarding the skip report.""" combatant, _report = load_sheet_detailed(path) return combatant