Compare commits

..

11 Commits

18 changed files with 573 additions and 180 deletions

View File

@ -13,6 +13,10 @@ spec/
# Compiled elm files
elo_tracker/static/elo_tracker.js
# Data files
games.jsonl
known_players.json
# ---> Elm
# elm-package generated files
elm-stuff

4
.gitignore vendored
View File

@ -5,6 +5,10 @@
# Compiled elm files
elo_tracker/static/elo_tracker.js
# Data files
games.jsonl
known_players.json
# ---> Elm
# elm-package generated files
elm-stuff

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

@ -32,7 +32,7 @@ class AgentOfChaos(Agent):
def __init__(self) -> None:
"""
Create a new instance of the agent of chaos.
Agent that always tries to make a "random" move.
"""
super().__init__(
name="Agent of chaos",
@ -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:
@ -62,3 +63,16 @@ def play_tic_tac_toe(payload : Payload) -> Payload:
if k in "0123456789" and v == ""
]
return { "move": random.choice(options) }
def play_royal_game_of_ur(payload : Payload) -> Payload:
"""
The royal game of Ur already returns a set of valid moves.
We can lazily just pick one of these options and return it.
:param payload: The incoming game state.
:type payload: dict[str, Any]
:return: The agent of chaos' random choice
:rtype: dict[str, Any]
"""
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

