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