Initial commit, with rules 0.5 and basic bots

This commit is contained in:
2026-09-26 16:01:46 +02:00
commit 0aaf113fc3
32 changed files with 4206 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
from pathlib import Path
import pytest
from dojo.board import Board, build_board
from dojo.grid import parse_grid
FIXTURES = Path(__file__).parent / "fixtures"
@pytest.fixture
def quadrant_text() -> str:
return (FIXTURES / "quadrant_valid.txt").read_text(encoding="utf-8")
@pytest.fixture
def board(quadrant_text: str) -> Board:
return build_board(parse_grid(quadrant_text))
+18
View File
@@ -0,0 +1,18 @@
# Minimal valid quadrant used by the tests. Mostly open, so the Tatami share is high.
+---+---+---+---+---+---+---+---+
| S S S | . . A . .
+ + + + + + + + +
| S S S . ? . 1 .
+ + + + + + + + +
| S S S | A . . . .
+---+ +---+ + + + + +
| . . . . 1 . F .
+ + + + + + + + +
| . P . . . . . .
+ + + + + + + + +
| . . K . . . . .
+ + + + + + +---+ +
| . . . . . . | D D
+ + + + + + + + +
| . . . . . . | D D
+ + + + + + + + +
+36
View File
@@ -0,0 +1,36 @@
"""Helpers to mutate a quadrant fixture at grid coordinates."""
def grid_lines(text: str) -> list[str]:
"""Grid lines without comments, right-padded to full width, as the parser sees them."""
lines = [line for line in text.splitlines() if line.strip() and not line.startswith("#")]
width = len(lines[0])
return [line.ljust(width) for line in lines]
def edit(text: str, line: int, column: int, replacement: str) -> str:
"""Overwrite characters of grid line ``line`` (0-based, comments excluded) at ``column``."""
lines = grid_lines(text)
target = lines[line]
lines[line] = target[:column] + replacement + target[column + len(replacement) :]
return "\n".join(lines) + "\n"
def cell_line(row: int) -> int:
return 2 * row + 1
def wall_line_above(row: int) -> int:
return 2 * row
def cell_column(col: int) -> int:
return 4 * col + 2
def wall_column_left_of(col: int) -> int:
return 4 * col
def h_segment_column(col: int) -> int:
return 4 * col + 1
+116
View File
@@ -0,0 +1,116 @@
"""Deterministic dice and agents for engine tests."""
from collections import deque
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field, replace
from pathlib import Path
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
FIXTURE = Path(__file__).parent / "fixtures" / "quadrant_valid.txt"
NO_CARDS: tuple[tuple[Card, int], ...] = ()
class ScriptedDice:
"""Rolls come from a fixed list; running out means the engine rolled more than expected."""
def __init__(self, rolls: Iterable[int] = ()) -> None:
self.rolls = deque(rolls)
def d6(self) -> int:
if not self.rolls:
raise AssertionError("unexpected die roll")
return self.rolls.popleft()
def shuffle(self, cards: list[Card]) -> None:
"""Keep the given order, so tests control the deck."""
@dataclass
class ScriptedAgent:
# Each entry picks the offered path ending on that square, or exactly that path.
ends: deque[Pos | Walk] = field(default_factory=deque)
start_square: Pos | None = None
use_tunnels: bool = False
tunnel_destination: Pos | None = None
sfida_target: int | None = None
irimi_target: int | None = None
exam_defender: int | None = None
keep_drawn: bool = False
plays: frozenset[Card] = frozenset()
fight_views: list[FightView] = field(default_factory=list)
offered_paths: list[Sequence[Walk]] = field(default_factory=list)
path_views: list[GameView] = field(default_factory=list)
def place_pawn(self, view: GameView, squares: Sequence[Pos]) -> Pos:
if self.start_square is not None and self.start_square in squares:
return self.start_square
return squares[0]
def choose_path(self, view: GameView, paths: Sequence[Walk]) -> Walk:
self.offered_paths.append(paths)
self.path_views.append(view)
if not self.ends:
return paths[0]
wanted = self.ends.popleft()
for path in paths:
if path == wanted or (isinstance(wanted, Pos) and path[-1] == wanted):
return path
raise AssertionError(f"no offered path matches {wanted}")
def use_tunnel(self, view: GameView, exit: Pos) -> bool:
return self.use_tunnels
def choose_tunnel_destination(self, view: GameView, tunnels: Sequence[Pos]) -> Pos:
return self.tunnel_destination if self.tunnel_destination is not None else tunnels[0]
def choose_sfida_target(self, view: GameView, targets: Sequence[int]) -> int:
return self.sfida_target if self.sfida_target is not None else targets[0]
def choose_irimi_target(self, view: GameView, targets: Sequence[int]) -> int:
return self.irimi_target if self.irimi_target is not None else targets[0]
def choose_exam_defender(self, view: GameView, candidates: Sequence[int]) -> int:
return self.exam_defender if self.exam_defender is not None else candidates[0]
def keep_tecnica(self, view: GameView, held: Card, drawn: Card) -> Card:
return drawn if self.keep_drawn else held
def play_tecnica(self, view: GameView, fight: FightView) -> bool:
self.fight_views.append(fight)
return view.tecnica in self.plays
def fixture_board() -> Board:
return build_board(parse_grid(FIXTURE.read_text(encoding="utf-8")))
def make_game(
players: int = 2,
config: RulesConfig = VARIANT_A,
dice: Iterable[int] = (),
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."""
agents = [ScriptedAgent() for _ in range(players)]
randomness = ScriptedDice(dice)
game = Game(board or fixture_board(), replace(config, deck=deck), agents, randomness)
return game, agents, randomness
def put(game: Game, player: int, position: Pos, track: int | None = None) -> None:
game.players[player].position = position
if track is not None:
game.players[player].track = track
def turn(game: Game, agents: list[ScriptedAgent], player: int, *ends: Pos | Walk) -> None:
"""Play ``player``'s turn, choosing paths by ``ends`` in order (see ``ScriptedAgent``)."""
game.current = player
agents[player].ends.extend(ends)
game.play_turn()
+190
View File
@@ -0,0 +1,190 @@
import pytest
from gridedit import (
cell_column,
cell_line,
edit,
h_segment_column,
wall_column_left_of,
wall_line_above,
)
from dojo.board import (
BOARD_SIZE,
Board,
BoardError,
Pos,
build_board,
edge,
load_board,
parse_legend,
quadrant_of,
rotate,
)
from dojo.grid import parse_grid, render_grid
def build(text: str, offsets: dict[str, int] | None = None) -> Board:
return build_board(parse_grid(text), offsets)
def build_problems(text: str, offsets: dict[str, int] | None = None) -> list[str]:
with pytest.raises(BoardError) as raised:
build(text, offsets)
return raised.value.problems
def test_rotation_is_clockwise_around_the_centre() -> None:
corner = Pos(0, 0)
assert [rotate(corner, k) for k in range(4)] == [(0, 0), (0, 15), (15, 15), (15, 0)]
assert [quadrant_of(rotate(Pos(3, 5), k)) for k in range(4)] == [0, 1, 2, 3]
assert rotate(Pos(3, 5), 4) == Pos(3, 5)
def test_board_is_sixteen_by_sixteen_with_central_dojo(board: Board) -> None:
assert board.size == BOARD_SIZE
assert board.dojo == {Pos(r, c) for r in range(6, 10) for c in range(6, 10)}
def test_every_cell_matches_its_rotated_source(board: Board, quadrant_text: str) -> None:
quadrant = parse_grid(quadrant_text)
for r in range(8):
for c in range(8):
for k in range(4):
assert board.code(rotate(Pos(r, c), k)) == quadrant.cells[r][c]
def test_walls_are_rotated(board: Board) -> None:
safe_room_east_wall = edge(Pos(0, 2), Pos(0, 3))
for k in range(4):
a, b = safe_room_east_wall
assert edge(rotate(a, k), rotate(b, k)) in board.walls
assert edge(Pos(1, 2), Pos(1, 3)) not in board.walls
def test_neighbours_respect_walls_and_border(board: Board) -> None:
assert set(board.neighbours(Pos(0, 0))) == {Pos(0, 1), Pos(1, 0)}
assert Pos(0, 3) not in set(board.neighbours(Pos(0, 2)))
assert Pos(1, 3) in set(board.neighbours(Pos(1, 2)))
def test_seams_join_neighbouring_quadrants(board: Board) -> None:
assert Pos(0, 8) in set(board.neighbours(Pos(0, 7)))
assert Pos(8, 0) in set(board.neighbours(Pos(7, 0)))
def test_to_grid_round_trips_through_render(board: Board) -> None:
grid = board.to_grid()
assert parse_grid(render_grid(grid)) == grid
def test_tunnels_pair_within_the_quadrant_by_default(board: Board) -> None:
a, b = Pos(1, 6), Pos(3, 4)
for k in range(4):
assert board.tunnels[rotate(a, k)] == rotate(b, k)
assert board.tunnels[rotate(b, k)] == rotate(a, k)
assert len(board.tunnels) == 8
@pytest.mark.parametrize("offset", [1, 2, 3])
def test_tunnel_offset_links_the_first_cell_to_a_later_quadrant(
quadrant_text: str, offset: int
) -> None:
board = build(quadrant_text, {"1": offset})
a, b = Pos(1, 6), Pos(3, 4)
for k in range(4):
assert board.tunnels[rotate(a, k)] == rotate(b, k + offset)
assert board.tunnels[rotate(b, k + offset)] == rotate(a, k)
assert all(board.tunnels[board.tunnels[p]] == p for p in board.tunnels)
def test_quadrant_must_be_eight_by_eight() -> None:
small = "+---+\n| D |\n+---+\n"
assert build_problems(small) == ["quadrant must be 8x8, got 1x1"]
def test_unknown_cell_code(quadrant_text: str) -> None:
text = edit(quadrant_text, cell_line(4), cell_column(4), "X")
assert "cell (4, 4): unknown code 'X'" in build_problems(text)
def test_outer_border_must_be_walled(quadrant_text: str) -> None:
open_top = edit(quadrant_text, wall_line_above(0), h_segment_column(4), " ")
open_left = edit(quadrant_text, cell_line(4), wall_column_left_of(0), " ")
assert any("top border" in p for p in build_problems(open_top))
assert any("left border" in p for p in build_problems(open_left))
def test_seams_must_match(quadrant_text: str) -> None:
text = edit(quadrant_text, cell_line(2), wall_column_left_of(8), "|")
assert any("seam mismatch: right border at row 2" in p for p in build_problems(text))
def test_matching_seam_walls_are_accepted(quadrant_text: str) -> None:
text = edit(quadrant_text, cell_line(2), wall_column_left_of(8), "|")
text = edit(text, wall_line_above(8), h_segment_column(2), "---")
board = build(text)
assert edge(Pos(2, 7), Pos(2, 8)) in board.walls
assert edge(Pos(7, 2), Pos(8, 2)) in board.walls
def test_dojo_cells_are_fixed(quadrant_text: str) -> None:
text = edit(quadrant_text, cell_line(5), cell_column(7), "D")
assert any("Dojo cells must be exactly" in p for p in build_problems(text))
def test_tunnel_needs_exactly_two_cells(quadrant_text: str) -> None:
text = edit(quadrant_text, cell_line(4), cell_column(4), "1")
assert "tunnel '1' must appear on exactly 2 cells, found 3" in build_problems(text)
def test_legend_offset_must_be_valid(quadrant_text: str) -> None:
problems = build_problems(quadrant_text, {"1": 4, "2": 0})
assert "tunnel '1': quadrant_offset must be 0..3, got 4" in problems
assert "legend links tunnel '2', which is not on the quadrant" in problems
def test_parse_legend() -> None:
assert parse_legend({"tunnels": {"1": {"quadrant_offset": 2}}}) == {"1": 2}
assert parse_legend({}) == {}
@pytest.mark.parametrize(
("data", "message"),
[
({"extra": {}}, "unknown sections"),
({"tunnels": 3}, "must be a table"),
({"tunnels": {"a": {"quadrant_offset": 1}}}, "must be a digit"),
({"tunnels": {"1": {"offset": 1}}}, "only quadrant_offset"),
],
)
def test_parse_legend_errors(data: dict[str, object], message: str) -> None:
with pytest.raises(BoardError) as raised:
parse_legend(data)
assert any(message in p for p in raised.value.problems)
def test_load_board_reads_legend(tmp_path, quadrant_text: str) -> None:
quadrant = tmp_path / "quadrant.txt"
legend = tmp_path / "legend.toml"
quadrant.write_text(quadrant_text, encoding="utf-8")
legend.write_text("[tunnels.1]\nquadrant_offset = 1\n", encoding="utf-8")
board = load_board(quadrant, legend)
assert board.tunnels[Pos(1, 6)] == rotate(Pos(3, 4), 1)
+108
View File
@@ -0,0 +1,108 @@
"""Bot decisions on the fixture board (positions as in test_engine.py)."""
import random
import pytest
from scripted import make_game, put
from dojo.board import Board, Pos
from dojo.bots import (
AGGRESSIVE,
BALANCED,
FARMER,
PROFILES,
HeuristicAgent,
Profile,
RandomAgent,
win_probability,
)
from dojo.engine import Agent, FightView, Game, RandomSource
from dojo.rules import VARIANT_A, VARIANT_B, Card, RulesConfig
ALLENAMENTO = Pos(0, 5)
FATICA = Pos(3, 6)
TATAMI = Pos(4, 4)
DOJO_ENTRANCE = Pos(5, 7)
DOJO = Pos(6, 7)
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)
def test_heuristic_prefers_allenamento_to_fatica() -> None:
game, _, _ = make_game()
choice = HeuristicAgent().choose_path(game.view(0), [(FATICA,), (ALLENAMENTO,)])
assert choice == (ALLENAMENTO,)
def test_brown_heuristic_takes_the_exam_when_it_can() -> None:
game, _, _ = make_game()
put(game, 0, DOJO_ENTRANCE, track=15)
choice = HeuristicAgent().choose_path(game.view(0), [(ALLENAMENTO,), (DOJO,)])
assert choice == (DOJO,)
@pytest.mark.parametrize(("profile", "fights"), [(FARMER, False), (AGGRESSIVE, True)])
def test_profiles_differ_on_an_even_fight(profile: Profile, fights: bool) -> None:
game, _, _ = make_game()
put(game, 0, Pos(4, 3), track=3)
put(game, 1, TATAMI, track=3)
choice = HeuristicAgent(profile).choose_path(game.view(0), [(Pos(5, 3),), (TATAMI,)])
assert (choice == (TATAMI,)) is fights
def fight(attacker_total: int, defender_total: int) -> FightView:
return FightView(0, 1, False, attacker_total, defender_total, played=())
@pytest.mark.parametrize(
("me", "card", "totals", "plays"),
[
(0, Card.KIAI, (3, 5), True), # 5 ties 5: Senshu gives it to the attacker.
(0, Card.KIAI, (2, 5), False), # Kiai cannot turn it: keep it.
(0, Card.KIAI, (5, 5), False), # Already winning.
(1, Card.KIAI, (5, 4), True), # The defender needs to beat, not tie.
(1, Card.KIAI, (6, 4), False),
(0, Card.KAESHI, (1, 6), True), # Losing: a reroll can only help.
(1, Card.UKE, (4, 4), True),
],
)
def test_heuristic_plays_a_tecnica_only_when_losing(
me: int, card: Card, totals: tuple[int, int], plays: bool
) -> None:
game, _, _ = make_game()
game.players[me].tecnica = card
assert HeuristicAgent().play_tecnica(game.view(me), fight(*totals)) is plays
def test_heuristic_keeps_the_tecnica_its_profile_ranks_first() -> None:
game, _, _ = make_game()
assert HeuristicAgent(BALANCED).keep_tecnica(game.view(0), Card.UKE, Card.KIAI) is Card.KIAI
assert HeuristicAgent(FARMER).keep_tecnica(game.view(0), Card.UKE, Card.KIAI) is Card.UKE
def play(board: Board, config: RulesConfig, seed: int) -> Game:
rng = random.Random(seed)
agents: list[Agent] = [HeuristicAgent(p) for p in PROFILES.values()]
agents.append(RandomAgent(rng))
game = Game(board, config, agents, RandomSource(seed))
game.run()
return game
@pytest.mark.parametrize("config", [VARIANT_A, VARIANT_B])
def test_bots_play_full_games_reproducibly(board: Board, config: RulesConfig) -> None:
first, again = play(board, config, seed=7), play(board, config, seed=7)
assert first.winner is not None
assert first.events == again.events
+58
View File
@@ -0,0 +1,58 @@
from pathlib import Path
import pytest
from dojo.cli import EXIT_DESIGN_ERRORS, EXIT_FORMAT_ERRORS, EXIT_OK, main
from dojo.grid import parse_grid
VALID = Path(__file__).parent / "fixtures" / "quadrant_valid.txt"
def test_validate_valid_board(capsys: pytest.CaptureFixture[str]) -> None:
assert main(["board", "validate", "--quadrant", str(VALID)]) == EXIT_OK
output = capsys.readouterr().out
assert "Shortest walk Safe Room -> Dojo: Q0 10, Q1 10, Q2 10, Q3 10" in output
assert output.rstrip().endswith("OK")
def test_validate_reports_design_errors(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
no_entrance = VALID.read_text(encoding="utf-8").replace(
"+ + + + + + +---+ +", "+ + + + + + +---+---+"
)
quadrant = tmp_path / "quadrant.txt"
quadrant.write_text(no_entrance, encoding="utf-8")
assert main(["board", "validate", "--quadrant", str(quadrant)]) == EXIT_DESIGN_ERRORS
assert "quadrant 0 has no Dojo entrance" in capsys.readouterr().out
def test_format_errors_and_missing_files_exit_with_format_code(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
malformed = tmp_path / "bad.txt"
malformed.write_text("+---+\n| . |\n", encoding="utf-8")
assert main(["board", "validate", "--quadrant", str(malformed)]) == EXIT_FORMAT_ERRORS
assert main(["board", "validate", "--quadrant", str(tmp_path / "none")]) == EXIT_FORMAT_ERRORS
assert "odd number of lines" in capsys.readouterr().err
def test_render_writes_a_parseable_full_board(tmp_path: Path) -> None:
output = tmp_path / "board.txt"
assert main(["board", "render", "--quadrant", str(VALID), "-o", str(output)]) == EXIT_OK
grid = parse_grid(output.read_text(encoding="utf-8"))
assert (grid.height, grid.width) == (16, 16)
def test_legend_next_to_the_quadrant_is_used_by_default(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
quadrant = tmp_path / "quadrant.txt"
quadrant.write_text(VALID.read_text(encoding="utf-8"), encoding="utf-8")
(tmp_path / "legend.toml").write_text("[tunnels.9]\nquadrant_offset = 1\n", encoding="utf-8")
assert main(["board", "validate", "--quadrant", str(quadrant)]) == EXIT_FORMAT_ERRORS
assert "legend links tunnel '9'" in capsys.readouterr().err
+670
View File
@@ -0,0 +1,670 @@
"""Rules engine behaviour, one RULES.md rule per test where possible.
Positions refer to the fixture board (tests/fixtures/quadrant_valid.txt). In quadrant 0:
Allenamento (0,5); Nebbia (1,4); tunnel (1,6)<->(3,4); Fatica (3,6); Passo Falso (4,1);
Sfida (5,2); Dojo entrance (5,7)->(6,7). Player 1's Safe Room starts at (0,13).
"""
import pytest
from scripted import make_game, put, turn
from dojo.board import Pos
from dojo.engine import Game, RandomSource, seat_corners
from dojo.events import (
CardDrawn,
ExamTaken,
FightKind,
GameOver,
KumiteFought,
Moved,
SentToStart,
Source,
TrackChanged,
TurnSkipped,
)
from dojo.rules import VARIANT_A, VARIANT_B, Card
START_0 = Pos(0, 0)
START_1 = Pos(0, 13)
NEBBIA = Pos(1, 4)
def events_of[T](game: Game, kind: type[T]) -> list[T]:
return [e for e in game.events if isinstance(e, kind)]
def only_fight(game: Game) -> KumiteFought:
fights = events_of(game, KumiteFought)
assert len(fights) == 1
return fights[0]
# Setup ------------------------------------------------------------------------------------
@pytest.mark.parametrize("players", range(2, 9))
def test_corner_mates_never_play_consecutively(players: int) -> None:
corners = seat_corners(players)
assert len(corners) == players
assert all(corners[i] != corners[(i + 1) % players] for i in range(players))
assert sorted(set(corners)) == list(range(min(players, 4)))
def test_seats_up_to_four_take_corners_clockwise() -> None:
assert seat_corners(4) == (0, 1, 2, 3)
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))
for player in game.players:
assert player.position in game.board.safe_room(player.corner)
assert player.track == 3
def test_illegal_agent_choice_is_rejected() -> None:
game, agents, _ = make_game(dice=[1, 1])
agents[0].ends.append(Pos(9, 9))
with pytest.raises(AssertionError, match="no offered path"):
game.play_turn()
# Kumite -----------------------------------------------------------------------------------
def test_senshu_tie_goes_to_the_attacker_and_equal_belts_swap_a_belt() -> None:
game, agents, dice = make_game(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))
fight = only_fight(game)
assert fight.attacker_won and fight.kind is FightKind.EQUAL
assert (game.players[0].track, game.players[1].track) == (6, 0)
assert game.players[0].position == Pos(4, 5)
assert game.players[1].position == START_1
assert not dice.rolls
def test_upset_swaps_a_belt() -> None:
game, agents, _ = make_game(dice=[1, 1, 6, 3])
put(game, 0, Pos(4, 3), track=0)
put(game, 1, Pos(4, 5), track=6)
turn(game, agents, 0, Pos(4, 5))
fight = only_fight(game)
assert fight.kind is FightKind.UPSET
assert (fight.attacker_total, fight.defender_total) == (6, 5)
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])
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[1].track == 0
assert game.players[1].position == START_1
def test_belt_bonus_is_capped_at_two() -> None:
game, agents, _ = make_game(dice=[1, 1, 1, 4])
put(game, 0, Pos(4, 3), track=15)
put(game, 1, Pos(4, 5), track=0)
turn(game, agents, 0, Pos(4, 5))
fight = only_fight(game)
assert (fight.attacker_total, fight.defender_total) == (3, 4)
assert not fight.attacker_won and fight.kind is FightKind.UPSET
assert (game.players[0].track, game.players[1].track) == (12, 3)
assert game.players[0].position == START_0
def test_losing_attacker_goes_to_start_and_skips_the_square_effect() -> None:
game, agents, _ = make_game(dice=[1, 1, 1, 2])
put(game, 0, Pos(0, 3), track=3)
put(game, 1, Pos(0, 5), track=3)
turn(game, agents, 0, Pos(0, 5))
assert game.players[0].position == START_0
assert (game.players[0].track, game.players[1].track) == (0, 6)
assert game.players[1].position == Pos(0, 5)
assert not [e for e in events_of(game, TrackChanged) if e.source is Source.SQUARE]
def test_winning_attacker_continues_its_move() -> 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))
assert game.players[0].position == Pos(4, 5)
assert game.players[1].position == START_1
assert [e.path for e in events_of(game, Moved)] == [
(Pos(4, 3),),
(Pos(4, 4), Pos(4, 5)),
]
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)
put(game, 1, Pos(4, 3), track=3)
turn(game, agents, 0, Pos(4, 5))
assert game.players[0].position == START_0
assert game.players[1].position == Pos(4, 3)
assert not dice.rolls
def test_no_kumite_inside_a_safe_room() -> None:
game, agents, _ = make_game(dice=[1, 1])
put(game, 1, Pos(1, 1))
put(game, 0, Pos(1, 3))
turn(game, agents, 0, Pos(1, 1))
assert not events_of(game, KumiteFought)
assert game.players[0].position == game.players[1].position == Pos(1, 1)
# Tecniche ---------------------------------------------------------------------------------
def test_kiai_adds_to_the_total() -> None:
game, agents, _ = make_game(dice=[1, 1, 2, 3])
put(game, 0, Pos(4, 3), track=3)
put(game, 1, Pos(4, 5), track=3)
game.players[0].tecnica = Card.KIAI
agents[0].plays = frozenset({Card.KIAI})
turn(game, agents, 0, Pos(4, 5))
fight = only_fight(game)
assert (fight.attacker_total, fight.defender_total) == (4, 3)
assert fight.cards == ((0, Card.KIAI),)
assert agents[0].fight_views[0].attacker_total == 2
assert game.players[0].tecnica is None
def test_kaeshi_rerolls_the_die() -> None:
game, agents, dice = make_game(dice=[1, 1, 1, 4, 5])
put(game, 0, Pos(4, 3), track=3)
put(game, 1, Pos(4, 5), track=3)
game.players[0].tecnica = Card.KAESHI
agents[0].plays = frozenset({Card.KAESHI})
turn(game, agents, 0, Pos(4, 5))
assert only_fight(game).attacker_total == 5
assert only_fight(game).attacker_won
assert not dice.rolls
def test_defender_sees_the_attackers_card_before_deciding() -> None:
game, agents, _ = make_game(dice=[1, 1, 3, 3])
put(game, 0, Pos(4, 3), track=3)
put(game, 1, Pos(4, 5), track=3)
game.players[0].tecnica = Card.KIAI
game.players[1].tecnica = Card.KIAI
agents[0].plays = agents[1].plays = frozenset({Card.KIAI})
turn(game, agents, 0, Pos(4, 5))
assert agents[1].fight_views[0].played == ((0, Card.KIAI),)
assert agents[1].fight_views[0].attacker_total == 5
assert only_fight(game).cards == ((0, Card.KIAI), (1, Card.KIAI))
def test_uke_losing_defender_stays_and_the_attacker_steps_on() -> None:
game, agents, _ = make_game(dice=[1, 1, 5, 1])
put(game, 0, Pos(4, 3), track=3)
put(game, 1, Pos(4, 5), track=3)
game.players[1].tecnica = Card.UKE
agents[1].plays = frozenset({Card.UKE})
turn(game, agents, 0, Pos(4, 5), Pos(4, 6))
assert game.players[1].position == Pos(4, 5)
assert game.players[1].track == 0
assert game.players[0].position == Pos(4, 6)
assert not events_of(game, SentToStart)
def test_uke_losing_attacker_returns_to_the_previous_square() -> None:
game, agents, _ = make_game(dice=[1, 1, 1, 6])
put(game, 0, Pos(4, 3), track=3)
put(game, 1, Pos(4, 5), track=3)
game.players[0].tecnica = Card.UKE
agents[0].plays = frozenset({Card.UKE})
turn(game, agents, 0, Pos(4, 5))
assert game.players[0].position == Pos(4, 4)
assert (game.players[0].track, game.players[1].track) == (0, 6)
# Meditazione ------------------------------------------------------------------------------
def test_meditating_pawn_is_passed_through_without_a_fight() -> None:
game, agents, _ = make_game(dice=[1, 1])
put(game, 0, Pos(4, 3))
put(game, 1, Pos(4, 4))
game.players[1].meditating = True
turn(game, agents, 0, Pos(4, 5))
assert not events_of(game, KumiteFought)
assert game.players[0].position == Pos(4, 5)
def test_ending_on_a_meditating_pawn_takes_one_extra_step() -> None:
game, agents, _ = make_game(dice=[1, 1])
put(game, 0, Pos(4, 2))
put(game, 1, Pos(4, 4))
game.players[1].meditating = True
turn(game, agents, 0, Pos(4, 4), Pos(4, 5))
assert game.players[0].position == Pos(4, 5)
extra_step_options = {path[-1] for path in agents[0].offered_paths[1]}
assert extra_step_options == {Pos(3, 4), Pos(4, 5), Pos(5, 4)}
def test_meditating_players_turn_is_skipped_once() -> None:
game, agents, _ = make_game()
game.players[1].meditating = True
turn(game, agents, 1)
assert events_of(game, TurnSkipped) == [TurnSkipped(round=1, player=1)]
assert not game.players[1].meditating
# Squares ----------------------------------------------------------------------------------
def test_allenamento_and_fatica() -> None:
game, agents, _ = make_game(dice=[1, 1, 1, 1])
put(game, 0, Pos(0, 3), track=3)
turn(game, agents, 0, Pos(0, 5))
assert game.players[0].track == 4
put(game, 0, Pos(4, 5))
turn(game, agents, 0, Pos(3, 6))
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])
put(game, 0, Pos(0, 3), track=14)
turn(game, agents, 0, Pos(0, 5))
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)
put(game, 1, Pos(4, 2), track=3)
turn(game, agents, 0, Pos(4, 1), Pos(4, 1))
assert game.players[0].position == Pos(4, 3)
assert game.players[0].track == 6
assert game.players[1].position == START_1
def test_sfida_square_fights_a_chosen_opponent_and_clamps_at_zero() -> None:
game, agents, _ = make_game(dice=[1, 1, 4, 4])
put(game, 0, Pos(5, 4), track=0)
put(game, 1, Pos(10, 10), track=0)
agents[0].sfida_target = 1
turn(game, agents, 0, Pos(5, 2))
assert only_fight(game).attacker_won
assert (game.players[0].track, game.players[1].track) == (3, 0)
assert game.players[0].position == Pos(5, 2)
assert game.players[1].position == START_1
@pytest.mark.parametrize("meditating", [False, True])
def test_sfida_cannot_target_safe_rooms_or_meditating_players(meditating: bool) -> None:
game, agents, _ = make_game(dice=[1, 1])
put(game, 0, Pos(5, 4))
if meditating:
put(game, 1, Pos(10, 10))
game.players[1].meditating = True
agents[0].sfida_target = 1
turn(game, agents, 0, Pos(5, 2))
assert not events_of(game, KumiteFought)
def test_tunnel_is_optional_and_ends_the_move() -> None:
game, agents, _ = make_game(dice=[1, 1, 1, 1])
put(game, 0, NEBBIA)
turn(game, agents, 0, Pos(1, 6))
assert game.players[0].position == Pos(1, 6)
put(game, 0, NEBBIA)
agents[0].use_tunnels = True
turn(game, agents, 0, Pos(1, 6))
assert game.players[0].position == Pos(3, 4)
def test_occupied_tunnel_exit_starts_a_kumite() -> None:
game, agents, _ = make_game(dice=[1, 1, 3, 3])
put(game, 0, NEBBIA, track=3)
put(game, 1, Pos(3, 4), track=3)
agents[0].use_tunnels = True
turn(game, agents, 0, Pos(1, 6))
assert only_fight(game).attacker_won
assert game.players[0].position == Pos(3, 4)
assert game.players[1].position == START_1
def test_tunnel_exit_with_a_meditating_pawn_cannot_be_used() -> None:
game, agents, _ = make_game(dice=[1, 1])
put(game, 0, NEBBIA)
put(game, 1, Pos(3, 4))
game.players[1].meditating = True
agents[0].use_tunnels = True
turn(game, agents, 0, Pos(1, 6))
assert game.players[0].position == Pos(1, 6)
# Nebbia del Dojo --------------------------------------------------------------------------
def draw_on_nebbia(
card: Card, players: int = 2, dice: tuple[int, ...] = (1, 1), **tracks: int
) -> tuple[Game, list]:
game, agents, _ = make_game(players=players, dice=dice, deck=((card, 1),))
put(game, 0, Pos(0, 3))
for name, track in tracks.items():
game.players[int(name[1:])].track = track
return game, agents
def test_card_is_drawn_when_stopping_on_nebbia() -> None:
game, agents = draw_on_nebbia(Card.ALLENAMENTO_INTENSIVO)
turn(game, agents, 0, NEBBIA)
assert events_of(game, CardDrawn) == [CardDrawn(0, Card.ALLENAMENTO_INTENSIVO)]
assert game.players[0].track == 1
def test_discard_pile_is_reshuffled_when_the_deck_runs_out() -> None:
game, agents = draw_on_nebbia(Card.ALLENAMENTO_INTENSIVO, dice=(1, 1, 1, 1))
turn(game, agents, 0, NEBBIA)
put(game, 0, Pos(0, 3))
turn(game, agents, 0, NEBBIA)
assert game.players[0].track == 2
def test_colpo_proibito() -> None:
game, agents = draw_on_nebbia(Card.COLPO_PROIBITO, p0=3)
turn(game, agents, 0, NEBBIA)
assert game.players[0].track == 2
def test_sensei_osserva_helps_every_lowest_player() -> None:
game, agents = draw_on_nebbia(Card.SENSEI_OSSERVA, players=3, p0=3, p1=0, p2=0)
turn(game, agents, 0, NEBBIA)
assert [p.track for p in game.players] == [3, 2, 2]
def test_peso_della_cintura_hits_every_highest_player() -> None:
game, agents = draw_on_nebbia(Card.PESO_DELLA_CINTURA, players=3, p0=3, p1=6, p2=6)
turn(game, agents, 0, NEBBIA)
assert [p.track for p in game.players] == [3, 5, 5]
def test_meditazione_card() -> None:
game, agents = draw_on_nebbia(Card.MEDITAZIONE)
turn(game, agents, 0, NEBBIA)
assert game.players[0].meditating
def test_passo_falso_card() -> None:
game, agents = draw_on_nebbia(Card.PASSO_FALSO)
turn(game, agents, 0, NEBBIA)
assert game.players[0].position == Pos(0, 3)
def test_irimi_swaps_places_without_a_fight() -> None:
game, agents = draw_on_nebbia(Card.IRIMI)
put(game, 1, Pos(10, 10))
agents[0].irimi_target = 1
turn(game, agents, 0, NEBBIA)
assert game.players[0].position == Pos(10, 10)
assert game.players[1].position == NEBBIA
assert not events_of(game, KumiteFought)
@pytest.mark.parametrize("card", [Card.SFIDA, Card.IRIMI])
def test_sfida_and_irimi_cards_are_mandatory(card: Card) -> None:
game, agents = draw_on_nebbia(card, dice=(1, 1, 3, 3))
put(game, 1, Pos(10, 10))
turn(game, agents, 0, NEBBIA)
if card is Card.SFIDA:
assert only_fight(game).defender == 1
else:
assert game.players[0].position == Pos(10, 10)
def test_tunnel_segreto_teleports_to_a_chosen_tunnel() -> None:
game, agents = draw_on_nebbia(Card.TUNNEL_SEGRETO)
agents[0].tunnel_destination = Pos(3, 4)
turn(game, agents, 0, NEBBIA)
assert game.players[0].position == Pos(3, 4)
def test_tsugi_ashi_makes_another_full_move() -> None:
game, agents = draw_on_nebbia(Card.TSUGI_ASHI, dice=(1, 1, 1, 1))
turn(game, agents, 0, NEBBIA, Pos(2, 5))
assert game.players[0].position == Pos(2, 5)
@pytest.mark.parametrize(("keep_drawn", "kept"), [(True, Card.UKE), (False, Card.KIAI)])
def test_only_one_tecnica_can_be_held(keep_drawn: bool, kept: Card) -> None:
game, agents = draw_on_nebbia(Card.UKE)
game.players[0].tecnica = Card.KIAI
agents[0].keep_drawn = keep_drawn
turn(game, agents, 0, NEBBIA)
assert game.players[0].tecnica is kept
# 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)
put(game, 0, Pos(4, 7), track=15)
for name, track in tracks.items():
game.players[int(name[1:])].track = track
return game, agents
def test_only_brown_players_may_enter_the_dojo() -> None:
game, agents = dojo_game([1, 1])
game.players[0].track = 14
turn(game, agents, 0)
offered = agents[0].offered_paths[0]
assert not any(game.board.dojo & set(path) for path in offered)
def test_passing_the_exam_wins_the_game() -> None:
game, agents = dojo_game([1, 1, 4, 1], p1=9, p2=12)
turn(game, agents, 0, Pos(6, 7))
assert events_of(game, ExamTaken) == [ExamTaken(challenger=0, defender=2, passed=True)]
assert game.winner == 0
assert game.finished
assert isinstance(game.events[-1], GameOver)
def test_failing_the_exam_costs_a_belt_and_the_defender_gains_nothing() -> None:
game, agents = dojo_game([1, 1, 1, 6], p1=9, p2=12)
turn(game, agents, 0, Pos(6, 7))
assert game.winner is None
assert game.players[0].track == 12
assert game.players[0].position == START_0
assert game.players[2].track == 12
def test_uke_in_the_exam_returns_to_the_entrance() -> None:
game, agents = dojo_game([1, 1, 1, 6], p1=9, p2=12)
game.players[0].tecnica = Card.UKE
agents[0].plays = frozenset({Card.UKE})
turn(game, agents, 0, Pos(6, 7))
assert game.players[0].position == Pos(5, 7)
assert game.players[0].track == 12
def test_challenger_picks_among_tied_defenders() -> None:
game, agents = dojo_game([1, 1, 4, 1], p1=12, p2=12)
agents[0].exam_defender = 1
turn(game, agents, 0, Pos(6, 7))
assert events_of(game, ExamTaken)[0].defender == 1
def test_meditating_players_cannot_defend() -> None:
game, agents = dojo_game([1, 1, 4, 1], p1=9, p2=12)
game.players[2].meditating = True
turn(game, agents, 0, Pos(6, 7))
assert events_of(game, ExamTaken)[0].defender == 1
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)
put(game, 1, Pos(5, 7), track=12)
turn(game, agents, 0, (Pos(5, 7), Pos(5, 6)), Pos(6, 7))
assert game.players[0].track == 17
assert events_of(game, ExamTaken) == [ExamTaken(challenger=0, defender=1, passed=True)]
assert game.winner == 0
assert not dice.rolls
# Whole game -------------------------------------------------------------------------------
def test_a_game_runs_to_completion() -> None:
from dataclasses import replace
from scripted import ScriptedAgent, fixture_board
config = replace(VARIANT_A, max_rounds=30)
game = Game(fixture_board(), config, [ScriptedAgent() for _ in range(4)], RandomSource(7))
game.run()
assert game.finished
assert isinstance(game.events[-1], GameOver)
with pytest.raises(RuntimeError):
game.play_turn()
def test_views_hide_which_tecnica_an_opponent_holds() -> None:
game, _, _ = make_game()
game.players[0].tecnica = Card.KIAI
theirs, mine = game.view(1), game.view(0)
assert theirs.tecnica is None
assert theirs.players[0].holds_tecnica
assert not theirs.players[1].holds_tecnica
assert mine.tecnica is Card.KIAI
def test_agents_decide_on_their_own_current_view() -> None:
game, agents, _ = make_game(dice=[1, 1])
put(game, 1, Pos(4, 4), track=7)
turn(game, agents, 1)
view = agents[1].path_views[0]
assert view.me == 1
assert view.players[1].position == Pos(4, 4)
assert view.belt(1) == 2
+59
View File
@@ -0,0 +1,59 @@
import pytest
from dojo.grid import GridFormatError, parse_grid, render_grid
SMALL = """\
+---+---+
| S | . |
+ +---+
| 1 +
+---+---+
"""
def test_parse_small_grid() -> None:
grid = parse_grid(SMALL)
assert (grid.height, grid.width) == (2, 2)
assert grid.cells == (("S", "."), ("1", "+"))
assert grid.h_walls == ((True, True), (False, True), (True, True))
assert grid.v_walls == ((True, True, True), (True, False, False))
def test_render_round_trips() -> None:
grid = parse_grid(SMALL)
assert parse_grid(render_grid(grid)) == grid
def test_stripped_trailing_space_is_an_open_right_border() -> None:
grid = parse_grid(SMALL)
assert grid.v_walls[1][2] is False
def test_comments_and_blank_lines_are_ignored() -> None:
commented = "# header\n\n" + SMALL.replace("+ +---+\n", "+ +---+\n# note\n")
assert parse_grid(commented) == parse_grid(SMALL)
@pytest.mark.parametrize(
("text", "message"),
[
("", "no grid lines"),
("+---+--+\n", "4*width+1"),
("+---+\n| . |\n", "odd number of lines"),
("+---+\n| . |\n----+\n", "expected '+'"),
("+---+\n| . |\n+-- +\n", "expected '---' or blanks"),
("+---+\n| . !\n+---+\n", "expected '|' or blank"),
("+---+\n| . |\n+---+\n", "longer than the top border"),
("+---+\n|. |\n+---+\n", "' X '"),
("+---+\n| |\n+---+\n", "' X '"),
],
)
def test_format_errors(text: str, message: str) -> None:
with pytest.raises(GridFormatError) as raised:
parse_grid(text, source="t.txt")
assert any(message in problem for problem in raised.value.problems)
+50
View File
@@ -0,0 +1,50 @@
from scripted import fixture_board
from dojo.board import Board, Pos, edge
from dojo.movement import walks
BOARD = fixture_board()
def test_walks_have_exact_length_and_never_repeat_a_square() -> None:
start = Pos(4, 4)
paths = walks(BOARD, start, 4, {start}, can_enter_dojo=False)
assert paths
for path in paths:
assert len(path) == 4
assert len({start, *path}) == 5
for a, b in zip((start, *path), path, strict=False):
assert b in set(BOARD.neighbours(a))
def test_visited_squares_are_never_entered() -> None:
start = Pos(4, 4)
visited = {start, Pos(4, 5), Pos(3, 4)}
paths = walks(BOARD, start, 3, visited, can_enter_dojo=False)
assert all(not visited & set(path) for path in paths)
def test_dojo_is_a_wall_unless_it_may_be_entered() -> None:
start = Pos(4, 7)
closed = walks(BOARD, start, 5, {start}, can_enter_dojo=False)
opened = walks(BOARD, start, 5, {start}, can_enter_dojo=True)
assert not any(BOARD.dojo & set(path) for path in closed)
into_dojo = [path for path in opened if path[-1] in BOARD.dojo]
assert (Pos(5, 7), Pos(6, 7)) in into_dojo
assert all(cell not in BOARD.dojo for path in into_dojo for cell in path[:-1])
assert set(opened) - set(into_dojo) == set(closed)
def test_boxed_in_pawn_moves_as_far_as_possible() -> None:
walls = frozenset(edge(Pos(0, c), Pos(1, c)) for c in range(3))
corridor = Board(cells=((".",) * 3,) * 3, walls=walls, tunnels={})
paths = walks(corridor, Pos(0, 0), 5, {Pos(0, 0)}, can_enter_dojo=False)
assert paths == [(Pos(0, 1), Pos(0, 2))]
+113
View File
@@ -0,0 +1,113 @@
from gridedit import (
cell_column,
cell_line,
edit,
h_segment_column,
wall_column_left_of,
wall_line_above,
)
from dojo.board import Board, build_board
from dojo.grid import parse_grid
from dojo.validate import Report, validate
def validate_text(text: str) -> Report:
return validate(build_board(parse_grid(text)))
def test_fixture_board_has_no_errors(board: Board) -> None:
report = validate(board)
assert report.errors == []
def test_fixture_stats(board: Board) -> None:
stats = validate(board).stats
assert stats is not None
assert stats.playable_squares == 4 * (64 - 9 - 4)
assert stats.square_counts == {
".": 172,
"?": 4,
"A": 8,
"F": 4,
"K": 4,
"P": 4,
"T": 8,
}
assert stats.dojo_entrances == 4
assert stats.tunnel_pairs == 4
assert stats.dojo_distance == (10, 10, 10, 10)
def test_tatami_share_outside_target_is_a_warning(board: Board) -> None:
report = validate(board)
assert any("Tatami share 84%" in w for w in report.warnings)
def test_wall_inside_dojo(quadrant_text: str) -> None:
text = edit(quadrant_text, wall_line_above(7), h_segment_column(6), "---")
report = validate_text(text)
assert "wall inside the Dojo between (6, 6) and (7, 6)" in report.errors
def test_dojo_without_entrance(quadrant_text: str) -> None:
text = edit(quadrant_text, wall_line_above(6), h_segment_column(7), "---")
report = validate_text(text)
assert "quadrant 0 has no Dojo entrance" in report.errors
def test_safe_room_with_one_exit(quadrant_text: str) -> None:
text = edit(quadrant_text, cell_line(1), wall_column_left_of(3), "|")
report = validate_text(text)
assert "Safe Room of quadrant 0: 1 exits, rules require 2" in report.errors
def test_safe_room_exits_on_the_same_side(quadrant_text: str) -> None:
text = edit(quadrant_text, cell_line(1), wall_column_left_of(3), "|")
text = edit(text, cell_line(0), wall_column_left_of(3), " ")
text = edit(text, wall_line_above(3), h_segment_column(1), "---")
text = edit(text, cell_line(2), wall_column_left_of(3), " ")
report = validate_text(text)
assert "Safe Room of quadrant 0: both exits face E" in report.errors
def test_safe_room_used_as_a_shortcut(quadrant_text: str) -> None:
text = edit(quadrant_text, cell_line(3), wall_column_left_of(2), "|")
report = validate_text(text)
assert any("Safe Room of quadrant 0: is a shortcut, 4 steps" in e for e in report.errors)
def test_safe_room_must_contain_the_corner(quadrant_text: str) -> None:
text = edit(quadrant_text, cell_line(0), cell_column(0), ".")
report = validate_text(text)
assert "Safe Room of quadrant 0: does not contain the board corner (0, 0)" in report.errors
assert "Safe Room of quadrant 0: 8 cells, rules expect 9" in report.warnings
def test_enclosed_square_is_unreachable(quadrant_text: str) -> None:
text = edit(quadrant_text, wall_line_above(4), h_segment_column(5), "---")
text = edit(text, wall_line_above(5), h_segment_column(5), "---")
text = edit(text, cell_line(4), wall_column_left_of(5), "|")
text = edit(text, cell_line(4), wall_column_left_of(6), "|")
report = validate_text(text)
assert any(
e.startswith("4 squares cannot be walked to from the Safe Room of quadrant 0: (4, 5)")
for e in report.errors
)