224 lines
8.2 KiB
Python
224 lines
8.2 KiB
Python
"""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
|