23ae7a2572
Report how often a Tecnica is played and how often Kiai decides a Kumite or an Exam fight. The tie rule moves to RulesConfig.attacker_wins, shared by the engine and the metric; games are unchanged. RULES.md drops the Kiai +1 and catch-up variants from the rules under test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
124 lines
5.7 KiB
Python
124 lines
5.7 KiB
Python
"""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%}"
|
|
)
|