23ae7a2572
Report how often a Tecnica is played and how often Kiai decides a Kumite or an Exam fight. The tie rule moves to RulesConfig.attacker_wins, shared by the engine and the metric; games are unchanged. RULES.md drops the Kiai +1 and catch-up variants from the rules under test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
186 lines
7.1 KiB
Python
186 lines
7.1 KiB
Python
from dataclasses import replace
|
|
|
|
import pytest
|
|
|
|
from dojo.board import Pos
|
|
from dojo.events import (
|
|
Event,
|
|
ExamTaken,
|
|
FightKind,
|
|
GameOver,
|
|
KumiteFought,
|
|
Moved,
|
|
MoveOptions,
|
|
Source,
|
|
TrackChanged,
|
|
TurnSkipped,
|
|
TurnStarted,
|
|
)
|
|
from dojo.metrics import KUMITE_SECONDS, TURN_SECONDS, GameMetrics, measure, summarize
|
|
from dojo.rules import DEFAULT_RULES, Card
|
|
|
|
HOME_0 = Pos(0, 1) # quadrant 0
|
|
|
|
|
|
def fought(
|
|
attacker: int, defender: int, kind: FightKind, *, won: bool = True, exam: bool = False
|
|
) -> KumiteFought:
|
|
return KumiteFought(attacker, defender, 0, 0, 4, 3, won, kind, exam, cards=())
|
|
|
|
|
|
def run(events: list[Event], players: int = 2) -> GameMetrics:
|
|
corners = tuple(range(players))
|
|
bots = ("balanced", "random", "farmer")[:players]
|
|
return measure(events, DEFAULT_RULES, corners, bots, scenario="test", seed=3)
|
|
|
|
|
|
def test_measure_counts_fights_income_and_exams() -> None:
|
|
metrics = run(
|
|
[
|
|
TurnStarted(1, 0, 7),
|
|
MoveOptions(0, ("A", "F", ".")),
|
|
Moved(0, (HOME_0,)),
|
|
TrackChanged(0, 0, 1, Source.SQUARE),
|
|
TurnStarted(1, 1, 5),
|
|
MoveOptions(1, ("A", "?")),
|
|
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),
|
|
TrackChanged(0, 5, 4, Source.CARD),
|
|
fought(0, 1, FightKind.EQUAL, won=False, exam=True),
|
|
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, metrics.exam_challengers) == (1, 1, 1)
|
|
assert (metrics.square_gain, metrics.square_loss) == (2, 0)
|
|
assert (metrics.kumite_gain, metrics.kumite_loss) == (3, -1)
|
|
assert (metrics.card_gain, metrics.card_loss) == (5, -1)
|
|
assert (metrics.square_income_home, metrics.square_income_away) == (1, 1)
|
|
assert (metrics.winner_seat, metrics.winner_bot, metrics.rounds) == (0, "balanced", 2)
|
|
assert (metrics.move_choices, metrics.end_squares) == (2, 5)
|
|
assert (metrics.allenamento_reachable, metrics.fatica_reachable) == (2, 1)
|
|
assert metrics.final_track == (4, 4)
|
|
assert (metrics.exam_attempts_by_seat, metrics.exam_defences_by_seat) == ((1, 0), (0, 1))
|
|
# Both Exam rounds count as fights for the time estimate.
|
|
assert metrics.minutes == pytest.approx(round((3 * TURN_SECONDS + 5 * KUMITE_SECONDS) / 60, 1))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("totals", "attacker_won", "cards", "decided"),
|
|
[
|
|
((5, 4), True, ((0, Card.KIAI),), True),
|
|
((5, 3), True, ((0, Card.KIAI),), False), # 3 vs 3 without it: Senshu, still won.
|
|
((8, 4), True, ((0, Card.KIAI),), False),
|
|
((4, 5), False, ((1, Card.KIAI),), True),
|
|
((6, 5), True, ((0, Card.KIAI), (1, Card.KIAI)), False),
|
|
((5, 4), True, ((0, Card.KAESHI),), False),
|
|
],
|
|
)
|
|
def test_kiai_decides_a_fight_only_when_the_result_flips_without_it(
|
|
totals: tuple[int, int],
|
|
attacker_won: bool,
|
|
cards: tuple[tuple[int, Card], ...],
|
|
decided: bool,
|
|
) -> None:
|
|
fight = KumiteFought(0, 1, 0, 0, *totals, attacker_won, FightKind.EQUAL, False, cards)
|
|
exam = replace(fight, exam=True)
|
|
|
|
metrics = run([TurnStarted(2, 0, 7), fight, fought(1, 0, FightKind.EQUAL), exam])
|
|
summary = summarize("test", [metrics])
|
|
|
|
assert (metrics.tecnica_kumite, metrics.kiai_decided) == (1, decided)
|
|
assert (metrics.exam_fights, metrics.exam_kiai_decided) == (1, decided)
|
|
assert summary.tecnica_rate == 0.5
|
|
assert summary.kiai_decided_rate == decided / 2
|
|
assert summary.exam_kiai_decided_rate == decided
|
|
|
|
|
|
def test_standings_are_sampled_at_turn_ends() -> None:
|
|
metrics = run(
|
|
[
|
|
TurnStarted(1, 0, 7),
|
|
TrackChanged(0, 0, 9, Source.CARD), # end of turn 1: (9, 0, 0), leader 0
|
|
TurnStarted(1, 1, 7),
|
|
TrackChanged(1, 0, 3, Source.SQUARE),
|
|
TrackChanged(1, 3, 10, Source.CARD), # end of turn 2: (9, 10, 0), leader 1
|
|
TurnStarted(1, 2, 7),
|
|
TrackChanged(2, 0, 10, Source.CARD), # end of turn 3: (9, 10, 10), no strict leader
|
|
TurnSkipped(2, 0),
|
|
TrackChanged(2, 10, 15, Source.KUMITE), # end of turn 4: (9, 10, 15), leader 2
|
|
GameOver(2, 2),
|
|
],
|
|
players=3,
|
|
)
|
|
|
|
assert metrics.lead_changes == 2
|
|
assert metrics.last_lead_change == pytest.approx(4 / 4)
|
|
assert metrics.lead_gap_mean == pytest.approx((9 + 1 + 0 + 5) / 4)
|
|
assert metrics.winner_margin == 5
|
|
assert metrics.winner_lead_share == pytest.approx(1 / 4)
|
|
# Halfway is the end of turn 2, led by player 1.
|
|
assert metrics.halfway_leader_won is False
|
|
assert metrics.contenders_at_end == 0
|
|
# Out (more than 9 behind): player 2 after turn 2 only.
|
|
assert metrics.out_of_contention_share == round(1 / 12, 3)
|
|
assert metrics.worst_out_of_contention_share == pytest.approx(1 / 4)
|
|
# 0>1 becomes 1>0; 0>2 becomes 2>0; 1>2 (ties skipped) becomes 2>1.
|
|
assert metrics.rank_swaps == 3
|
|
assert metrics.winner_turns_to_brown == 1
|
|
assert metrics.final_spread == 6
|
|
|
|
|
|
def test_leader_losing_a_kumite_is_counted() -> None:
|
|
metrics = run(
|
|
[
|
|
TurnStarted(1, 0, 7),
|
|
TrackChanged(0, 0, 3, Source.CARD),
|
|
TurnStarted(1, 1, 7),
|
|
fought(1, 0, FightKind.EQUAL), # Leader 0 loses.
|
|
fought(1, 0, FightKind.EQUAL, won=False), # The attacker loses: 0 wins.
|
|
GameOver(None, 1),
|
|
]
|
|
)
|
|
|
|
assert metrics.leader_kumite_losses == 1
|
|
assert metrics.winner_margin is None
|
|
assert metrics.halfway_leader_won is None
|
|
assert metrics.last_lead_change is None
|
|
|
|
|
|
def test_summarize_splits_wins_by_seat_and_bot() -> None:
|
|
base = run([TurnStarted(1, 0, 7), GameOver(None, 10)])
|
|
rows = [
|
|
replace(base, lineup="balanced,random", 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"),
|
|
replace(
|
|
base, final_track=(2, 9), exam_attempts_by_seat=(0, 3), exam_defences_by_seat=(2, 0)
|
|
),
|
|
]
|
|
|
|
summary = summarize("test", rows)
|
|
|
|
assert summary.finished == 0.75
|
|
assert summary.win_rate_by_seat == (0.5, 0.25)
|
|
assert summary.seats[0].final_track == pytest.approx(0.5)
|
|
assert summary.seats[1].exam_attempts_per_game == pytest.approx(0.75)
|
|
assert (summary.seats[0].exam_defended, summary.seats[1].exam_defended) == (0.25, 0.0)
|
|
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", [])
|