389 lines
14 KiB
Python
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
|