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:
@@ -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", [])
|
||||
Reference in New Issue
Block a user