Add Monte Carlo simulator, game metrics and sim CLI (Phase 5)

`dojo sim` plays seeded games over a scenario matrix (variant, players,
Kiai bonus, start position, bot lineup) in parallel, prints a summary per
scenario and optionally writes one CSV row per game. Metrics are replayed
from the event log. Plan updated with the Phase 5 baseline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 16:29:22 +02:00
parent 0aaf113fc3
commit 7b76b10175
9 changed files with 719 additions and 12 deletions
+53
View File
@@ -0,0 +1,53 @@
"""Simulation output: a plain-text summary per scenario and one CSV row per game."""
import csv
import dataclasses
from collections.abc import Iterable
from pathlib import Path
from dojo.events import Source
from dojo.metrics import GameMetrics, Spread, Summary
def format_summary(summary: Summary) -> str:
income = summary.income_per_turn
seats = ", ".join(f"{seat} {rate:.0%}" for seat, rate in enumerate(summary.win_rate_by_seat))
bots = ", ".join(f"{bot} {rate:.0%}" for bot, rate in summary.win_rate_by_bot.items())
lines = [
f"== {summary.scenario} ({summary.games} games, {summary.finished:.0%} finished)",
f"Rounds {_spread(summary.rounds)}",
f"Minutes {_spread(summary.minutes)}",
f"Kumite {summary.kumite_per_round:.2f}/round; upsets {summary.upset_rate:.0%}, "
f"higher beats lower {summary.expected_rate:.0%}",
f"Longest chain {_spread(summary.max_chain)} (Kumite in one turn, per game)",
f"Exam {summary.exam_attempts_per_game:.2f} attempts/game, "
f"{summary.exam_success:.0%} passed",
f"Stuck on 14 {summary.turns_on_14_per_game:.2f} turns/game, "
f"longest streak {summary.longest_14_streak} turns",
f"Income/turn square {income[Source.SQUARE]:+.2f} "
f"(home {summary.square_income_home_per_turn:+.2f}, "
f"away {summary.square_income_away_per_turn:+.2f}), "
f"card {income[Source.CARD]:+.2f}, kumite {income[Source.KUMITE]:+.2f}, "
f"exam {income[Source.EXAM]:+.2f}",
f"Lead changes {_spread(summary.lead_changes)}",
f"Final spread {_spread(summary.final_spread)}",
f"Wins by seat {seats}",
f"Wins by bot {bots}",
]
return "\n".join(lines)
def write_games_csv(path: Path, games: Iterable[GameMetrics]) -> None:
fields = [field.name for field in dataclasses.fields(GameMetrics)]
with path.open("w", encoding="utf-8", newline="") as file:
writer = csv.DictWriter(file, fieldnames=fields)
writer.writeheader()
for game in games:
writer.writerow(dataclasses.asdict(game))
def _spread(spread: Spread) -> str:
return (
f"mean {spread.mean:.1f} p10 {spread.p10:.1f} median {spread.median:.1f} "
f"p90 {spread.p90:.1f}"
)