Files
pf1e-simulator/src/pf1e_simulator/cli.py
T

137 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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