feat(combat): add 5-foot step (free action, no AoO, mutual exclusion with move)

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 5b8735f001
commit 94521394ef
3 changed files with 190 additions and 39 deletions
+67 -16
View File
@@ -5,13 +5,13 @@ Phase 0 documented deviations from PF1e (conventions):
(or one full-round action), plus swift/free/immediate. The policy returns an
ordered sequence of `Action` per turn; the engine executes them in order,
stopping early if the actor drops or one side is wiped. `default_policy`
returns `(move, attack)` when the nearest enemy is out of reach (move at
full speed along the Dijkstra path, then attack if a weapon is usable) and
`(attack,)` when already in range. The move action may replace the standard
action (e.g. a double move) but the default policy does not need this.
Immediate actions (off-turn, consume next swift) are deferred; flanking,
full attack, charge, and withdraw are modeled; attacks of opportunity are
resolved inline (see below).
returns `(full_attack,)` when already in range, `(charge,)` when a
straight-line charge path exists, `(5foot_step, full_attack)` when a single
step brings the target into range, or `(move, attack)` otherwise (move at
full speed along the Dijkstra path, then single attack). Immediate actions
(off-turn, consume next swift) are deferred; flanking, full attack, charge,
withdraw, and 5-foot step are modeled; attacks of opportunity are resolved
inline (see below).
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
in one attack action; remaining swings are lost once the target drops
(down or dead) mid-routine. BAB iterative attacks (full-round action
@@ -44,8 +44,10 @@ Phase 0 documented deviations from PF1e (conventions):
threatening enemy gets one AoO before each ranged swing. One AoO per
combatant per round (tracked in ``_aoo_used``, cleared at round start);
AoOs always strike (PF1e allows declining, the simulator does not). A
5-foot step (deferred) avoids AoOs from movement; the withdraw action
protects only the starting square (see below).
5-foot step never provokes AoOs (it is not a move action); the withdraw
action protects only the starting square (see below). A 5-foot step and
any other movement (move, charge, withdraw) are mutually exclusive within
the same turn (``moved_this_turn`` flag, cleared in ``_take_turn``).
- Charge: a full-round action that moves in a straight line (Bresenham) up to
2x speed toward the closest square from which the charger can melee the
target, then makes a single melee attack at +2. The charger takes -2 AC
@@ -60,6 +62,12 @@ Phase 0 documented deviations from PF1e (conventions):
move). The path is a greedy ascent of the Dijkstra cost field from the
threat. ``default_policy`` does not choose withdraw (it is only available
via a custom policy).
- 5-foot step: a free action that moves one square toward the nearest enemy
without provoking AoOs. Mutually exclusive with any other movement (move,
charge, withdraw) in the same turn — tracked via ``moved_this_turn``,
cleared at the start of each turn in ``_take_turn``. ``default_policy``
uses ``(5foot_step, full_attack)`` when a single step brings the target
into weapon range.
- Ranged: cumulative -2 per full range increment beyond the first, up to 10
range increments; the penalty applies to attack and crit-confirm rolls.
Thrown weapons (5 increments max) are not distinguished.
@@ -149,6 +157,7 @@ class CombatantState:
pos: Pos
hp: int
ac_penalty: int = 0
moved_this_turn: bool = False
@property
def active(self) -> bool:
@@ -186,14 +195,15 @@ class Action:
(single attack, highest BAB only — no iteratives), ``move`` is a move
action, ``full_attack`` is a full-round action (BAB iteratives), ``charge``
is a special full-round action (2x speed, straight line, +2 attack, -2
AC), ``swift`` and ``free`` are minor actions, ``immediate`` is an
off-turn reaction (deferred). ``wait`` is a no-op. The engine executes a
policy-returned sequence per turn.
AC), ``5foot_step`` is a free action (one square, no AoO, mutually
exclusive with move/charge/withdraw), ``swift`` and ``free`` are minor
actions, ``immediate`` is an off-turn reaction (deferred). ``wait`` is a
no-op. The engine executes a policy-returned sequence per turn.
"""
kind: Literal[
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
"full_attack", "charge", "withdraw",
"full_attack", "charge", "withdraw", "5foot_step",
]
target_id: str | None = None
@@ -241,6 +251,7 @@ class CombatEngine:
def _take_turn(self, state: CombatantState) -> bool:
"""Execute one combatant's full turn; return True if the battle is over."""
state.ac_penalty = 0
state.moved_this_turn = False
actions = self._policy(self, state)
before = len(self._transcript)
for action in actions:
@@ -463,6 +474,17 @@ class CombatEngine:
return True
return False
def can_step5_to_attack(self, state: CombatantState, target: CombatantState) -> bool:
"""True if a 5-foot step toward ``target`` would put ``state`` in weapon range."""
path = self._move_path(state, target)
if not path:
return False
orig = state.pos
state.pos = path[0]
weapon = self.weapon_for(state, target)
state.pos = orig
return weapon is not None
def can_charge(self, state: CombatantState, target: CombatantState) -> AttackSpec | None:
"""Return a melee weapon if ``state`` can charge ``target`` this turn, else None.
@@ -591,6 +613,7 @@ class CombatEngine:
attacker.pos = step
actual_path.append(step)
if actual_path:
attacker.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log(
f"round {self._current_round} {attacker.combatant.id}: charge {coords}"
@@ -611,6 +634,10 @@ class CombatEngine:
self._execute_charge(state, target)
elif action.kind == "withdraw":
self._withdraw(state, target)
elif action.kind == "5foot_step":
if target is None:
return
self._step5(state, target)
elif action.kind == "move":
if target is None:
return
@@ -732,7 +759,23 @@ class CombatEngine:
):
return
def _step5(self, state: CombatantState, target: CombatantState) -> None:
if state.moved_this_turn:
return
path = self._move_path(state, target)
if not path:
return
start = state.pos
state.pos = path[0]
state.moved_this_turn = True
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"5ft step ({start[0]},{start[1]})->({path[0][0]},{path[0][1]})"
)
def _move(self, state: CombatantState, target: CombatantState) -> None:
if state.moved_this_turn:
return
path = self._move_path(state, target)
if not path:
return
@@ -750,6 +793,7 @@ class CombatEngine:
state.pos = step
actual_path.append(step)
if actual_path:
state.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log(f"round {self._current_round} {state.combatant.id}: move {coords}")
@@ -869,6 +913,7 @@ class CombatEngine:
state.pos = step
actual_path.append(step)
if actual_path:
state.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log(
f"round {self._current_round} {state.combatant.id}: withdraw {coords}"
@@ -882,13 +927,14 @@ Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
"""Standard-attack the nearest enemy in range; charge if reachable; otherwise approach.
"""Full-attack the nearest enemy; charge, 5ft-step, or approach if out of range.
Decision order:
1. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed).
2. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC).
3. Move toward the target then single attack (standard + move economy).
4. Wait if nothing is possible.
3. 5-foot step then full-attack if a single step brings the target into weapon range.
4. Move toward the target then single attack (standard + move economy).
5. Wait if nothing is possible.
"""
target = engine.nearest_enemy(state)
if target is None:
@@ -898,6 +944,11 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action,
charge_weapon = engine.can_charge(state, target)
if charge_weapon is not None:
return (Action(kind="charge", target_id=target.combatant.id),)
if engine.can_step5_to_attack(state, target):
return (
Action(kind="5foot_step", target_id=target.combatant.id),
Action(kind="full_attack", target_id=target.combatant.id),
)
return (
Action(kind="move", target_id=target.combatant.id),
Action(kind="attack", target_id=target.combatant.id),