feat(models): combatant schema, Foundry sheet loader, monster JSON schema
This commit is contained in:
@@ -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