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