Rules v0.8: Fatica on crossing, no Kumite in round 1, board draft 2

- Fatica costs a stripe for every Fatica walked onto; a route whose Fatica
  drops a Brown player below 15 no longer enters the Dojo
- No Kumite in the first round: opponents are treated like meditating pawns
- Board draft 2: two Fatica tolls per quadrant, Allenamento moved to the ring
- Casual bot, --summary-csv, per-seat breakdown and move-options metric
- Out of contention measured at 3 belts
- Plan: Phase 6 baselines, decisions D34-D43

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 15:59:45 +02:00
parent 4998931cb0
commit bbc95b6722
18 changed files with 566 additions and 80 deletions
+57 -6
View File
@@ -1,15 +1,15 @@
"""Simulation output: a plain-text summary per scenario and one CSV row per game."""
"""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 GameMetrics, Spread, Summary
from dojo.metrics import CONTENTION_BELTS, GameMetrics, SeatSummary, Spread, Summary
def format_summary(summary: Summary) -> str:
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())
income = ", ".join(
f"{source} +{gain:.2f}/{loss:.2f}"
@@ -35,12 +35,14 @@ def format_summary(summary: Summary) -> str:
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 (>2 belts behind)",
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"Wins by seat {seats}",
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)
@@ -52,7 +54,56 @@ def write_games_csv(path: Path, games: Iterable[GameMetrics]) -> None:
writer = csv.DictWriter(file, fieldnames=fields)
writer.writeheader()
for game in games:
writer.writerow(dataclasses.asdict(game))
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: