diff --git a/agents/agent.py b/agents/agent.py index 71585ce..24f7ce7 100644 --- a/agents/agent.py +++ b/agents/agent.py @@ -115,6 +115,26 @@ class Agent: required_actions=[""], ) + def add_royal_game_of_ur( + self, + on_move : ActionFunction, + profile : Payload = {}, + ) -> None: + """ + Convenience registration for the royal game of Ur. + + :param on_move: Called function when the player is to make a move. + :type on_move: Callable[[dict[str, Any]], dict[str, Any]] + :param profile: Custom details to share about the game. + :type profile: dict[str, Any] + """ + return self.add_game( + name="royal-game-of-ur", + actions={"": on_move}, + profile=profile, + required_actions=[""], + ) + def get_action(self, game : str, action : str | None) -> ActionFunction | None: """ Get an action function for a given game, if it exists. diff --git a/agents/chaos.py b/agents/chaos.py index 372d5c1..7e4a74d 100644 --- a/agents/chaos.py +++ b/agents/chaos.py @@ -44,6 +44,7 @@ class AgentOfChaos(Agent): ) self.add_tic_tac_toe(on_move=play_tic_tac_toe, profile={}) + self.add_royal_game_of_ur(on_move=play_royal_game_of_ur, profile={}) def play_tic_tac_toe(payload : Payload) -> Payload: @@ -61,4 +62,7 @@ def play_tic_tac_toe(payload : Payload) -> Payload: for k, v in dict(payload).items() if k in "0123456789" and v == "" ] - return { "move": random.choice(options) } \ No newline at end of file + return { "move": random.choice(options) } + +def play_royal_game_of_ur(payload : Payload) -> Payload: + return { "move": random.choice(payload.get("valid_moves", [""])) } \ No newline at end of file diff --git a/agents/mute.py b/agents/mute.py index 5d728c5..7188733 100644 --- a/agents/mute.py +++ b/agents/mute.py @@ -42,6 +42,9 @@ class MuteAgent(Agent): }, ) + self.add_tic_tac_toe(on_move=respond_mute) + self.add_royal_game_of_ur(on_move=respond_mute) + def get_action(self, game: str, action: str | None) -> ActionFunction: """ Return the mute response for any game or action. diff --git a/pyclient/games/__init__.py b/pyclient/games/__init__.py index bb194f9..8d24593 100644 --- a/pyclient/games/__init__.py +++ b/pyclient/games/__init__.py @@ -4,9 +4,11 @@ from .game import FinishState, Game from .tic_tac_toe import TicTacToe +from .ur import RoyalGameOfUr __all__ = [ "FinishState", "Game", + "RoyalGameOfUr", "TicTacToe", ] diff --git a/pyclient/games/ur.py b/pyclient/games/ur.py new file mode 100644 index 0000000..4293f3a --- /dev/null +++ b/pyclient/games/ur.py @@ -0,0 +1,235 @@ +""" + 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