feat(models): combatant schema, Foundry sheet loader, monster JSON schema

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 2a58826bd4
commit 6ad37ae9ec
10 changed files with 1186 additions and 2 deletions
+20
View File
@@ -0,0 +1,20 @@
"""Sheet and monster loaders — the parsing boundary of the simulator."""
from pf1e_simulator.loaders.foundry import (
LoadReport,
SheetLoadError,
SkippedEntry,
load_sheet,
load_sheet_detailed,
)
from pf1e_simulator.loaders.monster import MonsterLoadError, load_monster
__all__ = [
"LoadReport",
"MonsterLoadError",
"SheetLoadError",
"SkippedEntry",
"load_monster",
"load_sheet",
"load_sheet_detailed",
]
+288
View File
@@ -0,0 +1,288 @@
"""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.dice import DiceParseError, parse_dice
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
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")
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")
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,
)
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
+62
View File
@@ -0,0 +1,62 @@
"""Loader for hand-authored monster JSON files (same Combatant schema).
These files are the future output of the LLM stat-block extractor, so
validation is strict: unknown keys are rejected and extraction errors surface
at load time, not mid-simulation. Damage formulas use plain dice notation
("2d6+3"); attack ids and the monster id default from the file stem when
omitted.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from pydantic import ValidationError
if TYPE_CHECKING:
from pathlib import Path
from pf1e_simulator.loaders.foundry import Json
from pf1e_simulator.models import Combatant
class MonsterLoadError(Exception):
"""Raised when a monster JSON file cannot be loaded."""
def __init__(self, path: Path, reason: str) -> None:
super().__init__(f"{path}: {reason}")
self.path = path
self.reason = reason
def load_monster(path: Path) -> Combatant:
try:
raw = path.read_text(encoding="utf-8")
except OSError as exc:
msg = f"cannot read file: {exc}"
raise MonsterLoadError(path, msg) from exc
try:
data: Json = json.loads(raw)
except json.JSONDecodeError as exc:
msg = f"invalid JSON: {exc}"
raise MonsterLoadError(path, msg) from exc
if not isinstance(data, dict):
msg = "monster file must contain a JSON object"
raise MonsterLoadError(path, msg)
monster_id = data.get("id")
if not isinstance(monster_id, str) or not monster_id:
monster_id = path.stem
data["id"] = monster_id
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}"
try:
return Combatant.model_validate(data)
except ValidationError as exc:
raise MonsterLoadError(path, str(exc)) from exc