feat(combat): add direction field to Action for directional maneuvers

This commit is contained in:
2026-09-05 18:37:26 +02:00
parent febd408f29
commit 7fbf3988e9
2 changed files with 270 additions and 18 deletions
+57 -18
View File
@@ -366,6 +366,7 @@ class Action:
deed_name: str | None = None # "up_close_and_deadly", "quick_clear"
class_ability: str | None = None # "deed"|"spell_combat"|"inspire_courage"|"hex"|"channel"
channel_mode: Literal["heal", "harm"] | None = None # channel energy polarity for this use
direction: tuple[int, int] | None = None # steering for directional maneuvers
@dataclass(frozen=True)
@@ -1467,7 +1468,7 @@ class CombatEngine:
if kind == "cast_spell":
self._execute_cast_spell(state, target, action.spell_name)
elif kind == "maneuver":
self._execute_maneuver(state, target, action.maneuver)
self._execute_maneuver(state, target, action.maneuver, direction=action.direction)
elif kind in ("channel_energy", "hex", "deed", "spell_combat", "inspire_courage"):
self._execute_class_ability(state, target, action)
elif kind == "charge":
@@ -1565,7 +1566,12 @@ class CombatEngine:
self._cast_spell(state, target, spell_name)
def _execute_maneuver(
self, state: CombatantState, target: CombatantState | None, maneuver: str | None
self,
state: CombatantState,
target: CombatantState | None,
maneuver: str | None,
*,
direction: tuple[int, int] | None = None,
) -> None:
"""Execute a combat maneuver (trip, disarm, bull_rush, sunder, grapple,
escape, overrun, dirty_trick, steal, reposition, drag)."""
@@ -1595,14 +1601,14 @@ class CombatEngine:
handlers: dict[str, Callable[[], None]] = {
"trip": lambda: self._maneuver_trip(state, target, m),
"disarm": lambda: self._maneuver_disarm(state, target, m),
"bull_rush": lambda: self._maneuver_bull_rush(state, target, m),
"bull_rush": lambda: self._maneuver_bull_rush(state, target, m, direction=direction),
"sunder": lambda: self._maneuver_sunder(state, target, m),
"grapple": lambda: self._maneuver_grapple(state, target, m),
"overrun": lambda: self._maneuver_overrun(state, target, m),
"dirty_trick": lambda: self._maneuver_dirty_trick(state, target, m),
"steal": lambda: self._maneuver_steal(state, target, m),
"reposition": lambda: self._maneuver_reposition(state, target, m),
"drag": lambda: self._maneuver_drag(state, target, m),
"reposition": lambda: self._maneuver_reposition(state, target, m, direction=direction),
"drag": lambda: self._maneuver_drag(state, target, m, direction=direction),
}
handlers[maneuver]()
@@ -1658,12 +1664,23 @@ class CombatEngine:
)
def _maneuver_bull_rush(
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
self,
state: CombatantState,
target: CombatantState,
m: _ManeuverRoll,
*,
direction: tuple[int, int] | None = None,
) -> None:
if not m.success:
self._maneuver_fail(state, target, "bull_rush", m)
return
pushed = self._bull_rush_push(state, target)
if direction is not None:
new_pos = (target.pos[0] + direction[0], target.pos[1] + direction[1])
pushed = self._cells_free(self._footprint_at(new_pos, target), target)
if pushed:
target.pos = new_pos
else:
pushed = self._bull_rush_push(state, target)
outcome = "SUCCESS (pushed)" if pushed else "SUCCESS (no space)"
self._log(
f"round {self._current_round} {state.combatant.id}: bull rush vs "
@@ -1805,21 +1822,35 @@ class CombatEngine:
)
def _maneuver_reposition(
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
self,
state: CombatantState,
target: CombatantState,
m: _ManeuverRoll,
*,
direction: tuple[int, int] | None = None,
) -> None:
if not m.success:
self._maneuver_fail(state, target, "reposition", m)
return
new_pos: Pos | None = None
for dr, dc in _NEIGHBOR_OFFSETS:
pos = (target.pos[0] + dr, target.pos[1] + dc)
if pos == state.pos:
continue
if not (0 <= pos[0] < self._grid.height and 0 <= pos[1] < self._grid.width):
continue
if self._cells_free(self._footprint_at(pos, target), target):
if direction is not None:
pos = (target.pos[0] + direction[0], target.pos[1] + direction[1])
if (
0 <= pos[0] < self._grid.height
and 0 <= pos[1] < self._grid.width
and self._cells_free(self._footprint_at(pos, target), target)
):
new_pos = pos
break
else:
for dr, dc in _NEIGHBOR_OFFSETS:
pos = (target.pos[0] + dr, target.pos[1] + dc)
if pos == state.pos:
continue
if not (0 <= pos[0] < self._grid.height and 0 <= pos[1] < self._grid.width):
continue
if self._cells_free(self._footprint_at(pos, target), target):
new_pos = pos
break
if new_pos is not None:
target.pos = new_pos
outcome = f"SUCCESS (moved to {new_pos})"
@@ -1832,7 +1863,12 @@ class CombatEngine:
)
def _maneuver_drag(
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
self,
state: CombatantState,
target: CombatantState,
m: _ManeuverRoll,
*,
direction: tuple[int, int] | None = None,
) -> None:
if not m.success:
self._maneuver_fail(state, target, "drag", m)
@@ -1840,7 +1876,10 @@ class CombatEngine:
ar, ac = state.pos
tr, tc = target.pos
adjacent = max(abs(tr - ar), abs(tc - ac)) == 1
beyond = self._beyond_cell(state.pos, target.pos)
if direction is not None:
beyond = (tr + direction[0], tc + direction[1])
else:
beyond = self._beyond_cell(state.pos, target.pos)
if adjacent and self._cells_free(self._footprint_at(beyond, target), target):
target_old = target.pos
target.pos = beyond
+213
View File
@@ -0,0 +1,213 @@
"""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:
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([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([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([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([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([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([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([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([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([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([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)