feat(combat): add 5-foot step (free action, no AoO, mutual exclusion with move)
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user