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>
This commit is contained in:
@@ -32,6 +32,23 @@ uv run dojo board validate --quadrant other.txt --legend other.toml # check an
|
|||||||
`validate` exits with `0` when the board is fine (warnings allowed), `1` on rule violations,
|
`validate` exits with `0` when the board is fine (warnings allowed), `1` on rule violations,
|
||||||
`2` when the file is malformed or missing.
|
`2` when the file is malformed or missing.
|
||||||
|
|
||||||
|
## Simulation
|
||||||
|
|
||||||
|
Plays many seeded games with bots and prints metrics per scenario. Every combination of the
|
||||||
|
listed values is a scenario; game `i` of each scenario uses seed `--seed + i`, and the lineup
|
||||||
|
rotates one seat per game. Bots: `random`, `balanced`, `farmer`, `aggressive`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uv run dojo sim # variants A and B, 4 players, 200 games each
|
||||||
|
uv run dojo sim --games 1000 --csv games.csv # more games; one CSV row per game
|
||||||
|
uv run dojo sim --variant B --players 3 4 6 8 --start 0 3 # player counts x start position
|
||||||
|
uv run dojo sim --kiai 2 1 # Kiai +2 vs +1
|
||||||
|
uv run dojo sim --lineup balanced,random balanced # several lineups (comma-separated seats)
|
||||||
|
uv run dojo sim --seed 42 --jobs 1 # other dice; single process
|
||||||
|
```
|
||||||
|
|
||||||
|
Minutes are estimated as 45 s per turn plus 30 s per Kumite.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -101,16 +101,29 @@ Deliverables:
|
|||||||
|
|
||||||
### Phase 5 — Simulator and metrics
|
### Phase 5 — Simulator and metrics
|
||||||
|
|
||||||
- [ ] 5.1 Monte Carlo runner over a config matrix; seeded and reproducible.
|
- [x] 5.1 Monte Carlo runner over a config matrix; seeded and reproducible. `src/dojo/sim.py`: `Scenario` (variant × players × Kiai bonus × start position × lineup), `matrix`, `run(files, scenarios, games, seed, jobs)`. Game `i` uses seed `base + i` in every scenario (same dice across scenarios); the lineup rotates one seat per game; process pool, results independent of `jobs`.
|
||||||
- [ ] 5.2 Metrics:
|
- [x] 5.2 Metrics (`src/dojo/metrics.py`, computed from the event log; Kumite counts exclude the Exam):
|
||||||
- [ ] game length in rounds and estimated minutes (turn and fight duration as parameters);
|
- [x] game length in rounds and estimated minutes (45 s per turn + 30 s per Kumite);
|
||||||
- [ ] Kumite per round, upset rate, chain length;
|
- [x] Kumite per round, upset rate, higher-beats-lower rate, longest chain per game (Kumite attacked by the active player in one turn);
|
||||||
- [ ] win rate by seat and by bot profile;
|
- [x] win rate by seat and by bot profile (per seat occupied);
|
||||||
- [ ] exam attempts per game, exam success rate;
|
- [x] exam attempts per game, exam success rate;
|
||||||
- [ ] rounds spent stuck on 14 (variant B deadlock check);
|
- [x] turns started on 14, longest streak (variant B deadlock check);
|
||||||
- [ ] stripe income split: squares vs cards vs Kumite;
|
- [x] net stripe income per turn: squares vs cards vs Kumite vs Exam; square income in the player's own quadrant vs elsewhere (home farming);
|
||||||
- [ ] lead changes per game, final spread.
|
- [x] lead changes per game (strict leader), final spread.
|
||||||
- [ ] 5.3 Report output (plain text / CSV).
|
- [x] 5.3 Report output: `uv run dojo sim` prints a text summary per scenario; `--csv` writes one row per game (`src/dojo/report.py`). Commands in `README.md`.
|
||||||
|
- Baseline (1,000 games per variant, 4 players, lineup balanced/farmer/aggressive, seed 0), for Phase 6:
|
||||||
|
|
||||||
|
| Metric | A | B |
|
||||||
|
|---|---|---|
|
||||||
|
| Rounds mean (p10–p90) | 18.4 (14–23) | 13.1 (9–17) |
|
||||||
|
| Minutes mean (p10–p90) | 66 (50–85) | 46 (33–62) |
|
||||||
|
| Kumite per round | 1.25 | 1.16 |
|
||||||
|
| Upsets / higher beats lower | 29% / 55% | 27% / 52% |
|
||||||
|
| Exam attempts per game, pass rate | 1.26, 79% | 1.22, 82% |
|
||||||
|
| Turns on 14 per game, longest streak | 1.26, 4 | 1.73, 7 |
|
||||||
|
| Income per turn: square (home / away), card, Kumite | +0.52 (0.38 / 0.14), 0.00, +0.04 | +0.54 (0.39 / 0.14), 0.00, +0.22 |
|
||||||
|
| Lead changes, final spread | 5.6, 9.3 | 3.5, 11.5 |
|
||||||
|
| Wins by seat | 23–27% | 23–27% |
|
||||||
|
|
||||||
### Phase 6 — Balance experiments
|
### Phase 6 — Balance experiments
|
||||||
|
|
||||||
@@ -147,14 +160,14 @@ Deliverables:
|
|||||||
### Open
|
### Open
|
||||||
|
|
||||||
- Q2 Board asymmetry: keep the quadrant as source of truth, or later switch to the full board as source of truth with a symmetry *report* (warnings) instead of generation? *(Deferred by the developer.)*
|
- Q2 Board asymmetry: keep the quadrant as source of truth, or later switch to the full board as source of truth with a symmetry *report* (warnings) instead of generation? *(Deferred by the developer.)*
|
||||||
- Q4 Target net income per turn — derived from 1–2 h and ~15–20 turns per player; confirm with first simulations.
|
- Q4 Target net income per turn — derived from 1–2 h and ~15–20 turns per player; confirm with first simulations. Baseline measured net income ≈ 0.56 (A) / 0.75 (B) stripes per turn.
|
||||||
|
|
||||||
### Risks
|
### Risks
|
||||||
|
|
||||||
- Movement enumeration: self-avoiding walks of length up to 12 can explode in open rooms. Keep rooms small; prune by outcome (destination + ordered opponents crossed + Dojo entry).
|
- Movement enumeration: self-avoiding walks of length up to 12 can explode in open rooms. Keep rooms small; prune by outcome (destination + ordered opponents crossed + Dojo entry).
|
||||||
- Engine speed ~1,400 turns/s (random and heuristic bots, real board). If Phase 5 needs more: precompute neighbour lists in `Board`.
|
- Engine speed ~1,400 turns/s (random and heuristic bots, real board). If Phase 5 needs more: precompute neighbour lists in `Board`.
|
||||||
- Simulation conclusions depend on bot quality; compare several profiles before trusting a result.
|
- Simulation conclusions depend on bot quality; compare several profiles before trusting a result.
|
||||||
- Board draft 1 puts 4 Allenamento in each 3x3 room next to the Safe Room: possible "home farming" (see the Phase 2 review). Measure stripe income by region in the simulator.
|
- Board draft 1 puts 4 Allenamento in each 3x3 room next to the Safe Room: possible "home farming" (see the Phase 2 review). Measured (Phase 5 baseline): square income is +0.38/turn in the own quadrant vs +0.14/turn elsewhere; 6.4 decides whether that is a problem.
|
||||||
|
|
||||||
## External documentation
|
## External documentation
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from itertools import groupby
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from dojo.board import QUADRANTS, Board, BoardError, load_board
|
from dojo.board import QUADRANTS, Board, BoardError, load_board
|
||||||
from dojo.grid import GridFormatError, render_grid
|
from dojo.grid import GridFormatError, render_grid
|
||||||
|
from dojo.metrics import summarize
|
||||||
|
from dojo.report import format_summary, write_games_csv
|
||||||
|
from dojo.sim import BOTS, VARIANTS, BoardFiles, matrix, run
|
||||||
from dojo.validate import Report, validate
|
from dojo.validate import Report, validate
|
||||||
|
|
||||||
DEFAULT_QUADRANT = Path("board/quadrant.txt")
|
DEFAULT_QUADRANT = Path("board/quadrant.txt")
|
||||||
@@ -13,6 +18,8 @@ EXIT_OK = 0
|
|||||||
EXIT_DESIGN_ERRORS = 1
|
EXIT_DESIGN_ERRORS = 1
|
||||||
EXIT_FORMAT_ERRORS = 2
|
EXIT_FORMAT_ERRORS = 2
|
||||||
|
|
||||||
|
DEFAULT_LINEUP = "balanced,farmer,aggressive"
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(prog="dojo")
|
parser = argparse.ArgumentParser(prog="dojo")
|
||||||
@@ -32,12 +39,36 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
"-o", "--output", type=Path, help="write to this file instead of stdout"
|
"-o", "--output", type=Path, help="write to this file instead of stdout"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sim_parser = commands.add_parser(
|
||||||
|
"sim", help="simulate many games and print metrics per scenario"
|
||||||
|
)
|
||||||
|
_add_board_arguments(sim_parser)
|
||||||
|
sim_parser.add_argument("--games", type=int, default=200, help="games per scenario")
|
||||||
|
sim_parser.add_argument("--seed", type=int, default=0, help="seed of the first game")
|
||||||
|
sim_parser.add_argument("--variant", nargs="+", choices=list(VARIANTS), default=["A", "B"])
|
||||||
|
sim_parser.add_argument("--players", nargs="+", type=int, default=[4])
|
||||||
|
sim_parser.add_argument("--kiai", nargs="+", type=int, default=[2], help="Kiai bonus")
|
||||||
|
sim_parser.add_argument("--start", nargs="+", type=int, default=[0], help="start position")
|
||||||
|
sim_parser.add_argument(
|
||||||
|
"--lineup",
|
||||||
|
nargs="+",
|
||||||
|
default=[DEFAULT_LINEUP],
|
||||||
|
help=f"comma-separated bots, repeated to fill the seats; bots: {', '.join(BOTS)}",
|
||||||
|
)
|
||||||
|
sim_parser.add_argument(
|
||||||
|
"--jobs", type=int, default=os.cpu_count() or 1, help="worker processes"
|
||||||
|
)
|
||||||
|
sim_parser.add_argument("--csv", type=Path, help="write one row per game to this file")
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
legend = _resolve_legend(args.quadrant, args.legend)
|
legend = _resolve_legend(args.quadrant, args.legend)
|
||||||
board = _load(args.quadrant, legend)
|
board = _load(args.quadrant, legend)
|
||||||
if board is None:
|
if board is None:
|
||||||
return EXIT_FORMAT_ERRORS
|
return EXIT_FORMAT_ERRORS
|
||||||
|
|
||||||
|
if args.command == "sim":
|
||||||
|
return _simulate(args, BoardFiles(args.quadrant, legend), parser)
|
||||||
|
|
||||||
if args.board_command == "validate":
|
if args.board_command == "validate":
|
||||||
report = validate(board)
|
report = validate(board)
|
||||||
print(_format_report(args.quadrant, legend, report))
|
print(_format_report(args.quadrant, legend, report))
|
||||||
@@ -51,6 +82,27 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return EXIT_OK
|
return EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
|
def _simulate(args: argparse.Namespace, files: BoardFiles, parser: argparse.ArgumentParser) -> int:
|
||||||
|
if args.games < 1:
|
||||||
|
parser.error("--games must be at least 1")
|
||||||
|
try:
|
||||||
|
scenarios = matrix(
|
||||||
|
args.variant,
|
||||||
|
args.players,
|
||||||
|
args.kiai,
|
||||||
|
args.start,
|
||||||
|
[tuple(lineup.split(",")) for lineup in args.lineup],
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
parser.error(str(error))
|
||||||
|
games = run(files, scenarios, args.games, seed=args.seed, jobs=max(1, args.jobs))
|
||||||
|
for scenario, rows in groupby(games, key=lambda game: game.scenario):
|
||||||
|
print(format_summary(summarize(scenario, rows)), end="\n\n")
|
||||||
|
if args.csv is not None:
|
||||||
|
write_games_csv(args.csv, games)
|
||||||
|
return EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
def _add_board_arguments(parser: argparse.ArgumentParser) -> None:
|
def _add_board_arguments(parser: argparse.ArgumentParser) -> None:
|
||||||
parser.add_argument("--quadrant", type=Path, default=DEFAULT_QUADRANT)
|
parser.add_argument("--quadrant", type=Path, default=DEFAULT_QUADRANT)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"""Per-game measurements taken from the event log, and their aggregation over many games."""
|
||||||
|
|
||||||
|
import statistics
|
||||||
|
from collections import Counter
|
||||||
|
from collections.abc import Iterable, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from dojo.board import Pos, quadrant_of
|
||||||
|
from dojo.events import (
|
||||||
|
Event,
|
||||||
|
ExamTaken,
|
||||||
|
FightKind,
|
||||||
|
GameOver,
|
||||||
|
KumiteFought,
|
||||||
|
Moved,
|
||||||
|
SentToStart,
|
||||||
|
Source,
|
||||||
|
Teleported,
|
||||||
|
TrackChanged,
|
||||||
|
TurnStarted,
|
||||||
|
)
|
||||||
|
from dojo.rules import RulesConfig
|
||||||
|
|
||||||
|
# Table-time estimate (plan, resolved questions).
|
||||||
|
TURN_SECONDS = 45
|
||||||
|
KUMITE_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GameMetrics:
|
||||||
|
"""One game, flat so it can be written as a CSV row. Kumite counts exclude the Exam."""
|
||||||
|
|
||||||
|
scenario: str
|
||||||
|
seed: int
|
||||||
|
lineup: str
|
||||||
|
winner_seat: int | None
|
||||||
|
winner_bot: str | None
|
||||||
|
rounds: int
|
||||||
|
turns: int
|
||||||
|
minutes: float
|
||||||
|
kumite: int
|
||||||
|
upsets: int
|
||||||
|
expected_wins: int
|
||||||
|
max_chain: int
|
||||||
|
exam_attempts: int
|
||||||
|
exam_passes: int
|
||||||
|
turns_on_14: int
|
||||||
|
longest_14_streak: int
|
||||||
|
income_square: int
|
||||||
|
income_card: int
|
||||||
|
income_kumite: int
|
||||||
|
income_exam: int
|
||||||
|
square_income_home: int
|
||||||
|
square_income_away: int
|
||||||
|
lead_changes: int
|
||||||
|
final_spread: int
|
||||||
|
|
||||||
|
|
||||||
|
def measure(
|
||||||
|
events: Sequence[Event],
|
||||||
|
config: RulesConfig,
|
||||||
|
corners: Sequence[int],
|
||||||
|
bots: Sequence[str],
|
||||||
|
*,
|
||||||
|
scenario: str,
|
||||||
|
seed: int,
|
||||||
|
) -> GameMetrics:
|
||||||
|
"""Replay ``events`` of one game. ``corners[seat]`` and ``bots[seat]`` describe each seat."""
|
||||||
|
players = len(corners)
|
||||||
|
track = [config.start_position] * players
|
||||||
|
position: list[Pos | None] = [None] * players
|
||||||
|
blue_top = config.brown_position - 1
|
||||||
|
streak = [0] * players
|
||||||
|
longest_streak = 0
|
||||||
|
turns_on_14 = 0
|
||||||
|
income = Counter[Source]()
|
||||||
|
home = away = 0
|
||||||
|
kumite = upsets = expected = 0
|
||||||
|
chain = max_chain = 0
|
||||||
|
exam_attempts = exam_passes = 0
|
||||||
|
turns = 0
|
||||||
|
rounds = 0
|
||||||
|
winner: int | None = None
|
||||||
|
leader: int | None = None
|
||||||
|
lead_changes = 0
|
||||||
|
active = 0
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
match event:
|
||||||
|
case TurnStarted(player=player):
|
||||||
|
turns += 1
|
||||||
|
active = player
|
||||||
|
chain = 0
|
||||||
|
if track[player] == blue_top:
|
||||||
|
turns_on_14 += 1
|
||||||
|
streak[player] += 1
|
||||||
|
longest_streak = max(longest_streak, streak[player])
|
||||||
|
else:
|
||||||
|
streak[player] = 0
|
||||||
|
case Moved(player=player, path=path) if path:
|
||||||
|
position[player] = path[-1]
|
||||||
|
case Teleported(player=player, destination=destination):
|
||||||
|
position[player] = destination
|
||||||
|
case SentToStart(player=player, square=square):
|
||||||
|
position[player] = square
|
||||||
|
case KumiteFought(exam=False, attacker=attacker, kind=kind):
|
||||||
|
kumite += 1
|
||||||
|
upsets += kind is FightKind.UPSET
|
||||||
|
expected += kind is FightKind.EXPECTED
|
||||||
|
if attacker == active:
|
||||||
|
chain += 1
|
||||||
|
max_chain = max(max_chain, chain)
|
||||||
|
case ExamTaken(passed=passed):
|
||||||
|
exam_attempts += 1
|
||||||
|
exam_passes += passed
|
||||||
|
case TrackChanged(player=player, before=before, after=after, source=source):
|
||||||
|
delta = after - before
|
||||||
|
income[source] += delta
|
||||||
|
square = position[player]
|
||||||
|
if source is Source.SQUARE and square is not None:
|
||||||
|
if quadrant_of(square) == corners[player]:
|
||||||
|
home += delta
|
||||||
|
else:
|
||||||
|
away += delta
|
||||||
|
track[player] = after
|
||||||
|
top = max(track)
|
||||||
|
leaders = [p for p in range(players) if track[p] == top]
|
||||||
|
if len(leaders) == 1 and leaders[0] != leader:
|
||||||
|
lead_changes += leader is not None
|
||||||
|
leader = leaders[0]
|
||||||
|
case GameOver(winner=game_winner, rounds=game_rounds):
|
||||||
|
winner, rounds = game_winner, game_rounds
|
||||||
|
case _:
|
||||||
|
pass
|
||||||
|
|
||||||
|
fights = kumite + exam_attempts
|
||||||
|
return GameMetrics(
|
||||||
|
scenario=scenario,
|
||||||
|
seed=seed,
|
||||||
|
lineup=",".join(bots),
|
||||||
|
winner_seat=winner,
|
||||||
|
winner_bot=None if winner is None else bots[winner],
|
||||||
|
rounds=rounds,
|
||||||
|
turns=turns,
|
||||||
|
minutes=round((turns * TURN_SECONDS + fights * KUMITE_SECONDS) / 60, 1),
|
||||||
|
kumite=kumite,
|
||||||
|
upsets=upsets,
|
||||||
|
expected_wins=expected,
|
||||||
|
max_chain=max_chain,
|
||||||
|
exam_attempts=exam_attempts,
|
||||||
|
exam_passes=exam_passes,
|
||||||
|
turns_on_14=turns_on_14,
|
||||||
|
longest_14_streak=longest_streak,
|
||||||
|
income_square=income[Source.SQUARE],
|
||||||
|
income_card=income[Source.CARD],
|
||||||
|
income_kumite=income[Source.KUMITE],
|
||||||
|
income_exam=income[Source.EXAM],
|
||||||
|
square_income_home=home,
|
||||||
|
square_income_away=away,
|
||||||
|
lead_changes=lead_changes,
|
||||||
|
final_spread=max(track) - min(track),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Spread:
|
||||||
|
mean: float
|
||||||
|
p10: float
|
||||||
|
median: float
|
||||||
|
p90: float
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def of(cls, values: Sequence[float]) -> Spread:
|
||||||
|
if len(values) < 2:
|
||||||
|
value = float(values[0]) if values else 0.0
|
||||||
|
return cls(value, value, value, value)
|
||||||
|
deciles = statistics.quantiles(values, n=10, method="inclusive")
|
||||||
|
return cls(statistics.fmean(values), deciles[0], statistics.median(values), deciles[-1])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Summary:
|
||||||
|
"""Aggregate of one scenario. Rates are per turn unless named otherwise."""
|
||||||
|
|
||||||
|
scenario: str
|
||||||
|
games: int
|
||||||
|
finished: float
|
||||||
|
rounds: Spread
|
||||||
|
minutes: Spread
|
||||||
|
kumite_per_round: float
|
||||||
|
upset_rate: float
|
||||||
|
expected_rate: float
|
||||||
|
max_chain: Spread
|
||||||
|
exam_attempts_per_game: float
|
||||||
|
exam_success: float
|
||||||
|
turns_on_14_per_game: float
|
||||||
|
longest_14_streak: int
|
||||||
|
income_per_turn: dict[Source, float]
|
||||||
|
square_income_home_per_turn: float
|
||||||
|
square_income_away_per_turn: float
|
||||||
|
lead_changes: Spread
|
||||||
|
final_spread: Spread
|
||||||
|
win_rate_by_seat: tuple[float, ...]
|
||||||
|
win_rate_by_bot: dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(scenario: str, games: Iterable[GameMetrics]) -> Summary:
|
||||||
|
rows = list(games)
|
||||||
|
if not rows:
|
||||||
|
raise ValueError(f"{scenario}: no games to summarize")
|
||||||
|
turns = sum(r.turns for r in rows) or 1
|
||||||
|
rounds = sum(r.rounds for r in rows) or 1
|
||||||
|
kumite = sum(r.kumite for r in rows)
|
||||||
|
attempts = sum(r.exam_attempts for r in rows)
|
||||||
|
seats = len(rows[0].lineup.split(","))
|
||||||
|
|
||||||
|
appearances = Counter[str]()
|
||||||
|
wins = Counter[str]()
|
||||||
|
for row in rows:
|
||||||
|
appearances.update(row.lineup.split(","))
|
||||||
|
if row.winner_bot is not None:
|
||||||
|
wins[row.winner_bot] += 1
|
||||||
|
seat_wins = Counter(r.winner_seat for r in rows if r.winner_seat is not None)
|
||||||
|
incomes = {
|
||||||
|
Source.SQUARE: sum(r.income_square for r in rows),
|
||||||
|
Source.CARD: sum(r.income_card for r in rows),
|
||||||
|
Source.KUMITE: sum(r.income_kumite for r in rows),
|
||||||
|
Source.EXAM: sum(r.income_exam for r in rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
return Summary(
|
||||||
|
scenario=scenario,
|
||||||
|
games=len(rows),
|
||||||
|
finished=sum(r.winner_seat is not None for r in rows) / len(rows),
|
||||||
|
rounds=Spread.of([r.rounds for r in rows]),
|
||||||
|
minutes=Spread.of([r.minutes for r in rows]),
|
||||||
|
kumite_per_round=kumite / rounds,
|
||||||
|
upset_rate=sum(r.upsets for r in rows) / (kumite or 1),
|
||||||
|
expected_rate=sum(r.expected_wins for r in rows) / (kumite or 1),
|
||||||
|
max_chain=Spread.of([r.max_chain for r in rows]),
|
||||||
|
exam_attempts_per_game=attempts / len(rows),
|
||||||
|
exam_success=sum(r.exam_passes for r in rows) / (attempts or 1),
|
||||||
|
turns_on_14_per_game=sum(r.turns_on_14 for r in rows) / len(rows),
|
||||||
|
longest_14_streak=max(r.longest_14_streak for r in rows),
|
||||||
|
income_per_turn={source: total / turns for source, total in incomes.items()},
|
||||||
|
square_income_home_per_turn=sum(r.square_income_home for r in rows) / turns,
|
||||||
|
square_income_away_per_turn=sum(r.square_income_away for r in rows) / turns,
|
||||||
|
lead_changes=Spread.of([r.lead_changes for r in rows]),
|
||||||
|
final_spread=Spread.of([r.final_spread for r in rows]),
|
||||||
|
win_rate_by_seat=tuple(seat_wins[s] / len(rows) for s in range(seats)),
|
||||||
|
# Wins per seat occupied: an average bot scores 1 / players.
|
||||||
|
win_rate_by_bot={bot: wins[bot] / count for bot, count in sorted(appearances.items())},
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Simulation output: a plain-text summary per scenario and one CSV row per game."""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import dataclasses
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dojo.events import Source
|
||||||
|
from dojo.metrics import GameMetrics, Spread, Summary
|
||||||
|
|
||||||
|
|
||||||
|
def format_summary(summary: Summary) -> str:
|
||||||
|
income = summary.income_per_turn
|
||||||
|
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())
|
||||||
|
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 {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",
|
||||||
|
f"Stuck on 14 {summary.turns_on_14_per_game:.2f} turns/game, "
|
||||||
|
f"longest streak {summary.longest_14_streak} turns",
|
||||||
|
f"Income/turn square {income[Source.SQUARE]:+.2f} "
|
||||||
|
f"(home {summary.square_income_home_per_turn:+.2f}, "
|
||||||
|
f"away {summary.square_income_away_per_turn:+.2f}), "
|
||||||
|
f"card {income[Source.CARD]:+.2f}, kumite {income[Source.KUMITE]:+.2f}, "
|
||||||
|
f"exam {income[Source.EXAM]:+.2f}",
|
||||||
|
f"Lead changes {_spread(summary.lead_changes)}",
|
||||||
|
f"Final spread {_spread(summary.final_spread)}",
|
||||||
|
f"Wins by seat {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(dataclasses.asdict(game))
|
||||||
|
|
||||||
|
|
||||||
|
def _spread(spread: Spread) -> str:
|
||||||
|
return (
|
||||||
|
f"mean {spread.mean:.1f} p10 {spread.p10:.1f} median {spread.median:.1f} "
|
||||||
|
f"p90 {spread.p90:.1f}"
|
||||||
|
)
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
"""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)
|
||||||
@@ -56,3 +56,28 @@ def test_legend_next_to_the_quadrant_is_used_by_default(
|
|||||||
|
|
||||||
assert main(["board", "validate", "--quadrant", str(quadrant)]) == EXIT_FORMAT_ERRORS
|
assert main(["board", "validate", "--quadrant", str(quadrant)]) == EXIT_FORMAT_ERRORS
|
||||||
assert "legend links tunnel '9'" in capsys.readouterr().err
|
assert "legend links tunnel '9'" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_sim_prints_a_summary_per_scenario_and_writes_games(
|
||||||
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
games = tmp_path / "games.csv"
|
||||||
|
argv = ["sim", "--games", "2", "--variant", "A", "B", "--players", "3", "--jobs", "1"]
|
||||||
|
|
||||||
|
assert main([*argv, "--csv", str(games)]) == EXIT_OK
|
||||||
|
|
||||||
|
output = capsys.readouterr().out
|
||||||
|
assert "== A p3 kiai+2 start0 balanced,farmer,aggressive (2 games" in output
|
||||||
|
assert "== B p3 kiai+2 start0" in output
|
||||||
|
rows = games.read_text(encoding="utf-8").splitlines()
|
||||||
|
assert rows[0].startswith("scenario,seed,lineup,winner_seat")
|
||||||
|
assert len(rows) == 1 + 4
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [["--lineup", "balanced,cheater"], ["--games", "0"]])
|
||||||
|
def test_sim_rejects_bad_arguments(bad: list[str], capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
with pytest.raises(SystemExit) as exit_info:
|
||||||
|
main(["sim", *bad])
|
||||||
|
|
||||||
|
assert exit_info.value.code == 2
|
||||||
|
assert "error:" in capsys.readouterr().err
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from dojo.board import Pos
|
||||||
|
from dojo.events import (
|
||||||
|
Event,
|
||||||
|
ExamTaken,
|
||||||
|
FightKind,
|
||||||
|
GameOver,
|
||||||
|
KumiteFought,
|
||||||
|
Moved,
|
||||||
|
Source,
|
||||||
|
TrackChanged,
|
||||||
|
TurnStarted,
|
||||||
|
)
|
||||||
|
from dojo.metrics import KUMITE_SECONDS, TURN_SECONDS, GameMetrics, measure, summarize
|
||||||
|
from dojo.rules import VARIANT_A
|
||||||
|
|
||||||
|
CORNERS = (0, 1)
|
||||||
|
BOTS = ("balanced", "random")
|
||||||
|
HOME_0 = Pos(0, 1) # quadrant 0
|
||||||
|
HOME_1 = Pos(0, 14) # quadrant 1
|
||||||
|
|
||||||
|
|
||||||
|
def fought(attacker: int, defender: int, kind: FightKind, *, exam: bool = False) -> KumiteFought:
|
||||||
|
return KumiteFought(attacker, defender, 0, 0, 4, 3, True, kind, exam, cards=())
|
||||||
|
|
||||||
|
|
||||||
|
def run(events: list[Event]) -> GameMetrics:
|
||||||
|
return measure(events, VARIANT_A, CORNERS, BOTS, scenario="test", seed=3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_measure_counts_fights_income_and_leads() -> None:
|
||||||
|
metrics = run(
|
||||||
|
[
|
||||||
|
TurnStarted(1, 0, 7),
|
||||||
|
Moved(0, (HOME_0,)),
|
||||||
|
TrackChanged(0, 0, 1, Source.SQUARE),
|
||||||
|
TurnStarted(1, 1, 5),
|
||||||
|
Moved(1, (HOME_0,)),
|
||||||
|
TrackChanged(1, 0, 1, Source.SQUARE),
|
||||||
|
fought(1, 0, FightKind.EQUAL),
|
||||||
|
TrackChanged(1, 1, 4, Source.KUMITE),
|
||||||
|
TrackChanged(0, 1, 0, Source.KUMITE),
|
||||||
|
fought(1, 0, FightKind.UPSET),
|
||||||
|
TurnStarted(2, 0, 6),
|
||||||
|
fought(1, 0, FightKind.EXPECTED), # Player 1 defends: not part of a chain.
|
||||||
|
TrackChanged(0, 0, 5, Source.CARD),
|
||||||
|
fought(0, 1, FightKind.EQUAL, exam=True),
|
||||||
|
ExamTaken(0, 1, passed=True),
|
||||||
|
GameOver(0, 2),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (metrics.kumite, metrics.upsets, metrics.expected_wins) == (3, 1, 1)
|
||||||
|
assert metrics.max_chain == 2
|
||||||
|
assert (metrics.exam_attempts, metrics.exam_passes) == (1, 1)
|
||||||
|
assert (metrics.income_square, metrics.income_kumite, metrics.income_card) == (2, 2, 5)
|
||||||
|
assert (metrics.square_income_home, metrics.square_income_away) == (1, 1)
|
||||||
|
# Leader: 0 (1-0), tie (1-1) keeps it, 1 (0-4), 0 (5-4).
|
||||||
|
assert metrics.lead_changes == 2
|
||||||
|
assert metrics.final_spread == 1
|
||||||
|
assert (metrics.winner_seat, metrics.winner_bot, metrics.rounds) == (0, "balanced", 2)
|
||||||
|
assert metrics.minutes == pytest.approx(round((3 * TURN_SECONDS + 4 * KUMITE_SECONDS) / 60, 1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_measure_tracks_turns_started_on_14() -> None:
|
||||||
|
metrics = run(
|
||||||
|
[
|
||||||
|
TrackChanged(0, 0, 14, Source.CARD),
|
||||||
|
TurnStarted(1, 0, 7),
|
||||||
|
TurnStarted(1, 1, 7),
|
||||||
|
TurnStarted(2, 0, 7),
|
||||||
|
TrackChanged(0, 14, 15, Source.KUMITE),
|
||||||
|
TurnStarted(2, 1, 7),
|
||||||
|
TurnStarted(3, 0, 7),
|
||||||
|
TrackChanged(0, 15, 14, Source.SQUARE),
|
||||||
|
TurnStarted(4, 0, 7),
|
||||||
|
GameOver(None, 4),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert metrics.turns_on_14 == 3
|
||||||
|
assert metrics.longest_14_streak == 2
|
||||||
|
assert metrics.winner_seat is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_splits_wins_by_seat_and_bot() -> None:
|
||||||
|
base = run([GameOver(None, 10)])
|
||||||
|
rows = [
|
||||||
|
replace(base, winner_seat=0, winner_bot="balanced"),
|
||||||
|
replace(base, lineup="random,balanced", winner_seat=1, winner_bot="balanced"),
|
||||||
|
replace(base, lineup="random,balanced", winner_seat=0, winner_bot="random"),
|
||||||
|
base,
|
||||||
|
]
|
||||||
|
|
||||||
|
summary = summarize("test", rows)
|
||||||
|
|
||||||
|
assert summary.finished == 0.75
|
||||||
|
assert summary.win_rate_by_seat == (0.5, 0.25)
|
||||||
|
assert summary.win_rate_by_bot == {"balanced": 0.5, "random": 0.25}
|
||||||
|
assert summary.rounds.median == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_needs_games() -> None:
|
||||||
|
with pytest.raises(ValueError, match="no games"):
|
||||||
|
summarize("test", [])
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from dojo.sim import BoardFiles, Scenario, matrix, run
|
||||||
|
|
||||||
|
BOARD = Path(__file__).parent.parent / "board"
|
||||||
|
FILES = BoardFiles(BOARD / "quadrant.txt", BOARD / "legend.toml")
|
||||||
|
LINEUP = ("balanced", "random")
|
||||||
|
|
||||||
|
|
||||||
|
def test_matrix_crosses_every_axis() -> None:
|
||||||
|
scenarios = matrix(["A", "B"], [3, 4], [2, 1], [0], [LINEUP])
|
||||||
|
|
||||||
|
assert len(scenarios) == 8
|
||||||
|
assert scenarios[0].name == "A p3 kiai+2 start0 balanced,random"
|
||||||
|
assert scenarios[-1].config.kiai_bonus == 1
|
||||||
|
assert scenarios[-1].config.brown_requires_kumite
|
||||||
|
|
||||||
|
|
||||||
|
def test_lineup_fills_the_seats_and_rotates_each_game() -> None:
|
||||||
|
scenario = Scenario("A", 3, 2, 0, LINEUP)
|
||||||
|
|
||||||
|
assert scenario.seating(0) == ("balanced", "random", "balanced")
|
||||||
|
assert scenario.seating(1) == ("random", "balanced", "balanced")
|
||||||
|
assert scenario.seating(3) == scenario.seating(0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("variant", "players", "lineup", "message"),
|
||||||
|
[
|
||||||
|
("C", 4, LINEUP, "unknown variant"),
|
||||||
|
("A", 4, ("balanced", "cheater"), "unknown bots"),
|
||||||
|
("A", 4, (), "unknown bots"),
|
||||||
|
("A", 9, LINEUP, "supported range"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_scenarios_are_rejected(
|
||||||
|
variant: str, players: int, lineup: tuple[str, ...], message: str
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(ValueError, match=message):
|
||||||
|
Scenario(variant, players, 2, 0, lineup)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runs_are_reproducible_and_independent_of_worker_count() -> None:
|
||||||
|
scenarios = matrix(["A", "B"], [3], [2], [0], [LINEUP])
|
||||||
|
|
||||||
|
serial = run(FILES, scenarios, games=2, seed=5, jobs=1)
|
||||||
|
parallel = run(FILES, scenarios, games=2, seed=5, jobs=2)
|
||||||
|
|
||||||
|
assert serial == parallel
|
||||||
|
assert [(g.scenario, g.seed) for g in serial] == [
|
||||||
|
(scenarios[0].name, 5),
|
||||||
|
(scenarios[0].name, 6),
|
||||||
|
(scenarios[1].name, 5),
|
||||||
|
(scenarios[1].name, 6),
|
||||||
|
]
|
||||||
|
assert serial[1].lineup == "random,balanced,balanced"
|
||||||
Reference in New Issue
Block a user