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

224 lines
8.2 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
import unicodedata
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
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)."""
_TRANSCRIPT_COLUMNS = ("Round", "Combattant", "Message")
_TRANSCRIPT_GUTTER = " "
def _display_width(text: str) -> int:
"""Terminal columns used by ``text``: wide CJK counts 2, combining marks 0."""
width = 0
for char in text:
if unicodedata.combining(char):
continue
width += 2 if unicodedata.east_asian_width(char) in ("W", "F") else 1
return width
def _pad_cell(text: str, width: int, *, right: bool = False) -> str:
"""Pad ``text`` with spaces to ``width`` display columns."""
filler = " " * (width - _display_width(text))
return filler + text if right else text + filler
def _parse_transcript_line(line: str) -> tuple[str, str, str] | None:
"""Split a ``round N id: message`` engine line into (round, id, message).
Returns None for lines that do not follow the pattern (initiative rolls,
battle-over notices); the renderer keeps them as raw fallback rows.
"""
if not line.startswith("round "):
return None
rest = line.removeprefix("round ")
space = rest.find(" ")
if space <= 0 or not rest[:space].isdigit():
return None
remainder = rest[space + 1 :]
colon = remainder.find(": ")
if colon <= 0 or " " in remainder[:colon]:
return None
return rest[:space], remainder[:colon], remainder[colon + 2 :]
def render_transcript_table(transcript: Sequence[str]) -> str:
"""Render transcript lines as an aligned three-column table (no truncation).
Entries shaped ``round N id: message`` fill the Round / Combattant /
Message columns (computed widths, display-width-aware padding so accents
and CJK stay aligned); any other entry is emitted verbatim as a raw
fallback row so no transcript line is ever dropped.
"""
rows: list[tuple[str, str, str] | None] = [_parse_transcript_line(line) for line in transcript]
parsed = [row for row in rows if row is not None]
widths = [
max([_display_width(header)] + [_display_width(row[index]) for row in parsed])
for index, header in enumerate(_TRANSCRIPT_COLUMNS)
]
lines = [
_TRANSCRIPT_GUTTER.join(
_pad_cell(header, width)
for header, width in zip(_TRANSCRIPT_COLUMNS, widths, strict=True)
),
_TRANSCRIPT_GUTTER.join("-" * width for width in widths),
]
for row, line in zip(rows, transcript, strict=True):
if row is None:
lines.append(line)
continue
cells = [
_pad_cell(row[0], widths[0], right=True),
*(
_pad_cell(cell, width)
for cell, width in zip(row[1:], widths[1:], strict=True)
),
]
lines.append(_TRANSCRIPT_GUTTER.join(cells))
return "\n".join(lines)
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 as a table before the report",
)
parser.add_argument(
"--simple-monster-death",
action="store_true",
help=(
"monsters die at hp <= 0 instead of the PF1e hp <= -CON rule "
"(player characters are never affected)"
),
)
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,
monsters_die_at_zero=args.simple_monster_death,
)
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(render_transcript_table(report.transcript))
print()
print(format_report(report, spec))
return 0