feat(models): combatant schema, Foundry sheet loader, monster JSON schema
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "Goblin",
|
||||||
|
"level": 1,
|
||||||
|
"size": "Small",
|
||||||
|
"cr": "1/3",
|
||||||
|
"xp": 135,
|
||||||
|
"source": "Bestiary > Goblin",
|
||||||
|
"abilities": {
|
||||||
|
"str_score": 11,
|
||||||
|
"dex_score": 15,
|
||||||
|
"con_score": 12,
|
||||||
|
"int_score": 10,
|
||||||
|
"wis_score": 9,
|
||||||
|
"cha_score": 6
|
||||||
|
},
|
||||||
|
"hp_max": 6,
|
||||||
|
"ac": { "total": 16, "touch": 13, "flat_footed": 14 },
|
||||||
|
"bab": 1,
|
||||||
|
"initiative_mod": 6,
|
||||||
|
"speed_land_ft": 30,
|
||||||
|
"saves": { "fort": 3, "ref": 2, "will": -1 },
|
||||||
|
"attacks": [
|
||||||
|
{
|
||||||
|
"name": "short sword",
|
||||||
|
"kind": "melee",
|
||||||
|
"attack_bonus": 2,
|
||||||
|
"damage": [{ "formula": "1d4", "types": ["slashing"] }],
|
||||||
|
"crit_range": 19,
|
||||||
|
"crit_mult": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "short bow",
|
||||||
|
"kind": "ranged",
|
||||||
|
"attack_bonus": 4,
|
||||||
|
"damage": [{ "formula": "1d4", "types": ["piercing"] }],
|
||||||
|
"crit_mult": 3,
|
||||||
|
"range_increment_ft": 60
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "Orc",
|
||||||
|
"level": 1,
|
||||||
|
"size": "Medium",
|
||||||
|
"cr": "1/3",
|
||||||
|
"xp": 135,
|
||||||
|
"source": "Bestiary > Orc",
|
||||||
|
"notes": "Ferocity defensive ability not modeled in Phase 0",
|
||||||
|
"abilities": {
|
||||||
|
"str_score": 17,
|
||||||
|
"dex_score": 11,
|
||||||
|
"con_score": 12,
|
||||||
|
"int_score": 7,
|
||||||
|
"wis_score": 8,
|
||||||
|
"cha_score": 6
|
||||||
|
},
|
||||||
|
"hp_max": 6,
|
||||||
|
"ac": { "total": 13, "touch": 10, "flat_footed": 13 },
|
||||||
|
"bab": 1,
|
||||||
|
"initiative_mod": 0,
|
||||||
|
"speed_land_ft": 30,
|
||||||
|
"saves": { "fort": 3, "ref": 0, "will": -1 },
|
||||||
|
"attacks": [
|
||||||
|
{
|
||||||
|
"name": "falchion",
|
||||||
|
"kind": "melee",
|
||||||
|
"attack_bonus": 5,
|
||||||
|
"damage": [{ "formula": "2d4", "types": ["slashing"] }],
|
||||||
|
"damage_bonus": 4,
|
||||||
|
"crit_range": 18,
|
||||||
|
"crit_mult": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "javelin",
|
||||||
|
"kind": "ranged",
|
||||||
|
"attack_bonus": 1,
|
||||||
|
"damage": [{ "formula": "1d6", "types": ["piercing"] }],
|
||||||
|
"damage_bonus": 3,
|
||||||
|
"crit_mult": 2,
|
||||||
|
"range_increment_ft": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+3
-2
@@ -40,8 +40,9 @@ ignore = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
# Tests: assert is expected, magic numbers are fine, annotations are noisy
|
# Tests: assert is expected, magic numbers are fine, annotations are noisy,
|
||||||
"tests/**" = ["S101", "PLR2004", "ANN"]
|
# parametrized golden tests legitimately take many arguments
|
||||||
|
"tests/**" = ["S101", "PLR2004", "ANN", "PLR0913", "PLR0917"]
|
||||||
# rng.py: `random.Random` is used for reproducible Monte Carlo streams, not cryptography
|
# rng.py: `random.Random` is used for reproducible Monte Carlo streams, not cryptography
|
||||||
"src/pf1e_simulator/rng.py" = ["S311"]
|
"src/pf1e_simulator/rng.py" = ["S311"]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Combat data model — the boundary contract for the whole simulator.
|
||||||
|
|
||||||
|
Every combatant (PC from a Foundry sheet or hand-authored monster JSON) is
|
||||||
|
parsed into a `Combatant` exactly once, at the loading boundary. The engine
|
||||||
|
never sees raw JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from pf1e_simulator.dice import DiceExpr, DiceParseError, parse_dice
|
||||||
|
|
||||||
|
|
||||||
|
class _FrozenModel(BaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class AbilityScores(_FrozenModel):
|
||||||
|
str_score: int
|
||||||
|
dex_score: int
|
||||||
|
con_score: int
|
||||||
|
int_score: int
|
||||||
|
wis_score: int
|
||||||
|
cha_score: int
|
||||||
|
|
||||||
|
|
||||||
|
class ACProfile(_FrozenModel):
|
||||||
|
total: int
|
||||||
|
touch: int
|
||||||
|
flat_footed: int
|
||||||
|
|
||||||
|
|
||||||
|
class Saves(_FrozenModel):
|
||||||
|
fort: int
|
||||||
|
ref: int
|
||||||
|
will: int
|
||||||
|
|
||||||
|
|
||||||
|
class DamageComponent(_FrozenModel):
|
||||||
|
"""One damage roll with its types (e.g. 1d8 piercing).
|
||||||
|
|
||||||
|
The formula accepts an already-parsed DiceExpr (Foundry loader path) or a
|
||||||
|
plain notation string like "2d6+3" (hand-authored monster JSON path).
|
||||||
|
"""
|
||||||
|
|
||||||
|
formula: DiceExpr
|
||||||
|
types: list[str] = Field(min_length=1)
|
||||||
|
|
||||||
|
@field_validator("formula", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _parse_formula(cls, value: object) -> object:
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return parse_dice(value)
|
||||||
|
except DiceParseError as exc:
|
||||||
|
msg = str(exc)
|
||||||
|
raise ValueError(msg) from exc
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class AttackSpec(_FrozenModel):
|
||||||
|
"""One attack menu entry (weapon or natural attack).
|
||||||
|
|
||||||
|
`count` captures repeated identical swings ("2x Talons") without listing
|
||||||
|
the same entry twice. `reach_ft` applies to melee/touch kinds,
|
||||||
|
`range_increment_ft` to ranged/touch kinds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
kind: Literal["melee", "ranged", "touch"]
|
||||||
|
attack_bonus: int
|
||||||
|
damage: list[DamageComponent] = Field(min_length=1)
|
||||||
|
damage_bonus: int = 0
|
||||||
|
crit_range: int = Field(default=20, ge=2, le=20)
|
||||||
|
crit_mult: int = Field(default=2, ge=2, le=4)
|
||||||
|
reach_ft: int = 5
|
||||||
|
range_increment_ft: int | None = None
|
||||||
|
count: int = Field(default=1, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class DamageReduction(_FrozenModel):
|
||||||
|
"""DR amount and the damage types that bypass it; empty bypass = DR/—."""
|
||||||
|
|
||||||
|
amount: int = Field(ge=1)
|
||||||
|
bypass: frozenset[str] = frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
class Combatant(_FrozenModel):
|
||||||
|
"""A fully parsed combatant, ready for the engine."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
level: int = Field(ge=1)
|
||||||
|
size: str = "Medium"
|
||||||
|
abilities: AbilityScores
|
||||||
|
hp_max: int = Field(ge=1)
|
||||||
|
ac: ACProfile
|
||||||
|
bab: int
|
||||||
|
initiative_mod: int
|
||||||
|
speed_land_ft: int = Field(ge=0)
|
||||||
|
speed_fly_ft: int | None = None
|
||||||
|
saves: Saves
|
||||||
|
attacks: list[AttackSpec]
|
||||||
|
dr: DamageReduction | None = None
|
||||||
|
cr: str | None = None
|
||||||
|
xp: int | None = None
|
||||||
|
source: str = ""
|
||||||
|
notes: str = ""
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""Golden tests for the Foundry VTT pf1-sheet/v1 loader on the 8 real sheets."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pf1e_simulator.dice import DiceExpr
|
||||||
|
from pf1e_simulator.loaders import LoadReport, load_sheet, load_sheet_detailed
|
||||||
|
|
||||||
|
SHEETS_DIR = Path(__file__).resolve().parent.parent / "fiches_personnages"
|
||||||
|
|
||||||
|
# Golden pins — each tuple follows the parametrize argument order below.
|
||||||
|
_GOLDENS = [
|
||||||
|
("harvie_randu_sheet.json", "qeqv1DtvWsApYj3e", "Harvie", 2, "Small", 15, 10, 80, 2, 0),
|
||||||
|
("esha_randu_sheet.json", "9ZRe6qCM2Jw2vQGd", "Esha Randu", 4, "Medium", 32, 25, None, 3, 0),
|
||||||
|
("ierlieth_randu_sheet.json", "xsyBSseBE2avH4bP", "Ierlieth", 4, "Tiny", 29, 15, None, 2, 0),
|
||||||
|
("jeanne_randu_sheet.json", "FACxIckfWhKKC086", "Jeanne", 4, "Large", 31, 40, None, 2, 0),
|
||||||
|
("misty_randu_sheet.json", "1E9HfSar4ac6ryJ5", "Misty", 4, "Medium", 31, 30, None, 5, 0),
|
||||||
|
(
|
||||||
|
"nairda_randu_sheet.json", "oyyhwcAfinGasDoH", "Nairda Guisenda", 4, "Medium",
|
||||||
|
31, 30, None, 2, 0,
|
||||||
|
),
|
||||||
|
("oni_randu_sheet.json", "7Ga6fQh1NrqC3NbG", "Oni Triumvir", 4, "Medium", 36, 30, None, 3, 3),
|
||||||
|
(
|
||||||
|
"tammara_randu_sheet.json", "k8sPaX8AuMnn6jXc", "Tammara Cailean", 4, "Medium",
|
||||||
|
36, 30, None, 5, 0,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _sheet(filename: str) -> Path:
|
||||||
|
return SHEETS_DIR / filename
|
||||||
|
|
||||||
|
|
||||||
|
class TestAllSheetsLoad:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
(
|
||||||
|
"filename", "sheet_id", "name", "level", "size",
|
||||||
|
"hp_max", "land", "fly", "kept", "skipped",
|
||||||
|
),
|
||||||
|
_GOLDENS,
|
||||||
|
ids=[g[0] for g in _GOLDENS],
|
||||||
|
)
|
||||||
|
def test_sheet_golden(
|
||||||
|
self,
|
||||||
|
filename: str,
|
||||||
|
sheet_id: str,
|
||||||
|
name: str,
|
||||||
|
level: int,
|
||||||
|
size: str,
|
||||||
|
hp_max: int,
|
||||||
|
land: int,
|
||||||
|
fly: int | None,
|
||||||
|
kept: int,
|
||||||
|
skipped: int,
|
||||||
|
) -> None:
|
||||||
|
# Given a real Foundry sheet export
|
||||||
|
# When it is loaded with its report
|
||||||
|
combatant, report = load_sheet_detailed(_sheet(filename))
|
||||||
|
# Then identity, core stats, and kept/skipped counts match the sheet
|
||||||
|
assert combatant.id == sheet_id
|
||||||
|
assert combatant.name == name
|
||||||
|
assert combatant.level == level
|
||||||
|
assert combatant.size == size
|
||||||
|
assert combatant.hp_max == hp_max
|
||||||
|
assert combatant.speed_land_ft == land
|
||||||
|
assert combatant.speed_fly_ft == fly
|
||||||
|
assert len(combatant.attacks) == kept
|
||||||
|
assert report.path == str(_sheet(filename))
|
||||||
|
assert report.kept == kept
|
||||||
|
assert len(report.skipped) == skipped
|
||||||
|
|
||||||
|
def test_load_sheet_delegates_and_discards_report(self) -> None:
|
||||||
|
# Given a real sheet
|
||||||
|
# When loaded through the simple entry point
|
||||||
|
combatant = load_sheet(_sheet("harvie_randu_sheet.json"))
|
||||||
|
detailed, report = load_sheet_detailed(_sheet("harvie_randu_sheet.json"))
|
||||||
|
# Then the result is identical to the detailed load
|
||||||
|
assert combatant == detailed
|
||||||
|
assert isinstance(report, LoadReport)
|
||||||
|
|
||||||
|
def test_attack_ids_are_indexed_over_kept_attacks(self) -> None:
|
||||||
|
# Given Oni's sheet (3 kept out of 6)
|
||||||
|
# When loaded
|
||||||
|
combatant, _report = load_sheet_detailed(_sheet("oni_randu_sheet.json"))
|
||||||
|
# Then ids are indexed over the KEPT attacks only
|
||||||
|
assert [a.id for a in combatant.attacks] == [
|
||||||
|
"7Ga6fQh1NrqC3NbG:0",
|
||||||
|
"7Ga6fQh1NrqC3NbG:1",
|
||||||
|
"7Ga6fQh1NrqC3NbG:2",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestHarviePins:
|
||||||
|
def test_core_stats(self) -> None:
|
||||||
|
# Given Harvie's sheet
|
||||||
|
# When loaded
|
||||||
|
harvie = load_sheet(_sheet("harvie_randu_sheet.json"))
|
||||||
|
# Then the pinned core stats hold
|
||||||
|
assert harvie.hp_max == 15
|
||||||
|
assert harvie.ac.total == 14
|
||||||
|
assert harvie.ac.touch == 13
|
||||||
|
assert harvie.ac.flat_footed == 12
|
||||||
|
assert harvie.initiative_mod == 2
|
||||||
|
assert harvie.saves == harvie.saves.model_validate({"fort": 4, "ref": 5, "will": 2})
|
||||||
|
|
||||||
|
def test_talons_count_prefix(self) -> None:
|
||||||
|
# Given the "2x Talons" attackName quirk
|
||||||
|
# When loaded
|
||||||
|
harvie = load_sheet(_sheet("harvie_randu_sheet.json"))
|
||||||
|
talons = harvie.attacks[0]
|
||||||
|
# Then the prefix becomes count=2, the bonus stays as printed (no secondary -5)
|
||||||
|
assert talons.count == 2
|
||||||
|
assert talons.attack_bonus == 2
|
||||||
|
assert "2x" not in talons.name
|
||||||
|
assert talons.kind == "melee"
|
||||||
|
assert talons.damage[0].formula == DiceExpr(count=1, sides=4)
|
||||||
|
assert talons.damage[0].types == ["slashing"]
|
||||||
|
assert talons.reach_ft == 5
|
||||||
|
|
||||||
|
def test_bite_secondary_baked_in(self) -> None:
|
||||||
|
# Given the Bite entry whose -5 secondary penalty is already baked in
|
||||||
|
# When loaded
|
||||||
|
harvie = load_sheet(_sheet("harvie_randu_sheet.json"))
|
||||||
|
bite = harvie.attacks[1]
|
||||||
|
# Then the printed -3 is kept verbatim
|
||||||
|
assert bite.attack_bonus == -3
|
||||||
|
assert bite.count == 1
|
||||||
|
|
||||||
|
def test_dict_speeds_use_total(self) -> None:
|
||||||
|
# Given land/fly encoded as {base, total} dicts
|
||||||
|
# When loaded
|
||||||
|
harvie = load_sheet(_sheet("harvie_randu_sheet.json"))
|
||||||
|
# Then the effective totals are used
|
||||||
|
assert harvie.speed_land_ft == 10
|
||||||
|
assert harvie.speed_fly_ft == 80
|
||||||
|
|
||||||
|
|
||||||
|
class TestEshaPins:
|
||||||
|
def test_lance_reach_and_size_roll(self) -> None:
|
||||||
|
# Given the Lance with formula "sizeRoll(1, 8, @size)" and units "reach"
|
||||||
|
# When loaded
|
||||||
|
esha = load_sheet(_sheet("esha_randu_sheet.json"))
|
||||||
|
lance = next(a for a in esha.attacks if "Lance" in a.name)
|
||||||
|
# Then the Medium sizeRoll resolves to 1d8 and reach is 10 ft
|
||||||
|
assert lance.kind == "melee"
|
||||||
|
assert lance.reach_ft == 10
|
||||||
|
assert lance.damage[0].formula == DiceExpr(count=1, sides=8)
|
||||||
|
assert lance.damage_bonus == 4
|
||||||
|
assert lance.crit_mult == 3
|
||||||
|
|
||||||
|
def test_shortbow_range_increment(self) -> None:
|
||||||
|
# Given the Composite Shortbow with units "ft" value "70"
|
||||||
|
# When loaded
|
||||||
|
esha = load_sheet(_sheet("esha_randu_sheet.json"))
|
||||||
|
bow = next(a for a in esha.attacks if "Shortbow" in a.name)
|
||||||
|
# Then it is a ranged attack with a 70 ft increment and 1d6 damage
|
||||||
|
assert bow.kind == "ranged"
|
||||||
|
assert bow.range_increment_ft == 70
|
||||||
|
assert bow.damage[0].formula == DiceExpr(count=1, sides=6)
|
||||||
|
assert bow.damage_bonus == 3
|
||||||
|
|
||||||
|
def test_missing_crit_range_defaults_to_20(self) -> None:
|
||||||
|
# Given the Earth Breaker entry which has no critRange key at all
|
||||||
|
# When loaded
|
||||||
|
esha = load_sheet(_sheet("esha_randu_sheet.json"))
|
||||||
|
breaker = next(a for a in esha.attacks if "Earth Breaker" in a.name)
|
||||||
|
# Then crit_range defaults to 20
|
||||||
|
assert breaker.crit_range == 20
|
||||||
|
assert breaker.damage[0].formula == DiceExpr(count=2, sides=6)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOtherSheetPins:
|
||||||
|
def test_misty_has_keen_crit(self) -> None:
|
||||||
|
# Given Misty's sheet (MWK Rapier 18-20)
|
||||||
|
# When loaded
|
||||||
|
misty = load_sheet(_sheet("misty_randu_sheet.json"))
|
||||||
|
# Then at least one attack threatens on 18
|
||||||
|
assert any(a.crit_range == 18 for a in misty.attacks)
|
||||||
|
|
||||||
|
def test_misty_thrown_dagger_keeps_melee_kind_with_increment(self) -> None:
|
||||||
|
# Given the Dagger: actionType mwak but range units "ft"
|
||||||
|
# When loaded
|
||||||
|
misty = load_sheet(_sheet("misty_randu_sheet.json"))
|
||||||
|
dagger = next(a for a in misty.attacks if "Dagger" in a.name)
|
||||||
|
# Then kind comes from actionType and the increment is still recorded
|
||||||
|
assert dagger.kind == "melee"
|
||||||
|
assert dagger.range_increment_ft == 10
|
||||||
|
|
||||||
|
def test_tammara_has_x4_crit(self) -> None:
|
||||||
|
# Given Tammara's pistols (x4 crit)
|
||||||
|
# When loaded
|
||||||
|
tammara = load_sheet(_sheet("tammara_randu_sheet.json"))
|
||||||
|
# Then at least one attack multiplies by 4
|
||||||
|
assert any(a.crit_mult == 4 for a in tammara.attacks)
|
||||||
|
|
||||||
|
def test_tammara_touch_attack_kind(self) -> None:
|
||||||
|
# Given the Alchemist's Fire with actionType twak
|
||||||
|
# When loaded
|
||||||
|
tammara = load_sheet(_sheet("tammara_randu_sheet.json"))
|
||||||
|
fire = next(a for a in tammara.attacks if "Alchemist" in a.name)
|
||||||
|
# Then it maps to a touch attack
|
||||||
|
assert fire.kind == "touch"
|
||||||
|
assert fire.damage[0].types == ["fire"]
|
||||||
|
|
||||||
|
def test_oni_keeps_exactly_three_attacks(self) -> None:
|
||||||
|
# Given Oni's sheet with 3 invalid entries among 6
|
||||||
|
# When loaded
|
||||||
|
oni, report = load_sheet_detailed(_sheet("oni_randu_sheet.json"))
|
||||||
|
# Then exactly 3 attacks are kept
|
||||||
|
assert len(oni.attacks) == 3
|
||||||
|
assert report.kept == 3
|
||||||
|
|
||||||
|
def test_oni_skipped_entries_have_reasons(self) -> None:
|
||||||
|
# Given Oni's sheet
|
||||||
|
# When loaded
|
||||||
|
_oni, report = load_sheet_detailed(_sheet("oni_randu_sheet.json"))
|
||||||
|
skipped = {entry.weapon: entry.reason for entry in report.skipped}
|
||||||
|
# Then each skipped entry is recorded with its reason
|
||||||
|
assert set(skipped) == {"Channel Energy", "une Pioche", "Weapon"}
|
||||||
|
assert "other" in skipped["Channel Energy"]
|
||||||
|
assert "damage" in skipped["une Pioche"]
|
||||||
|
assert "damage" in skipped["Weapon"]
|
||||||
|
|
||||||
|
def test_jeanne_count_prefix_and_dict_speed(self) -> None:
|
||||||
|
# Given Jeanne ("2x Hooves", land {base 50, total 40})
|
||||||
|
# When loaded
|
||||||
|
jeanne = load_sheet(_sheet("jeanne_randu_sheet.json"))
|
||||||
|
hooves = jeanne.attacks[0]
|
||||||
|
# Then the count prefix and the effective speed are applied
|
||||||
|
assert hooves.count == 2
|
||||||
|
assert hooves.attack_bonus == 8
|
||||||
|
assert jeanne.speed_land_ft == 40
|
||||||
|
|
||||||
|
def test_ierlieth_land_dict_uses_total_not_base(self) -> None:
|
||||||
|
# Given Ierlieth's land speed {base 20, total 15}
|
||||||
|
# When loaded
|
||||||
|
ierlieth = load_sheet(_sheet("ierlieth_randu_sheet.json"))
|
||||||
|
# Then the effective value wins
|
||||||
|
assert ierlieth.speed_land_ft == 15
|
||||||
|
assert ierlieth.size == "Tiny"
|
||||||
|
|
||||||
|
def test_nairda_negative_damage_bonus_preserved(self) -> None:
|
||||||
|
# Given Nairda's unarmed strike with damageBonus -1
|
||||||
|
# When loaded
|
||||||
|
nairda = load_sheet(_sheet("nairda_randu_sheet.json"))
|
||||||
|
# Then the negative bonus is preserved (low STR)
|
||||||
|
assert nairda.attacks[0].damage_bonus == -1
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Unit tests for the combat data models (contracts for later stages)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from pf1e_simulator.dice import DiceExpr
|
||||||
|
from pf1e_simulator.models import (
|
||||||
|
AbilityScores,
|
||||||
|
AttackSpec,
|
||||||
|
Combatant,
|
||||||
|
DamageComponent,
|
||||||
|
DamageReduction,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _abilities() -> AbilityScores:
|
||||||
|
return AbilityScores(
|
||||||
|
str_score=10,
|
||||||
|
dex_score=11,
|
||||||
|
con_score=12,
|
||||||
|
int_score=13,
|
||||||
|
wis_score=14,
|
||||||
|
cha_score=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _attack_dict(**overrides: object) -> dict[str, object]:
|
||||||
|
base: dict[str, object] = {
|
||||||
|
"id": "dummy:0",
|
||||||
|
"name": "bite",
|
||||||
|
"kind": "melee",
|
||||||
|
"attack_bonus": 3,
|
||||||
|
"damage": [{"formula": "1d6", "types": ["piercing"]}],
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _combatant_dict(**overrides: object) -> dict[str, object]:
|
||||||
|
base: dict[str, object] = {
|
||||||
|
"id": "dummy",
|
||||||
|
"name": "Dummy",
|
||||||
|
"level": 1,
|
||||||
|
"abilities": _abilities(),
|
||||||
|
"hp_max": 10,
|
||||||
|
"ac": {"total": 15, "touch": 12, "flat_footed": 13},
|
||||||
|
"bab": 1,
|
||||||
|
"initiative_mod": 2,
|
||||||
|
"speed_land_ft": 30,
|
||||||
|
"saves": {"fort": 2, "ref": 3, "will": 4},
|
||||||
|
"attacks": [_attack_dict()],
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
class TestDamageComponent:
|
||||||
|
def test_parses_string_formula(self) -> None:
|
||||||
|
# Given a raw string formula
|
||||||
|
# When the component is validated
|
||||||
|
component = DamageComponent.model_validate({"formula": "2d6+3", "types": ["fire"]})
|
||||||
|
# Then the formula is parsed into a DiceExpr
|
||||||
|
assert component.formula == DiceExpr(count=2, sides=6, bonus=3)
|
||||||
|
assert component.types == ["fire"]
|
||||||
|
|
||||||
|
def test_accepts_dice_expr_formula(self) -> None:
|
||||||
|
# Given an already-parsed DiceExpr
|
||||||
|
expr = DiceExpr(count=1, sides=4)
|
||||||
|
# When the component is built with it
|
||||||
|
component = DamageComponent(formula=expr, types=["slashing"])
|
||||||
|
# Then it is kept as-is
|
||||||
|
assert component.formula is expr
|
||||||
|
|
||||||
|
def test_rejects_empty_types(self) -> None:
|
||||||
|
# Given a component with no damage type
|
||||||
|
# When/Then validation rejects it
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
DamageComponent.model_validate({"formula": "1d6", "types": []})
|
||||||
|
|
||||||
|
def test_rejects_unparseable_formula(self) -> None:
|
||||||
|
# Given a formula that is not dice notation
|
||||||
|
# When/Then validation rejects it
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
DamageComponent.model_validate({"formula": "not dice", "types": ["fire"]})
|
||||||
|
|
||||||
|
|
||||||
|
class TestAttackSpec:
|
||||||
|
def test_defaults(self) -> None:
|
||||||
|
# Given a minimal attack spec
|
||||||
|
# When validated
|
||||||
|
attack = AttackSpec.model_validate(_attack_dict())
|
||||||
|
# Then the optional fields take their contract defaults
|
||||||
|
assert attack.damage_bonus == 0
|
||||||
|
assert attack.crit_range == 20
|
||||||
|
assert attack.crit_mult == 2
|
||||||
|
assert attack.reach_ft == 5
|
||||||
|
assert attack.range_increment_ft is None
|
||||||
|
assert attack.count == 1
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["melee", "ranged", "touch"])
|
||||||
|
def test_accepts_all_kinds(self, kind: str) -> None:
|
||||||
|
# Given each allowed kind
|
||||||
|
# When/Then validation succeeds
|
||||||
|
attack = AttackSpec.model_validate(_attack_dict(kind=kind))
|
||||||
|
assert attack.kind == kind
|
||||||
|
|
||||||
|
def test_rejects_unknown_kind(self) -> None:
|
||||||
|
# Given a kind outside the Literal
|
||||||
|
# When/Then validation rejects it
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AttackSpec.model_validate(_attack_dict(kind="spell"))
|
||||||
|
|
||||||
|
def test_rejects_empty_damage_list(self) -> None:
|
||||||
|
# Given an attack with no damage component
|
||||||
|
# When/Then validation rejects it
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AttackSpec.model_validate(_attack_dict(damage=[]))
|
||||||
|
|
||||||
|
def test_rejects_crit_mult_below_2(self) -> None:
|
||||||
|
# Given crit_mult=1 (a weapon that never multiplies is not PF1e)
|
||||||
|
# When/Then validation rejects it
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AttackSpec.model_validate(_attack_dict(crit_mult=1))
|
||||||
|
|
||||||
|
def test_rejects_crit_mult_above_4(self) -> None:
|
||||||
|
# Given crit_mult=5
|
||||||
|
# When/Then validation rejects it
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AttackSpec.model_validate(_attack_dict(crit_mult=5))
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("crit_range", [0, 1, 21])
|
||||||
|
def test_rejects_crit_range_out_of_bounds(self, crit_range: int) -> None:
|
||||||
|
# Given a crit range outside 2..20
|
||||||
|
# When/Then validation rejects it
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AttackSpec.model_validate(_attack_dict(crit_range=crit_range))
|
||||||
|
|
||||||
|
def test_rejects_zero_count(self) -> None:
|
||||||
|
# Given count=0
|
||||||
|
# When/Then validation rejects it
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AttackSpec.model_validate(_attack_dict(count=0))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCombatant:
|
||||||
|
def test_defaults(self) -> None:
|
||||||
|
# Given a minimal combatant
|
||||||
|
# When validated
|
||||||
|
combatant = Combatant.model_validate(_combatant_dict())
|
||||||
|
# Then the optional fields take their contract defaults
|
||||||
|
assert combatant.size == "Medium"
|
||||||
|
assert combatant.speed_fly_ft is None
|
||||||
|
assert combatant.dr is None
|
||||||
|
assert combatant.cr is None
|
||||||
|
assert combatant.xp is None
|
||||||
|
assert combatant.source == ""
|
||||||
|
assert combatant.notes == ""
|
||||||
|
|
||||||
|
def test_rejects_hp_max_zero(self) -> None:
|
||||||
|
# Given hp_max=0
|
||||||
|
# When/Then validation rejects it
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Combatant.model_validate(_combatant_dict(hp_max=0))
|
||||||
|
|
||||||
|
def test_rejects_negative_land_speed(self) -> None:
|
||||||
|
# Given a negative land speed
|
||||||
|
# When/Then validation rejects it
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Combatant.model_validate(_combatant_dict(speed_land_ft=-5))
|
||||||
|
|
||||||
|
def test_rejects_unknown_extra_key(self) -> None:
|
||||||
|
# Given an unknown top-level key
|
||||||
|
# When/Then validation rejects it (strict schema)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Combatant.model_validate(_combatant_dict(hit_dice="1d8"))
|
||||||
|
|
||||||
|
def test_frozen(self) -> None:
|
||||||
|
# Given a validated combatant
|
||||||
|
combatant = Combatant.model_validate(_combatant_dict())
|
||||||
|
# When/Then mutation is rejected
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
combatant.name = "Mutilated"
|
||||||
|
|
||||||
|
def test_full_combatant_roundtrip(self) -> None:
|
||||||
|
# Given a fully populated combatant
|
||||||
|
combatant = Combatant.model_validate(
|
||||||
|
_combatant_dict(
|
||||||
|
size="Small",
|
||||||
|
speed_fly_ft=80,
|
||||||
|
dr={"amount": 5, "bypass": ["magic"]},
|
||||||
|
cr="1/3",
|
||||||
|
xp=135,
|
||||||
|
source="Bestiary > Goblin",
|
||||||
|
notes="note",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Then every field round-trips
|
||||||
|
assert combatant.size == "Small"
|
||||||
|
assert combatant.speed_fly_ft == 80
|
||||||
|
assert combatant.dr is not None
|
||||||
|
assert combatant.dr.amount == 5
|
||||||
|
assert combatant.dr.bypass == frozenset({"magic"})
|
||||||
|
assert combatant.cr == "1/3"
|
||||||
|
assert combatant.xp == 135
|
||||||
|
assert combatant.source == "Bestiary > Goblin"
|
||||||
|
assert combatant.notes == "note"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDamageReduction:
|
||||||
|
def test_empty_bypass_means_dr_dash(self) -> None:
|
||||||
|
# Given DR with an empty bypass set
|
||||||
|
dr = DamageReduction(amount=10, bypass=frozenset())
|
||||||
|
# Then it represents DR 10/—
|
||||||
|
assert dr.amount == 10
|
||||||
|
assert dr.bypass == frozenset()
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Golden and validation tests for the hand-authored monster JSON loader."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pf1e_simulator.dice import DiceExpr
|
||||||
|
from pf1e_simulator.loaders.monster import MonsterLoadError, load_monster
|
||||||
|
|
||||||
|
MONSTERS_DIR = Path(__file__).resolve().parents[1] / "data" / "monsters"
|
||||||
|
|
||||||
|
|
||||||
|
def _write(tmp_path: Path, payload: dict[str, object]) -> Path:
|
||||||
|
target = tmp_path / "monster.json"
|
||||||
|
target.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_payload() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"name": "Testling",
|
||||||
|
"level": 1,
|
||||||
|
"abilities": {
|
||||||
|
"str_score": 10,
|
||||||
|
"dex_score": 10,
|
||||||
|
"con_score": 10,
|
||||||
|
"int_score": 10,
|
||||||
|
"wis_score": 10,
|
||||||
|
"cha_score": 10,
|
||||||
|
},
|
||||||
|
"hp_max": 5,
|
||||||
|
"ac": {"total": 12, "touch": 10, "flat_footed": 12},
|
||||||
|
"bab": 0,
|
||||||
|
"initiative_mod": 1,
|
||||||
|
"speed_land_ft": 20,
|
||||||
|
"saves": {"fort": 1, "ref": 2, "will": 3},
|
||||||
|
"attacks": [
|
||||||
|
{
|
||||||
|
"name": "Bite",
|
||||||
|
"kind": "melee",
|
||||||
|
"attack_bonus": 1,
|
||||||
|
"damage": [{"formula": "1d4", "types": ["piercing"]}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_goblin_golden() -> None:
|
||||||
|
goblin = load_monster(MONSTERS_DIR / "goblin.json")
|
||||||
|
assert goblin.name == "Goblin"
|
||||||
|
assert goblin.size == "Small"
|
||||||
|
assert goblin.cr == "1/3"
|
||||||
|
assert goblin.xp == 135
|
||||||
|
assert goblin.hp_max == 6
|
||||||
|
assert goblin.ac.total == 16
|
||||||
|
assert goblin.ac.touch == 13
|
||||||
|
assert goblin.ac.flat_footed == 14
|
||||||
|
assert goblin.initiative_mod == 6
|
||||||
|
assert goblin.speed_land_ft == 30
|
||||||
|
assert goblin.saves.fort == 3
|
||||||
|
assert goblin.saves.ref == 2
|
||||||
|
assert goblin.saves.will == -1
|
||||||
|
assert goblin.abilities.dex_score == 15
|
||||||
|
assert len(goblin.attacks) == 2
|
||||||
|
|
||||||
|
sword = next(a for a in goblin.attacks if a.name == "short sword")
|
||||||
|
assert sword.kind == "melee"
|
||||||
|
assert sword.attack_bonus == 2
|
||||||
|
assert sword.crit_range == 19
|
||||||
|
assert sword.crit_mult == 2
|
||||||
|
assert sword.damage[0].formula == DiceExpr(count=1, sides=4)
|
||||||
|
|
||||||
|
bow = next(a for a in goblin.attacks if a.name == "short bow")
|
||||||
|
assert bow.kind == "ranged"
|
||||||
|
assert bow.attack_bonus == 4
|
||||||
|
assert bow.crit_mult == 3
|
||||||
|
assert bow.range_increment_ft == 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_orc_golden() -> None:
|
||||||
|
orc = load_monster(MONSTERS_DIR / "orc.json")
|
||||||
|
assert orc.name == "Orc"
|
||||||
|
assert orc.size == "Medium"
|
||||||
|
assert orc.hp_max == 6
|
||||||
|
assert orc.ac.total == 13
|
||||||
|
assert "Ferocity" in orc.notes # documented Phase 0 omission
|
||||||
|
|
||||||
|
falchion = next(a for a in orc.attacks if a.name == "falchion")
|
||||||
|
assert falchion.attack_bonus == 5
|
||||||
|
assert falchion.damage[0].formula == DiceExpr(count=2, sides=4)
|
||||||
|
assert falchion.damage_bonus == 4
|
||||||
|
assert falchion.crit_range == 18
|
||||||
|
assert falchion.crit_mult == 2
|
||||||
|
|
||||||
|
javelin = next(a for a in orc.attacks if a.name == "javelin")
|
||||||
|
assert javelin.kind == "ranged"
|
||||||
|
assert javelin.range_increment_ft == 30
|
||||||
|
assert javelin.damage_bonus == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_monster_attack_ids_auto_assigned() -> None:
|
||||||
|
goblin = load_monster(MONSTERS_DIR / "goblin.json")
|
||||||
|
assert [a.id for a in goblin.attacks] == ["goblin:0", "goblin:1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_minimal_monster_loads(tmp_path: Path) -> None:
|
||||||
|
monster = load_monster(_write(tmp_path, _valid_payload()))
|
||||||
|
assert monster.name == "Testling"
|
||||||
|
assert monster.attacks[0].id == "monster:0" # falls back to file stem
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_crit_mult_one(tmp_path: Path) -> None:
|
||||||
|
payload = _valid_payload()
|
||||||
|
payload["attacks"] = [
|
||||||
|
{
|
||||||
|
"name": "Bite",
|
||||||
|
"kind": "melee",
|
||||||
|
"attack_bonus": 1,
|
||||||
|
"damage": [{"formula": "1d4", "types": ["piercing"]}],
|
||||||
|
"crit_mult": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
with pytest.raises(MonsterLoadError):
|
||||||
|
load_monster(_write(tmp_path, payload))
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_zero_hp(tmp_path: Path) -> None:
|
||||||
|
payload = _valid_payload()
|
||||||
|
payload["hp_max"] = 0
|
||||||
|
with pytest.raises(MonsterLoadError):
|
||||||
|
load_monster(_write(tmp_path, payload))
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_unknown_key(tmp_path: Path) -> None:
|
||||||
|
payload = _valid_payload()
|
||||||
|
payload["bogus_field"] = 1
|
||||||
|
with pytest.raises(MonsterLoadError):
|
||||||
|
load_monster(_write(tmp_path, payload))
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_bad_formula(tmp_path: Path) -> None:
|
||||||
|
payload = _valid_payload()
|
||||||
|
payload["attacks"] = [
|
||||||
|
{
|
||||||
|
"name": "Bite",
|
||||||
|
"kind": "melee",
|
||||||
|
"attack_bonus": 1,
|
||||||
|
"damage": [{"formula": "banana", "types": ["piercing"]}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
with pytest.raises(MonsterLoadError):
|
||||||
|
load_monster(_write(tmp_path, payload))
|
||||||
Reference in New Issue
Block a user