feat(combat): add direction field to Action for directional maneuvers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user