diff --git a/pyproject.toml b/pyproject.toml index 9cac199..3cc2f38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,8 @@ allowed-confusables = ["σ"] "src/pf1e_simulator/cli.py" = ["T201"] # combat.py: CombatEngine.__init__ needs 6 params (rng, grid, states, round_cap, policy, spell_registry) "src/pf1e_simulator/combat.py" = ["PLR0913"] +# runner.py: run_encounter needs 6 params (spec, runs, seed, policy, spell_registry, capture_first_transcript) +"src/pf1e_simulator/runner.py" = ["PLR0913"] # ── Basedpyright ────────────────────────────────────────────────────────────── [tool.basedpyright] diff --git a/src/pf1e_simulator/cli.py b/src/pf1e_simulator/cli.py index 4b83e76..353f935 100644 --- a/src/pf1e_simulator/cli.py +++ b/src/pf1e_simulator/cli.py @@ -100,6 +100,11 @@ def main(argv: list[str] | None = None) -> 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") @@ -113,9 +118,19 @@ def main(argv: list[str] | None = None) -> int: return 2 try: registry = load_spell_registry(SPELLS_DIR) - report = run_encounter(spec, runs=args.runs, seed=args.seed, spell_registry=registry) + 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 diff --git a/src/pf1e_simulator/runner.py b/src/pf1e_simulator/runner.py index df6b29a..da237a6 100644 --- a/src/pf1e_simulator/runner.py +++ b/src/pf1e_simulator/runner.py @@ -57,7 +57,12 @@ class CombatantAttrition: @dataclass(frozen=True) class EncounterReport: - """Aggregated results of a Monte Carlo encounter.""" + """Aggregated results of a Monte Carlo encounter. + + `transcript` holds the turn-by-turn log of the first battle when + `run_encounter` is called with ``capture_first_transcript=True``; it is + None otherwise (the default keeps the report cheap for pure Monte Carlo). + """ runs: int seed: int @@ -65,6 +70,7 @@ class EncounterReport: draws: int avg_rounds: float attrition: dict[str, CombatantAttrition] + transcript: tuple[str, ...] | None = None def win_rate(self, side: str) -> float: """Fraction of runs won by `side` (closed form, see metrics).""" @@ -126,11 +132,15 @@ def run_encounter( *, policy: Policy | None = None, spell_registry: dict[str, SpellSpec] | None = None, + capture_first_transcript: bool = False, ) -> EncounterReport: """Run `runs` battles (one SeededRng per run) and aggregate balance metrics. The spell registry is loaded once (from ``data/spells/`` unless overridden) and passed into every ``CombatEngine`` so caster PCs actually cast. + + When ``capture_first_transcript`` is True, the turn-by-turn log of the + first battle (``seed + 0``) is stored in ``report.transcript``. """ if runs <= 0: msg = "runs must be positive" @@ -144,6 +154,7 @@ def run_encounter( draws = 0 total_rounds = 0 totals = {cid: CombatantStats() for cid in ids} + transcript: tuple[str, ...] | None = None for index in range(runs): if index > 0: states = build_states(spec) @@ -156,6 +167,8 @@ def run_encounter( policy=policy, spell_registry=spell_registry, ).run() + if capture_first_transcript and index == 0: + transcript = result.transcript if result.winner is None: draws += 1 else: @@ -185,4 +198,5 @@ def run_encounter( draws=draws, avg_rounds=total_rounds / runs, attrition=attrition, + transcript=transcript, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index c0f2e99..06a0edb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -57,3 +57,31 @@ def test_cli_rejects_missing_deployment_side(capsys: pytest.CaptureFixture[str]) rc = main(["--map", str(MAP), "--side", "players", str(GOBLIN), "--runs", "10"]) assert rc == 2 assert "monsters" in capsys.readouterr().err + + +def test_cli_transcript_prints_first_battle_log(capsys: pytest.CaptureFixture[str]) -> None: + rc = main( + [ + "--map", + str(MAP), + "--side", + "players", + str(GOBLIN), + "--side", + "monsters", + str(ORC), + "--runs", + "10", + "--seed", + "1", + "--transcript", + ] + ) + out = capsys.readouterr().out + assert rc == 0 + assert "Déroulé du premier combat" in out + assert "initiative:" in out + assert "round 1" in out + # The balance report must still be printed after the transcript. + assert "Rapport d'équilibrage" in out + assert out.index("Déroulé du premier combat") < out.index("Rapport d'équilibrage")