"""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.combatant import CombatantLoadError, load_combatant MONSTERS_DIR = Path(__file__).resolve().parents[1] / "data" / "monsters" PCS_DIR = Path(__file__).resolve().parents[1] / "data" / "pcs" @pytest.mark.parametrize( "stem", ["esha", "harvie", "ierlieth", "jeanne", "misty", "nairda", "oni", "tammara"], ) def test_pc_sheet_loads(stem: str) -> None: """Every transposed PC sheet loads through the strict monster JSON path.""" combatant = load_combatant(PCS_DIR / f"{stem}.json") assert combatant.attacks assert combatant.hp_max >= 1 assert combatant.ac.total >= 10 assert combatant.speed_land_ft >= 0 def test_pc_sheets_golden_spots() -> None: esha = load_combatant(PCS_DIR / "esha.json") assert esha.name == "Esha Randu" assert [a.name for a in esha.attacks] == [ "Lance", "Composite Shortbow +3", "Earth Breaker", ] assert esha.attacks[0].reach_ft == 10 assert esha.attacks[1].range_increment_ft == 70 harvie = load_combatant(PCS_DIR / "harvie.json") assert harvie.size == "Small" assert harvie.speed_fly_ft == 80 oni = load_combatant(PCS_DIR / "oni.json") assert "Pioche" in oni.notes assert "Weapon" in oni.notes tammara = load_combatant(PCS_DIR / "tammara.json") assert tammara.initiative_mod == 7 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_combatant(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_combatant(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_combatant(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_combatant(_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(CombatantLoadError): load_combatant(_write(tmp_path, payload)) def test_rejects_zero_hp(tmp_path: Path) -> None: payload = _valid_payload() payload["hp_max"] = 0 with pytest.raises(CombatantLoadError): load_combatant(_write(tmp_path, payload)) def test_rejects_unknown_key(tmp_path: Path) -> None: payload = _valid_payload() payload["bogus_field"] = 1 with pytest.raises(CombatantLoadError): load_combatant(_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(CombatantLoadError): load_combatant(_write(tmp_path, payload))