feat(runner): Monte Carlo runner, closed-form metrics and balance CLI

Adds the S6 layer on top of the deterministic combat engine:
- metrics.py: closed-form binomial statistics (win rate, standard error,
  3-sigma confidence band) with clamped degenerate proportions.
- runner.py: EncounterSpec/Side definitions, per-run SeededRng streams,
  global duplicate-id disambiguation, attrition averages per combatant.
- cli.py: pf1e-sim entry point producing a French balance report with
  win rates, 3-sigma bands, draws, rounds, and attrition.
- Movement fix: greedy straight-line heuristic could oscillate at walls;
  _step_toward now follows the true shortest path via an unbounded
  Dijkstra cost field from the target (Grid.reachable budget=None).
- Regression tests: melee unit routes around a wall and engages; grid
  unbounded-budget coverage.
This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 577aec33c4
commit a21e234b41
10 changed files with 625 additions and 7 deletions
+10 -5
View File
@@ -7,8 +7,8 @@ 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: greedy single step toward the nearest enemy minimizing
accumulated cost + remaining grid distance; ties keep delta order.
- 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.
- Ranged attacks ignore cover and range penalties in Phase 0.
"""
@@ -301,15 +301,20 @@ class CombatEngine:
if speed_cells <= 0:
return None
blocked = frozenset(s.pos for s in self._states if s is not state and s.active)
costs = self._grid.reachable(state.pos, speed_cells, blocked)
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 = costs.get(nxt)
cost = to_target.get(nxt)
if cost is None:
continue
score = cost + self._grid.distance(nxt, target.pos)
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: