fix(rules): distinguish natural reach from reach weapons

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>
This commit is contained in:
2026-09-11 22:06:46 +02:00
parent 4f6b076a49
commit 4d89305881
11 changed files with 292 additions and 80 deletions
+38
View File
@@ -36,6 +36,7 @@ def test_pc_sheets_golden_spots() -> None:
"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")
@@ -138,6 +139,43 @@ def test_orc_golden() -> None:
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"]