"""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 CombatantLoadError, SheetLoadError, load_combatant from pf1e_simulator.map import MapValidationError, load_map from pf1e_simulator.runner import EncounterSpec, Side, run_encounter from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry 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_combatant(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)", ) parser.add_argument( "--transcript", action="store_true", help="print the turn-by-turn log of the first battle before the report", ) 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, CombatantLoadError, SheetLoadError, OSError) as exc: print(f"pf1e-sim: {exc}", file=sys.stderr) return 2 try: registry = load_spell_registry(SPELLS_DIR) report = run_encounter( spec, runs=args.runs, seed=args.seed, spell_registry=registry, capture_first_transcript=args.transcript, ) except ValueError as exc: print(f"pf1e-sim: {exc}", file=sys.stderr) return 2 if args.transcript and report.transcript is not None: print("=== Déroulé du premier combat ===") print("\n".join(report.transcript)) print() print(format_report(report, spec)) return 0