Files
pf1e-simulator/src/pf1e_simulator/loaders/foundry.py
T
2026-09-04 23:02:17 +02:00

389 lines
14 KiB
Python

"""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