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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user