feat(models): combatant schema, Foundry sheet loader, monster JSON schema

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 2a58826bd4
commit 6ad37ae9ec
10 changed files with 1186 additions and 2 deletions
+248
View File
@@ -0,0 +1,248 @@
"""Golden tests for the Foundry VTT pf1-sheet/v1 loader on the 8 real sheets."""
from pathlib import Path
import pytest
from pf1e_simulator.dice import DiceExpr
from pf1e_simulator.loaders import LoadReport, load_sheet, load_sheet_detailed
SHEETS_DIR = Path(__file__).resolve().parent.parent / "fiches_personnages"
# Golden pins — each tuple follows the parametrize argument order below.
_GOLDENS = [
("harvie_randu_sheet.json", "qeqv1DtvWsApYj3e", "Harvie", 2, "Small", 15, 10, 80, 2, 0),
("esha_randu_sheet.json", "9ZRe6qCM2Jw2vQGd", "Esha Randu", 4, "Medium", 32, 25, None, 3, 0),
("ierlieth_randu_sheet.json", "xsyBSseBE2avH4bP", "Ierlieth", 4, "Tiny", 29, 15, None, 2, 0),
("jeanne_randu_sheet.json", "FACxIckfWhKKC086", "Jeanne", 4, "Large", 31, 40, None, 2, 0),
("misty_randu_sheet.json", "1E9HfSar4ac6ryJ5", "Misty", 4, "Medium", 31, 30, None, 5, 0),
(
"nairda_randu_sheet.json", "oyyhwcAfinGasDoH", "Nairda Guisenda", 4, "Medium",
31, 30, None, 2, 0,
),
("oni_randu_sheet.json", "7Ga6fQh1NrqC3NbG", "Oni Triumvir", 4, "Medium", 36, 30, None, 3, 3),
(
"tammara_randu_sheet.json", "k8sPaX8AuMnn6jXc", "Tammara Cailean", 4, "Medium",
36, 30, None, 5, 0,
),
]
def _sheet(filename: str) -> Path:
return SHEETS_DIR / filename
class TestAllSheetsLoad:
@pytest.mark.parametrize(
(
"filename", "sheet_id", "name", "level", "size",
"hp_max", "land", "fly", "kept", "skipped",
),
_GOLDENS,
ids=[g[0] for g in _GOLDENS],
)
def test_sheet_golden(
self,
filename: str,
sheet_id: str,
name: str,
level: int,
size: str,
hp_max: int,
land: int,
fly: int | None,
kept: int,
skipped: int,
) -> None:
# Given a real Foundry sheet export
# When it is loaded with its report
combatant, report = load_sheet_detailed(_sheet(filename))
# Then identity, core stats, and kept/skipped counts match the sheet
assert combatant.id == sheet_id
assert combatant.name == name
assert combatant.level == level
assert combatant.size == size
assert combatant.hp_max == hp_max
assert combatant.speed_land_ft == land
assert combatant.speed_fly_ft == fly
assert len(combatant.attacks) == kept
assert report.path == str(_sheet(filename))
assert report.kept == kept
assert len(report.skipped) == skipped
def test_load_sheet_delegates_and_discards_report(self) -> None:
# Given a real sheet
# When loaded through the simple entry point
combatant = load_sheet(_sheet("harvie_randu_sheet.json"))
detailed, report = load_sheet_detailed(_sheet("harvie_randu_sheet.json"))
# Then the result is identical to the detailed load
assert combatant == detailed
assert isinstance(report, LoadReport)
def test_attack_ids_are_indexed_over_kept_attacks(self) -> None:
# Given Oni's sheet (3 kept out of 6)
# When loaded
combatant, _report = load_sheet_detailed(_sheet("oni_randu_sheet.json"))
# Then ids are indexed over the KEPT attacks only
assert [a.id for a in combatant.attacks] == [
"7Ga6fQh1NrqC3NbG:0",
"7Ga6fQh1NrqC3NbG:1",
"7Ga6fQh1NrqC3NbG:2",
]
class TestHarviePins:
def test_core_stats(self) -> None:
# Given Harvie's sheet
# When loaded
harvie = load_sheet(_sheet("harvie_randu_sheet.json"))
# Then the pinned core stats hold
assert harvie.hp_max == 15
assert harvie.ac.total == 14
assert harvie.ac.touch == 13
assert harvie.ac.flat_footed == 12
assert harvie.initiative_mod == 2
assert harvie.saves == harvie.saves.model_validate({"fort": 4, "ref": 5, "will": 2})
def test_talons_count_prefix(self) -> None:
# Given the "2x Talons" attackName quirk
# When loaded
harvie = load_sheet(_sheet("harvie_randu_sheet.json"))
talons = harvie.attacks[0]
# Then the prefix becomes count=2, the bonus stays as printed (no secondary -5)
assert talons.count == 2
assert talons.attack_bonus == 2
assert "2x" not in talons.name
assert talons.kind == "melee"
assert talons.damage[0].formula == DiceExpr(count=1, sides=4)
assert talons.damage[0].types == ["slashing"]
assert talons.reach_ft == 5
def test_bite_secondary_baked_in(self) -> None:
# Given the Bite entry whose -5 secondary penalty is already baked in
# When loaded
harvie = load_sheet(_sheet("harvie_randu_sheet.json"))
bite = harvie.attacks[1]
# Then the printed -3 is kept verbatim
assert bite.attack_bonus == -3
assert bite.count == 1
def test_dict_speeds_use_total(self) -> None:
# Given land/fly encoded as {base, total} dicts
# When loaded
harvie = load_sheet(_sheet("harvie_randu_sheet.json"))
# Then the effective totals are used
assert harvie.speed_land_ft == 10
assert harvie.speed_fly_ft == 80
class TestEshaPins:
def test_lance_reach_and_size_roll(self) -> None:
# Given the Lance with formula "sizeRoll(1, 8, @size)" and units "reach"
# When loaded
esha = load_sheet(_sheet("esha_randu_sheet.json"))
lance = next(a for a in esha.attacks if "Lance" in a.name)
# Then the Medium sizeRoll resolves to 1d8 and reach is 10 ft
assert lance.kind == "melee"
assert lance.reach_ft == 10
assert lance.damage[0].formula == DiceExpr(count=1, sides=8)
assert lance.damage_bonus == 4
assert lance.crit_mult == 3
def test_shortbow_range_increment(self) -> None:
# Given the Composite Shortbow with units "ft" value "70"
# When loaded
esha = load_sheet(_sheet("esha_randu_sheet.json"))
bow = next(a for a in esha.attacks if "Shortbow" in a.name)
# Then it is a ranged attack with a 70 ft increment and 1d6 damage
assert bow.kind == "ranged"
assert bow.range_increment_ft == 70
assert bow.damage[0].formula == DiceExpr(count=1, sides=6)
assert bow.damage_bonus == 3
def test_missing_crit_range_defaults_to_20(self) -> None:
# Given the Earth Breaker entry which has no critRange key at all
# When loaded
esha = load_sheet(_sheet("esha_randu_sheet.json"))
breaker = next(a for a in esha.attacks if "Earth Breaker" in a.name)
# Then crit_range defaults to 20
assert breaker.crit_range == 20
assert breaker.damage[0].formula == DiceExpr(count=2, sides=6)
class TestOtherSheetPins:
def test_misty_has_keen_crit(self) -> None:
# Given Misty's sheet (MWK Rapier 18-20)
# When loaded
misty = load_sheet(_sheet("misty_randu_sheet.json"))
# Then at least one attack threatens on 18
assert any(a.crit_range == 18 for a in misty.attacks)
def test_misty_thrown_dagger_keeps_melee_kind_with_increment(self) -> None:
# Given the Dagger: actionType mwak but range units "ft"
# When loaded
misty = load_sheet(_sheet("misty_randu_sheet.json"))
dagger = next(a for a in misty.attacks if "Dagger" in a.name)
# Then kind comes from actionType and the increment is still recorded
assert dagger.kind == "melee"
assert dagger.range_increment_ft == 10
def test_tammara_has_x4_crit(self) -> None:
# Given Tammara's pistols (x4 crit)
# When loaded
tammara = load_sheet(_sheet("tammara_randu_sheet.json"))
# Then at least one attack multiplies by 4
assert any(a.crit_mult == 4 for a in tammara.attacks)
def test_tammara_touch_attack_kind(self) -> None:
# Given the Alchemist's Fire with actionType twak
# When loaded
tammara = load_sheet(_sheet("tammara_randu_sheet.json"))
fire = next(a for a in tammara.attacks if "Alchemist" in a.name)
# Then it maps to a touch attack
assert fire.kind == "touch"
assert fire.damage[0].types == ["fire"]
def test_oni_keeps_exactly_three_attacks(self) -> None:
# Given Oni's sheet with 3 invalid entries among 6
# When loaded
oni, report = load_sheet_detailed(_sheet("oni_randu_sheet.json"))
# Then exactly 3 attacks are kept
assert len(oni.attacks) == 3
assert report.kept == 3
def test_oni_skipped_entries_have_reasons(self) -> None:
# Given Oni's sheet
# When loaded
_oni, report = load_sheet_detailed(_sheet("oni_randu_sheet.json"))
skipped = {entry.weapon: entry.reason for entry in report.skipped}
# Then each skipped entry is recorded with its reason
assert set(skipped) == {"Channel Energy", "une Pioche", "Weapon"}
assert "other" in skipped["Channel Energy"]
assert "damage" in skipped["une Pioche"]
assert "damage" in skipped["Weapon"]
def test_jeanne_count_prefix_and_dict_speed(self) -> None:
# Given Jeanne ("2x Hooves", land {base 50, total 40})
# When loaded
jeanne = load_sheet(_sheet("jeanne_randu_sheet.json"))
hooves = jeanne.attacks[0]
# Then the count prefix and the effective speed are applied
assert hooves.count == 2
assert hooves.attack_bonus == 8
assert jeanne.speed_land_ft == 40
def test_ierlieth_land_dict_uses_total_not_base(self) -> None:
# Given Ierlieth's land speed {base 20, total 15}
# When loaded
ierlieth = load_sheet(_sheet("ierlieth_randu_sheet.json"))
# Then the effective value wins
assert ierlieth.speed_land_ft == 15
assert ierlieth.size == "Tiny"
def test_nairda_negative_damage_bonus_preserved(self) -> None:
# Given Nairda's unarmed strike with damageBonus -1
# When loaded
nairda = load_sheet(_sheet("nairda_randu_sheet.json"))
# Then the negative bonus is preserved (low STR)
assert nairda.attacks[0].damage_bonus == -1
+215
View File
@@ -0,0 +1,215 @@
"""Unit tests for the combat data models (contracts for later stages)."""
import pytest
from pydantic import ValidationError
from pf1e_simulator.dice import DiceExpr
from pf1e_simulator.models import (
AbilityScores,
AttackSpec,
Combatant,
DamageComponent,
DamageReduction,
)
def _abilities() -> AbilityScores:
return AbilityScores(
str_score=10,
dex_score=11,
con_score=12,
int_score=13,
wis_score=14,
cha_score=15,
)
def _attack_dict(**overrides: object) -> dict[str, object]:
base: dict[str, object] = {
"id": "dummy:0",
"name": "bite",
"kind": "melee",
"attack_bonus": 3,
"damage": [{"formula": "1d6", "types": ["piercing"]}],
}
base.update(overrides)
return base
def _combatant_dict(**overrides: object) -> dict[str, object]:
base: dict[str, object] = {
"id": "dummy",
"name": "Dummy",
"level": 1,
"abilities": _abilities(),
"hp_max": 10,
"ac": {"total": 15, "touch": 12, "flat_footed": 13},
"bab": 1,
"initiative_mod": 2,
"speed_land_ft": 30,
"saves": {"fort": 2, "ref": 3, "will": 4},
"attacks": [_attack_dict()],
}
base.update(overrides)
return base
class TestDamageComponent:
def test_parses_string_formula(self) -> None:
# Given a raw string formula
# When the component is validated
component = DamageComponent.model_validate({"formula": "2d6+3", "types": ["fire"]})
# Then the formula is parsed into a DiceExpr
assert component.formula == DiceExpr(count=2, sides=6, bonus=3)
assert component.types == ["fire"]
def test_accepts_dice_expr_formula(self) -> None:
# Given an already-parsed DiceExpr
expr = DiceExpr(count=1, sides=4)
# When the component is built with it
component = DamageComponent(formula=expr, types=["slashing"])
# Then it is kept as-is
assert component.formula is expr
def test_rejects_empty_types(self) -> None:
# Given a component with no damage type
# When/Then validation rejects it
with pytest.raises(ValidationError):
DamageComponent.model_validate({"formula": "1d6", "types": []})
def test_rejects_unparseable_formula(self) -> None:
# Given a formula that is not dice notation
# When/Then validation rejects it
with pytest.raises(ValidationError):
DamageComponent.model_validate({"formula": "not dice", "types": ["fire"]})
class TestAttackSpec:
def test_defaults(self) -> None:
# Given a minimal attack spec
# When validated
attack = AttackSpec.model_validate(_attack_dict())
# Then the optional fields take their contract defaults
assert attack.damage_bonus == 0
assert attack.crit_range == 20
assert attack.crit_mult == 2
assert attack.reach_ft == 5
assert attack.range_increment_ft is None
assert attack.count == 1
@pytest.mark.parametrize("kind", ["melee", "ranged", "touch"])
def test_accepts_all_kinds(self, kind: str) -> None:
# Given each allowed kind
# When/Then validation succeeds
attack = AttackSpec.model_validate(_attack_dict(kind=kind))
assert attack.kind == kind
def test_rejects_unknown_kind(self) -> None:
# Given a kind outside the Literal
# When/Then validation rejects it
with pytest.raises(ValidationError):
AttackSpec.model_validate(_attack_dict(kind="spell"))
def test_rejects_empty_damage_list(self) -> None:
# Given an attack with no damage component
# When/Then validation rejects it
with pytest.raises(ValidationError):
AttackSpec.model_validate(_attack_dict(damage=[]))
def test_rejects_crit_mult_below_2(self) -> None:
# Given crit_mult=1 (a weapon that never multiplies is not PF1e)
# When/Then validation rejects it
with pytest.raises(ValidationError):
AttackSpec.model_validate(_attack_dict(crit_mult=1))
def test_rejects_crit_mult_above_4(self) -> None:
# Given crit_mult=5
# When/Then validation rejects it
with pytest.raises(ValidationError):
AttackSpec.model_validate(_attack_dict(crit_mult=5))
@pytest.mark.parametrize("crit_range", [0, 1, 21])
def test_rejects_crit_range_out_of_bounds(self, crit_range: int) -> None:
# Given a crit range outside 2..20
# When/Then validation rejects it
with pytest.raises(ValidationError):
AttackSpec.model_validate(_attack_dict(crit_range=crit_range))
def test_rejects_zero_count(self) -> None:
# Given count=0
# When/Then validation rejects it
with pytest.raises(ValidationError):
AttackSpec.model_validate(_attack_dict(count=0))
class TestCombatant:
def test_defaults(self) -> None:
# Given a minimal combatant
# When validated
combatant = Combatant.model_validate(_combatant_dict())
# Then the optional fields take their contract defaults
assert combatant.size == "Medium"
assert combatant.speed_fly_ft is None
assert combatant.dr is None
assert combatant.cr is None
assert combatant.xp is None
assert combatant.source == ""
assert combatant.notes == ""
def test_rejects_hp_max_zero(self) -> None:
# Given hp_max=0
# When/Then validation rejects it
with pytest.raises(ValidationError):
Combatant.model_validate(_combatant_dict(hp_max=0))
def test_rejects_negative_land_speed(self) -> None:
# Given a negative land speed
# When/Then validation rejects it
with pytest.raises(ValidationError):
Combatant.model_validate(_combatant_dict(speed_land_ft=-5))
def test_rejects_unknown_extra_key(self) -> None:
# Given an unknown top-level key
# When/Then validation rejects it (strict schema)
with pytest.raises(ValidationError):
Combatant.model_validate(_combatant_dict(hit_dice="1d8"))
def test_frozen(self) -> None:
# Given a validated combatant
combatant = Combatant.model_validate(_combatant_dict())
# When/Then mutation is rejected
with pytest.raises(ValidationError):
combatant.name = "Mutilated"
def test_full_combatant_roundtrip(self) -> None:
# Given a fully populated combatant
combatant = Combatant.model_validate(
_combatant_dict(
size="Small",
speed_fly_ft=80,
dr={"amount": 5, "bypass": ["magic"]},
cr="1/3",
xp=135,
source="Bestiary > Goblin",
notes="note",
)
)
# Then every field round-trips
assert combatant.size == "Small"
assert combatant.speed_fly_ft == 80
assert combatant.dr is not None
assert combatant.dr.amount == 5
assert combatant.dr.bypass == frozenset({"magic"})
assert combatant.cr == "1/3"
assert combatant.xp == 135
assert combatant.source == "Bestiary > Goblin"
assert combatant.notes == "note"
class TestDamageReduction:
def test_empty_bypass_means_dr_dash(self) -> None:
# Given DR with an empty bypass set
dr = DamageReduction(amount=10, bypass=frozenset())
# Then it represents DR 10/—
assert dr.amount == 10
assert dr.bypass == frozenset()
+155
View File
@@ -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))