Skip to content

Commit

Permalink
Implement board logic for moving pieces.
Browse files Browse the repository at this point in the history
Add tests for Move and Game classes.

Only remaining todo is implement logic for get_available_moves. Tests are already written.
  • Loading branch information
Casper-Guo committed May 18, 2024
1 parent 2e38db3 commit 63c4371
Show file tree
Hide file tree
Showing 12 changed files with 241 additions and 57 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
# royal-tournament-of-ur
Interface for benchmarking agents and heuristics for the royal game of Ur tournament-style

## Setup
Install with `pip install -e .`
8 changes: 7 additions & 1 deletion royal_game/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ def __init__(self, player) -> None:
class MoveError(Exception):
pass

# ImpossibleMove denotes a move that is invalid in any circumstances
# InvalidMove denotes a move that is invalid on a specific board
class ImpossibleMove(MoveError):
def __init__(self) -> None:
super().__init__(f"The requested move is impossible.")
super().__init__("The requested move is impossible.")

class InvalidMove(MoveError):
def __init__(self) -> None:
super().__init__("This move is invalid.")
56 changes: 42 additions & 14 deletions royal_game/modules/board.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from royal_game._exceptions import InvalidNumberofPieces
from royal_game.modules.grid import Grid, StartEndGrid
from royal_game.modules.grid_status import GridStatus
from royal_game.modules.move import Move


class Board:
Expand All @@ -41,7 +42,7 @@ class Board:
122138132480 = (7 << 28) + (7 << 34)
"""

def __init__(self, seed: int = 122138132480) -> None:
def __init__(self, seed: int = 122138132480, no_verify: bool = False) -> None:
self.board: dict[str, Grid] = {}
white_total = 0
black_total = 0
Expand Down Expand Up @@ -88,14 +89,15 @@ def __init__(self, seed: int = 122138132480) -> None:
offset += 3
self.board[name] = StartEndGrid(num_pieces, name)

white_total += self.board["WS"].num_pieces + self.board["WE"].num_pieces
black_total += self.board["BS"].num_pieces + self.board["BE"].num_pieces
if not no_verify:
white_total += self.board["WS"].num_pieces + self.board["WE"].num_pieces
black_total += self.board["BS"].num_pieces + self.board["BE"].num_pieces

if white_total != 7:
raise InvalidNumberofPieces("white", white_total)
if white_total != 7:
raise InvalidNumberofPieces("white", white_total)

if black_total != 7:
raise InvalidNumberofPieces("black", black_total)
if black_total != 7:
raise InvalidNumberofPieces("black", black_total)

def __repr__(self):
fmt = ""
Expand All @@ -119,12 +121,17 @@ def __repr__(self):
return fmt

def __int__(self) -> int:
"""Recalculate the integer representation since the board may be modified."""
"""
Recalculate the integer representation since the board may be modified.
Grid objects are converted to integers based on the value of their status.
This is the desired behavior for the public grids but not the private grids.
"""
board_int = 0
offset = 0

for name, _ in chain(white_iter(), black_iter()):
board_int += (int(self.board[name])) << offset
board_int += int(self.board[name].status != GridStatus.empty) << offset
offset += 1

for name, _ in public_iter():
Expand Down Expand Up @@ -154,8 +161,29 @@ def is_end_state(self):
"""
return self.board["BE"].num_pieces == 7 or self.board["WE"].num_pieces == 7

"""
Logic to be implemented:
1, Get available moves on the board, based on the dice roll
2, Execute a move and modify the board, including validity check
"""
def get_available_moves(self, white_turn: bool, dice_roll: int) -> tuple[Move]:
"""Return a tuple of valid moves."""
pass

def make_move(self, move: Move) -> None:
"""Modify the board based on the board."""
if move.is_onboard:
self.board[move.grid1].num_pieces -= 1
if move.grid1 == "WS":
self.board[move.grid2].status = GridStatus.white
elif move.grid1 == "BS":
self.board[move.grid2].status = GridStatus.black
elif move.is_capture:
if self.board[move.grid2].status == GridStatus.white:
self.board["WS"].num_pieces += 1
if self.board[move.grid2].status == GridStatus.black:
self.board["BS"].num_pieces += 1

