4d89305881
PF1e CRB 'Big and Little Creatures in Combat': a creature's natural reach is a disk (a Large creature still threatens and strikes adjacent squares), while a reach weapon doubles the wielder's natural reach but 'can't strike at their natural reach or less' (a ring whose dead zone is the natural reach). The engine keyed the ring off reach_ft > 5, so reach-10 natural attacks (giant belostomatid bite/claw, mounted hooves) wrongly got a 5-ft dead zone. AttackSpec gains reach_weapon (parsed from the combatant JSON); the disk-vs-ring rule is centralized in _melee_reach_zone_cells and applied consistently to weapon selection, melee usability, threatened squares and charge end placement. Esha's Lance is the only reach weapon in the shipped data; Large natural attackers keep the default (disk). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
231 lines
7.5 KiB
Python
231 lines
7.5 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[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
|
|
|
|
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))
|