diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 051f20e..dd21d14 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 diff --git a/tests/test_direction.py b/tests/test_direction.py new file mode 100644 index 0000000..7088a33 --- /dev/null +++ b/tests/test_direction.py @@ -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)