56c8970d5b
Rename _threatened_squares -> threatened_squares and _enemies_threatening -> enemies_threatening (public read-only API for a future LLM policy), and add reachable_ft(state, budget) and movement_cost_ft(state, target) accessors returning movement costs in feet. Pure exposure: no behavior change, resolution engine untouched, goldens bit-identical.
324 lines
11 KiB
Python
324 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 effective reach for non-reach weapons 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,
|
|
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_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 _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 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 (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()
|