@ -20,6 +20,7 @@ class RemoteAgent(Agent):
"""
url : str
games : set[str]
def __init__(self, url : str, timeout : float = 1.0) -> None:
"""
@ -57,6 +58,8 @@ class RemoteAgent(Agent):
profile=content,
)
self.games = set()
games = content.get("games")
if isinstance(games, dict):
if "tic-tac-toe" in games:
@ -64,6 +67,13 @@ class RemoteAgent(Agent):
on_move=lambda x : self.poll("tic-tac-toe", "", x),
profile=games["tic-tac-toe"],
)
if "royal-game-of-ur" in games:
self.add_royal_game_of_ur(
on_move=lambda x : self.poll("royal-game-of-ur", "", x),
profile=games["royal-game-of-ur"],
)
self.games = set(games.keys())
def get_action(self, game: str, action: str | None) -> Callable[[dict[str, Any]], dict[str, Any]] | None:
"""

View File

@ -1,89 +0,0 @@
"""
The sneaky agent is built to trick the naive line counter agent.
I believe if the sneaky agent starts, they will win against the line counter agent.
I have not tested this yet, as the naive line counter agent wasn't in the branch I checked out and I'm lazy,
so time will tell...
"""
from __future__ import annotations
import random
from .agent import Agent, Payload
class SneakyAgent(Agent):
"""
It tries to get both opposite corners (1 and 9) and then get another corner that would give it two routes to win
"""
def __init__(self) -> None:
"""
Create a new instance of the sneaky agent.
"""
super().__init__(
# Give your bot a name to display in leaderboards
name="Sneaky agent",
# Your name, to give you credit
author="Sam",
# Update the version to indicate the agent behaves differently.
# This will later allow you to compare different versions of your
# agent against one another.
version="1.0.0",
# Add extra custom information about the agent to this dictionary.
profile={},
)
# Indicate that you're willing to play tic-tac-toe
# Remove this if you don't want your bot to participate there.
self.add_tic_tac_toe(on_move=self.play_tic_tac_toe, profile={})
@staticmethod
def play_tic_tac_toe(payload : Payload) -> Payload:
"""
I've placed move comments to indicate which move that would be in the ideal game against the line counter.
This assumes this agent starts and so move 1 is {"move": 1}, etc...
If none of the options are valid, we return a random move
"""
# Try printing the payload to see what it looks like!
print(payload)
# Current State
cs = dict(payload)
# Move 1
if cs["1"] == "":
return { "move": 1}
# Move 2
if cs["9"] == "":
return { "move": 9}
# Move 4
if cs["2"] == "" and cs["1"] == cs["your_token"] and cs["3"] == cs["your_token"]:
return { "move": 2}
if cs["6"] == "" and cs["3"] == cs["your_token"] and cs["9"] == cs["your_token"]:
return { "move": 6}
if cs["8"] == "" and cs["7"] == cs["your_token"] and cs["9"] == cs["your_token"]:
return { "move": 8}
if cs["4"] == "" and cs["1"] == cs["your_token"] and cs["7"] == cs["your_token"]:
return { "move": 4}
# Move 3
if cs["2"] == "" and cs["6"] == "" and cs["3"] == "":
return { "move": 3}
if cs["4"] == "" and cs["8"] == "" and cs["7"] == "":
return { "move": 7}
# If the sneaky strategy doesn't work
options = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, ]
return { "move": random.choice(options) }

View File

@ -30,6 +30,7 @@ type alias EloStat =
, losses : Int
, name : String
, url : String
, version : Maybe String
, wins : Int
}
@ -39,7 +40,7 @@ type alias HealthCheck =
type alias Leaderboard =
{ name : String, players : Players }
List { name : String, players : Players }
type alias Match =
@ -63,12 +64,13 @@ type alias Players =
eloStatDecoder : D.Decoder EloStat
eloStatDecoder =
D.map6 EloStat
D.map7 EloStat
(D.field "draws" D.int)
(D.field "elo" D.int)
(D.field "losses" D.int)
(D.field "name" D.string)
(D.field "url" D.string)
(D.maybe <| D.field "version" D.string)
(D.field "wins" D.int)
@ -129,9 +131,10 @@ healthCheckDecoder =
leaderboardDecoder : D.Decoder Leaderboard
leaderboardDecoder =
D.map2 Leaderboard
D.map2 (\name players -> { name = name, players = players })
(D.field "name" D.string)
(D.field "players" playersDecoder)
|> D.list
matchDecoder : D.Decoder Match

View File

@ -2,17 +2,17 @@ module EloTracker exposing (main)
import Api.Elo as Elo
import Element exposing (Element)
import Element.Background
import Element.Font
import Http
import Json.Decode as D
import Layout
import Pixels
import Program
import Screen.Leaderboard
import Theme
import Time
import Widget.Icon
import Layout
import Theme
import Screen.Leaderboard
import Element.Font
import Pixels
import Element.Background
main =
@ -35,12 +35,14 @@ type alias Model =
{ baseUrl : String
, leaderboard : Result Int Elo.Leaderboard
, matches : Result Int (List Elo.Match)
, selectedName : String
}
type Msg
= OnLeaderboard (Result Http.Error Elo.Leaderboard)
| OnMatches (Result Http.Error (List Elo.Match))
| OnSelectLeaderboardName String
| RefreshLeaderboard
| RefreshMatches
@ -60,6 +62,7 @@ init flag =
{ baseUrl = baseUrl
, leaderboard = Err 0
, matches = Err 0
, selectedName = "tic-tac-toe"
}
in
( model
@ -99,6 +102,9 @@ update msg model =
OnMatches (Ok v) ->
( { model | matches = Ok v }, Cmd.none )
OnSelectLeaderboardName name ->
( { model | selectedName = name }, Cmd.none )
RefreshLeaderboard ->
( model, getLeaderboard model )
@ -181,7 +187,7 @@ view data =
, Element.spacing 10
]
[ Element.text "Loading leaderboard..."
, Element.el [ Element.centerX, Element.Font.color (Theme.redUI data.flavor) ] (Element.text ("Failed " ++ (String.fromInt n) ++ " times"))
, Element.el [ Element.centerX, Element.Font.color (Theme.redUI data.flavor) ] (Element.text ("Failed " ++ String.fromInt n ++ " times"))
, Layout.loadingIndicator { color = Theme.lavender data.flavor }
|> Element.el [ Element.centerX ]
]
@ -189,5 +195,11 @@ view data =
Ok leaderboard ->
Screen.Leaderboard.view
{ flavor = data.flavor, model = leaderboard, size = data.size }
{ flavor = data.flavor
, model =
{ board = leaderboard
, onSelect = OnSelectLeaderboardName
, selectedName = data.model.selectedName
}
, size = data.size
}

View File

@ -3,26 +3,88 @@ module Screen.Leaderboard exposing (..)
{-| Widget module for a leaderboard page.
-}
import Api.Elo exposing (EloStat, Leaderboard)
import Api.Elo exposing (EloStat, Leaderboard, Players)
import Color
import Element exposing (Element)
import Element.Background
import Element.Border
import Element.Events
import Element.Font
import Layout
import Pixels exposing (Pixels)
import Program exposing (ViewBox)
import Quantity exposing (Quantity)
import Theme
import Element.Background
import Element.Border
import Color
view : ViewBox Leaderboard -> Element msg
view : ViewBox { board : Leaderboard, onSelect : String -> msg, selectedName : String } -> Element msg
view data =
Element.column
[]
[ selectBoard
{ flavor = data.flavor
, leaderboard = data.model.board
, onSelect = data.model.onSelect
, selectedName = data.model.selectedName
, width = data.size.width
}
, data.model.board
|> List.map
(\l ->
if l.name == data.model.selectedName then
viewSingleBoard
{ flavor = data.flavor
, model = l
, size = data.size
}
else
Element.none
)
|> Element.column []
]
selectBoard :
{ flavor : Theme.Flavor
, leaderboard : Leaderboard
, onSelect : String -> msg
, selectedName : String
, width : Quantity Int Pixels
}
-> Element msg
selectBoard data =
data.leaderboard
|> List.map .name
|> List.map
(\s ->
Element.el
[ if data.selectedName == s then
Element.Background.color (Theme.subtext0UI data.flavor)
else
Element.Background.color (Theme.surface1UI data.flavor)
, Element.Border.rounded 25
, Element.centerX
, Element.Events.onClick (data.onSelect s)
, Element.padding 10
]
(Element.text s)
)
|> Element.wrappedRow
[ Element.padding 20
, Element.spacing 10
, Element.width <| Element.px <| Pixels.inPixels data.width
]
viewSingleBoard : ViewBox { name : String, players : Players } -> Element msg
viewSingleBoard data =
let
boardWidth =
data.size.width
|> Quantity.toFloatQuantity
|> Quantity.multiplyBy (2/3)
|> Quantity.multiplyBy (2 / 3)
|> Quantity.floor
|> Quantity.clamp (Pixels.pixels 600) (Pixels.pixels 1800)
|> Pixels.inPixels
@ -33,10 +95,10 @@ view data =
|> List.map (viewEloStat { flavor = data.flavor })
|> Element.column [ Element.centerX, boardWidth ]
|> List.singleton
|> (::) (
Element.text data.model.name
|> Element.el
[ Element.centerX, Element.Font.size 50 ]
|> (::)
(Element.text data.model.name
|> Element.el
[ Element.centerX, Element.Font.size 50 ]
)
|> Element.column
[ Element.padding 15
@ -62,7 +124,13 @@ viewEloStat data stat =
[ Element.Font.color (Theme.subtext0UI data.flavor)
, Element.Font.size 15
]
(Element.text stat.url)
(case stat.version of
Nothing ->
Element.text stat.url
Just v ->
Element.text (stat.url ++ " | " ++ v)
)
]
-- Bar to push the rest to the right
@ -74,6 +142,7 @@ viewEloStat data stat =
, field { text = "LOSSES", value = stat.losses, color = Theme.redUI data.flavor }
]
field : { text : String, value : Int, color : Element.Color } -> Element msg
field { text, value, color } =
Element.column

9
elo.py
View File

@ -3,7 +3,7 @@
"""
from elo_tracker import EloTracker
from pyclient.games import TicTacToe
from pyclient.games import RoyalGameOfUr, TicTacToe
GAME_FILE = "games.jsonl"
PLAYER_FILE = "known_players.json"
@ -21,6 +21,13 @@ def main() -> int:
player_count=2,
)
tracker.start_periodic_matches(
game=RoyalGameOfUr.empty(),
interval_seconds=9 * 60,
max_turns=1_500,
player_count=2,
)
try:
tracker.start_server(import_name=__name__)
except KeyboardInterrupt:

