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:
@@ -0,0 +1,119 @@
|
||||
"""Command-line balance simulator: load a map and sides, run Monte Carlo, report.
|
||||
|
||||
Usage: pf1e-sim --map MAP.yaml --side NAME FILE.json [FILE.json ...] [--runs N]
|
||||
|
||||
Side names must match the map's `deployment` section (zone letters). Each side
|
||||
may repeat the same monster JSON file; the runner disambiguates the ids.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pf1e_simulator.runner import EncounterReport, EncounterSpec
|
||||
|
||||
from pf1e_simulator.loaders import MonsterLoadError, SheetLoadError, load_monster
|
||||
from pf1e_simulator.map import MapValidationError, load_map
|
||||
from pf1e_simulator.runner import EncounterSpec, Side, run_encounter
|
||||
|
||||
|
||||
class CliError(Exception):
|
||||
"""User-facing CLI error (bad arguments or unusable files)."""
|
||||
|
||||
|
||||
def _build_spec(args: argparse.Namespace) -> EncounterSpec:
|
||||
map_spec = load_map(args.map)
|
||||
provided = [tuple(raw) for raw in args.side]
|
||||
provided_names = {raw[0] for raw in provided}
|
||||
missing = sorted(set(map_spec.deployment) - provided_names)
|
||||
if missing:
|
||||
msg = f"sides in map deployment without --side: {', '.join(missing)}"
|
||||
raise CliError(msg)
|
||||
unknown = sorted(provided_names - set(map_spec.deployment))
|
||||
if unknown:
|
||||
msg = f"--side names not in map deployment: {', '.join(unknown)}"
|
||||
raise CliError(msg)
|
||||
sides: list[Side] = []
|
||||
for raw in provided:
|
||||
name = raw[0]
|
||||
files = raw[1:]
|
||||
if not files:
|
||||
msg = f"side {name!r}: at least one monster JSON file is required"
|
||||
raise CliError(msg)
|
||||
combatants = tuple(load_monster(Path(path)) for path in files)
|
||||
sides.append(Side(name=name, combatants=combatants, zone=map_spec.deployment[name]))
|
||||
return EncounterSpec(map=map_spec, sides=tuple(sides), round_cap=args.round_cap)
|
||||
|
||||
|
||||
def format_report(report: EncounterReport, spec: EncounterSpec) -> str:
|
||||
"""Render a human-readable French balance report."""
|
||||
lines = ["=== Rapport d'équilibrage ===", f"Carte : {spec.map.name}"]
|
||||
lines.extend(f"{side.name} : {len(side.combatants)} combatants" for side in spec.sides)
|
||||
lines.append(
|
||||
f"{report.runs} combats simulés (seed {report.seed}, plafond {spec.round_cap} rounds)"
|
||||
)
|
||||
lines.append("")
|
||||
for side in spec.sides:
|
||||
wins = report.wins.get(side.name, 0)
|
||||
rate = report.win_rate(side.name)
|
||||
low, high = report.win_rate_band(side.name)
|
||||
lines.append(
|
||||
f"Victoires {side.name} : {rate:.1%} ({wins}) — "
|
||||
f"bande 3σ : [{low:.1%}, {high:.1%}]"
|
||||
)
|
||||
lines.append(f"Nuls : {report.draws / report.runs:.1%} ({report.draws})")
|
||||
lines.append(f"Rounds moyens : {report.avg_rounds:.1f}")
|
||||
lines.append("")
|
||||
lines.append("Attrition moyenne par combatant :")
|
||||
lines.extend(
|
||||
f" {cid} : {attrition.hits:.2f} touches · {attrition.crits:.2f} critiques · "
|
||||
f"{attrition.damage_dealt:.2f} dégâts infligés · {attrition.damage_taken:.2f} subis"
|
||||
for cid, attrition in report.attrition.items()
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI entry point; returns the process exit code."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="pf1e-sim",
|
||||
description="Run Monte Carlo battles on a map and print a balance report.",
|
||||
)
|
||||
parser.add_argument("--map", required=True, type=Path, help="map YAML file")
|
||||
parser.add_argument(
|
||||
"--side",
|
||||
action="append",
|
||||
nargs="+",
|
||||
metavar=("NAME", "FILE"),
|
||||
help="side NAME with one or more monster JSON files (repeatable)",
|
||||
)
|
||||
parser.add_argument("--runs", type=int, default=1000, help="number of battles (default: 1000)")
|
||||
parser.add_argument("--seed", type=int, default=1, help="RNG seed (default: 1)")
|
||||
parser.add_argument(
|
||||
"--round-cap",
|
||||
type=int,
|
||||
default=100,
|
||||
help="max rounds per battle (default: 100)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if not args.side:
|
||||
parser.error("at least one --side is required")
|
||||
try:
|
||||
spec = _build_spec(args)
|
||||
except CliError as exc:
|
||||
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
except (MapValidationError, MonsterLoadError, SheetLoadError, OSError) as exc:
|
||||
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
report = run_encounter(spec, runs=args.runs, seed=args.seed)
|
||||
except ValueError as exc:
|
||||
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
print(format_report(report, spec))
|
||||
return 0
|
||||
Reference in New Issue
Block a user