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
+7 -5
View File
@@ -3,8 +3,8 @@ and movement-cost fields (read-only API for a future LLM policy).
Covers:
- ``threatened_squares`` (public, renamed from ``_threatened_squares``): the
disk of effective reach for non-reach weapons and the ring (dead zone) for
reach weapons.
disk of natural reach for plain/natural attacks and the ring (dead zone)
for reach weapons.
- ``enemies_threatening`` (public, renamed from ``_enemies_threatening``):
active enemies whose threatened squares include a given cell, in
``self._states`` order; allies never count.
@@ -38,6 +38,7 @@ def make_combatant(
*,
size: str = "Medium",
reach_ft: int = 5,
reach_weapon: bool = False,
hp: int = 20,
ac: int = 15,
attack_bonus: int = 5,
@@ -46,11 +47,12 @@ def make_combatant(
"""Build a single-melee-weapon combatant."""
attack = AttackSpec(
id=f"{cid}-w",
name="longspear" if reach_ft > 5 else "short sword",
name="longspear" if reach_weapon else "short sword",
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
reach_ft=reach_ft,
reach_weapon=reach_weapon,
)
return Combatant(
id=cid,
@@ -126,8 +128,8 @@ def test_threatened_squares_public_non_reach_disk() -> None:
def test_threatened_squares_public_reach_dead_zone() -> None:
"""A longspear (reach 10 ft) threatens the ring at distance 2, not adjacent squares."""
spearman = make_combatant("spearman", reach_ft=10)
"""A longspear (reach weapon) threatens the ring at distance 2, not adjacent squares."""
spearman = make_combatant("spearman", reach_ft=10, reach_weapon=True)
state = make_state(spearman, "players", (3, 3))
engine = make_engine([state])
threatened = engine.threatened_squares(state)