feat(combat): support variable melee reach for weapons and creature size
This commit is contained in:
@@ -138,6 +138,17 @@ _SIZE_MODIFIERS: dict[str, int] = {
|
|||||||
"medium": 0, "large": 1, "huge": 2, "gargantuan": 4, "colossal": 8,
|
"medium": 0, "large": 1, "huge": 2, "gargantuan": 4, "colossal": 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# PF1e natural reach for tall creatures (simplification: no long/quadruped
|
||||||
|
# distinction). Unknown sizes fall back to 5 ft (the historical behavior).
|
||||||
|
_SIZE_NATURAL_REACH_FT: dict[str, int] = {
|
||||||
|
"Small": 5,
|
||||||
|
"Medium": 5,
|
||||||
|
"Large": 10,
|
||||||
|
"Huge": 15,
|
||||||
|
"Gargantuan": 20,
|
||||||
|
"Colossal": 30,
|
||||||
|
}
|
||||||
|
|
||||||
# Row-major scan order of the 8 adjacent cells, used by reposition.
|
# Row-major scan order of the 8 adjacent cells, used by reposition.
|
||||||
_NEIGHBOR_OFFSETS: tuple[tuple[int, int], ...] = (
|
_NEIGHBOR_OFFSETS: tuple[tuple[int, int], ...] = (
|
||||||
(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1),
|
(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1),
|
||||||
@@ -162,6 +173,11 @@ _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)
|
||||||
|
|
||||||
|
|
||||||
def _bresenham_line(start: Pos, end: Pos) -> list[Pos]:
|
def _bresenham_line(start: Pos, end: Pos) -> list[Pos]:
|
||||||
"""Integer Bresenham line from start to end (exclusive of start, inclusive of end)."""
|
"""Integer Bresenham line from start to end (exclusive of start, inclusive of end)."""
|
||||||
r0, c0 = start
|
r0, c0 = start
|
||||||
@@ -547,7 +563,12 @@ class CombatEngine:
|
|||||||
return None
|
return None
|
||||||
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
|
dist_ft = self._grid.distance(attacker.pos, target.pos) * _SQUARE_FT
|
||||||
for weapon in attacker.usable_attacks():
|
for weapon in attacker.usable_attacks():
|
||||||
if weapon.kind in ("melee", "touch") and dist_ft <= weapon.reach_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 weapon
|
||||||
|
elif dist_ft <= effective_reach_ft:
|
||||||
return weapon
|
return weapon
|
||||||
max_increments = (
|
max_increments = (
|
||||||
_MAX_TOUCH_RANGE_INCREMENTS
|
_MAX_TOUCH_RANGE_INCREMENTS
|
||||||
@@ -964,13 +985,42 @@ class CombatEngine:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _threatened_squares(self, state: CombatantState) -> frozenset[Pos]:
|
def _threatened_squares(self, state: CombatantState) -> frozenset[Pos]:
|
||||||
"""Squares this combatant threatens with a melee weapon (8 adjacent for 5 ft reach)."""
|
"""Squares this combatant threatens with a melee weapon.
|
||||||
|
|
||||||
|
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 reach dead zone). The union over usable melee weapons is
|
||||||
|
returned.
|
||||||
|
"""
|
||||||
if not state.active:
|
if not state.active:
|
||||||
return frozenset()
|
return frozenset()
|
||||||
if not any(w.kind == "melee" for w in state.usable_attacks()):
|
melee = [w for w in state.usable_attacks() if w.kind == "melee"]
|
||||||
|
if not melee:
|
||||||
return frozenset()
|
return frozenset()
|
||||||
|
threatened: set[Pos] = set()
|
||||||
|
for weapon in melee:
|
||||||
|
threatened |= self._weapon_threatened_squares(state, weapon)
|
||||||
|
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."""
|
||||||
r, c = state.pos
|
r, c = state.pos
|
||||||
return frozenset((r + dr, c + dc) for dr, dc in _STEP_DELTAS)
|
threatened: set[Pos] = set()
|
||||||
|
if weapon.reach_ft > _SQUARE_FT:
|
||||||
|
ring = weapon.reach_ft // _SQUARE_FT
|
||||||
|
for dr in range(-ring, ring + 1):
|
||||||
|
for dc in range(-ring, ring + 1):
|
||||||
|
if self._grid.distance(state.pos, (r + dr, c + dc)) == ring:
|
||||||
|
threatened.add((r + dr, c + dc))
|
||||||
|
else:
|
||||||
|
reach_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT
|
||||||
|
for dr in range(-reach_cells, reach_cells + 1):
|
||||||
|
for dc in range(-reach_cells, reach_cells + 1):
|
||||||
|
if (dr, dc) == (0, 0):
|
||||||
|
continue
|
||||||
|
if self._grid.distance(state.pos, (r + dr, c + dc)) <= reach_cells:
|
||||||
|
threatened.add((r + dr, c + dc))
|
||||||
|
return threatened
|
||||||
|
|
||||||
def _enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]:
|
def _enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]:
|
||||||
"""Active enemies whose threatened squares include pos (in self._states order)."""
|
"""Active enemies whose threatened squares include pos (in self._states order)."""
|
||||||
@@ -1111,8 +1161,11 @@ class CombatEngine:
|
|||||||
for weapon in state.usable_attacks():
|
for weapon in state.usable_attacks():
|
||||||
if weapon.kind != "melee":
|
if weapon.kind != "melee":
|
||||||
continue
|
continue
|
||||||
reach_cells = weapon.reach_ft // _SQUARE_FT
|
reach_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT
|
||||||
end = self._charge_end(state, target, reach_cells, max_cells, blocked)
|
min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0
|
||||||
|
end = self._charge_end(
|
||||||
|
state, target, reach_cells, max_cells, blocked, min_reach_cells=min_reach_cells
|
||||||
|
)
|
||||||
if end is not None:
|
if end is not None:
|
||||||
return weapon
|
return weapon
|
||||||
return None
|
return None
|
||||||
@@ -1124,6 +1177,8 @@ class CombatEngine:
|
|||||||
reach_cells: int,
|
reach_cells: int,
|
||||||
max_cells: int,
|
max_cells: int,
|
||||||
blocked: frozenset[Pos],
|
blocked: frozenset[Pos],
|
||||||
|
*,
|
||||||
|
min_reach_cells: int = 0,
|
||||||
) -> Pos | None:
|
) -> Pos | None:
|
||||||
"""Find the closest square from which ``state`` can melee ``target`` via a charge.
|
"""Find the closest square from which ``state`` can melee ``target`` via a charge.
|
||||||
|
|
||||||
@@ -1133,7 +1188,9 @@ class CombatEngine:
|
|||||||
Ties break toward lower distance from state.
|
Ties break toward lower distance from state.
|
||||||
"""
|
"""
|
||||||
candidates: list[tuple[int, Pos]] = []
|
candidates: list[tuple[int, Pos]] = []
|
||||||
for end in self._charge_candidates(target, reach_cells, blocked, state.pos):
|
for end in self._charge_candidates(
|
||||||
|
target, reach_cells, blocked, state.pos, min_reach_cells
|
||||||
|
):
|
||||||
path = _bresenham_line(state.pos, end)
|
path = _bresenham_line(state.pos, end)
|
||||||
if len(path) < _CHARGE_MIN_CELLS or len(path) > max_cells:
|
if len(path) < _CHARGE_MIN_CELLS or len(path) > max_cells:
|
||||||
continue
|
continue
|
||||||
@@ -1151,6 +1208,7 @@ class CombatEngine:
|
|||||||
reach_cells: int,
|
reach_cells: int,
|
||||||
blocked: frozenset[Pos],
|
blocked: frozenset[Pos],
|
||||||
start: Pos,
|
start: Pos,
|
||||||
|
min_reach_cells: int = 0,
|
||||||
) -> list[Pos]:
|
) -> list[Pos]:
|
||||||
"""Squares within reach of target, passable, unoccupied, not the start."""
|
"""Squares within reach of target, passable, unoccupied, not the start."""
|
||||||
tr, tc = target.pos
|
tr, tc = target.pos
|
||||||
@@ -1168,7 +1226,8 @@ class CombatEngine:
|
|||||||
continue
|
continue
|
||||||
if end in blocked:
|
if end in blocked:
|
||||||
continue
|
continue
|
||||||
if self._grid.distance(end, target.pos) > reach_cells:
|
dist = self._grid.distance(end, target.pos)
|
||||||
|
if dist > reach_cells or dist < min_reach_cells:
|
||||||
continue
|
continue
|
||||||
result.append(end)
|
result.append(end)
|
||||||
return result
|
return result
|
||||||
@@ -1194,13 +1253,16 @@ class CombatEngine:
|
|||||||
(cleared in ``_take_turn``). Movement during a charge provokes AoOs step-by-step
|
(cleared in ``_take_turn``). Movement during a charge provokes AoOs step-by-step
|
||||||
as for any move action.
|
as for any move action.
|
||||||
"""
|
"""
|
||||||
reach_cells = weapon.reach_ft // _SQUARE_FT
|
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
|
||||||
speed_cells = attacker.combatant.speed_land_ft // _SQUARE_FT
|
speed_cells = attacker.combatant.speed_land_ft // _SQUARE_FT
|
||||||
max_cells = 2 * speed_cells
|
max_cells = 2 * speed_cells
|
||||||
blocked = frozenset(
|
blocked = frozenset(
|
||||||
s.pos for s in self._states if s is not attacker and s is not target and s.active
|
s.pos for s in self._states if s is not attacker and s is not target and s.active
|
||||||
)
|
)
|
||||||
end = self._charge_end(attacker, target, reach_cells, max_cells, blocked)
|
end = self._charge_end(
|
||||||
|
attacker, target, reach_cells, max_cells, blocked, min_reach_cells=min_reach_cells
|
||||||
|
)
|
||||||
if end is None:
|
if end is None:
|
||||||
return
|
return
|
||||||
path = _bresenham_line(attacker.pos, end)
|
path = _bresenham_line(attacker.pos, end)
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"""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;
|
||||||
|
- ``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,
|
||||||
|
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_ft > 5 else "short sword",
|
||||||
|
kind="melee",
|
||||||
|
attack_bonus=attack_bonus,
|
||||||
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
|
||||||
|
reach_ft=reach_ft,
|
||||||
|
)
|
||||||
|
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 10 ft) threatens the ring at distance 2, not adjacent squares."""
|
||||||
|
spearman = make_combatant("spearman", reach_ft=10)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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 (natural reach 10 ft) with a 5 ft sword threatens the disk of radius 2."""
|
||||||
|
giant = make_combatant("giant", size="Large")
|
||||||
|
state = make_state(giant, "players", (3, 3))
|
||||||
|
engine = make_engine([state])
|
||||||
|
assert engine._threatened_squares(state) == frozenset(_disk((3, 3), 2))
|
||||||
|
|
||||||
|
|
||||||
|
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)))
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user