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
+3 -2
View File
@@ -87,11 +87,12 @@ class Grid:
flank_b = (to[0], frm[1])
return self.passable(flank_a) and self.passable(flank_b)
def reachable(self, start: Pos, budget: int, blocked: frozenset[Pos]) -> dict[Pos, int]:
def reachable(self, start: Pos, budget: int | None, blocked: frozenset[Pos]) -> dict[Pos, int]:
"""Cheapest movement cost per reachable square, capped at budget.
`blocked` holds creature-occupied squares: unenterable, but never
affecting diagonal_allowed. The start square is included at cost 0.
budget=None disables the cap (full cost field).
"""
best_per_state: dict[tuple[Pos, int], int] = {(start, 0): 0}
results: dict[Pos, int] = {start: 0}
@@ -109,7 +110,7 @@ class Grid:
if is_diagonal and not self.diagonal_allowed(pos, nxt):
continue
new_cost = cost + self.step_cost(pos, nxt, parity)
if new_cost > budget:
if budget is not None and new_cost > budget:
continue
new_parity = (parity + 1) % 2 if is_diagonal else parity
state = (nxt, new_parity)