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
+23 -9
View File
@@ -269,7 +269,13 @@ jusqu'à 10 incréments, une arme `touch` jusqu'à 5 (jet/firearm), au-delà ell
n'est pas sélectionnable. `damage_bonus` et `dr` sont optionnels.
Le booléen `two_handed` (optionnel, défaut `false`) marque une arme tenue à
deux mains : Power Attack donne alors +3X (et non +2X) aux dégâts (conformance
B16). `spell_resistance` (entier), `resistances` (dict type → montant) et
B16). Le booléen `reach_weapon` (optionnel, défaut `false`) marque une arme
dotée de la qualité « reach » du CRB (lance, glaive, longspear, etc.) : elle
double l'allonge naturelle du porteur mais ne frappe pas à cette allonge
naturelle ou moins — anneau avec dead zone (conformance R1, voir « Mêlée »).
Le `reach_ft` d'une attaque naturelle (morsure, sabots) n'est **pas** une arme
à allonge : il encode l'allonge naturelle de la créature, qui reste un disque.
`spell_resistance` (entier), `resistances` (dict type → montant) et
`immunities` (liste de types) sont optionnels.
Deux monstres d'exemple sont fournis : gobelin et orc.
@@ -565,11 +571,19 @@ Règles modélisées :
aucun jet n'est effectué. Sans `weapon_id`, la sélection automatique est
inchangée. `default_policy` ne choisit pas d'arme (disponible via une
politique personnalisée).
- Mêlée : allonge effective = max(allonge naturelle du gabarit (Small/Medium
5 ft, Large 10 ft, Huge 15 ft, Gargantuan 20 ft, Colossal 30 ft), `reach_ft`
de l'arme). Une arme à allonge (`reach_ft` > 5) menace l'anneau de cases à
distance égale à son allonge — les cases à 5 ft sont une « dead zone » où
elle ne frappe pas ; une arme sans allonge menace les cases adjacentes.
- Mêlée (conformance R1, CRB « Big and Little Creatures in Combat ») :
l'**allonge naturelle** du gabarit (Small/Medium 5 ft, Large 10 ft, Huge
15 ft, Gargantuan 20 ft, Colossal 30 ft) est un **disque** : la créature
frappe et menace toutes les cases jusqu'à son allonge, l'adjacence
comprise — une créature Large dont l'attaque naturelle a `reach_ft` 10
menace donc aussi les cases adjacentes (le `reach_ft` d'une attaque
naturelle EST son allonge naturelle). Une **arme à allonge**
(`reach_weapon: true` dans le JSON) est un **anneau** : elle double
l'allonge naturelle du porteur mais ne frappe pas à l'allonge naturelle ou
moins — la zone utilisable est `allonge naturelle < distance ≤ 2 ×
allonge naturelle`, la dead zone vaut donc 5 ft pour un porteur
Small/Medium et 10 ft pour un porteur Large. Une arme ordinaire sans
allonge menace les cases adjacentes (disque de 5 ft).
Mouvement : un déplacement parcourt jusqu'à la
vitesse du combattant en cases le long du plus court chemin réel (champ de
coût Dijkstra depuis la cible, diagonales 5-10-5), en s'arrêtant adjacent à
@@ -647,9 +661,9 @@ Règles modélisées :
(étourdi/paralysé ne flanque pas). Les attaques à distance ignorent le
flanquement ; un allié désarmé ou à distance ne compte pas.
- Attaques d'opportunité : un combattant actif doté d'une arme de mêlée menace
les cases à distance de son allonge effective (les 8 cases adjacentes pour
une arme sans allonge ; l'anneau à son allonge pour une arme reach — la
dead zone de 5 ft n'est pas menacée). Deux déclencheurs sont modélisés : (1) quitter une
les cases de sa zone d'allonge (voir « Mêlée » : le disque de l'allonge
naturelle — les 8 cases adjacentes pour un Small/Medium sans allonge — ou
l'anneau d'une arme à allonge, dont la dead zone n'est pas menacée). Deux déclencheurs sont modélisés : (1) quitter une
case menacée — le mouvement est résolu pas à pas, et chaque ennemi qui menace
la case actuelle porte une AoO avant que le mouvant ne la quitte ; (2) tirer
avec une arme à distance depuis une case menacée — chaque ennemi menaçant
+1
View File
@@ -44,6 +44,7 @@
"crit_range": 20,
"crit_mult": 3,
"reach_ft": 10,
"reach_weapon": true,
"count": 1
},
{
+53 -42
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)
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
+1
View File
@@ -71,6 +71,7 @@ def _reach_sword(cid: str) -> AttackSpec:
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
reach_ft=10,
reach_weapon=True,
)
+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))
+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)
+38
View File
@@ -36,6 +36,7 @@ def test_pc_sheets_golden_spots() -> None:
"Earth Breaker",
]
assert esha.attacks[0].reach_ft == 10
assert esha.attacks[0].reach_weapon is True
assert esha.attacks[1].range_increment_ft == 70
harvie = load_combatant(PCS_DIR / "harvie.json")
@@ -138,6 +139,43 @@ def test_orc_golden() -> None:
assert javelin.damage_bonus == 3
def test_reach_weapon_flag_parses_and_defaults(tmp_path: Path) -> None:
"""R1: ``"reach_weapon": true`` parses through the strict JSON loader and
defaults to False when absent (natural attacks stay a disk)."""
payload = _valid_payload()
attacks = payload["attacks"]
assert isinstance(attacks, list)
lance = attacks[0]
assert isinstance(lance, dict)
lance["reach_ft"] = 10
lance["reach_weapon"] = True
combatant = load_combatant(_write(tmp_path, payload))
assert combatant.attacks[0].reach_weapon is True
natural = load_combatant(_write(tmp_path, _valid_payload()))
assert natural.attacks[0].reach_weapon is False
def test_data_reach_flags_match_raw() -> None:
"""R1 data audit: the only reach weapon in the shipped data is Esha's
Lance; every Large natural attacker (bite/claw/hooves, reach_ft 10) keeps
the default False so its natural reach stays a disk."""
flagged: list[tuple[str, str]] = []
for path in sorted(MONSTERS_DIR.glob("*.json")) + sorted(PCS_DIR.glob("*.json")):
flagged.extend(
(path.name, attack.name)
for attack in load_combatant(path).attacks
if attack.reach_weapon
)
assert flagged == [("esha.json", "Lance")]
belostomatid = load_combatant(MONSTERS_DIR / "giant_belostomatid.json")
assert {a.name for a in belostomatid.attacks if a.reach_ft > 5} == {"bite", "claw"}
assert all(not a.reach_weapon for a in belostomatid.attacks)
for mount in ("esha_mount.json", "jeanne.json"):
hooves = load_combatant(PCS_DIR / mount)
assert any(a.reach_ft == 10 and not a.reach_weapon for a in hooves.attacks)
def test_monster_attack_ids_auto_assigned() -> None:
goblin = load_combatant(MONSTERS_DIR / "goblin.json")
assert [a.id for a in goblin.attacks] == ["goblin:0", "goblin:1"]
+5 -3
View File
@@ -702,9 +702,10 @@ def test_observation_enriched_pair_existing_keys_preserved() -> None:
def make_reach_combatant(cid: str, *, reach_ft: int = 10) -> Combatant:
"""Build a combatant whose melee weapon has the requested reach.
``reach_ft=10`` is a typical reach weapon (e.g. longspear): threatens
the ring at 2 squares (5-10-5) and NOT the 1-square dead zone.
``reach_ft=5`` is a non-reach melee weapon: threatens the disk of 1 cell.
``reach_ft=10`` is a typical reach weapon (e.g. longspear, flagged
``reach_weapon=True``): threatens the ring at 2 squares (5-10-5) and
NOT the 1-square dead zone. ``reach_ft=5`` is a non-reach melee weapon:
threatens the disk of 1 cell.
"""
attack = AttackSpec(
id=f"{cid}-w",
@@ -713,6 +714,7 @@ def make_reach_combatant(cid: str, *, reach_ft: int = 10) -> Combatant:
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d8"), types=["piercing"])],
reach_ft=reach_ft,
reach_weapon=reach_ft > 5,
)
return Combatant(
id=cid,
+1
View File
@@ -82,6 +82,7 @@ def _spear(cid: str) -> AttackSpec:
kind="melee",
attack_bonus=5,
reach_ft=10,
reach_weapon=True,
damage=[DamageComponent(formula=parse_dice("1d8"), types=["piercing"])],
)
+124 -11
View File
@@ -1,10 +1,14 @@
"""Tests for B5.3: melee reach depends on weapon reach and creature size.
Covers the PF1e reach rules implemented in this phase:
- 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 (the PF1e reach dead zone: a longspear cannot hit an adjacent target);
- a Large creature's natural reach (10 ft) extends its non-reach weapons;
Covers the PF1e reach rules implemented in this phase (R1 conformance:
CRB "Big and Little Creatures in Combat" — natural reach is a disk, a
reach weapon is a ring):
- natural reach (plain weapons and natural attacks) threatens the DISK of
effective reach: a Large creature's 10 ft bite still strikes and
threatens ADJACENT squares;
- reach weapons (``reach_weapon=True``) threaten only the RING beyond the
wielder's natural reach up to double it (the PF1e reach dead zone: a
longspear cannot hit an adjacent target);
- ``weapon_for`` respects the reach dead zone;
- AoOs trigger from ring squares threatened by a reach weapon.
"""
@@ -31,6 +35,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,
@@ -39,11 +44,12 @@ def make_combatant(
"""Build a single-melee-weapon combatant (used by most reach tests)."""
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,
@@ -125,8 +131,11 @@ def test_medium_5ft_weapon_threatens_adjacent_only() -> None:
def test_reach_weapon_threatens_ring_not_adjacent() -> 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.
R1: the ring is now driven by ``reach_weapon=True``, not by ``reach_ft > 5``.
"""
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)
@@ -154,6 +163,7 @@ def test_sword_plus_reach_weapon_union() -> None:
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
reach_ft=10,
reach_weapon=True,
)
combatant = Combatant(
id="dual",
@@ -205,9 +215,108 @@ def test_unknown_size_defaults_to_5ft() -> None:
assert engine.threatened_squares(state) == frozenset(_adjacent((3, 3)))
# ── R1 conformance: natural reach is a disk, a reach weapon is a ring ────────
def _natural_attack_combatant(
cid: str, *, size: str, reach_ft: int, reach_weapon: bool
) -> Combatant:
"""Combatant with one melee attack carrying the given reach flags."""
attack = AttackSpec(
id=f"{cid}-w",
name="bite" if not reach_weapon else "longspear",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
reach_ft=reach_ft,
reach_weapon=reach_weapon,
)
return Combatant(
id=cid,
name=cid,
level=1,
size=size,
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=12,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=20,
ac=ACProfile(total=15, touch=15, flat_footed=15),
bab=5,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[attack],
)
def test_large_natural_attack_threatens_adjacent_disk() -> None:
"""R1 (CRB "Big and Little Creatures in Combat"): a Large creature's
natural 10 ft bite is a DISK — it threatens adjacent squares too, unlike
a reach weapon's ring. The old model keyed the ring off ``reach_ft > 5``
and wrongly gave the bite a 5 ft dead zone (giant_belostomatid.json,
esha_mount.json, jeanne.json all have reach-10 natural attacks)."""
bug = _natural_attack_combatant("bug", size="Large", reach_ft=10, reach_weapon=False)
state = make_state(bug, "players", (3, 3))
engine = make_engine([state])
threatened = engine.threatened_squares(state)
footprint = state.occupied_cells()
expected: set[tuple[int, int]] = set()
for cell in footprint:
expected |= _disk(cell, 2)
expected -= footprint
assert threatened == frozenset(expected)
# Adjacent to the footprint (1 cell) is threatened: the disk, not a ring.
assert (3, 2) in threatened
assert (2, 2) in threatened
def _bug_attack_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "bug":
return (Action(kind="attack", target_id="rider-target"),)
return (Action(kind="wait"),)
def test_large_natural_attack_hits_adjacent_target() -> None:
"""R1: the reach-10 natural attack of a Large creature STRIKES an adjacent
target (disk), which the old ring model refused."""
bug = _natural_attack_combatant("bug", size="Large", reach_ft=10, reach_weapon=False)
target = make_combatant("rider-target", hp=20, ac=12)
bug_state = make_state(bug, "players", (3, 3))
target_state = make_state(target, "monsters", (3, 5)) # adjacent to (3, 4)
engine = CombatEngine(
ScriptedRng([20, 1, 15, 3]),
make_grid(), [bug_state, target_state],
round_cap=1, policy=_bug_attack_policy,
)
result = engine.run()
assert any("bite vs rider-target" in line and "-> HIT" in line
for line in result.transcript), result.transcript
assert target_state.hp == 17 # 20 - 1d6(3)
def test_large_reach_weapon_ring_beyond_natural_reach() -> None:
"""R1 (CRB): "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" — for a Large longspear wielder the usable band is 15..20 ft: dead
at 10 ft (natural reach), live at 15 ft, dead beyond 20 ft."""
giant = _natural_attack_combatant(
"giant", size="Large", reach_ft=10, reach_weapon=True
)
target = make_combatant("target")
attacker = make_state(giant, "players", (3, 3))
# Footprint spans cols 3-4; distance = foe_col - 4 cells (x5 ft).
# (3, 6): 10 ft = natural reach -> dead zone. (3, 7): 15 ft -> in ring.
for foe_col, in_ring in ((6, False), (7, True)):
foe = make_state(target, "monsters", (3, foe_col))
engine = make_engine([attacker, foe])
weapon = engine.weapon_for(attacker, foe)
assert (weapon is not None) is in_ring, f"distance {foe_col - 4} cells"
def test_weapon_for_reach_weapon_at_distance_2_not_1() -> None:
"""weapon_for selects a reach weapon at distance 2 but not at distance 1 (dead zone)."""
spearman = make_combatant("spearman", reach_ft=10)
spearman = make_combatant("spearman", reach_ft=10, reach_weapon=True)
target = make_combatant("target")
attacker = make_state(spearman, "players", (3, 3))
@@ -242,7 +351,9 @@ def _move_to_dest(engine: CombatEngine, state: CombatantState) -> tuple[Action,
def test_aoo_from_reach_ring() -> None:
"""Moving through a ring square threatened by a longspear provokes an AoO."""
runner = make_combatant("runner", hp=20, ac=12, speed=30)
spearman = make_combatant("spearman", hp=20, ac=15, attack_bonus=5, speed=0, reach_ft=10)
spearman = make_combatant(
"spearman", hp=20, ac=15, attack_bonus=5, speed=0, reach_ft=10, reach_weapon=True,
)
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
states = [
make_state(runner, "players", (4, 3)),
@@ -269,7 +380,9 @@ def test_aoo_from_reach_ring() -> None:
def test_no_aoo_from_adjacent_to_reach_weapon() -> None:
"""A longspear does not threaten adjacent squares: no AoO when leaving one."""
runner = make_combatant("runner", hp=20, ac=12, speed=30)
spearman = make_combatant("spearman", hp=20, ac=15, attack_bonus=5, speed=0, reach_ft=10)
spearman = make_combatant(
"spearman", hp=20, ac=15, attack_bonus=5, speed=0, reach_ft=10, reach_weapon=True,
)
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
states = [
make_state(runner, "players", (4, 1)),