Files
dojo_cate/src/dojo/sim.py
T
eisterman 7b76b10175 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>
2026-09-26 16:29:22 +02:00

129 lines
4.0 KiB
Python

"""Monte Carlo runner: many seeded games over a matrix of rule configurations.
Game ``i`` of every scenario uses seed ``base + i``, so scenarios are compared on the same
dice. The lineup rotates one seat per game, separating seat effects from bot effects.
Results do not depend on the number of worker processes.
"""
import itertools
import random
from collections.abc import Sequence
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass, replace
from functools import cache
from pathlib import Path
from dojo.board import Board, load_board
from dojo.bots import PROFILES, HeuristicAgent, RandomAgent
from dojo.engine import Agent, Game, RandomSource, seat_corners
from dojo.metrics import GameMetrics, measure
from dojo.rules import VARIANT_A, VARIANT_B, RulesConfig
VARIANTS = {"A": VARIANT_A, "B": VARIANT_B}
RANDOM_BOT = "random"
BOTS = (RANDOM_BOT, *PROFILES)
@dataclass(frozen=True)
class Scenario:
variant: str
players: int
kiai_bonus: int
start_position: int
# Bot names, repeated in order to fill the seats.
lineup: tuple[str, ...]
def __post_init__(self) -> None:
if self.variant not in VARIANTS:
raise ValueError(f"unknown variant {self.variant!r}: expected one of {list(VARIANTS)}")
unknown = [bot for bot in self.lineup if bot not in BOTS]
if unknown or not self.lineup:
raise ValueError(f"unknown bots {unknown}: expected some of {list(BOTS)}")
seat_corners(self.players)
@property
def name(self) -> str:
return (
f"{self.variant} p{self.players} kiai+{self.kiai_bonus} start{self.start_position} "
+ ",".join(self.lineup)
)
@property
def config(self) -> RulesConfig:
return replace(
VARIANTS[self.variant],
kiai_bonus=self.kiai_bonus,
start_position=self.start_position,
)
def seating(self, game: int) -> tuple[str, ...]:
seats = [self.lineup[seat % len(self.lineup)] for seat in range(self.players)]
shift = game % self.players
return tuple(seats[shift:] + seats[:shift])
def matrix(
variants: Sequence[str],
players: Sequence[int],
kiai_bonuses: Sequence[int],
start_positions: Sequence[int],
lineups: Sequence[tuple[str, ...]],
) -> list[Scenario]:
return [
Scenario(*axes)
for axes in itertools.product(variants, players, kiai_bonuses, start_positions, lineups)
]
@dataclass(frozen=True)
class BoardFiles:
quadrant: Path
legend: Path | None
def make_agent(bot: str, seed: int, seat: int) -> Agent:
if bot == RANDOM_BOT:
return RandomAgent(random.Random(f"bot:{seed}:{seat}"))
return HeuristicAgent(PROFILES[bot])
def play(board: Board, scenario: Scenario, index: int, seed: int) -> GameMetrics:
bots = scenario.seating(index)
agents = [make_agent(bot, seed, seat) for seat, bot in enumerate(bots)]
game = Game(board, scenario.config, agents, RandomSource(seed))
game.run()
return measure(
game.events,
scenario.config,
seat_corners(scenario.players),
bots,
scenario=scenario.name,
seed=seed,
)
def run(
files: BoardFiles,
scenarios: Sequence[Scenario],
games: int,
seed: int = 0,
jobs: int = 1,
) -> list[GameMetrics]:
"""Play ``games`` games of each scenario; results are ordered by scenario, then game."""
tasks = [(s, i, seed + i) for s in scenarios for i in range(games)]
if jobs == 1:
board = _board(files)
return [play(board, *task) for task in tasks]
with ProcessPoolExecutor(jobs) as pool:
chunk = max(1, len(tasks) // (jobs * 8))
return list(pool.map(_play_task, itertools.repeat(files), tasks, chunksize=chunk))
def _play_task(files: BoardFiles, task: tuple[Scenario, int, int]) -> GameMetrics:
return play(_board(files), *task)
@cache
def _board(files: BoardFiles) -> Board:
return load_board(files.quadrant, files.legend)