"""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)