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"
|
deed_name: str | None = None # "up_close_and_deadly", "quick_clear"
|
||||||
class_ability: str | None = None # "deed"|"spell_combat"|"inspire_courage"|"hex"|"channel"
|
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
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -1467,7 +1468,7 @@ class CombatEngine:
|
|||||||
if kind == "cast_spell":
|
if kind == "cast_spell":
|
||||||
self._execute_cast_spell(state, target, action.spell_name)
|
self._execute_cast_spell(state, target, action.spell_name)
|
||||||
elif kind == "maneuver":
|
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"):
|
elif kind in ("channel_energy", "hex", "deed", "spell_combat", "inspire_courage"):
|
||||||
self._execute_class_ability(state, target, action)
|
self._execute_class_ability(state, target, action)
|
||||||
elif kind == "charge":
|
elif kind == "charge":
|
||||||
@@ -1565,7 +1566,12 @@ class CombatEngine:
|
|||||||
self._cast_spell(state, target, spell_name)
|
self._cast_spell(state, target, spell_name)
|
||||||
|
|
||||||
def _execute_maneuver(
|
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:
|
) -> None:
|
||||||
"""Execute a combat maneuver (trip, disarm, bull_rush, sunder, grapple,
|
"""Execute a combat maneuver (trip, disarm, bull_rush, sunder, grapple,
|
||||||
escape, overrun, dirty_trick, steal, reposition, drag)."""
|
escape, overrun, dirty_trick, steal, reposition, drag)."""
|
||||||
@@ -1595,14 +1601,14 @@ class CombatEngine:
|
|||||||
handlers: dict[str, Callable[[], None]] = {
|
handlers: dict[str, Callable[[], None]] = {
|
||||||
"trip": lambda: self._maneuver_trip(state, target, m),
|
"trip": lambda: self._maneuver_trip(state, target, m),
|
||||||
"disarm": lambda: self._maneuver_disarm(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),
|
"sunder": lambda: self._maneuver_sunder(state, target, m),
|
||||||
"grapple": lambda: self._maneuver_grapple(state, target, m),
|
"grapple": lambda: self._maneuver_grapple(state, target, m),
|
||||||
"overrun": lambda: self._maneuver_overrun(state, target, m),
|
"overrun": lambda: self._maneuver_overrun(state, target, m),
|
||||||
"dirty_trick": lambda: self._maneuver_dirty_trick(state, target, m),
|
"dirty_trick": lambda: self._maneuver_dirty_trick(state, target, m),
|
||||||
"steal": lambda: self._maneuver_steal(state, target, m),
|
"steal": lambda: self._maneuver_steal(state, target, m),
|
||||||
"reposition": lambda: self._maneuver_reposition(state, target, m),
|
"reposition": lambda: self._maneuver_reposition(state, target, m, direction=direction),
|
||||||
"drag": lambda: self._maneuver_drag(state, target, m),
|
"drag": lambda: self._maneuver_drag(state, target, m, direction=direction),
|
||||||
}
|
}
|
||||||
handlers[maneuver]()
|
handlers[maneuver]()
|
||||||
|
|
||||||
@@ -1658,11 +1664,22 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _maneuver_bull_rush(
|
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:
|
) -> None:
|
||||||
if not m.success:
|
if not m.success:
|
||||||
self._maneuver_fail(state, target, "bull_rush", m)
|
self._maneuver_fail(state, target, "bull_rush", m)
|
||||||
return
|
return
|
||||||
|
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)
|
pushed = self._bull_rush_push(state, target)
|
||||||
outcome = "SUCCESS (pushed)" if pushed else "SUCCESS (no space)"
|
outcome = "SUCCESS (pushed)" if pushed else "SUCCESS (no space)"
|
||||||
self._log(
|
self._log(
|
||||||
@@ -1805,12 +1822,26 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _maneuver_reposition(
|
def _maneuver_reposition(
|
||||||
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
self,
|
||||||
|
state: CombatantState,
|
||||||
|
target: CombatantState,
|
||||||
|
m: _ManeuverRoll,
|
||||||
|
*,
|
||||||
|
direction: tuple[int, int] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not m.success:
|
if not m.success:
|
||||||
self._maneuver_fail(state, target, "reposition", m)
|
self._maneuver_fail(state, target, "reposition", m)
|
||||||
return
|
return
|
||||||
new_pos: Pos | None = None
|
new_pos: Pos | None = None
|
||||||
|
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
|
||||||
|
else:
|
||||||
for dr, dc in _NEIGHBOR_OFFSETS:
|
for dr, dc in _NEIGHBOR_OFFSETS:
|
||||||
pos = (target.pos[0] + dr, target.pos[1] + dc)
|
pos = (target.pos[0] + dr, target.pos[1] + dc)
|
||||||
if pos == state.pos:
|
if pos == state.pos:
|
||||||
@@ -1832,7 +1863,12 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _maneuver_drag(
|
def _maneuver_drag(
|
||||||
self, state: CombatantState, target: CombatantState, m: _ManeuverRoll
|
self,
|
||||||
|
state: CombatantState,
|
||||||
|
target: CombatantState,
|
||||||
|
m: _ManeuverRoll,
|
||||||
|
*,
|
||||||
|
direction: tuple[int, int] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not m.success:
|
if not m.success:
|
||||||
self._maneuver_fail(state, target, "drag", m)
|
self._maneuver_fail(state, target, "drag", m)
|
||||||
@@ -1840,6 +1876,9 @@ class CombatEngine:
|
|||||||
ar, ac = state.pos
|
ar, ac = state.pos
|
||||||
tr, tc = target.pos
|
tr, tc = target.pos
|
||||||
adjacent = max(abs(tr - ar), abs(tc - ac)) == 1
|
adjacent = max(abs(tr - ar), abs(tc - ac)) == 1
|
||||||
|
if direction is not None:
|
||||||
|
beyond = (tr + direction[0], tc + direction[1])
|
||||||
|
else:
|
||||||
beyond = self._beyond_cell(state.pos, target.pos)
|
beyond = self._beyond_cell(state.pos, target.pos)
|
||||||
if adjacent and self._cells_free(self._footprint_at(beyond, target), target):
|
if adjacent and self._cells_free(self._footprint_at(beyond, target), target):
|
||||||
target_old = target.pos
|
target_old = target.pos
|
||||||
|
|||||||
@@ -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