feat(combat): activate standard+move economy (full-speed movement, move-then-attack)

- default_policy returns (move, attack) when target out of reach: full-speed
  move along the Dijkstra path (up to speed cells, 5-10-5 diagonals, stops
  adjacent to nearest enemy), then attack if a weapon is usable; returns
  (attack,) when already in range.
- _move logs the full path in one line (start)->(step1)->...->(end); silent
  on empty path. _execute is silent on invalid attacks/moves (no spurious
  wait log); run() adds a wait line only when a combatant's turn produced
  no transcript line.
- Extract _take_turn helper from run() to keep cyclomatic complexity <= 10.
- Module docstring updated for activated economy.
- 4 pinned transcripts regenerated (move-then-attack shifts combat pacing):
  test_default_policy_moves_toward_enemy, test_scripted_2v2_transcript
  (monsters win in R2 instead of R3 — orc-2 moves + attacks same turn),
  test_ranged_no_line_of_effect_moves_around_wall (archer rounds wall and
  shoots in 1 turn), test_ranged_weapon_unusable_beyond_maximum_range
  (archer moves 6 cells then attacks at -14 penalty).
- README: demo numbers refreshed (1v2: 49.6%->22.0%, 11.4->5.2 rounds;
  3v2: 97.8%->91.4%, 6.9->6.0 rounds), rules and non-modeled sections
  updated for the activated economy.
This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 791199150b
commit 3c31b88b13
3 changed files with 127 additions and 88 deletions
+69 -49
View File
@@ -1,12 +1,16 @@
"""Deterministic combat engine: attacks, crits, DR, hp states, initiative, rounds.
Phase 0 documented deviations from PF1e (conventions):
- Action economy framework: `Action.kind` covers the six PF1e action types
(attack=standard, move, full_round, swift, free, immediate); the engine
executes a policy-returned sequence of actions per turn. The default policy
currently emits one action per turn (attack OR move); standard+move
activation and special actions (AoO, flanking, full attack, charge,
withdraw) are deferred.
- Action economy: a normal turn grants one standard action + one move action
(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) and special actions
(AoO, flanking, full attack, charge, withdraw) are deferred.
- 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 are not modeled.
@@ -15,8 +19,9 @@ Phase 0 documented deviations from PF1e (conventions):
- DR applies once, after crit multiplication; any bypassing type defeats DR.
- Death when hp < min(-10, -CON); hp <= 0 cannot act.
- Initiative ties: higher initiative_mod first, then list order (no re-roll).
- Movement: one step per move action toward the nearest enemy, following the
true shortest path (Dijkstra cost field from the target); ties keep delta order.
- Movement: a move action travels up to the combatant's speed in cells along
the true shortest path (Dijkstra cost field from the target, 5-10-5
diagonals), stopping adjacent to the nearest enemy; ties keep delta order.
- Line of effect gates all attacks: a target fully behind blocking terrain
cannot be attacked, and the policy moves to gain sight instead.
- Cover (corner rule) grants +4 AC on hit and crit-confirm rolls; melee and
@@ -163,6 +168,20 @@ class CombatEngine:
self._transcript: list[str] = []
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
def _take_turn(self, state: CombatantState, round_no: int) -> bool:
"""Execute one combatant's full turn; return True if the battle is over."""
actions = self._policy(self, state)
before = len(self._transcript)
for action in actions:
if not state.active:
break
self._execute(state, action, round_no)
if self._winner_side() is not None:
break
if state.active and len(self._transcript) == before:
self._log(f"round {round_no} {state.combatant.id}: wait")
return self._winner_side() is not None
def run(self) -> CombatResult:
"""Run the battle and return the final result and transcript."""
order = self._roll_initiative()
@@ -171,14 +190,7 @@ class CombatEngine:
for state in order:
if not state.active:
continue
actions = self._policy(self, state)
for action in actions:
if not state.active:
break
self._execute(state, action, round_no)
if self._winner_side() is not None:
break
if self._winner_side() is not None:
if self._take_turn(state, round_no):
break
if self._winner_side() is not None:
break
@@ -308,19 +320,16 @@ class CombatEngine:
target = next((s for s in self._states if s.combatant.id == action.target_id), None)
if action.kind == "attack":
if target is None or not target.active:
self._log(f"round {round_no} {state.combatant.id}: wait")
return
weapon = self.weapon_for(state, target)
if weapon is None:
self._log(f"round {round_no} {state.combatant.id}: wait")
return
self._attack(state, target, weapon, round_no)
elif action.kind == "move":
if target is None:
self._log(f"round {round_no} {state.combatant.id}: wait")
return
self._move(state, target, round_no)
else:
elif action.kind == "wait":
self._log(f"round {round_no} {state.combatant.id}: wait")
def _attack(
@@ -358,40 +367,48 @@ class CombatEngine:
return
def _move(self, state: CombatantState, target: CombatantState, round_no: int) -> None:
step = self._step_toward(state, target)
if step is None:
self._log(f"round {round_no} {state.combatant.id}: wait")
path = self._move_path(state, target)
if not path:
return
self._log(
f"round {round_no} {state.combatant.id}: move "
f"({state.pos[0]},{state.pos[1]})->({step[0]},{step[1]})"
)
state.pos = step
coords = "->".join(f"({p[0]},{p[1]})" for p in (state.pos, *path))
self._log(f"round {round_no} {state.combatant.id}: move {coords}")
state.pos = path[-1]
def _step_toward(self, state: CombatantState, target: CombatantState) -> Pos | None:
def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]:
"""Full movement path toward the target, up to the combatant's speed."""
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT
if speed_cells <= 0:
return None
return []
blocked = frozenset(s.pos for s in self._states if s is not state and s.active)
to_target = self._grid.reachable(target.pos, None, blocked)
if state.pos not in to_target:
return None
best: tuple[int, Pos] | None = None
row, col = state.pos
for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col)
cost = to_target.get(nxt)
if cost is None:
continue
step = self._grid.step_cost(state.pos, nxt, 0)
if step > speed_cells:
continue
score = step + cost
if best is None or score < best[0]:
best = (score, nxt)
if best is None:
return None
return best[1]
return []
path: list[Pos] = []
current = state.pos
budget = speed_cells
while budget > 0:
current_cost = to_target[current]
best: tuple[int, int, Pos] | None = None
row, col = current
for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col)
if nxt in blocked:
continue
nxt_cost = to_target.get(nxt)
if nxt_cost is None or nxt_cost >= current_cost:
continue
step = self._grid.step_cost(current, nxt, 0)
if step > budget:
continue
score = step + nxt_cost
if best is None or score < best[0]:
best = (score, step, nxt)
if best is None:
break
current = best[2]
path.append(current)
budget -= best[1]
return path
def _log(self, line: str) -> None:
self._transcript.append(line)
@@ -401,10 +418,13 @@ Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
"""Attack the nearest active enemy when a weapon is in range, else approach."""
"""Attack the nearest enemy in range; otherwise approach at full speed then try to attack."""
target = engine.nearest_enemy(state)
if target is None:
return (Action(kind="wait"),)
if engine.weapon_for(state, target) is not None:
return (Action(kind="attack", target_id=target.combatant.id),)
return (Action(kind="move", target_id=target.combatant.id),)
return (
Action(kind="move", target_id=target.combatant.id),
Action(kind="attack", target_id=target.combatant.id),
)