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