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
+54 -43
View File
@@ -395,8 +395,36 @@ _STEP_DELTAS: tuple[Pos, ...] = (
def _effective_reach_ft(state: CombatantState, weapon: AttackSpec) -> int:
"""Effective melee reach in feet: max of natural size reach and weapon reach."""
return max(_SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5), weapon.reach_ft)
"""Outer edge, in feet, of a melee attack's usable reach zone.
PF1e distinguishes two cases (CRB "Big and Little Creatures in Combat"):
a creature's NATURAL reach is a disk, while a reach weapon doubles that
natural reach but cannot strike at the wielder's natural reach or less
(a ring). See ``_melee_reach_zone_cells`` for the full zone.
"""
natural_ft = _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5)
if weapon.reach_weapon:
return 2 * natural_ft
return max(natural_ft, weapon.reach_ft)
def _melee_reach_zone_cells(state: CombatantState, weapon: AttackSpec) -> tuple[int, int]:
"""Inclusive grid-cell reach zone ``(inner, outer)`` of one melee attack.
PF1e, CRB "Big and Little Creatures in Combat": "Unlike when someone
uses a reach weapon, a creature with greater than normal natural reach
(more than 5 feet) still threatens squares adjacent to it" — natural
reach is a DISK, so the zone is ``0 .. max(natural, weapon.reach_ft)``
(a natural attack's ``reach_ft`` IS the creature's natural reach). A
reach weapon instead "can strike up to double their natural reach but
can't strike at their natural reach or less" — the zone is the RING
``natural+1 .. 2 * natural``, whose dead zone is the wielder's natural
reach.
"""
natural_cells = _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5) // _SQUARE_FT
inner_cells = natural_cells + 1 if weapon.reach_weapon else 0
outer_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT
return inner_cells, outer_cells
def _speed_ft(state: CombatantState) -> int:
@@ -1961,11 +1989,8 @@ class CombatEngine:
return False
dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT
if weapon.kind in ("melee", "touch"):
effective_reach_ft = _effective_reach_ft(attacker, weapon)
if weapon.reach_ft > _SQUARE_FT:
if _SQUARE_FT < dist_ft <= effective_reach_ft:
return True
elif dist_ft <= effective_reach_ft:
inner_cells, outer_cells = _melee_reach_zone_cells(attacker, weapon)
if inner_cells * _SQUARE_FT <= dist_ft <= outer_cells * _SQUARE_FT:
return True
max_increments = (
_MAX_TOUCH_RANGE_INCREMENTS
@@ -2782,9 +2807,10 @@ class CombatEngine:
def threatened_squares(self, state: CombatantState) -> frozenset[Pos]:
"""Squares this combatant threatens with a melee weapon.
(API publique, lecture pour politique LLM.) Non-reach weapons
(reach_ft == 5) threaten the disk of effective reach; reach weapons
(reach_ft > 5) threaten only the ring at exactly their reach (PF1e
(API publique, lecture pour politique LLM.) Natural reach (plain
weapons and natural attacks) is a disk: every square up to the
effective reach, adjacency included. A reach weapon threatens only
the ring beyond the wielder's natural reach up to double it (PF1e
reach dead zone). The union over usable melee weapons is returned.
"""
if not state.active:
@@ -2798,42 +2824,31 @@ class CombatEngine:
return frozenset(threatened)
def _weapon_threatened_squares(self, state: CombatantState, weapon: AttackSpec) -> set[Pos]:
"""Squares one melee weapon threatens: disk for non-reach, ring for reach.
"""Squares one melee weapon threatens: disk for natural reach, ring for reach weapons.
The union over every occupied cell of the creature's footprint; the
creature's own cells are never threatened.
"""
inner_cells, outer_cells = _melee_reach_zone_cells(state, weapon)
threatened: set[Pos] = set()
if weapon.reach_ft > _SQUARE_FT:
ring = weapon.reach_ft // _SQUARE_FT
for r, c in state.occupied_cells():
threatened |= self._ring_cells((r, c), ring)
else:
reach_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT
for r, c in state.occupied_cells():
threatened |= self._disk_cells((r, c), reach_cells)
for r, c in state.occupied_cells():
threatened |= self._reach_band_cells((r, c), inner_cells, outer_cells)
threatened -= state.occupied_cells()
return threatened
def _ring_cells(self, center: Pos, ring: int) -> set[Pos]:
"""Cells at exactly ``ring`` squares from ``center`` (5-10-5 distance)."""
r, c = center
return {
(r + dr, c + dc)
for dr in range(-ring, ring + 1)
for dc in range(-ring, ring + 1)
if self._grid.distance(center, (r + dr, c + dc)) == ring
}
def _reach_band_cells(self, center: Pos, inner: int, outer: int) -> set[Pos]:
"""Cells whose 5-10-5 distance from ``center`` lies in ``[inner, outer]``.
def _disk_cells(self, center: Pos, radius: int) -> set[Pos]:
"""Cells within ``radius`` squares of ``center`` (5-10-5 distance)."""
``inner == 0`` gives the plain disk (the center itself is never
included); ``inner > 0`` gives the reach-weapon ring dead-zone band.
"""
r, c = center
return {
(r + dr, c + dc)
for dr in range(-radius, radius + 1)
for dc in range(-radius, radius + 1)
for dr in range(-outer, outer + 1)
for dc in range(-outer, outer + 1)
if (dr, dc) != (0, 0)
and self._grid.distance(center, (r + dr, c + dc)) <= radius
and inner <= self._grid.distance(center, (r + dr, c + dc)) <= outer
}
def enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]:
@@ -3042,10 +3057,9 @@ class CombatEngine:
if not melee_weapons:
return None
for weapon in melee_weapons:
reach_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT
min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0
inner_cells, reach_cells = _melee_reach_zone_cells(state, weapon)
end = self._charge_end(
state, target, reach_cells, max_cells, blocked, min_reach_cells=min_reach_cells
state, target, reach_cells, max_cells, blocked, min_reach_cells=inner_cells
)
if end is not None:
distance_ft = self._grid.distance(state.pos, end) * _SQUARE_FT
@@ -3144,13 +3158,12 @@ class CombatEngine:
"""
if attacker.moved_this_turn:
return
reach_cells = _effective_reach_ft(attacker, weapon) // _SQUARE_FT
min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0
inner_cells, reach_cells = _melee_reach_zone_cells(attacker, weapon)
speed_cells = _speed_ft(attacker) // _SQUARE_FT
max_cells = 2 * speed_cells
blocked = self._blocked(attacker, target)
end = self._charge_end(
attacker, target, reach_cells, max_cells, blocked, min_reach_cells=min_reach_cells
attacker, target, reach_cells, max_cells, blocked, min_reach_cells=inner_cells
)
if end is None:
return
@@ -5568,10 +5581,8 @@ class CombatEngine:
):
return False
dist_ft = self.creature_distance(attacker, target) * _SQUARE_FT
effective_reach_ft = _effective_reach_ft(attacker, weapon)
if weapon.reach_ft > _SQUARE_FT:
return _SQUARE_FT < dist_ft <= effective_reach_ft
return dist_ft <= effective_reach_ft
inner_cells, outer_cells = _melee_reach_zone_cells(attacker, weapon)
return inner_cells * _SQUARE_FT <= dist_ft <= outer_cells * _SQUARE_FT
def _step5(self, state: CombatantState, target: CombatantState) -> None:
if state.moved_this_turn:
+9 -1
View File
@@ -242,7 +242,14 @@ class AttackSpec(_FrozenModel):
increment and against full AC beyond it. `two_handed` marks a weapon swung
in two hands (two-handed weapon, one-handed weapon used two hands, or a
primary natural with 1.5x Strength) — PF1e Power Attack rolls +50% damage
with such a weapon (CRB "Power Attack").
with such a weapon (CRB "Power Attack"). `reach_weapon` marks a weapon
with the reach weapon quality (CRB "Big and Little Creatures in Combat"):
it doubles the wielder's NATURAL reach but cannot strike at the wielder's
natural reach or less — the usable zone is a RING (dead zone = natural
reach). Without the flag a melee attack follows the creature's natural
reach, which is a DISK: a Large creature's natural attack (its JSON
`reach_ft` IS its natural reach, 10 ft) still strikes and threatens
adjacent squares.
"""
id: str
@@ -254,6 +261,7 @@ class AttackSpec(_FrozenModel):
crit_range: int = Field(default=20, ge=2, le=20)
crit_mult: int = Field(default=2, ge=2, le=4)
reach_ft: int = 5
reach_weapon: bool = False
range_increment_ft: int | None = None
count: int = Field(default=1, ge=1)
firearm: bool = False