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:
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user