c88d8fd6f4
Rename monster.py → combatant.py with _KNOWN_FEATS lookup table that resolves feat name strings to AbilitySpec constants (Power Attack, Iron Will, Toughness, Weapon Focus, etc.). Unknown feats produce minimal AbilitySpec (data preserved for future implementation). Migrate all 8 PC JSONs (data/pcs/) with complete feat/spell lists extracted from Foundry sheets — including traits, racial abilities, and all spells. Monsters (goblin, orc) unchanged (no feats/spells). Update all imports, add backward-compatible aliases (load_monster, MonsterLoadError), update CLI to use load_combatant. Gate: 377 tests, ruff clean, basedpyright clean.
193 lines
5.8 KiB
Python
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.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))
|