Files
pf1e-simulator/tests/test_monster_loader.py
T
ctan 7297494e56 feat(data): transpose Foundry PC sheets into data/pcs
All 8 sheets from fiches_personnages/ converted to the strict Combatant
JSON schema (data/monsters convention) so the CLI can run PCs directly:

  --side players data/pcs/esha.json

- id derived from the file stem; source field points at the original sheet
- oni: placeholder attacks without encoded damage ('une Pioche', 'Weapon')
  left out, documented in notes (pick stats ambiguous light/heavy in the
  rules KB; no identification possible for 'Weapon')
- README: PC section now documents data/pcs usage
- tests: parametrized load check + golden spots for the 8 PC files
2026-08-17 22:49:50 +02:00

193 lines
5.8 KiB
Python

"""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"
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_monster(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_monster(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_monster(PCS_DIR / "harvie.json")
assert harvie.size == "Small"
assert harvie.speed_fly_ft == 80
oni = load_monster(PCS_DIR / "oni.json")
assert "Pioche" in oni.notes
assert "Weapon" in oni.notes
tammara = load_monster(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_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))