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
+29 -8
View File
@@ -47,6 +47,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,
@@ -61,6 +62,7 @@ def make_combatant(
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
reach_ft=reach_ft,
reach_weapon=reach_weapon,
)
return Combatant(
id=cid,
@@ -266,12 +268,23 @@ class TestThreatenedSquaresLarge:
assert (5, 5) not in threatened
def test_large_reach_weapon_threatens_union_of_rings(self) -> None:
"""A Large at (2,2) with a reach weapon (10ft) threatens ring(2) from each cell."""
"""A Large at (2,2) wielding a reach weapon threatens the ring beyond its
10 ft natural reach, up to 20 ft (5-10-5 cells 3..4).
R1 conformance: CRB "Big and Little Creatures in Combat" — "Large or
larger creatures using reach weapons can strike up to double their
natural reach but can't strike at their natural reach or less." The
previous expectation (ring at exactly 2 cells, i.e. 10 ft) encoded the
old bug where a reach weapon's ring was keyed off ``reach_ft`` instead
of the wielder's natural reach, so it put the whole natural disk in the
dead zone.
"""
spear = AttackSpec(
id="spear", name="longspear", kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
reach_ft=10,
reach_weapon=True,
)
combatant = Combatant(
id="g", name="g", level=1, size="Large",
@@ -288,11 +301,18 @@ class TestThreatenedSquaresLarge:
state = make_state(combatant, "p", (2, 2))
engine = make_engine([state])
threatened = engine.threatened_squares(state)
# Ring at distance 2 from each occupied cell is included
# (2,2) ring(2) includes (0,2) etc.
# (3,3) ring(2) includes (1,3), (5,3), (3,1), (3,5) etc.
assert (1, 3) in threatened
assert (3, 5) in threatened
# Dead zone: within the 10 ft natural reach of every occupied cell.
# (1, 3) is adjacent to (2, 3); (1, 2) is at most 2 cells from each.
assert (1, 3) not in threatened
assert (1, 2) not in threatened
# Ring: 15 ft (3 cells) and 20 ft (4 cells) out are threatened.
# (5, 2) is 3 cells from the occupied cell (2, 2).
assert (5, 2) in threatened
# (6, 2) is 4 cells from (2, 2).
assert (6, 2) in threatened
# Beyond double natural reach: (8, 8) is 5+ cells from every
# occupied cell ((3, 3) -> d_row=5, d_col=5 -> 5+2=7).
assert (8, 8) not in threatened
# ---------------------------------------------------------------------------
@@ -401,9 +421,10 @@ class TestLargeAoO:
get AoOs."""
giant = make_combatant("g", size="Large", speed=30, hp=30, ac=12)
g_state = make_state(giant, "p", (3, 3))
# enemy with reach weapon at (3,6) — threatens ring(2) = dist 2 from (3,6)
# enemy with a longspear (reach weapon) at (3,6) — Medium natural reach
# 5 ft, so it threatens the ring at dist 2 from (3,6)
# (3,4) is at dist 2 from (3,6), so it's in the threatened zone
enemy = make_combatant("e", reach_ft=10, hp=100, speed=0)
enemy = make_combatant("e", reach_ft=10, reach_weapon=True, hp=100, speed=0)
e_state = make_state(enemy, "m", (3, 6))
dest = make_combatant("d", hp=100, speed=0)
d_state = make_state(dest, "e", (0, 0))