"""Simulation output: a plain-text summary per scenario, and CSV files with one row per game or one row per scenario.""" import csv import dataclasses from collections.abc import Iterable from pathlib import Path from dojo.metrics import CONTENTION_BELTS, GameMetrics, SeatSummary, Spread, Summary def format_summary(summary: Summary) -> str: bots = ", ".join(f"{bot} {rate:.0%}" for bot, rate in summary.win_rate_by_bot.items()) income = ", ".join( f"{source} +{gain:.2f}/{loss:.2f}" for source, (gain, loss) in summary.income_per_turn.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 (no stripes change) {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, {summary.exam_challengers:.2f} challengers/game", f"Tecnica played in {summary.tecnica_rate:.0%} of Kumite; Kiai decided " f"{summary.kiai_decided_rate:.0%} of Kumite, {summary.exam_kiai_decided_rate:.0%} of " "Exam fights", f"Income/turn {income}", f"Square income home {summary.square_income_home_per_turn:+.2f}/turn, " f"away {summary.square_income_away_per_turn:+.2f}/turn (net)", f"Winner pace {_spread(summary.winner_turns_to_brown)} (own turns to reach Brown)", f"Lead changes {_spread(summary.lead_changes)}", f"Last change {_shares(summary.last_lead_change)} (point of the game)", f"Gap 1st-2nd {_spread(summary.lead_gap)} (per-game mean, stripes)", f"Winner margin {_spread(summary.winner_margin)} (over best rival at the end)", f"Winner led {summary.winner_lead_share:.0%} of turns; halfway leader won " f"{summary.halfway_leader_wins:.0%} of {summary.halfway_games} games", f"Contention {summary.contenders_at_end:.2f} rivals within one belt at the end; " f"{summary.out_of_contention:.0%} of player-turns out (>{CONTENTION_BELTS} belts behind)", f"Worst player {_shares(summary.worst_out_of_contention)} (share of turns out)", f"Rank swaps {_spread(summary.rank_swaps)}", f"Leader beaten {summary.leader_kumite_losses:.2f} Kumite lost by the leader per game", f"Final spread {_spread(summary.final_spread)}", f"Move options {summary.end_squares_per_move:.1f} end squares/move; can end on " f"Allenamento {summary.allenamento_reachable:.0%}, Fatica {summary.fatica_reachable:.0%}", *(f"Seat {seat:<11}{_seat(stats)}" for seat, stats in enumerate(summary.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( { name: ",".join(map(str, value)) if isinstance(value, tuple) else value for name, value in dataclasses.asdict(game).items() } ) def write_summary_csv(path: Path, summaries: Iterable[Summary]) -> None: """One row per scenario; a column missing from a scenario (fewer seats, other bots) is empty.""" rows = [_summary_row(summary) for summary in summaries] fields = list(dict.fromkeys(name for row in rows for name in row)) with path.open("w", encoding="utf-8", newline="") as file: writer = csv.DictWriter(file, fieldnames=fields, restval="") writer.writeheader() writer.writerows(rows) def _summary_row(summary: Summary) -> dict[str, object]: row: dict[str, object] = {} for field in dataclasses.fields(Summary): name, value = field.name, getattr(summary, field.name) match value: case Spread(): row |= {f"{name}_{stat}": round(v, 3) for stat, v in _numbers(value).items()} case float(): row[name] = round(value, 4) case str() | int(): row[name] = value case _: pass # Income, seats and bots are flattened below. for source, (gain, loss) in summary.income_per_turn.items(): row[f"{source}_gain_per_turn"] = round(gain, 4) row[f"{source}_loss_per_turn"] = round(loss, 4) for seat, stats in enumerate(summary.seats): row |= {f"seat{seat}_{stat}": round(v, 4) for stat, v in _numbers(stats).items()} row |= {f"win_rate_{bot}": round(v, 4) for bot, v in summary.win_rate_by_bot.items()} return row def _numbers(record: Spread | SeatSummary) -> dict[str, float]: return dataclasses.asdict(record) def _seat(stats: SeatSummary) -> str: return ( f"won {stats.win_rate:.0%}, final track {stats.final_track:.1f}, " f"Exam {stats.exam_attempts_per_game:.2f} attempts/game, " f"defended in {stats.exam_defended:.0%} of games" ) def _spread(spread: Spread) -> str: return ( f"mean {spread.mean:.1f} p10 {spread.p10:.1f} median {spread.median:.1f} " f"p90 {spread.p90:.1f}" ) def _shares(spread: Spread) -> str: return ( f"mean {spread.mean:.0%} p10 {spread.p10:.0%} median {spread.median:.0%} " f"p90 {spread.p90:.0%}" )