4d89305881
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>
406 lines
15 KiB
Python
406 lines
15 KiB
Python
"""Tests for B5.3: melee reach depends on weapon reach and creature size.
|
|
|
|
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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
|
|
from pf1e_simulator.dice import parse_dice
|
|
from pf1e_simulator.grid import Grid
|
|
from pf1e_simulator.map import MapSpec, TerrainType
|
|
from pf1e_simulator.models import (
|
|
AbilityScores,
|
|
ACProfile,
|
|
AttackSpec,
|
|
Combatant,
|
|
DamageComponent,
|
|
Saves,
|
|
)
|
|
from pf1e_simulator.rng import ScriptedRng
|
|
|
|
|
|
def make_combatant(
|
|
cid: str,
|
|
*,
|
|
size: str = "Medium",
|
|
reach_ft: int = 5,
|
|
reach_weapon: bool = False,
|
|
hp: int = 20,
|
|
ac: int = 15,
|
|
attack_bonus: int = 5,
|
|
speed: int = 30,
|
|
) -> Combatant:
|
|
"""Build a single-melee-weapon combatant (used by most reach tests)."""
|
|
attack = AttackSpec(
|
|
id=f"{cid}-w",
|
|
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,
|
|
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=hp,
|
|
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
|
|
bab=attack_bonus,
|
|
initiative_mod=0,
|
|
speed_land_ft=speed,
|
|
saves=Saves(fort=0, ref=0, will=0),
|
|
attacks=[attack],
|
|
)
|
|
|
|
|
|
def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
|
|
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
|
|
|
|
|
|
def make_grid() -> Grid:
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
|
|
return Grid.from_spec(spec)
|
|
|
|
|
|
def make_engine(states: list[CombatantState]) -> CombatEngine:
|
|
return CombatEngine(ScriptedRng([]), make_grid(), states)
|
|
|
|
|
|
def _grid_distance(a: tuple[int, int], b: tuple[int, int]) -> int:
|
|
"""5-10-5 diagonal distance in squares (mirrors Grid.distance)."""
|
|
d_row = abs(a[0] - b[0])
|
|
d_col = abs(a[1] - b[1])
|
|
return max(d_row, d_col) + min(d_row, d_col) // 2
|
|
|
|
|
|
def _ring(pos: tuple[int, int], radius: int) -> set[tuple[int, int]]:
|
|
"""Cells at exactly ``radius`` squares from ``pos`` (5-10-5 distance)."""
|
|
r, c = pos
|
|
return {
|
|
(r + dr, c + dc)
|
|
for dr in range(-radius, radius + 1)
|
|
for dc in range(-radius, radius + 1)
|
|
if (dr, dc) != (0, 0) and _grid_distance(pos, (r + dr, c + dc)) == radius
|
|
}
|
|
|
|
|
|
def _disk(pos: tuple[int, int], radius: int) -> set[tuple[int, int]]:
|
|
"""Cells within ``radius`` squares of ``pos`` (5-10-5 distance), excluding pos."""
|
|
r, c = pos
|
|
return {
|
|
(r + dr, c + dc)
|
|
for dr in range(-radius, radius + 1)
|
|
for dc in range(-radius, radius + 1)
|
|
if (dr, dc) != (0, 0) and _grid_distance(pos, (r + dr, c + dc)) <= radius
|
|
}
|
|
|
|
|
|
def _adjacent(pos: tuple[int, int]) -> set[tuple[int, int]]:
|
|
"""The 8 cells adjacent to ``pos``."""
|
|
return _disk(pos, 1)
|
|
|
|
|
|
def test_medium_5ft_weapon_threatens_adjacent_only() -> None:
|
|
"""A Medium combatant with a 5 ft weapon threatens exactly the 8 adjacent squares."""
|
|
fighter = make_combatant("fighter")
|
|
state = make_state(fighter, "players", (3, 3))
|
|
engine = make_engine([state])
|
|
assert engine.threatened_squares(state) == frozenset(_adjacent((3, 3)))
|
|
|
|
|
|
def test_reach_weapon_threatens_ring_not_adjacent() -> None:
|
|
"""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)
|
|
assert threatened == frozenset(_ring((3, 3), 2))
|
|
assert (3, 2) not in threatened # adjacent orthogonal
|
|
assert (3, 4) not in threatened
|
|
assert (2, 2) not in threatened # adjacent diagonal
|
|
assert (4, 4) not in threatened
|
|
|
|
|
|
def test_sword_plus_reach_weapon_union() -> None:
|
|
"""A combatant with a 5 ft sword and a reach weapon threatens disk(1) plus ring(2)."""
|
|
sword = AttackSpec(
|
|
id="sword",
|
|
name="short sword",
|
|
kind="melee",
|
|
attack_bonus=5,
|
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
|
|
reach_ft=5,
|
|
)
|
|
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="dual",
|
|
name="dual",
|
|
level=1,
|
|
size="Medium",
|
|
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=[sword, spear],
|
|
)
|
|
state = make_state(combatant, "players", (3, 3))
|
|
engine = make_engine([state])
|
|
assert engine.threatened_squares(state) == frozenset(
|
|
_adjacent((3, 3)) | _ring((3, 3), 2)
|
|
)
|
|
|
|
|
|
def test_large_creature_sword_threatens_disk_radius_2() -> None:
|
|
"""A Large creature (2x2 footprint, natural reach 10 ft) with a 5 ft sword
|
|
threatens the union of radius-2 disks around each occupied cell."""
|
|
giant = make_combatant("giant", size="Large")
|
|
state = make_state(giant, "players", (3, 3))
|
|
engine = make_engine([state])
|
|
footprint = state.occupied_cells()
|
|
expected = set()
|
|
for cell in footprint:
|
|
expected |= _disk(cell, 2)
|
|
expected -= footprint
|
|
assert engine.threatened_squares(state) == frozenset(expected)
|
|
|
|
|
|
def test_unknown_size_defaults_to_5ft() -> None:
|
|
"""An unlisted size falls back to the current 5 ft natural reach."""
|
|
weird = make_combatant("weird", size="Weird")
|
|
state = make_state(weird, "players", (3, 3))
|
|
engine = make_engine([state])
|
|
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, reach_weapon=True)
|
|
target = make_combatant("target")
|
|
attacker = make_state(spearman, "players", (3, 3))
|
|
|
|
foe_at_ring = make_state(target, "monsters", (3, 5)) # distance 2
|
|
engine_ring = make_engine([attacker, foe_at_ring])
|
|
weapon = engine_ring.weapon_for(attacker, foe_at_ring)
|
|
assert weapon is not None
|
|
assert weapon.reach_ft == 10
|
|
|
|
foe_adjacent = make_state(target, "monsters", (3, 4)) # distance 1
|
|
engine_adjacent = make_engine([attacker, foe_adjacent])
|
|
assert engine_adjacent.weapon_for(attacker, foe_adjacent) is None
|
|
|
|
|
|
def test_weapon_for_large_creature_sword_at_distance_2() -> None:
|
|
"""A Large creature's 5 ft sword is selectable at distance 2 (natural reach 10 ft)."""
|
|
giant = make_combatant("giant", size="Large")
|
|
target = make_combatant("target")
|
|
attacker = make_state(giant, "players", (3, 3))
|
|
foe = make_state(target, "monsters", (3, 5)) # distance 2
|
|
engine = make_engine([attacker, foe])
|
|
weapon = engine.weapon_for(attacker, foe)
|
|
assert weapon is not None
|
|
assert weapon.reach_ft == 5
|
|
|
|
|
|
def _move_to_dest(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
"""Test policy: always move toward the 'dest' combatant."""
|
|
return (Action(kind="move", target_id="dest"),)
|
|
|
|
|
|
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, reach_weapon=True,
|
|
)
|
|
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
|
|
states = [
|
|
make_state(runner, "players", (4, 3)),
|
|
make_state(spearman, "monsters", (3, 1)),
|
|
make_state(dest, "monsters", (4, 5)),
|
|
]
|
|
engine = CombatEngine(
|
|
ScriptedRng([10, 9, 8, 15, 3, 5]),
|
|
make_grid(), states, round_cap=1, policy=_move_to_dest,
|
|
)
|
|
result = engine.run()
|
|
assert result.transcript == (
|
|
"initiative: runner d20=10+0=10",
|
|
"initiative: spearman d20=9+0=9",
|
|
"initiative: dest d20=8+0=8",
|
|
"round 1 spearman: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)",
|
|
"round 1 runner: move (4,3)->(3,4)",
|
|
"round 1 spearman: wait",
|
|
"round 1 dest: wait",
|
|
"battle over: draw after 1 rounds",
|
|
)
|
|
|
|
|
|
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, reach_weapon=True,
|
|
)
|
|
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
|
|
states = [
|
|
make_state(runner, "players", (4, 1)),
|
|
make_state(spearman, "monsters", (3, 1)),
|
|
make_state(dest, "monsters", (4, 3)),
|
|
]
|
|
engine = CombatEngine(
|
|
ScriptedRng([10, 9, 8, 15, 3, 5]),
|
|
make_grid(), states, round_cap=1, policy=_move_to_dest,
|
|
)
|
|
result = engine.run()
|
|
assert result.transcript == (
|
|
"initiative: runner d20=10+0=10",
|
|
"initiative: spearman d20=9+0=9",
|
|
"initiative: dest d20=8+0=8",
|
|
"round 1 runner: move (4,1)->(3,2)",
|
|
"round 1 spearman: wait",
|
|
"round 1 dest: wait",
|
|
"battle over: draw after 1 rounds",
|
|
)
|