feat(cli): add --transcript option
This commit is contained in:
@@ -54,6 +54,8 @@ allowed-confusables = ["σ"]
|
|||||||
"src/pf1e_simulator/cli.py" = ["T201"]
|
"src/pf1e_simulator/cli.py" = ["T201"]
|
||||||
# combat.py: CombatEngine.__init__ needs 6 params (rng, grid, states, round_cap, policy, spell_registry)
|
# combat.py: CombatEngine.__init__ needs 6 params (rng, grid, states, round_cap, policy, spell_registry)
|
||||||
"src/pf1e_simulator/combat.py" = ["PLR0913"]
|
"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 ──────────────────────────────────────────────────────────────
|
# ── Basedpyright ──────────────────────────────────────────────────────────────
|
||||||
[tool.basedpyright]
|
[tool.basedpyright]
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
default=100,
|
default=100,
|
||||||
help="max rounds per battle (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)
|
args = parser.parse_args(argv)
|
||||||
if not args.side:
|
if not args.side:
|
||||||
parser.error("at least one --side is required")
|
parser.error("at least one --side is required")
|
||||||
@@ -113,9 +118,19 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return 2
|
return 2
|
||||||
try:
|
try:
|
||||||
registry = load_spell_registry(SPELLS_DIR)
|
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:
|
except ValueError as exc:
|
||||||
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
||||||
return 2
|
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))
|
print(format_report(report, spec))
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -57,7 +57,12 @@ class CombatantAttrition:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class EncounterReport:
|
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
|
runs: int
|
||||||
seed: int
|
seed: int
|
||||||
@@ -65,6 +70,7 @@ class EncounterReport:
|
|||||||
draws: int
|
draws: int
|
||||||
avg_rounds: float
|
avg_rounds: float
|
||||||
attrition: dict[str, CombatantAttrition]
|
attrition: dict[str, CombatantAttrition]
|
||||||
|
transcript: tuple[str, ...] | None = None
|
||||||
|
|
||||||
def win_rate(self, side: str) -> float:
|
def win_rate(self, side: str) -> float:
|
||||||
"""Fraction of runs won by `side` (closed form, see metrics)."""
|
"""Fraction of runs won by `side` (closed form, see metrics)."""
|
||||||
@@ -126,11 +132,15 @@ def run_encounter(
|
|||||||
*,
|
*,
|
||||||
policy: Policy | None = None,
|
policy: Policy | None = None,
|
||||||
spell_registry: dict[str, SpellSpec] | None = None,
|
spell_registry: dict[str, SpellSpec] | None = None,
|
||||||
|
capture_first_transcript: bool = False,
|
||||||
) -> EncounterReport:
|
) -> EncounterReport:
|
||||||
"""Run `runs` battles (one SeededRng per run) and aggregate balance metrics.
|
"""Run `runs` battles (one SeededRng per run) and aggregate balance metrics.
|
||||||
|
|
||||||
The spell registry is loaded once (from ``data/spells/`` unless overridden)
|
The spell registry is loaded once (from ``data/spells/`` unless overridden)
|
||||||
and passed into every ``CombatEngine`` so caster PCs actually cast.
|
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:
|
if runs <= 0:
|
||||||
msg = "runs must be positive"
|
msg = "runs must be positive"
|
||||||
@@ -144,6 +154,7 @@ def run_encounter(
|
|||||||
draws = 0
|
draws = 0
|
||||||
total_rounds = 0
|
total_rounds = 0
|
||||||
totals = {cid: CombatantStats() for cid in ids}
|
totals = {cid: CombatantStats() for cid in ids}
|
||||||
|
transcript: tuple[str, ...] | None = None
|
||||||
for index in range(runs):
|
for index in range(runs):
|
||||||
if index > 0:
|
if index > 0:
|
||||||
states = build_states(spec)
|
states = build_states(spec)
|
||||||
@@ -156,6 +167,8 @@ def run_encounter(
|
|||||||
policy=policy,
|
policy=policy,
|
||||||
spell_registry=spell_registry,
|
spell_registry=spell_registry,
|
||||||
).run()
|
).run()
|
||||||
|
if capture_first_transcript and index == 0:
|
||||||
|
transcript = result.transcript
|
||||||
if result.winner is None:
|
if result.winner is None:
|
||||||
draws += 1
|
draws += 1
|
||||||
else:
|
else:
|
||||||
@@ -185,4 +198,5 @@ def run_encounter(
|
|||||||
draws=draws,
|
draws=draws,
|
||||||
avg_rounds=total_rounds / runs,
|
avg_rounds=total_rounds / runs,
|
||||||
attrition=attrition,
|
attrition=attrition,
|
||||||
|
transcript=transcript,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"])
|
rc = main(["--map", str(MAP), "--side", "players", str(GOBLIN), "--runs", "10"])
|
||||||
assert rc == 2
|
assert rc == 2
|
||||||
assert "monsters" in capsys.readouterr().err
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user