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
+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()