Drop variant B, adopt Black-belt Exam defender, add one-sidedness metrics

- Remove variant B (Brown cap, +1 for higher beating lower) from rules,
  engine, bots and simulator; a higher belt beating a lower one gains 0.
- Final Exam: the defender fights as a Black belt (RULES.md v0.7). Ties to
  the defender and best-of-3 stay as simulator switches (`--exam`).
- Report stripe income per source as gains and losses.
- Add ten one-sidedness measures sampled at turn ends (lead gap, winner
  margin and lead share, halfway leader, last lead change, contention,
  rank swaps, leader Kumite losses, Exam challengers, winner pace).
- Plan: decisions D28-D33 and the detailed Phase 6.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 17:09:42 +02:00
parent 7b76b10175
commit 4998931cb0
16 changed files with 707 additions and 256 deletions
+2 -2
View File
@@ -9,7 +9,7 @@ from dojo.board import Board, Pos, build_board
from dojo.engine import FightView, Game, GameView
from dojo.grid import parse_grid
from dojo.movement import Path as Walk
from dojo.rules import VARIANT_A, Card, RulesConfig
from dojo.rules import DEFAULT_RULES, Card, RulesConfig
FIXTURE = Path(__file__).parent / "fixtures" / "quadrant_valid.txt"
NO_CARDS: tuple[tuple[Card, int], ...] = ()
@@ -91,7 +91,7 @@ def fixture_board() -> Board:
def make_game(
players: int = 2,
config: RulesConfig = VARIANT_A,
config: RulesConfig = DEFAULT_RULES,
dice: Iterable[int] = (),
deck: tuple[tuple[Card, int], ...] = NO_CARDS,
board: Board | None = None,
+25 -2
View File
@@ -14,10 +14,11 @@ from dojo.bots import (
HeuristicAgent,
Profile,
RandomAgent,
series_probability,
win_probability,
)
from dojo.engine import Agent, FightView, Game, RandomSource
from dojo.rules import VARIANT_A, VARIANT_B, Card, RulesConfig
from dojo.rules import DEFAULT_RULES, Card, RulesConfig
ALLENAMENTO = Pos(0, 5)
FATICA = Pos(3, 6)
@@ -30,6 +31,22 @@ def test_win_probability_counts_senshu_and_bonuses() -> None:
assert win_probability(0, 0) == pytest.approx(21 / 36)
assert win_probability(2, 0) == pytest.approx(30 / 36)
assert win_probability(0, 2) == pytest.approx(10 / 36)
assert win_probability(0, 0, senshu=False) == pytest.approx(15 / 36)
def test_series_probability_is_a_best_of_n() -> None:
assert series_probability(0.3, 1) == pytest.approx(0.3)
assert series_probability(0.5, 3) == pytest.approx(0.5)
assert series_probability(0.6, 3) == pytest.approx(0.6**2 * (3 - 2 * 0.6))
def test_kiai_that_only_ties_the_exam_is_kept_when_ties_go_to_the_defender() -> None:
game, _, _ = make_game(config=RulesConfig(exam_ties_to_defender=True))
game.players[0].tecnica = Card.KIAI
exam = FightView(0, 1, True, 3, 5, played=())
assert not HeuristicAgent().play_tecnica(game.view(0), exam)
assert HeuristicAgent().play_tecnica(game.view(0), fight(3, 5))
def test_heuristic_prefers_allenamento_to_fatica() -> None:
@@ -100,7 +117,13 @@ def play(board: Board, config: RulesConfig, seed: int) -> Game:
return game
@pytest.mark.parametrize("config", [VARIANT_A, VARIANT_B])
@pytest.mark.parametrize(
"config",
[
DEFAULT_RULES,
RulesConfig(exam_ties_to_defender=True, exam_defender_black=True, exam_rounds=3),
],
)
def test_bots_play_full_games_reproducibly(board: Board, config: RulesConfig) -> None:
first, again = play(board, config, seed=7), play(board, config, seed=7)
+6 -4
View File
@@ -62,19 +62,21 @@ 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"]
argv = ["sim", "--games", "2", "--exam", "c", "none", "--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
assert "== exam:none p3 kiai+2 start0 balanced,farmer,aggressive (2 games" in output
assert "== exam:c 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"]])
@pytest.mark.parametrize(
"bad", [["--lineup", "balanced,cheater"], ["--games", "0"], ["--exam", "b"]]
)
def test_sim_rejects_bad_arguments(bad: list[str], capsys: pytest.CaptureFixture[str]) -> None:
with pytest.raises(SystemExit) as exit_info:
main(["sim", *bad])
+89 -31
View File
@@ -22,7 +22,7 @@ from dojo.events import (
TrackChanged,
TurnSkipped,
)
from dojo.rules import VARIANT_A, VARIANT_B, Card
from dojo.rules import BLACK, DEFAULT_RULES, Card, RulesConfig
START_0 = Pos(0, 0)
START_1 = Pos(0, 13)
@@ -58,7 +58,7 @@ def test_seats_up_to_four_take_corners_clockwise() -> None:
def test_players_start_in_their_own_safe_room() -> None:
from dataclasses import replace
game, _, _ = make_game(players=5, config=replace(VARIANT_A, start_position=3))
game, _, _ = make_game(players=5, config=replace(DEFAULT_RULES, start_position=3))
for player in game.players:
assert player.position in game.board.safe_room(player.corner)
@@ -104,16 +104,15 @@ def test_upset_swaps_a_belt() -> None:
assert (game.players[0].track, game.players[1].track) == (3, 3)
@pytest.mark.parametrize(("config", "gain"), [(VARIANT_A, 0), (VARIANT_B, 1)])
def test_higher_belt_beating_lower_gains_the_variant_amount(config, gain: int) -> None:
game, agents, _ = make_game(config=config, dice=[1, 1, 1, 2])
def test_higher_belt_beating_lower_gains_nothing() -> None:
game, agents, _ = make_game(dice=[1, 1, 1, 2])
put(game, 0, Pos(4, 3), track=6)
put(game, 1, Pos(4, 5), track=0)
turn(game, agents, 0, Pos(4, 5))
assert only_fight(game).kind is FightKind.EXPECTED
assert game.players[0].track == 6 + gain
assert game.players[0].track == 6
assert game.players[1].track == 0
assert game.players[1].position == START_1
@@ -311,18 +310,8 @@ def test_allenamento_and_fatica() -> None:
assert game.players[0].track == 3
@pytest.mark.parametrize(("before", "after"), [(14, 14), (13, 14), (15, 16), (17, 17)])
def test_variant_b_squares_cannot_make_a_player_brown(before: int, after: int) -> None:
game, agents, _ = make_game(config=VARIANT_B, dice=[1, 1])
put(game, 0, Pos(0, 3), track=before)
turn(game, agents, 0, Pos(0, 5))
assert game.players[0].track == after
def test_variant_a_squares_can_make_a_player_brown() -> None:
game, agents, _ = make_game(config=VARIANT_A, dice=[1, 1])
def test_squares_can_make_a_player_brown() -> None:
game, agents, _ = make_game(dice=[1, 1])
put(game, 0, Pos(0, 3), track=14)
turn(game, agents, 0, Pos(0, 5))
@@ -330,16 +319,6 @@ def test_variant_a_squares_can_make_a_player_brown() -> None:
assert game.players[0].track == 15
def test_variant_b_kumite_can_make_a_player_brown() -> None:
game, agents, _ = make_game(config=VARIANT_B, dice=[1, 1, 3, 3])
put(game, 0, Pos(4, 3), track=14)
put(game, 1, Pos(4, 5), track=12)
turn(game, agents, 0, Pos(4, 5))
assert game.players[0].track == 17
def test_passo_falso_returns_to_the_move_start_and_fights_stand() -> None:
game, agents, _ = make_game(dice=[1, 1, 6, 1])
put(game, 0, Pos(4, 3), track=3)
@@ -545,8 +524,10 @@ def test_only_one_tecnica_can_be_held(keep_drawn: bool, kept: Card) -> None:
# Central Dojo and Final Exam --------------------------------------------------------------
def dojo_game(dice: list[int], players: int = 3, **tracks: int) -> tuple[Game, list]:
game, agents, _ = make_game(players=players, dice=dice)
def dojo_game(
dice: list[int], players: int = 3, config: RulesConfig = DEFAULT_RULES, **tracks: int
) -> tuple[Game, list]:
game, agents, _ = make_game(players=players, config=config, dice=dice)
put(game, 0, Pos(4, 7), track=15)
for name, track in tracks.items():
game.players[int(name[1:])].track = track
@@ -614,6 +595,83 @@ def test_meditating_players_cannot_defend() -> None:
assert events_of(game, ExamTaken)[0].defender == 1
@pytest.mark.parametrize(("ties_to_defender", "passed"), [(False, True), (True, False)])
def test_exam_ties_can_go_to_the_defender(ties_to_defender: bool, passed: bool) -> None:
config = RulesConfig(exam_ties_to_defender=ties_to_defender)
# Black belt defender: +1, so 4 vs 3 is a tie.
game, agents = dojo_game([1, 1, 4, 3], config=config, p1=9, p2=15)
turn(game, agents, 0, Pos(6, 7))
assert events_of(game, ExamTaken)[0].passed is passed
def test_exam_ties_go_to_the_defender_only_in_the_exam() -> None:
game, agents, _ = make_game(config=RulesConfig(exam_ties_to_defender=True), dice=[1, 1, 3, 3])
put(game, 0, Pos(4, 3), track=3)
put(game, 1, Pos(4, 5), track=3)
turn(game, agents, 0, Pos(4, 5))
assert only_fight(game).attacker_won
@pytest.mark.parametrize(("black", "passed"), [(False, True), (True, False)])
def test_exam_defender_may_fight_as_a_black_belt(black: bool, passed: bool) -> None:
config = RulesConfig(exam_defender_black=black)
game, agents = dojo_game([1, 1, 4, 4], config=config, p1=9, p2=12)
turn(game, agents, 0, Pos(6, 7))
fight = only_fight(game)
assert fight.defender_belt == (BLACK if black else 4)
assert (fight.attacker_total, fight.defender_total) == ((4, 5) if black else (5, 4))
assert events_of(game, ExamTaken)[0].passed is passed
def test_best_of_three_exam_is_passed_on_two_rounds() -> None:
game, agents = dojo_game(
[1, 1, 6, 1, 1, 6, 6, 1], config=RulesConfig(exam_rounds=3), p1=9, p2=15
)
turn(game, agents, 0, Pos(6, 7))
rounds = events_of(game, KumiteFought)
assert [r.attacker_won for r in rounds] == [True, False, True]
assert all(r.exam for r in rounds)
assert events_of(game, ExamTaken) == [ExamTaken(challenger=0, defender=2, passed=True)]
assert game.winner == 0
def test_best_of_three_exam_stops_once_decided() -> None:
game, agents = dojo_game([1, 1, 1, 6, 1, 6], config=RulesConfig(exam_rounds=3), p1=9, p2=15)
turn(game, agents, 0, Pos(6, 7))
assert len(events_of(game, KumiteFought)) == 2
assert game.winner is None
assert game.players[0].track == 12
assert game.players[0].position == START_0
def test_uke_played_in_any_exam_round_saves_the_challenger() -> None:
game, agents = dojo_game([1, 1, 1, 6, 1, 6], config=RulesConfig(exam_rounds=3), p1=9, p2=15)
game.players[0].tecnica = Card.UKE
agents[0].plays = frozenset({Card.UKE})
turn(game, agents, 0, Pos(6, 7))
assert events_of(game, KumiteFought)[0].cards == ((0, Card.UKE),)
assert game.players[0].position == Pos(5, 7)
assert game.players[0].track == 12
@pytest.mark.parametrize("rounds", [0, 2])
def test_exam_rounds_must_be_odd(rounds: int) -> None:
with pytest.raises(ValueError, match="odd"):
RulesConfig(exam_rounds=rounds)
def test_kumite_won_mid_move_can_open_the_dojo() -> None:
game, agents, dice = make_game(dice=[1, 1, 3, 3, 6, 1])
put(game, 0, Pos(4, 7), track=14)
@@ -635,7 +693,7 @@ def test_a_game_runs_to_completion() -> None:
from scripted import ScriptedAgent, fixture_board
config = replace(VARIANT_A, max_rounds=30)
config = replace(DEFAULT_RULES, max_rounds=30)
game = Game(fixture_board(), config, [ScriptedAgent() for _ in range(4)], RandomSource(7))
game.run()
+64 -29
View File
@@ -12,26 +12,28 @@ from dojo.events import (
Moved,
Source,
TrackChanged,
TurnSkipped,
TurnStarted,
)
from dojo.metrics import KUMITE_SECONDS, TURN_SECONDS, GameMetrics, measure, summarize
from dojo.rules import VARIANT_A
from dojo.rules import DEFAULT_RULES
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 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]) -> GameMetrics:
return measure(events, VARIANT_A, CORNERS, BOTS, scenario="test", seed=3)
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_leads() -> None:
def test_measure_counts_fights_income_and_exams() -> None:
metrics = run(
[
TurnStarted(1, 0, 7),
@@ -47,6 +49,8 @@ def test_measure_counts_fights_income_and_leads() -> None:
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),
@@ -55,41 +59,72 @@ def test_measure_counts_fights_income_and_leads() -> None:
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.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)
# 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))
# Both Exam rounds count as fights for the time estimate.
assert metrics.minutes == pytest.approx(round((3 * TURN_SECONDS + 5 * KUMITE_SECONDS) / 60, 1))
def test_measure_tracks_turns_started_on_14() -> None:
def test_standings_are_sampled_at_turn_ends() -> None:
metrics = run(
[
TrackChanged(0, 0, 14, Source.CARD),
TurnStarted(1, 0, 7),
TrackChanged(0, 0, 9, Source.CARD), # end of turn 1: (9, 0, 0), leader 0
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),
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 6 behind): players 1 and 2 after turn 1, player 2 after turn 2.
assert metrics.out_of_contention_share == pytest.approx(3 / 12)
assert metrics.worst_out_of_contention_share == pytest.approx(0.5)
# 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.turns_on_14 == 3
assert metrics.longest_14_streak == 2
assert metrics.winner_seat is None
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([GameOver(None, 10)])
base = run([TurnStarted(1, 0, 7), GameOver(None, 10)])
rows = [
replace(base, winner_seat=0, winner_bot="balanced"),
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"),
base,
+35 -13
View File
@@ -2,6 +2,7 @@ from pathlib import Path
import pytest
from dojo.rules import RulesConfig
from dojo.sim import BoardFiles, Scenario, matrix, run
BOARD = Path(__file__).parent.parent / "board"
@@ -10,16 +11,35 @@ LINEUP = ("balanced", "random")
def test_matrix_crosses_every_axis() -> None:
scenarios = matrix(["A", "B"], [3, 4], [2, 1], [0], [LINEUP])
scenarios = matrix(["none", "acd"], [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
assert scenarios[0].name == "exam:none p3 kiai+2 start0 balanced,random"
assert scenarios[0].config == RulesConfig(exam_defender_black=False)
assert scenarios[-1].config == RulesConfig(
exam_ties_to_defender=True, exam_defender_black=True, exam_rounds=3, kiai_bonus=1
)
@pytest.mark.parametrize(
("exam", "ties", "black", "rounds"),
[
("none", False, False, 1),
("a", True, False, 1),
("c", False, True, 1),
("d", False, False, 3),
("cd", False, True, 3),
],
)
def test_exam_options_map_to_rules(exam: str, ties: bool, black: bool, rounds: int) -> None:
config = Scenario(exam, 4, 2, 0, LINEUP).config
assert (config.exam_ties_to_defender, config.exam_defender_black) == (ties, black)
assert config.exam_rounds == rounds
def test_lineup_fills_the_seats_and_rotates_each_game() -> None:
scenario = Scenario("A", 3, 2, 0, LINEUP)
scenario = Scenario("none", 3, 2, 0, LINEUP)
assert scenario.seating(0) == ("balanced", "random", "balanced")
assert scenario.seating(1) == ("random", "balanced", "balanced")
@@ -27,23 +47,25 @@ def test_lineup_fills_the_seats_and_rotates_each_game() -> None:
@pytest.mark.parametrize(
("variant", "players", "lineup", "message"),
("exam", "players", "lineup", "message"),
[
("C", 4, LINEUP, "unknown variant"),
("A", 4, ("balanced", "cheater"), "unknown bots"),
("A", 4, (), "unknown bots"),
("A", 9, LINEUP, "supported range"),
("b", 4, LINEUP, "unknown Exam options"),
("aa", 4, LINEUP, "unknown Exam options"),
("", 4, LINEUP, "unknown Exam options"),
("none", 4, ("balanced", "cheater"), "unknown bots"),
("none", 4, (), "unknown bots"),
("none", 9, LINEUP, "supported range"),
],
)
def test_invalid_scenarios_are_rejected(
variant: str, players: int, lineup: tuple[str, ...], message: str
exam: str, players: int, lineup: tuple[str, ...], message: str
) -> None:
with pytest.raises(ValueError, match=message):
Scenario(variant, players, 2, 0, lineup)
Scenario(exam, players, 2, 0, lineup)
def test_runs_are_reproducible_and_independent_of_worker_count() -> None:
scenarios = matrix(["A", "B"], [3], [2], [0], [LINEUP])
scenarios = matrix(["none", "acd"], [3], [2], [0], [LINEUP])
serial = run(FILES, scenarios, games=2, seed=5, jobs=1)
parallel = run(FILES, scenarios, games=2, seed=5, jobs=2)