self.board[move.grid2].status = self.board[move.grid1].status
self.board[move.grid1].status = GridStatus.empty
elif move.is_ascension:
self.board[move.grid1].status = GridStatus.empty
self.board[move.grid2].num_pieces += 1
else:
self.board[move.grid2].status = self.board[move.grid1].status
self.board[move.grid1].status = GridStatus.empty
65 changes: 52 additions & 13 deletions royal_game/modules/game.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
"""Implements game loop."""

from royal_game._exceptions import InvalidPlayer
import logging
from random import choices
from typing import Optional

from royal_game._exceptions import InvalidMove, InvalidPlayer
from royal_game.modules.board import Board
from royal_game.modules.player import Player

logging.basicConfig(filename="games.log", filemode="w", format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)


class Game:
"""
Expand All @@ -13,7 +20,7 @@ class Game:
implements select_move.
"""

def __init__(self, player1: Player, player2: Player):
def __init__(self, player1: Player, player2: Player, board: Optional[Board] = None):
if "select_move" not in dir(player1):
raise InvalidPlayer(player1)

Expand All @@ -22,18 +29,50 @@ def __init__(self, player1: Player, player2: Player):

self.player1 = player1
self.player2 = player2
self.board = Board()

if board is None:
self.board = Board()
else:
self.board = board
self.white_turn = True

def play(self) -> None:
"""
Implement the game loop.
def play(self) -> bool:
"""Return true if white wins, and vice versa."""
logger.info("%s is white.\n%s is black.", self.player1, self.player2)

1, roll dices. If 0, skip to 5
2, send available moves to player
3, player returns selected move
4, update board
5, determine who has the next turn
"""
while not self.board.is_end_state():
pass
current_player = self.player1 if self.white_turn else self.player2
dice_roll = choices(
[0, 1, 2, 3, 4], weights=[1 / 16, 1 / 4, 3 / 8, 1 / 4, 1 / 16], k=1
)

if dice_roll == 0:
logger.info(
"%s rolled a zero. The turn is automatically passed", current_player
)
self.white_turn = not self.white_turn
continue
logger.info("%s rolled a %d.", current_player, dice_roll)

available_moves = self.board.get_available_moves(self.white_turn, dice_roll)
move_selected = current_player.select_move(self.board, available_moves)

try:
self.board.make_move(move_selected)
logger.info(move_selected)
logger.info("%s", self.board)
except InvalidMove as e:
logger.critical(move_selected)
raise e

if not move_selected.is_rosette:
self.white_turn = not self.white_turn

# no other return statement needed since it is guaranteed that
# one of the following two conditions is True
if self.board.board["WE"].num_pieces == 7:
logger.info("%s wins!", self.player1)
return True
if self.board.board["BE"].num_pieces == 7: # noqa: RET503
logger.info("%s wins!", self.player2)
return False
6 changes: 3 additions & 3 deletions royal_game/modules/grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ class Grid:
def __init__(
self, name: str, is_rosette: bool, status: GridStatus = GridStatus.empty
) -> None:
self.status = status
self.name = name
self.is_rosette = is_rosette
self.status = status

def __str__(self) -> str:
symbol = " "
Expand All @@ -26,7 +26,7 @@ def __str__(self) -> str:
return f"---\n|{symbol}|\n---\n"

def __repr__(self) -> str:
return f"{self.__name__}({self.name}, {self.is_rosette}, {self.status})"
return f"Grid({self.name}, {self.is_rosette}, {self.status})"

def __int__(self) -> int:
return self.status.value
Expand All @@ -45,7 +45,7 @@ def __str__(self) -> str:
return f" \n {self.num_pieces} \n \n"

def __repr__(self) -> str:
return f"{self.__name__}({self.num_pieces}, {self.name})"
return f"StartEndGrid({self.num_pieces}, {self.name})"

