feat(models): combatant schema, Foundry sheet loader, monster JSON schema
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user