Initial commit, with rules 0.5 and basic bots
This commit is contained in:
@@ -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))
|
||||
Vendored
+18
@@ -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
|
||||
+ + + + + + + + +
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))]
|
||||
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user