From 6ad37ae9ecd2ee2f7c058a226189caa0a26f41ec Mon Sep 17 00:00:00 2001 From: Thien An Date: Mon, 17 Aug 2026 22:49:50 +0200 Subject: [PATCH] feat(models): combatant schema, Foundry sheet loader, monster JSON schema --- data/monsters/goblin.json | 40 ++++ data/monsters/orc.json | 43 ++++ pyproject.toml | 5 +- src/pf1e_simulator/loaders/__init__.py | 20 ++ src/pf1e_simulator/loaders/foundry.py | 288 +++++++++++++++++++++++++ src/pf1e_simulator/loaders/monster.py | 62 ++++++ src/pf1e_simulator/models.py | 112 ++++++++++ tests/test_foundry_loader.py | 248 +++++++++++++++++++++ tests/test_models.py | 215 ++++++++++++++++++ tests/test_monster_loader.py | 155 +++++++++++++ 10 files changed, 1186 insertions(+), 2 deletions(-) create mode 100644 data/monsters/goblin.json create mode 100644 data/monsters/orc.json create mode 100644 src/pf1e_simulator/loaders/__init__.py create mode 100644 src/pf1e_simulator/loaders/foundry.py create mode 100644 src/pf1e_simulator/loaders/monster.py create mode 100644 src/pf1e_simulator/models.py create mode 100644 tests/test_foundry_loader.py create mode 100644 tests/test_models.py create mode 100644 tests/test_monster_loader.py diff --git a/data/monsters/goblin.json b/data/monsters/goblin.json new file mode 100644 index 0000000..1714b1f --- /dev/null +++ b/data/monsters/goblin.json @@ -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 + } + ] +} diff --git a/data/monsters/orc.json b/data/monsters/orc.json new file mode 100644 index 0000000..bd2febe --- /dev/null +++ b/data/monsters/orc.json @@ -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 + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index 4e97df8..009632c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,8 +40,9 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -# Tests: assert is expected, magic numbers are fine, annotations are noisy -"tests/**" = ["S101", "PLR2004", "ANN"] +# Tests: assert is expected, magic numbers are fine, annotations are noisy, +# 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 "src/pf1e_simulator/rng.py" = ["S311"] diff --git a/src/pf1e_simulator/loaders/__init__.py b/src/pf1e_simulator/loaders/__init__.py new file mode 100644 index 0000000..467fd0c --- /dev/null +++ b/src/pf1e_simulator/loaders/__init__.py @@ -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", +] diff --git a/src/pf1e_simulator/loaders/foundry.py b/src/pf1e_simulator/loaders/foundry.py new file mode 100644 index 0000000..46a602a --- /dev/null +++ b/src/pf1e_simulator/loaders/foundry.py @@ -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 diff --git a/src/pf1e_simulator/loaders/monster.py b/src/pf1e_simulator/loaders/monster.py new file mode 100644 index 0000000..d00e510 --- /dev/null +++ b/src/pf1e_simulator/loaders/monster.py @@ -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 diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py new file mode 100644 index 0000000..2cd45ff --- /dev/null +++ b/src/pf1e_simulator/models.py @@ -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 = "" diff --git a/tests/test_foundry_loader.py b/tests/test_foundry_loader.py new file mode 100644 index 0000000..f2e2ab5 --- /dev/null +++ b/tests/test_foundry_loader.py @@ -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 diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..0fc77f5 --- /dev/null +++ b/tests/test_models.py @@ -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() diff --git a/tests/test_monster_loader.py b/tests/test_monster_loader.py new file mode 100644 index 0000000..a7d0610 --- /dev/null +++ b/tests/test_monster_loader.py @@ -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))