""" The Royal game of Ur is supposedly one of the world's oldest known board games. Players race 7 horses to the finish line while trying to sabotage each other. """ from __future__ import annotations from typing import Any, Generator import random from .game import FinishState, Game from dataclasses import dataclass # On these fields, you can safely place your token without getting it removed # if the opponent plays a token on their respective spot. SAFE_FIELDS = set([ 0, 1, 2, 3, 4, 13, 14, 15 ]) # Whenever you land a token on this piece, you may play another turn. STAR_FIELDS = set([ 4, 8, 14 ]) # You may place multiple pieces on these fields. STACK_FIELDS = set([ 0, 15 ]) # Length of the board BOARD_LENGTH = 1 + 4 + (4 + 2 + 2) + 2 + 1 @dataclass(frozen=True) class RoyalGameOfUr(Game): """ The Royal Game of Ur is a game where players roll dice to get to the finish line. """ board : list[tuple[int, int]] mover : int roll : int def __updated_board(self, start : int) -> list[tuple[int, int]]: new_board = [] assert self.roll > 0 assert 0 <= start < BOARD_LENGTH assert start + self.roll < BOARD_LENGTH elim = False for i, (a, b) in enumerate(self.board): if i == start: if self.player_to_move() == 1: new_board.append(( a - 1, b )) else: new_board.append(( a, b - 1 )) elif i == start + self.roll: if self.player_to_move() == 1: if b > 0 and i not in SAFE_FIELDS: elim = True b = 0 new_board.append(( a + 1, b )) else: if a > 0 and i not in SAFE_FIELDS: elim = True a = 0 new_board.append(( a, b + 1 )) else: new_board.append(( a, b )) if elim: if self.player_to_move() == 1: new_board[0] = ( new_board[0][0], new_board[0][1] + 1) else: new_board[0] = ( new_board[0][0] + 1, new_board[0][1] ) return new_board def __valid_moves(self) -> Generator[int, None, None]: for i in range(BOARD_LENGTH - self.roll): dest = i + self.roll if self.board[i][self.mover] > 0: if dest in STACK_FIELDS or self.board[dest][self.mover] == 0: yield i def action_name(self) -> str: return "" # Players can only move in this game def as_seen_by(self, player: int) -> dict[str, Any]: my_index = player - 1 ot_index = 1 - my_index d : dict[str, Any] = dict( enemies_at_start=self.board[0][ot_index], enemies_at_finish=self.board[15][ot_index], safe_fields=list(SAFE_FIELDS), stack_fields=list(STACK_FIELDS), star_fields=list(STAR_FIELDS), tokens_at_start=self.board[0][my_index], tokens_at_finish=self.board[15][my_index], valid_moves=list(self.__valid_moves()), ) for i in range(BOARD_LENGTH): d[str(i)] = ( "YOU" if self.board[i][my_index] > 0 else ( "ENEMY" if my_index not in SAFE_FIELDS and self.board[i][ot_index] > 0 else ( "" ))) for i in SAFE_FIELDS: d[f"{i}_enemy"] = ( "ENEMY" if self.board[i][ot_index] > 0 else "" ) return d @classmethod def empty(cls) -> RoyalGameOfUr: """ Create a new game. :return: Start state of the board :rtype: RoyalGameOfUr """ times, roll = cls.roll_repeatedly() return cls( board=[ (7, 7) ] + ((BOARD_LENGTH - 1) * [ (0, 0 ) ]), mover=(1 + times) % 2, roll=roll, ) def game_name(self) -> str: return "royal-game-of-ur" def move_default(self) -> Game: """ Fallback move option for when a user isn't available or cannot decide. In this case, the player attempts to move whatever is the furthest in the back. """ player = self.player_to_move() for i in self.__valid_moves(): new_board = self.__updated_board(start=i) new_mover = self.mover if i + self.roll not in STAR_FIELDS: new_mover += 1 return RoyalGameOfUr( board=new_board, mover=new_mover % 2, roll=self.roll_once(), ).skip_if_possible() else: raise ValueError( "Player has no valid moves even though those should've been erased." ) def move(self, payload : dict[str, Any] | None = None): if not isinstance(payload, dict): return self.move_default() key = str(payload.get("move", "")) for i in self.__valid_moves(): if str(i) == key: new_board = self.__updated_board(start=i) new_mover = self.mover if i + self.roll not in STAR_FIELDS: new_mover += 1 return RoyalGameOfUr( board=new_board, mover=new_mover % 2, roll=self.roll_once(), ).skip_if_possible() else: return self.move_default() def player_to_move(self) -> int: return self.mover + 1 @staticmethod def roll_once() -> int: tot = 0 for _ in range(4): tot += random.randint(0, 1) return tot @staticmethod def roll_repeatedly() -> tuple[int, int]: """ Roll until you don't roll a zero. """ n, roll = 0, 0 while roll == 0: n += 1 roll = RoyalGameOfUr.roll_once() return n, roll def skip_if_possible(self) -> RoyalGameOfUr: """ Skip this turn. Allow the next player to make a move. Players are not allowed to skip if they can make a valid move. """ # Exit if at least one valid move exists if self.roll > 0: for _ in self.__valid_moves(): return self # Exit if game has already finished if self.winner() is not None: return self return RoyalGameOfUr( board=self.board, mover=(self.mover + 1) % 2, roll=self.roll_once() ).skip_if_possible() def to_dict(self) -> dict[str, Any]: return self.as_seen_by(player=1) def winner(self) -> dict[int, FinishState] | None: a, b = self.board[-1] if a == 7: return { 1 : FinishState.win, 2 : FinishState.loss, } elif b == 7: return { 1 : FinishState.loss, 2 : FinishState.win, } else: return None