View File

@ -14,8 +14,9 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Sequence
import agents
import agents.remote
import pyclient
import time
from pyclient.games import FinishState, Game
@ -38,7 +39,7 @@ class PlayerIdentifier:
return (self.name, self.url, self.version)
@classmethod
def from_server_agent(cls, agent : agents.RemoteAgent) -> "PlayerIdentifier":
def from_server_agent(cls, agent : agents.remote.RemoteAgent) -> "PlayerIdentifier":
"""
Gain a player identifier from an agent.
"""
@ -176,7 +177,7 @@ class Match:
@classmethod
def from_replay(
cls,
players : list[agents.RemoteAgent],
players : list[agents.remote.RemoteAgent],
replay : pyclient.GameReplay,
timestamp : str | None,
) -> "Match":
@ -280,7 +281,7 @@ class EloTracker:
# Threading variables
self.__lock = threading.RLock()
self.__scheduler_stop = threading.Event()
self.__scheduler_thread: threading.Thread | None = None
self.__scheduler_threads: list[threading.Thread] = []
# Immutable values
self.debug: bool = debug
@ -290,9 +291,9 @@ class EloTracker:
# Thread-unsafe variables
# Please use a lock while doing CRUD operations on them
self.players: list[agents.RemoteAgent] = []
self.players: list[agents.remote.RemoteAgent] = []
self.__matches: list[Match] = []
self.__stats: dict[PlayerIdentifier, EloStat] = {}
self.__stats: dict[str, dict[PlayerIdentifier, EloStat]] = {}
# Initialize tracker
self.__load_matches()
@ -309,25 +310,30 @@ class EloTracker:
with self.__lock:
print(f"[EloTracker] {message}")
def __get_stat(self, player_id : PlayerIdentifier) -> EloStat:
def __get_stat(self, game_name : str, player_id : PlayerIdentifier) -> EloStat:
"""
Get a player's statistics based on their player identifier.
If the player wasn't known, the function returns a newly
initialized record in the database for them.
:param game_name: Name of the relevant game
:type game_name: str
:param player_id: Unique player identifier.
:type player_id: PlayerIdentifier
:return: Elo statistics
"""
with self.__lock:
stat = self.__stats.get(player_id, None)
stat = self.__stats.get(game_name, {}).get(player_id, None)
if stat is not None:
return stat
if game_name not in self.__stats:
self.__stats[game_name] = {}
stat = EloStat.new(player_id=player_id)
self.__stats[player_id] = stat
self.__stats[game_name][player_id] = stat
return stat
@ -387,13 +393,13 @@ class EloTracker:
# Do not apply them yet, in order to guarantee fair ELO shifts
for player_id1, result1 in m.participants:
total_k = 0.0
rating_1 = self.__get_stat(player_id=player_id1).elo
rating_1 = self.__get_stat(game_name=m.game_name, player_id=player_id1).elo
for player_id2, result2 in m.participants:
if player_id1 == player_id2:
continue
rating_2 = self.__get_stat(player_id=player_id2).elo
rating_2 = self.__get_stat(game_name=m.game_name, player_id=player_id2).elo
expected_score = 1 / (1 + 10 ** ((rating_2 - rating_1) / STD_DEV_DIFF))
@ -414,7 +420,7 @@ class EloTracker:
# Then, apply the ELO score update + count the wins, draws & losses
for player_id, result in m.participants:
player = self.__get_stat(player_id=player_id)
player = self.__get_stat(game_name=m.game_name, player_id=player_id)
player.elo += scores[player_id]
match result:
@ -430,6 +436,7 @@ class EloTracker:
game: Game,
interval_seconds: float,
player_count: int,
max_turns : int | None = None,
) -> None:
"""
Perform a schedule in which you play a random game.
@ -440,13 +447,20 @@ class EloTracker:
:type interval_seconds: float
:param player_count: The number of players that are supposed to participate
:type player_count: int
:param max_turns: Optional maximum number of turns to allow
:type max_turns: int | None
"""
while not self.__scheduler_stop.is_set():
try:
self.load_players()
with self.__lock:
available = len(self.players)
players = [
player for player in self.players
if game.game_name() in player.games
]
available = len(players)
if available < player_count:
self.__debug(
@ -455,9 +469,13 @@ class EloTracker:
)
else:
self.__debug(
"Playing a new scheduled match"
f"Playing a new scheduled match of {game.game_name()}"
)
self.play_random_match(
game=game,
max_turns=max_turns,
player_count=player_count,
)
self.play_random_match(game=game, player_count=player_count)
except Exception as exc:
self.__debug(f"Scheduled match failed: {exc}")
# raise exc
@ -473,7 +491,7 @@ class EloTracker:
:type import_name: str
"""
try:
from flask import Flask, Response, jsonify
from flask import Flask, Response, jsonify, request
except ImportError as exc:
raise ImportError(
"Flask is required to host the EloTracker server. "
@ -526,8 +544,15 @@ class EloTracker:
return jsonify(self.get_json_matches())
@app.get("/players")
def players() -> Response:
return jsonify(self.get_json_players())
def players() -> tuple[Response, int]:
payload = request.get_json(silent=True) or {}
game = payload.get("game", None)
if isinstance(game, str):
return jsonify(self.get_json_players(game_name=game)), 200
else:
return jsonify({ "error": "No `game` parameter was given" }), 400
return app
@ -539,7 +564,11 @@ class EloTracker:
:rtype: bool
"""
with self.__lock:
return self.__scheduler_thread is not None and self.__scheduler_thread.is_alive()
for thread in self.__scheduler_threads:
if thread.is_alive():
return True
else:
return False
def load_players(self) -> None:
"""
@ -560,13 +589,13 @@ class EloTracker:
"Expected `players` field to be a list of strings."
)
players : list[agents.RemoteAgent] = []
players : list[agents.remote.RemoteAgent] = []
for url in urls:
if not isinstance(url, str):
continue
try:
agent = agents.RemoteAgent(url=url)
agent = agents.remote.RemoteAgent(url=url)
except ValueError:
pass # Not an available player right now
else:
@ -576,49 +605,71 @@ class EloTracker:
self.players = players
for agent in players:
self.__get_stat(PlayerIdentifier.from_server_agent(agent))
for game_name in self.__stats:
self.__get_stat(
game_name=game_name,
player_id=PlayerIdentifier.from_server_agent(agent)
)
def play_match(self, players: list[str], game: Game) -> pyclient.GameReplay:
def play_match(
self,
players: list[str],
game: Game,
max_turns : int | None = None,
) -> pyclient.GameReplay | None:
"""
Play a single match with appointed players.
:param players: List of URLs that participate.
:type players: list[str]
:param game: The game to start playing
:type game: Game
:param max_turns: Optional maximum number of turns to allow
:type max_turns: int | None
:return: A summary of the game.
:rtype: pyclient.GameReplay
:raises ValueError: One of the URLs could not be accessed.
"""
ags : list[Any] = [ agents.RemoteAgent(url=url) for url in players ]
ags : list[Any] = [ agents.remote.RemoteAgent(url=url) for url in players ]
replay = pyclient.PyClient(debug=self.debug).play_game(
players=ags,
start=game,
)
try:
replay = pyclient.PyClient(debug=self.debug).play_game(
players=ags,
start=game,
max_turns=max_turns,
)
m = Match.from_replay(players=ags, replay=replay, timestamp=Match.now())
except ValueError:
return None # Exceeded turn limit
# Record match
m.log(self.game_file_name)
self.__register_match(m)
else:
m = Match.from_replay(players=ags, replay=replay, timestamp=Match.now())
return replay
# Record match
m.log(self.game_file_name)
self.__register_match(m)
return replay
def play_random_match(
self,
game: Game,
max_turns : int | None = None,
player_count: int | None = None,
) -> pyclient.GameReplay:
) -> pyclient.GameReplay | None:
"""
Play a game with any known players.
:param game: The game to start playing
:type game: Game
:param max_turns: Optional maximum number of turns to allow
:type max_turns: int | None
:param player_count: Optional number of players to select.
:type player_count: int | None
:raises ValueError: One of the randomly chosen URLs could not be accessed.
"""
with self.__lock:
players = [agent.url for agent in self.players]
players = [agent.url for agent in self.players if game.game_name() in agent.games]
random.shuffle(players)
@ -631,6 +682,7 @@ class EloTracker:
self,
game: Game,
interval_seconds: float = 300,
max_turns : int | None = None,
player_count: int = 2,
) -> None:
"""
@ -640,6 +692,8 @@ class EloTracker:
:type game: pyclient.Game
:param interval_seconds: Number of seconds to sleep between games
:type interval_seconds: float
:param max_turns: Optional maximum number of turns to allow
:type max_turns: int | None
:param player_count: The number of players that are supposed to participate
:type player_count: int
"""
@ -649,40 +703,51 @@ class EloTracker:
raise ValueError("player_count must be greater than one.")
with self.__lock:
if self.is_running_periodic_matches():
self.stop_periodic_matches()
self.__scheduler_stop.clear()
self.__scheduler_thread = threading.Thread(
thread = threading.Thread(
target=self.__scheduler_loop,
args=(game, interval_seconds, player_count),
kwargs={"max_turns": max_turns},
daemon=True,
)
self.__scheduler_thread.start()
self.__scheduler_threads.append(thread)
thread.start()
def stop_periodic_matches(self) -> None:
"""
Stop the periodic match scheduler and wait briefly for it to exit.
"""
thread: threading.Thread | None
max_wait_time_s = 5
now = time.time()
time_left = lambda : max(0.1, now + max_wait_time_s - time.time())
to_remove : list[threading.Thread] = []
# Announce that all threads need to be stopped
with self.__lock:
self.__scheduler_stop.set()
thread = self.__scheduler_thread
to_remove = [ thread for thread in self.__scheduler_threads ]
if thread is not None:
thread.join(timeout=5)
# Then, quietly remove all existing threads
for thread in self.__scheduler_threads:
if thread is not None:
thread.join(timeout=time_left())
# Remove all stopped threads
# Keep in mind that another threads might've added new threads in the
# meantime.
with self.__lock:
if self.__scheduler_thread is thread:
self.__scheduler_thread = None
for thread in to_remove:
self.__scheduler_threads.remove(thread)
def get_json_players(self) -> list[dict[str, Any]]:
def get_json_players(self, game_name : str) -> list[dict[str, Any]]:
"""
Return known currently available players as notebook-friendly dicts.
"""
with self.__lock:
players : list[EloStat] = list(self.__stats.values())
players : list[EloStat] = [
player for player in self.__stats.get(game_name, {}).values()
if abs(player.wins + player.draws + player.losses) > 0
]
players.sort(
key=lambda player: (-int(player.elo), player.player_id.name)
@ -709,14 +774,17 @@ class EloTracker:
return [ m.to_json() for m in matches ]
def get_json_leaderboard(self) -> dict[str, Any]:
def get_json_leaderboard(self) -> list[dict[str, Any]]:
"""
Return aggregate player statistics for local use or JSON APIs.
"""
return {
"name": self.name,
"players": self.get_json_players(),
}
return [
{
"name": game_name,
"players": self.get_json_players(game_name=game_name),
}
for game_name in self.__stats
]
def start_server(
self,

View File

@ -19,7 +19,12 @@ class PyClient:
debug : bool
def gen_game(self, players : list[Agent], start : Game) -> Generator[Turn, None, None]:
def gen_game(
self,
players : list[Agent],
start : Game,
max_turns : int | None = None,
) -> Generator[Turn, None, None]:
"""
Generate a game by polling the players.
@ -27,12 +32,22 @@ class PyClient:
:type players: list[Agent]
:param start: The start state of the game.
:type start: Game
:param max_turns: The maximum number of turns a game may take
:type max_turns: int
:return: A generator that yields turns.
:rtype: Generator[Turn, None, None]
:raises ValueError: The game is taking too long.
"""
current_state = start
turns_taken = 0
while current_state.winner() is None:
if max_turns is not None and turns_taken >= max_turns:
raise ValueError(
f"Turn limit exceeded: played for {turns_taken} turns and still needed more."
)
turns_taken += 1
player = current_state.player_to_move()
if len(players) < player:
@ -61,7 +76,12 @@ class PyClient:
yield Turn(action=payload, player=player, state=current_state)
def play_game(self, players : list[Agent], start : Game) -> GameReplay:
def play_game(
self,
players : list[Agent],
start : Game,
max_turns : int | None = None,
) -> GameReplay:
"""
Generate a game by polling the players. Collect all moves in a
summary.
@ -70,11 +90,16 @@ class PyClient:
:type players: list[Agent]
:param start: The start state of the game.
:type start: Game
:param max_turns: The maximum number of turns a game may take
:type max_turns: int
:return: Summary describing how the game went.
:rtype: GameReplay
:raises ValueError: The game is taking too long.
"""
return GameReplay(
game_name=start.game_name(),
start=start,
turns=list(self.gen_game(players=players, start=start)),
turns=list(self.gen_game(
players=players, start=start, max_turns=max_turns
)),
)

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

View File

@ -19,8 +19,10 @@ RUN pip install --no-cache-dir --no-index --find-links=/wheels \
-r requirements-pyserver.txt && rm -rf /wheels
# Install PyServer code
COPY pyserver/ pyserver/
COPY agents/ agents/
COPY pyserver/ pyserver/
COPY server.py .
ENV CONTAINERIZED=1
CMD ["python", "server.py"]

View File

@ -2,6 +2,8 @@
from __future__ import annotations
import os
from flask import Flask, jsonify, request
from agents import Agent
from typing import Any
@ -24,6 +26,7 @@ class PyServer:
:param import_name: Flask application import name
:type import_name: str
"""
self.containerized : bool = bool(int(os.environ.get("CONTAINERIZED", "0")))
self.agent = agent
self.app = Flask(import_name)
@ -49,6 +52,7 @@ class PyServer:
d = { **self.agent.profile, **d }
d["me.noordstar.peanuts.containerized"] = self.containerized
d["games"] = {}
for game, (profile, _) in self.agent.registered_games.items():
d["games"][game] = profile

View File

@ -4,7 +4,7 @@
from __future__ import annotations
import agents.sneaky
import agents
from pyserver import PyServer
@ -15,7 +15,7 @@ def main() -> int:
:return: Exit code
:rtype: int
"""
player = PyServer(agents.sneaky.SneakyAgent())
player = PyServer(agents.AgentOfChaos())
# Start listening for games
player.start(