241 lines
8.1 KiB
Python
241 lines
8.1 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, parse_dice
|
|
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[0].reach_weapon is True
|
|
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
|
|
# Sheet pins (fiches_personnages/harvie_sheet.json): Wis 14 (Will +2
|
|
# implies it), "2x Talons" primary +2 1d4 slashing, Bite secondary -3
|
|
# 1d4 piercing (stored as base +2 with the engine's runtime -5).
|
|
assert harvie.abilities.wis_score == 14
|
|
bite, talons = harvie.attacks
|
|
assert (bite.name, bite.attack_bonus, bite.secondary) == ("Bite", 2, True)
|
|
assert bite.damage[0].formula == parse_dice("1d4")
|
|
assert (talons.name, talons.attack_bonus, talons.count) == ("Talons", 2, 2)
|
|
assert talons.damage[0].formula == parse_dice("1d4")
|
|
assert talons.damage[0].types == ["slashing"]
|
|
|
|
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_reach_weapon_flag_parses_and_defaults(tmp_path: Path) -> None:
|
|
"""R1: ``"reach_weapon": true`` parses through the strict JSON loader and
|
|
defaults to False when absent (natural attacks stay a disk)."""
|
|
payload = _valid_payload()
|
|
attacks = payload["attacks"]
|
|
assert isinstance(attacks, list)
|
|
lance = attacks[0]
|
|
assert isinstance(lance, dict)
|
|
lance["reach_ft"] = 10
|
|
lance["reach_weapon"] = True
|
|
combatant = load_combatant(_write(tmp_path, payload))
|
|
assert combatant.attacks[0].reach_weapon is True
|
|
|
|
natural = load_combatant(_write(tmp_path, _valid_payload()))
|
|
assert natural.attacks[0].reach_weapon is False
|
|
|
|
|
|
def test_data_reach_flags_match_raw() -> None:
|
|
"""R1 data audit: the only reach weapon in the shipped data is Esha's
|
|
Lance; every Large natural attacker (bite/claw/hooves, reach_ft 10) keeps
|
|
the default False so its natural reach stays a disk."""
|
|
flagged: list[tuple[str, str]] = []
|
|
for path in sorted(MONSTERS_DIR.glob("*.json")) + sorted(PCS_DIR.glob("*.json")):
|
|
flagged.extend(
|
|
(path.name, attack.name)
|
|
for attack in load_combatant(path).attacks
|
|
if attack.reach_weapon
|
|
)
|
|
assert flagged == [("esha.json", "Lance")]
|
|
belostomatid = load_combatant(MONSTERS_DIR / "giant_belostomatid.json")
|
|
assert {a.name for a in belostomatid.attacks if a.reach_ft > 5} == {"bite", "claw"}
|
|
assert all(not a.reach_weapon for a in belostomatid.attacks)
|
|
for mount in ("esha_mount.json", "jeanne.json"):
|
|
hooves = load_combatant(PCS_DIR / mount)
|
|
assert any(a.reach_ft == 10 and not a.reach_weapon for a in hooves.attacks)
|
|
|
|
|
|
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))
|