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>
326 lines
11 KiB
Python
326 lines
11 KiB
Python
"""Tests for L5: public exposure of threatened squares, enemies threatening,
|
|
and movement-cost fields (read-only API for a future LLM policy).
|
|
|
|
Covers:
|
|
- ``threatened_squares`` (public, renamed from ``_threatened_squares``): the
|
|
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.
|
|
- ``reachable_ft``: movement cost in feet from a state's anchor to each
|
|
reachable cell (same cost model as ``Grid.reachable``).
|
|
- ``movement_cost_ft``: minimal cost in feet to move ``state`` to a cell from
|
|
which it can melee ``target``, or ``None`` if unreachable under speed.
|
|
- Determinism + non-mutation: the public accessors never mutate state nor
|
|
consume RNG.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pf1e_simulator.combat import 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."""
|
|
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 _adjacent(pos: tuple[int, int]) -> set[tuple[int, int]]:
|
|
"""The 8 cells adjacent to ``pos``."""
|
|
r, c = pos
|
|
return {
|
|
(r + dr, c + dc)
|
|
for dr in (-1, 0, 1)
|
|
for dc in (-1, 0, 1)
|
|
if (dr, dc) != (0, 0)
|
|
}
|
|
|
|
|
|
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 max(abs(dr), abs(dc)) + min(abs(dr), abs(dc)) // 2 == radius
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. threatened_squares (public)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_threatened_squares_public_non_reach_disk() -> 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_threatened_squares_public_reach_dead_zone() -> None:
|
|
"""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)
|
|
assert threatened == frozenset(_ring((3, 3), 2))
|
|
assert (3, 2) not in threatened # adjacent orthogonal (dead zone)
|
|
assert (3, 4) not in threatened
|
|
assert (2, 2) not in threatened # adjacent diagonal (dead zone)
|
|
assert (4, 4) not in threatened
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. enemies_threatening (public)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_enemies_threatening_public_order_and_allies() -> None:
|
|
"""Only active enemies whose threatened squares include pos appear, in
|
|
self._states order; allies never count."""
|
|
hero = make_combatant("hero")
|
|
foe_near = make_combatant("foe_near")
|
|
foe_far = make_combatant("foe_far")
|
|
ally = make_combatant("ally")
|
|
|
|
hero_state = make_state(hero, "players", (3, 3))
|
|
# foe_near adjacent to (3,3) -> threatens it
|
|
foe_near_state = make_state(foe_near, "monsters", (3, 4))
|
|
# foe_far far away -> does not threaten (3,3)
|
|
foe_far_state = make_state(foe_far, "monsters", (6, 6))
|
|
# ally adjacent but same side -> never counts
|
|
ally_state = make_state(ally, "players", (4, 3))
|
|
|
|
engine = make_engine([hero_state, foe_near_state, foe_far_state, ally_state])
|
|
result = engine.enemies_threatening(hero_state, (3, 3))
|
|
assert result == [foe_near_state]
|
|
|
|
|
|
def test_enemies_threatening_public_empty_when_none() -> None:
|
|
"""No enemies threatening a cell -> empty list."""
|
|
hero = make_combatant("hero")
|
|
foe_far = make_combatant("foe_far")
|
|
hero_state = make_state(hero, "players", (3, 3))
|
|
foe_far_state = make_state(foe_far, "monsters", (6, 6))
|
|
engine = make_engine([hero_state, foe_far_state])
|
|
assert engine.enemies_threatening(hero_state, (3, 3)) == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. reachable_ft
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_reachable_ft_costs_and_budget() -> None:
|
|
"""reachable_ft returns movement cost in feet; budget is in feet."""
|
|
fighter = make_combatant("fighter")
|
|
state = make_state(fighter, "players", (0, 0))
|
|
engine = make_engine([state])
|
|
|
|
field = engine.reachable_ft(state)
|
|
assert field[(0, 0)] == 0
|
|
assert field[(0, 1)] == 5 # orthogonal 1 cell
|
|
assert field[(1, 1)] == 5 # first diagonal = 1 cell (5-10-5)
|
|
assert field[(0, 2)] == 10 # orthogonal 2 cells
|
|
|
|
# budget in feet: cells with cost <= 15 present, beyond absent
|
|
budgeted = engine.reachable_ft(state, budget=15)
|
|
assert (0, 1) in budgeted
|
|
assert (1, 1) in budgeted
|
|
assert (0, 2) in budgeted
|
|
assert (0, 3) in budgeted # 3 orthogonal cells = 15 ft, within budget
|
|
assert (0, 4) not in budgeted # 4 cells = 20 ft > 15
|
|
|
|
|
|
def test_reachable_ft_difficult_terrain() -> None:
|
|
"""A difficult-terrain cell (move_cost 2) costs 10 ft."""
|
|
legend = {".": TerrainType(type="floor", move_cost=1),
|
|
"D": TerrainType(type="rubble", move_cost=2)}
|
|
spec = MapSpec(
|
|
name="test",
|
|
terrain=(
|
|
".D......",
|
|
"........",
|
|
"........",
|
|
"........",
|
|
"........",
|
|
"........",
|
|
"........",
|
|
"........",
|
|
),
|
|
legend=legend,
|
|
)
|
|
grid = Grid.from_spec(spec)
|
|
fighter = make_combatant("fighter")
|
|
state = make_state(fighter, "players", (0, 0))
|
|
engine = CombatEngine(ScriptedRng([]), grid, [state])
|
|
|
|
field = engine.reachable_ft(state)
|
|
assert field[(0, 1)] == 10 # difficult terrain cell costs 2 cells = 10 ft
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. movement_cost_ft
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_movement_cost_ft_distance() -> None:
|
|
"""movement_cost_ft returns the minimal cost in feet to reach a cell from
|
|
which state can melee target (state 2 cells away -> ~10 ft)."""
|
|
fighter = make_combatant("fighter", speed=30)
|
|
target = make_combatant("target")
|
|
state = make_state(fighter, "players", (0, 0))
|
|
target_state = make_state(target, "monsters", (0, 2)) # 2 cells away
|
|
engine = make_engine([state, target_state])
|
|
|
|
cost = engine.movement_cost_ft(state, target_state)
|
|
assert cost == 10
|
|
|
|
|
|
def test_movement_cost_ft_unreachable_returns_none() -> None:
|
|
"""If the target is sealed behind impassable walls, movement_cost_ft is None."""
|
|
legend = {
|
|
".": TerrainType(type="floor", move_cost=1),
|
|
"#": TerrainType(type="wall", move_cost=None),
|
|
}
|
|
walled = MapSpec(
|
|
name="test",
|
|
terrain=(
|
|
"........",
|
|
"........",
|
|
"..####..",
|
|
"..#..#..",
|
|
"..#..#..",
|
|
"..####..",
|
|
"........",
|
|
"........",
|
|
),
|
|
legend=legend,
|
|
)
|
|
grid = Grid.from_spec(walled)
|
|
fighter = make_combatant("fighter", speed=30)
|
|
target = make_combatant("target")
|
|
# state outside the box, target inside -> no path
|
|
state = make_state(fighter, "players", (0, 0))
|
|
target_state = make_state(target, "monsters", (3, 3))
|
|
engine = CombatEngine(ScriptedRng([]), grid, [state, target_state])
|
|
|
|
assert engine.movement_cost_ft(state, target_state) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. Determinism + non-mutation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_public_accessors_do_not_mutate_state() -> None:
|
|
"""Calling the public accessors leaves state and engine unchanged."""
|
|
fighter = make_combatant("fighter", speed=30)
|
|
target = make_combatant("target")
|
|
state = make_state(fighter, "players", (0, 0))
|
|
target_state = make_state(target, "monsters", (0, 2))
|
|
engine = make_engine([state, target_state])
|
|
|
|
pos_before = state.pos
|
|
hp_before = state.hp
|
|
states_before = list(engine._states)
|
|
|
|
engine.threatened_squares(state)
|
|
engine.enemies_threatening(state, (0, 1))
|
|
engine.reachable_ft(state)
|
|
engine.movement_cost_ft(state, target_state)
|
|
|
|
assert state.pos == pos_before
|
|
assert state.hp == hp_before
|
|
assert list(engine._states) == states_before
|
|
|
|
|
|
def test_public_accessors_deterministic_two_runs() -> None:
|
|
"""Two runs with the same seed and default policy produce identical
|
|
transcripts (the resolution engine still uses the renamed private logic)."""
|
|
|
|
def run() -> tuple:
|
|
fighter = make_combatant("fighter", speed=30)
|
|
target = make_combatant("target")
|
|
states = [
|
|
make_state(fighter, "players", (0, 0)),
|
|
make_state(target, "monsters", (0, 3)),
|
|
]
|
|
eng = CombatEngine(
|
|
ScriptedRng([10, 9, 8, 15, 3, 5]),
|
|
make_grid(), states, round_cap=1,
|
|
)
|
|
return eng.run().transcript
|
|
|
|
assert run() == run()
|