Rules v0.8: Fatica on crossing, no Kumite in round 1, board draft 2

- Fatica costs a stripe for every Fatica walked onto; a route whose Fatica
  drops a Brown player below 15 no longer enters the Dojo
- No Kumite in the first round: opponents are treated like meditating pawns
- Board draft 2: two Fatica tolls per quadrant, Allenamento moved to the ring
- Casual bot, --summary-csv, per-seat breakdown and move-options metric
- Out of contention measured at 3 belts
- Plan: Phase 6 baselines, decisions D34-D43

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 15:59:45 +02:00
parent 4998931cb0
commit bbc95b6722
18 changed files with 566 additions and 80 deletions
+5 -1
View File
@@ -96,10 +96,14 @@ def make_game(
deck: tuple[tuple[Card, int], ...] = NO_CARDS,
board: Board | None = None,
) -> tuple[Game, list[ScriptedAgent], ScriptedDice]:
"""A game on the fixture board with an empty deck unless ``deck`` is given."""
"""A game on the fixture board with an empty deck unless ``deck`` is given.
It starts in the first round that allows Kumite; tests of round 1 set ``game.round``.
"""
agents = [ScriptedAgent() for _ in range(players)]
randomness = ScriptedDice(dice)
game = Game(board or fixture_board(), replace(config, deck=deck), agents, randomness)
game.round = config.first_kumite_round
return game, agents, randomness
+43
View File
@@ -11,6 +11,7 @@ from dojo.bots import (
BALANCED,
FARMER,
PROFILES,
CasualAgent,
HeuristicAgent,
Profile,
RandomAgent,
@@ -57,6 +58,26 @@ def test_heuristic_prefers_allenamento_to_fatica() -> None:
assert choice == (ALLENAMENTO,)
def test_heuristic_walks_around_fatica() -> None:
game, _, _ = make_game()
put(game, 0, Pos(2, 6), track=3)
paths = [(FATICA, Pos(3, 7)), (Pos(2, 7), Pos(3, 7))]
assert HeuristicAgent().choose_path(game.view(0), paths) == paths[1]
def test_heuristic_sees_no_opponent_to_fight_in_round_one() -> None:
game, _, _ = make_game()
game.round = 1
put(game, 0, Pos(4, 3), track=3)
put(game, 1, TATAMI, track=3)
# In round 2 the aggressive bot picks the fight (test_profiles_differ_on_an_even_fight).
choice = HeuristicAgent(AGGRESSIVE).choose_path(game.view(0), [(Pos(5, 3),), (TATAMI,)])
assert choice == (Pos(5, 3),)
def test_brown_heuristic_takes_the_exam_when_it_can() -> None:
game, _, _ = make_game()
put(game, 0, DOJO_ENTRANCE, track=15)
@@ -108,6 +129,28 @@ def test_heuristic_keeps_the_tecnica_its_profile_ranks_first() -> None:
assert HeuristicAgent(FARMER).keep_tecnica(game.view(0), Card.UKE, Card.KIAI) is Card.UKE
def test_casual_bot_compares_every_move_when_offered_few() -> None:
game, _, _ = make_game()
choice = CasualAgent(random.Random(0)).choose_path(game.view(0), [(FATICA,), (ALLENAMENTO,)])
assert choice == (ALLENAMENTO,)
def test_casual_bot_compares_only_a_sample_of_many_moves() -> None:
game, _, _ = make_game()
paths = [(Pos(4, col),) for col in range(8)]
choice = CasualAgent(random.Random(3), sample=1).choose_path(game.view(0), paths)
assert choice == random.Random(3).sample(paths, 1)[0]
def test_casual_bot_needs_a_sample() -> None:
with pytest.raises(ValueError, match="sample size 0"):
CasualAgent(random.Random(0), sample=0)
def play(board: Board, config: RulesConfig, seed: int) -> Game:
rng = random.Random(seed)
agents: list[Agent] = [HeuristicAgent(p) for p in PROFILES.values()]
+17
View File
@@ -1,3 +1,4 @@
import csv
from pathlib import Path
import pytest
@@ -74,6 +75,22 @@ def test_sim_prints_a_summary_per_scenario_and_writes_games(
assert len(rows) == 1 + 4
def test_sim_writes_one_summary_row_per_scenario(tmp_path: Path) -> None:
path = tmp_path / "summary.csv"
argv = ["sim", "--games", "2", "--players", "3", "4", "--lineup", "casual", "--jobs", "1"]
assert main([*argv, "--summary-csv", str(path)]) == EXIT_OK
with path.open(encoding="utf-8", newline="") as file:
rows = list(csv.DictReader(file))
assert [row["scenario"] for row in rows] == [
"exam:c p3 kiai+2 start0 casual",
"exam:c p4 kiai+2 start0 casual",
]
assert {"rounds_mean", "square_gain_per_turn", "win_rate_casual"} <= rows[0].keys()
assert (rows[0]["seat3_win_rate"], rows[1]["seat3_win_rate"] != "") == ("", True)
@pytest.mark.parametrize(
"bad", [["--lineup", "balanced,cheater"], ["--games", "0"], ["--exam", "b"]]
)
+112 -1
View File
@@ -17,6 +17,7 @@ from dojo.events import (
GameOver,
KumiteFought,
Moved,
MoveOptions,
SentToStart,
Source,
TrackChanged,
@@ -159,6 +160,21 @@ def test_winning_attacker_continues_its_move() -> None:
]
def test_every_path_choice_records_the_squares_it_could_end_on() -> None:
game, agents, _ = make_game(dice=[1, 2, 6, 1])
put(game, 0, Pos(4, 2), track=3)
put(game, 1, Pos(4, 3), track=3)
turn(game, agents, 0, Pos(4, 5), Pos(4, 5))
offered = agents[0].offered_paths
assert len(offered) == 2
assert [e.ends for e in events_of(game, MoveOptions)] == [
tuple(game.board.code(end) for end in sorted({path[-1] for path in paths}))
for paths in offered
]
def test_losing_attacker_stops_even_with_steps_left() -> None:
game, agents, dice = make_game(dice=[1, 2, 1, 6])
put(game, 0, Pos(4, 2), track=3)
@@ -182,6 +198,59 @@ def test_no_kumite_inside_a_safe_room() -> None:
assert game.players[0].position == game.players[1].position == Pos(1, 1)
def test_no_kumite_in_round_one_opponents_are_passed_through() -> None:
game, agents, _ = make_game(dice=[1, 2])
game.round = 1
put(game, 0, Pos(4, 2), track=3)
put(game, 1, Pos(4, 3), track=3)
turn(game, agents, 0, Pos(4, 5))
assert not events_of(game, KumiteFought)
assert (game.players[0].position, game.players[1].position) == (Pos(4, 5), Pos(4, 3))
def test_no_kumite_in_round_one_a_move_cannot_end_on_an_opponent() -> None:
game, agents, _ = make_game(dice=[1, 1])
game.round = 1
put(game, 0, Pos(4, 2))
put(game, 1, Pos(4, 4))
turn(game, agents, 0, Pos(4, 4))
assert not events_of(game, KumiteFought)
assert game.players[0].position != Pos(4, 4)
assert len(agents[0].offered_paths) == 2 # The move, then the Meditazione extra step.
def test_no_kumite_in_round_one_sfida_has_no_target() -> None:
game, agents, _ = make_game(dice=[1, 1])
game.round = 1
put(game, 0, Pos(5, 0))
put(game, 1, Pos(4, 4))
turn(game, agents, 0, Pos(5, 2))
assert not events_of(game, KumiteFought)
def test_no_kumite_in_round_one_an_occupied_tunnel_exit_cannot_be_used() -> None:
game, agents, _ = make_game(dice=[1, 1])
game.round = 1
put(game, 0, NEBBIA)
put(game, 1, Pos(3, 4))
agents[0].use_tunnels = True
turn(game, agents, 0, Pos(1, 6))
assert game.players[0].position == Pos(1, 6)
def test_first_kumite_round_must_be_positive() -> None:
with pytest.raises(ValueError, match="first_kumite_round"):
RulesConfig(first_kumite_round=0)
# Tecniche ---------------------------------------------------------------------------------
@@ -289,10 +358,11 @@ def test_ending_on_a_meditating_pawn_takes_one_extra_step() -> None:
def test_meditating_players_turn_is_skipped_once() -> None:
game, agents, _ = make_game()
game.players[1].meditating = True
start = game.round
turn(game, agents, 1)
assert events_of(game, TurnSkipped) == [TurnSkipped(round=1, player=1)]
assert events_of(game, TurnSkipped) == [TurnSkipped(round=start, player=1)]
assert not game.players[1].meditating
@@ -310,6 +380,47 @@ def test_allenamento_and_fatica() -> None:
assert game.players[0].track == 3
def test_fatica_costs_a_stripe_when_walked_over() -> None:
game, agents, _ = make_game(dice=[1, 1])
put(game, 0, Pos(3, 5), track=3)
turn(game, agents, 0, (Pos(3, 6), Pos(3, 7)))
assert game.players[0].track == 2
def test_fatica_walked_over_is_free_when_only_stopping_costs() -> None:
game, agents, _ = make_game(dice=[1, 1], config=RulesConfig(fatica_on_crossing=False))
put(game, 0, Pos(3, 5), track=3)
turn(game, agents, 0, (Pos(3, 6), Pos(3, 7)))
assert game.players[0].track == 3
def test_routes_to_one_square_are_offered_apart_when_one_crosses_fatica() -> None:
game, agents, _ = make_game(dice=[1, 1])
put(game, 0, Pos(2, 6))
turn(game, agents, 0, (Pos(2, 7), Pos(3, 7)))
to_corner = [path for path in agents[0].offered_paths[0] if path[-1] == Pos(3, 7)]
assert sorted(to_corner) == [(Pos(2, 7), Pos(3, 7)), (Pos(3, 6), Pos(3, 7))]
assert game.players[0].track == 0
def test_fatica_that_ends_brown_closes_the_dojo_for_that_route() -> None:
game, agents, _ = make_game(dice=[2, 3])
put(game, 0, Pos(3, 5), track=15)
agents[0].ends.append(Pos(3, 4))
game.play_turn()
into_dojo = [path for path in agents[0].offered_paths[0] if path[-1] == Pos(6, 7)]
assert into_dojo
assert all(Pos(3, 6) not in path for path in into_dojo)
def test_squares_can_make_a_player_brown() -> None:
game, agents, _ = make_game(dice=[1, 1])
put(game, 0, Pos(0, 3), track=14)
+16 -4
View File
@@ -10,6 +10,7 @@ from dojo.events import (
GameOver,
KumiteFought,
Moved,
MoveOptions,
Source,
TrackChanged,
TurnSkipped,
@@ -37,9 +38,11 @@ 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),
@@ -65,6 +68,10 @@ def test_measure_counts_fights_income_and_exams() -> None:
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))
@@ -94,9 +101,9 @@ def test_standings_are_sampled_at_turn_ends() -> None:
# 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)
# 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
@@ -127,13 +134,18 @@ def test_summarize_splits_wins_by_seat_and_bot() -> None:
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,
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