def __int__(self) -> int:
return self.num_pieces
52 changes: 41 additions & 11 deletions royal_game/modules/move.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,65 @@ class Move:
"""Move representation and benchmarking metadata."""

def __init__(
self, grid1: str, grid2: str, is_rosette: bool, is_capture: bool, is_ascension: bool
self,
grid1: str,
grid2: str,
is_rosette: bool = False,
is_capture: bool = False,
is_ascension: bool = False,
is_onboard: bool = False,
no_verify: bool = False,
) -> None:
self.grid1 = grid1
self.grid2 = grid2
self.is_rosette = is_rosette
self.is_capture = is_capture
self.is_ascension = is_ascension
self.is_onboard = is_onboard

try:
if int(is_rosette) + int(is_capture) + int(is_ascension) > 1:
raise ImpossibleMove
except ImpossibleMove as e:
print(self)
raise e
# set no_verify to avoid repeat validation in game scenarios
# all moves generated should be assumed valid
if not no_verify:
self.verify_move()

def __str__(self) -> str:
return (
f"Move the piece on {self.grid1} to {self.grid2} "
f"{rosette if self.is_rosette else ""}{capture if self.is_capture else ""}"
f"{ascension if self.is_ascension else ""}"
f"{rosette if self.is_rosette else ''}{capture if self.is_capture else ''}"
f"{ascension if self.is_ascension else ''}"
)

def __repr__(self) -> str:
return (
f"{self.grid1} -> {self.grid2} {rosette if self.is_rosette else ""}"
f"{capture if self.is_capture else ""}{ascension if self.is_ascension else ""}"
f"{self.grid1} -> {self.grid2} {rosette if self.is_rosette else ''}"
f"{capture if self.is_capture else ''}{ascension if self.is_ascension else ''}"
)

# It would be a nice QoL improvement to implement a full partial order
# so moves can be reasonably sorted
# Not urgent due to minimal player interaction and need for nice interface
def __eq__(self, other: object) -> bool:
if not isinstance(other, Move):
return NotImplemented
return self.grid1 == other.grid1 and self.grid2 == other.grid2

def verify_move(self) -> None:
"""Check move validity."""
try:
if int(self.is_rosette) + int(self.is_capture) + int(self.is_ascension) > 1:
raise ImpossibleMove
if self.grid1 == self.grid2:
raise ImpossibleMove
if (self.grid1[0] == "W" and self.grid2[0] == "B") or (
self.grid1[0] == "B" and self.grid2[0] == "W"
):
raise ImpossibleMove
if self.is_onboard and self.grid1 not in ("WS", "BS"):
raise ImpossibleMove
if self.is_ascension and self.grid2 not in ("WE", "BE"):
raise ImpossibleMove
if self.is_rosette and self.grid2 not in ("W4", "B4", "8", "W14", "B14"):
raise ImpossibleMove
except ImpossibleMove as e:
print(self)
raise e
13 changes: 8 additions & 5 deletions royal_game/players/example.py → royal_game/players/dummy.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
"""An example player implementation."""
"""An example player implementation that also serves as a dummy for testing."""

from royal_game.modules.player import Player
from typing import Iterable
from royal_game.modules.board import Board
from royal_game.modules.move import Move

class SamplePlayer(Player):
class DummyPlayer(Player):
"""You must implement the select_move method!"""
def __init__(self):
"""choose a name for your player"""
super().__init__("{name goes here}")
"""Choose a name for your player"""
name = ""
super().__init__(name)
def select_move(self, board: Board, available_moves: Iterable[Move]) -> Move:
"""
Select a move based on some heuristic.
Expand All @@ -20,5 +21,7 @@ def select_move(self, board: Board, available_moves: Iterable[Move]) -> Move:
white (W): W4 W3 W2 W1 WS WE W14 W13
public: 5 6 7 8 9 10 11 12
black (B): B4 B3 B2 B1 BS BE B14 B13
The dummy always return the first available move.
"""
pass
return available_moves[0]
Loading

0 comments on commit 63c4371

Please sign in to comment.