Implement royal game of Ur

bram/mute
Bram van den Heuvel 2026-07-01 16:14:31 +02:00
parent 90d2bea528
commit da26f5c016
5 changed files with 265 additions and 1 deletions

View File

@ -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.

View File

@ -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) }
return { "move": random.choice(options) }
def play_royal_game_of_ur(payload : Payload) -> Payload:
return { "move": random.choice(payload.get("valid_moves", [""])) }

View File

@ -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.

View File

@ -4,9 +4,11 @@
from .game import FinishState, Game
from .tic_tac_toe import TicTacToe
from .ur import RoyalGameOfUr
__all__ = [
"FinishState",
"Game",
"RoyalGameOfUr",
"TicTacToe",
]

235
pyclient/games/ur.py Normal file
View File

@ -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