522 lines
19 KiB
Python
522 lines
19 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",
|
|
)
|
|
|
|
|
|
# ── S4c: Tiny/Diminutive/Fine natural reach 0 and co-occupancy ───────────────
|
|
|
|
|
|
def test_tiny_reach_zero_threatens_nothing() -> None:
|
|
"""S4c: a Tiny creature's natural reach is 0 — it threatens no squares
|
|
(no AoO) and cannot strike adjacent squares."""
|
|
tiny = make_combatant("tiny", size="Tiny", reach_ft=0)
|
|
state = make_state(tiny, "players", (3, 3))
|
|
engine = make_engine([state])
|
|
assert engine.threatened_squares(state) == frozenset()
|
|
assert (3, 2) not in engine.threatened_squares(state)
|
|
|
|
|
|
def test_tiny_reach_zero_attacks_own_cell_only() -> None:
|
|
"""S4c: a 0-reach Tiny can attack a creature in its OWN cell (distance 0)
|
|
but not an adjacent one (distance 1)."""
|
|
tiny = make_combatant("tiny", size="Tiny", reach_ft=0)
|
|
target = make_combatant("target", hp=20, ac=12)
|
|
tiny_state = make_state(tiny, "players", (3, 3))
|
|
# Same cell: distance 0 -> the 0-reach attack is usable.
|
|
foe_same = make_state(target, "monsters", (3, 3))
|
|
engine_same = make_engine([tiny_state, foe_same])
|
|
assert engine_same.weapon_for(tiny_state, foe_same) is not None
|
|
# Adjacent cell: distance 1 (5 ft) -> out of the 0-reach zone.
|
|
foe_adj = make_state(target, "monsters", (3, 4))
|
|
engine_adj = make_engine([tiny_state, foe_adj])
|
|
assert engine_adj.weapon_for(tiny_state, foe_adj) is None
|
|
|
|
|
|
def _tiny_attack_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "tiny":
|
|
return (Action(kind="attack", target_id="target"),)
|
|
return (Action(kind="wait"),)
|
|
|
|
|
|
def test_tiny_reach_zero_hits_creature_in_own_cell() -> None:
|
|
"""S4c: a 0-reach Tiny strikes a creature sharing its cell (the 'entered
|
|
the square' abstraction) — the attack resolves at distance 0."""
|
|
tiny = make_combatant("tiny", size="Tiny", reach_ft=0)
|
|
target = make_combatant("target", hp=20, ac=12)
|
|
tiny_state = make_state(tiny, "players", (3, 3))
|
|
target_state = make_state(target, "monsters", (3, 3))
|
|
engine = CombatEngine(
|
|
ScriptedRng([20, 1, 15, 3]),
|
|
make_grid(), [tiny_state, target_state],
|
|
round_cap=1, policy=_tiny_attack_policy,
|
|
)
|
|
result = engine.run()
|
|
assert any(
|
|
"short sword vs target" in line and "-> HIT" in line
|
|
for line in result.transcript
|
|
), result.transcript
|
|
assert target_state.hp == 17 # 20 - 1d6(3)
|
|
|
|
|
|
def _tiny_move_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "tiny":
|
|
return (Action(kind="move", destination=(3, 3)),)
|
|
return (Action(kind="wait"),)
|
|
|
|
|
|
def test_tiny_ends_move_in_medium_cell() -> None:
|
|
"""S4c: a Tiny creature may END its move in a cell occupied by a larger
|
|
creature (co-occupancy)."""
|
|
tiny = make_combatant("tiny", size="Tiny", reach_ft=0)
|
|
medium = make_combatant("medium", hp=20, ac=15, speed=0)
|
|
tiny_state = make_state(tiny, "players", (3, 2))
|
|
medium_state = make_state(medium, "monsters", (3, 3))
|
|
engine = CombatEngine(
|
|
ScriptedRng([20, 1, 5]),
|
|
make_grid(), [tiny_state, medium_state],
|
|
round_cap=1, policy=_tiny_move_policy,
|
|
)
|
|
result = engine.run()
|
|
assert any("move -> (3,3)" in line for line in result.transcript), result.transcript
|
|
assert tiny_state.pos == (3, 3)
|
|
|
|
|
|
def _medium_move_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "a":
|
|
return (Action(kind="move", destination=(3, 3)),)
|
|
return (Action(kind="wait"),)
|
|
|
|
|
|
def test_two_medium_cannot_share_cell() -> None:
|
|
"""S4c: two Medium creatures still cannot share a cell — the move onto
|
|
the occupied cell is rejected."""
|
|
a = make_combatant("a", speed=30)
|
|
b = make_combatant("b", hp=20, ac=15, speed=0)
|
|
a_state = make_state(a, "players", (3, 2))
|
|
b_state = make_state(b, "monsters", (3, 3))
|
|
engine = CombatEngine(
|
|
ScriptedRng([20, 1]),
|
|
make_grid(), [a_state, b_state],
|
|
round_cap=1, policy=_medium_move_policy,
|
|
)
|
|
result = engine.run()
|
|
assert any(
|
|
"move rejected (unreachable_destination)" in line for line in result.transcript
|
|
), result.transcript
|
|
assert a_state.pos == (3, 2)
|
|
|
|
|
|
def test_observation_exposes_natural_reach_ft() -> None:
|
|
"""S4c: the combatant snapshot exposes the size's natural reach."""
|
|
tiny = make_combatant("tiny", size="Tiny", reach_ft=0)
|
|
medium = make_combatant("medium")
|
|
tiny_state = make_state(tiny, "players", (3, 3))
|
|
medium_state = make_state(medium, "monsters", (3, 5))
|
|
engine = make_engine([tiny_state, medium_state])
|
|
obs = engine._build_observation(tiny_state).to_dict()
|
|
combatants = {c["id"]: c for c in obs["combatants"]}
|
|
assert combatants["tiny"]["natural_reach_ft"] == 0
|
|
assert combatants["medium"]["natural_reach_ft"] == 5
|