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:
2026-09-26 16:29:22 +02:00
parent 0aaf113fc3
commit 7b76b10175
9 changed files with 719 additions and 12 deletions
+25
View File
@@ -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 "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
+108
View File
@@ -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", [])
+58
View File
@@ -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"