refactor(loaders): unify combatant loader, migrate PC features/spells

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.
This commit is contained in:
2026-08-17 22:49:50 +02:00
parent e9e8c26d50
commit c88d8fd6f4
14 changed files with 421 additions and 96 deletions
+18 -18
View File
@@ -8,7 +8,7 @@ from pathlib import Path
import pytest
from pf1e_simulator.dice import DiceExpr
from pf1e_simulator.loaders.monster import MonsterLoadError, load_monster
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"
@@ -20,7 +20,7 @@ PCS_DIR = Path(__file__).resolve().parents[1] / "data" / "pcs"
)
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")
combatant = load_combatant(PCS_DIR / f"{stem}.json")
assert combatant.attacks
assert combatant.hp_max >= 1
assert combatant.ac.total >= 10
@@ -28,7 +28,7 @@ def test_pc_sheet_loads(stem: str) -> None:
def test_pc_sheets_golden_spots() -> None:
esha = load_monster(PCS_DIR / "esha.json")
esha = load_combatant(PCS_DIR / "esha.json")
assert esha.name == "Esha Randu"
assert [a.name for a in esha.attacks] == [
"Lance",
@@ -38,15 +38,15 @@ def test_pc_sheets_golden_spots() -> None:
assert esha.attacks[0].reach_ft == 10
assert esha.attacks[1].range_increment_ft == 70
harvie = load_monster(PCS_DIR / "harvie.json")
harvie = load_combatant(PCS_DIR / "harvie.json")
assert harvie.size == "Small"
assert harvie.speed_fly_ft == 80
oni = load_monster(PCS_DIR / "oni.json")
oni = load_combatant(PCS_DIR / "oni.json")
assert "Pioche" in oni.notes
assert "Weapon" in oni.notes
tammara = load_monster(PCS_DIR / "tammara.json")
tammara = load_combatant(PCS_DIR / "tammara.json")
assert tammara.initiative_mod == 7
@@ -86,7 +86,7 @@ def _valid_payload() -> dict[str, object]:
def test_goblin_golden() -> None:
goblin = load_monster(MONSTERS_DIR / "goblin.json")
goblin = load_combatant(MONSTERS_DIR / "goblin.json")
assert goblin.name == "Goblin"
assert goblin.size == "Small"
assert goblin.cr == "1/3"
@@ -118,7 +118,7 @@ def test_goblin_golden() -> None:
def test_orc_golden() -> None:
orc = load_monster(MONSTERS_DIR / "orc.json")
orc = load_combatant(MONSTERS_DIR / "orc.json")
assert orc.name == "Orc"
assert orc.size == "Medium"
assert orc.hp_max == 6
@@ -139,12 +139,12 @@ def test_orc_golden() -> None:
def test_monster_attack_ids_auto_assigned() -> None:
goblin = load_monster(MONSTERS_DIR / "goblin.json")
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_monster(_write(tmp_path, _valid_payload()))
monster = load_combatant(_write(tmp_path, _valid_payload()))
assert monster.name == "Testling"
assert monster.attacks[0].id == "monster:0" # falls back to file stem
@@ -160,22 +160,22 @@ def test_rejects_crit_mult_one(tmp_path: Path) -> None:
"crit_mult": 1,
}
]
with pytest.raises(MonsterLoadError):
load_monster(_write(tmp_path, payload))
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(MonsterLoadError):
load_monster(_write(tmp_path, payload))
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(MonsterLoadError):
load_monster(_write(tmp_path, payload))
with pytest.raises(CombatantLoadError):
load_combatant(_write(tmp_path, payload))
def test_rejects_bad_formula(tmp_path: Path) -> None:
@@ -188,5 +188,5 @@ def test_rejects_bad_formula(tmp_path: Path) -> None:
"damage": [{"formula": "banana", "types": ["piercing"]}],
}
]
with pytest.raises(MonsterLoadError):
load_monster(_write(tmp_path, payload))
with pytest.raises(CombatantLoadError):
load_combatant(_write(tmp_path, payload))