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