217 lines
7.4 KiB
Python
217 lines
7.4 KiB
Python
"""Tests for the directional ``direction`` field on directional maneuvers.
|
|
|
|
L2 of the ``llm-policy-contract`` plan: ``Action`` gains a ``direction``
|
|
field that lets a policy steer the three directional maneuvers (reposition,
|
|
drag, bull_rush) instead of relying on the deterministic default direction
|
|
(first free neighbor / beyond the target / away from the attacker).
|
|
|
|
When ``direction`` is ``None`` the previous deterministic behavior is kept
|
|
bit-identical.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
|
|
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, SeededRng
|
|
|
|
|
|
def make_combatant(cid: str, *, attack_bonus: int = 2) -> Combatant:
|
|
attack = AttackSpec(
|
|
id=f"{cid}-w",
|
|
name="sword",
|
|
kind="melee",
|
|
attack_bonus=attack_bonus,
|
|
damage=[DamageComponent(formula="1d4", types=["slashing"])],
|
|
)
|
|
return Combatant(
|
|
id=cid,
|
|
name=cid,
|
|
level=1,
|
|
size="Medium",
|
|
abilities=AbilityScores(
|
|
str_score=10, dex_score=10, con_score=10,
|
|
int_score=10, wis_score=10, cha_score=10,
|
|
),
|
|
hp_max=6,
|
|
ac=ACProfile(total=13, touch=13, flat_footed=13),
|
|
bab=attack_bonus,
|
|
initiative_mod=0,
|
|
speed_land_ft=30,
|
|
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(queue: list[int], states: list[CombatantState]) -> CombatEngine:
|
|
# A maneuver provokes an AoO from the (threatening) target BEFORE the CMB
|
|
# check (B13 conformance): the scripted queues carry a leading natural 1
|
|
# so the provoking attack always misses.
|
|
return CombatEngine(ScriptedRng(queue), make_grid(), states)
|
|
|
|
|
|
# --- Test 1: reposition with direction -------------------------------------
|
|
|
|
def test_reposition_direction_right() -> None:
|
|
a_spec = make_combatant("a")
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
engine = make_engine([1, 12], [a, b])
|
|
engine._execute_maneuver(a, b, "reposition", direction=(0, 1))
|
|
assert b.pos == (1, 3)
|
|
|
|
|
|
def test_reposition_direction_down() -> None:
|
|
a_spec = make_combatant("a")
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
engine = make_engine([1, 12], [a, b])
|
|
engine._execute_maneuver(a, b, "reposition", direction=(1, 0))
|
|
assert b.pos == (2, 2)
|
|
|
|
|
|
# --- Test 2: drag with direction -------------------------------------------
|
|
|
|
def test_drag_direction_up() -> None:
|
|
a_spec = make_combatant("a")
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
engine = make_engine([1, 12], [a, b])
|
|
engine._execute_maneuver(a, b, "drag", direction=(-1, 0))
|
|
assert b.pos == (0, 2)
|
|
assert a.pos == (1, 2)
|
|
|
|
|
|
# --- Test 3: bull_rush with direction --------------------------------------
|
|
|
|
def test_bull_rush_direction_left() -> None:
|
|
a_spec = make_combatant("a")
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (2, 2))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
engine = make_engine([1, 12], [a, b])
|
|
engine._execute_maneuver(a, b, "bull_rush", direction=(0, -1))
|
|
assert b.pos == (1, 1)
|
|
|
|
|
|
# --- Test 4: invalid direction -> no movement -------------------------------
|
|
|
|
def test_reposition_direction_out_of_bounds_no_move() -> None:
|
|
a_spec = make_combatant("a")
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (0, 0))
|
|
engine = make_engine([1, 12], [a, b])
|
|
engine._execute_maneuver(a, b, "reposition", direction=(-1, 0))
|
|
assert b.pos == (0, 0)
|
|
|
|
|
|
def test_reposition_direction_occupied_no_move() -> None:
|
|
a_spec = make_combatant("a")
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
engine = make_engine([1, 12], [a, b])
|
|
# direction points at the attacker's own cell -> occupied -> no move
|
|
engine._execute_maneuver(a, b, "reposition", direction=(0, -1))
|
|
assert b.pos == (1, 2)
|
|
|
|
|
|
def test_bull_rush_direction_out_of_bounds_no_move() -> None:
|
|
a_spec = make_combatant("a")
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (0, 0))
|
|
engine = make_engine([1, 12], [a, b])
|
|
engine._execute_maneuver(a, b, "bull_rush", direction=(-1, 0))
|
|
assert b.pos == (0, 0)
|
|
|
|
|
|
# --- Test 5: direction=None keeps previous behavior -------------------------
|
|
|
|
def test_reposition_none_uses_first_free_neighbor() -> None:
|
|
a_spec = make_combatant("a")
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
engine = make_engine([1, 12], [a, b])
|
|
engine._execute_maneuver(a, b, "reposition")
|
|
# First free neighbor in _NEIGHBOR_OFFSETS order, skipping attacker's cell.
|
|
assert b.pos == (0, 1)
|
|
|
|
|
|
def test_drag_none_uses_beyond_cell() -> None:
|
|
a_spec = make_combatant("a")
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
engine = make_engine([1, 12], [a, b])
|
|
engine._execute_maneuver(a, b, "drag")
|
|
# Beyond the target from the attacker: (1, 3).
|
|
assert b.pos == (1, 3)
|
|
assert a.pos == (1, 2)
|
|
|
|
|
|
def test_bull_rush_none_uses_away_from_attacker() -> None:
|
|
a_spec = make_combatant("a")
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (2, 2))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
engine = make_engine([1, 12], [a, b])
|
|
engine._execute_maneuver(a, b, "bull_rush")
|
|
# Away from attacker (up, since target is above): (0, 2).
|
|
assert b.pos == (0, 2)
|
|
|
|
|
|
# --- Test 6: determinism ----------------------------------------------------
|
|
|
|
def _run_with_direction(direction: tuple[int, int] | None) -> tuple[str, ...]:
|
|
a_spec = make_combatant("a", attack_bonus=5)
|
|
b_spec = make_combatant("b")
|
|
a = make_state(a_spec, "players", (1, 1))
|
|
b = make_state(b_spec, "monsters", (1, 2))
|
|
|
|
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
|
if state.combatant.id == "a":
|
|
return (
|
|
Action(
|
|
kind="maneuver", target_id="b", maneuver="reposition",
|
|
direction=direction,
|
|
),
|
|
)
|
|
return (Action(kind="wait"),)
|
|
|
|
engine = CombatEngine(SeededRng(42), make_grid(), [a, b], policy=policy)
|
|
return engine.run().transcript
|
|
|
|
|
|
def test_determinism_same_seed_same_transcript() -> None:
|
|
assert _run_with_direction((0, 1)) == _run_with_direction((0, 1))
|
|
assert _run_with_direction(None) == _run_with_direction(None)
|
|
|
|
|
|
def test_direction_changes_transcript() -> None:
|
|
assert _run_with_direction((0, 1)) != _run_with_direction(None)
|