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:
+5
-1
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user