a21e234b41
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.
172 lines
5.6 KiB
Python
172 lines
5.6 KiB
Python
"""Monte Carlo runner: many deterministic battles aggregated into balance metrics.
|
|
|
|
Each run builds fresh combatant states (duplicate ids get -N suffixes), places
|
|
each side inside its deployment zone, and plays one battle with a dedicated
|
|
SeededRng stream derived from the encounter seed. Results are aggregated into
|
|
an EncounterReport: wins per side, draws, average rounds, and per-combatant
|
|
attrition averages. All statistical helpers live in metrics.py.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from pf1e_simulator.combat import Policy
|
|
from pf1e_simulator.grid import Grid
|
|
from pf1e_simulator.map import MapSpec
|
|
from pf1e_simulator.models import Combatant
|
|
|
|
from pf1e_simulator.combat import CombatantState, CombatantStats, CombatEngine
|
|
from pf1e_simulator.grid import Grid
|
|
from pf1e_simulator.map import zone_cells
|
|
from pf1e_simulator.metrics import win_rate, win_rate_band, win_rate_sigma
|
|
from pf1e_simulator.rng import SeededRng
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Side:
|
|
"""One faction in an encounter: combatants deployed inside one zone letter."""
|
|
|
|
name: str
|
|
combatants: tuple[Combatant, ...]
|
|
zone: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EncounterSpec:
|
|
"""A full encounter definition: map, sides, and a battle round cap."""
|
|
|
|
map: MapSpec
|
|
sides: tuple[Side, ...]
|
|
round_cap: int = 100
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CombatantAttrition:
|
|
"""Average per-combatant outcomes over a Monte Carlo run."""
|
|
|
|
hits: float
|
|
crits: float
|
|
damage_dealt: float
|
|
damage_taken: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EncounterReport:
|
|
"""Aggregated results of a Monte Carlo encounter."""
|
|
|
|
runs: int
|
|
seed: int
|
|
wins: dict[str, int]
|
|
draws: int
|
|
avg_rounds: float
|
|
attrition: dict[str, CombatantAttrition]
|
|
|
|
def win_rate(self, side: str) -> float:
|
|
"""Fraction of runs won by `side` (closed form, see metrics)."""
|
|
return win_rate(self.wins.get(side, 0), self.runs)
|
|
|
|
def win_rate_sigma(self, side: str) -> float:
|
|
"""Standard error of the side's win rate: sqrt(p(1-p)/n)."""
|
|
return win_rate_sigma(self.win_rate(side), self.runs)
|
|
|
|
def win_rate_band(self, side: str) -> tuple[float, float]:
|
|
"""3-sigma confidence band for the side's win rate."""
|
|
return win_rate_band(self.win_rate(side), self.runs)
|
|
|
|
|
|
def build_states(spec: EncounterSpec) -> list[CombatantState]:
|
|
"""Fresh states for one battle; duplicate combatant ids get -N suffixes.
|
|
|
|
Combatants of a side fill the zone's cells in (row, col) order. A side with
|
|
more combatants than zone cells, or a zone with no cells, is a spec error.
|
|
"""
|
|
states: list[CombatantState] = []
|
|
seen: dict[str, int] = {}
|
|
for side in spec.sides:
|
|
cells = zone_cells(spec.map, side.zone)
|
|
if not cells:
|
|
msg = f"side {side.name!r}: zone {side.zone!r} has no cells"
|
|
raise ValueError(msg)
|
|
if len(cells) < len(side.combatants):
|
|
msg = (
|
|
f"side {side.name!r}: zone {side.zone!r} has {len(cells)} cells "
|
|
f"for {len(side.combatants)} combatants"
|
|
)
|
|
raise ValueError(msg)
|
|
for combatant, pos in zip(side.combatants, cells, strict=False):
|
|
count = seen.get(combatant.id, 0) + 1
|
|
seen[combatant.id] = count
|
|
cid = combatant.id if count == 1 else f"{combatant.id}-{count}"
|
|
effective = (
|
|
combatant
|
|
if cid == combatant.id
|
|
else combatant.model_copy(update={"id": cid})
|
|
)
|
|
states.append(
|
|
CombatantState(
|
|
combatant=effective,
|
|
side=side.name,
|
|
pos=pos,
|
|
hp=effective.hp_max,
|
|
)
|
|
)
|
|
return states
|
|
|
|
|
|
def run_encounter(
|
|
spec: EncounterSpec,
|
|
runs: int,
|
|
seed: int,
|
|
*,
|
|
policy: Policy | None = None,
|
|
) -> EncounterReport:
|
|
"""Run `runs` battles (one SeededRng per run) and aggregate balance metrics."""
|
|
if runs <= 0:
|
|
msg = "runs must be positive"
|
|
raise ValueError(msg)
|
|
grid = Grid.from_spec(spec.map)
|
|
states = build_states(spec)
|
|
ids = [s.combatant.id for s in states]
|
|
wins = {side.name: 0 for side in spec.sides}
|
|
draws = 0
|
|
total_rounds = 0
|
|
totals = {cid: CombatantStats() for cid in ids}
|
|
for index in range(runs):
|
|
if index > 0:
|
|
states = build_states(spec)
|
|
rng = SeededRng(seed + index)
|
|
result = CombatEngine(rng, grid, states, round_cap=spec.round_cap, policy=policy).run()
|
|
if result.winner is None:
|
|
draws += 1
|
|
else:
|
|
wins[result.winner] = wins.get(result.winner, 0) + 1
|
|
total_rounds += result.rounds
|
|
for cid, stats in result.stats.items():
|
|
current = totals[cid]
|
|
totals[cid] = CombatantStats(
|
|
hits=current.hits + stats.hits,
|
|
crits=current.crits + stats.crits,
|
|
damage_dealt=current.damage_dealt + stats.damage_dealt,
|
|
damage_taken=current.damage_taken + stats.damage_taken,
|
|
)
|
|
attrition = {
|
|
cid: CombatantAttrition(
|
|
hits=stats.hits / runs,
|
|
crits=stats.crits / runs,
|
|
damage_dealt=stats.damage_dealt / runs,
|
|
damage_taken=stats.damage_taken / runs,
|
|
)
|
|
for cid, stats in totals.items()
|
|
}
|
|
return EncounterReport(
|
|
runs=runs,
|
|
seed=seed,
|
|
wins=wins,
|
|
draws=draws,
|
|
avg_rounds=total_rounds / runs,
|
|
attrition=attrition,
|
|
)
|