Compare commits
11 Commits
sam/sneaky
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
94ea1ae2f0 | |
|
|
c9899c5d65 | |
|
|
220c956ba0 | |
|
|
194f962746 | |
|
|
d7ac20a6dd | |
|
|
1d78cdfd4d | |
|
|
36d5aa30fc | |
|
|
ca7bfdc50a | |
|
|
e2daa9a205 | |
|
|
da26f5c016 | |
|
|
c004d9d3e7 |
|
|
@ -13,6 +13,10 @@ spec/
|
||||||
# Compiled elm files
|
# Compiled elm files
|
||||||
elo_tracker/static/elo_tracker.js
|
elo_tracker/static/elo_tracker.js
|
||||||
|
|
||||||
|
# Data files
|
||||||
|
games.jsonl
|
||||||
|
known_players.json
|
||||||
|
|
||||||
# ---> Elm
|
# ---> Elm
|
||||||
# elm-package generated files
|
# elm-package generated files
|
||||||
elm-stuff
|
elm-stuff
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
# Compiled elm files
|
# Compiled elm files
|
||||||
elo_tracker/static/elo_tracker.js
|
elo_tracker/static/elo_tracker.js
|
||||||
|
|
||||||
|
# Data files
|
||||||
|
games.jsonl
|
||||||
|
known_players.json
|
||||||
|
|
||||||
# ---> Elm
|
# ---> Elm
|
||||||
# elm-package generated files
|
# elm-package generated files
|
||||||
elm-stuff
|
elm-stuff
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,9 @@
|
||||||
from .agent import Agent
|
from .agent import Agent
|
||||||
from .chaos import AgentOfChaos
|
from .chaos import AgentOfChaos
|
||||||
from .mute import MuteAgent
|
from .mute import MuteAgent
|
||||||
from .remote import RemoteAgent
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Agent",
|
"Agent",
|
||||||
"AgentOfChaos",
|
"AgentOfChaos",
|
||||||
"MuteAgent",
|
"MuteAgent",
|
||||||
"RemoteAgent",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,26 @@ class Agent:
|
||||||
required_actions=[""],
|
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:
|
def get_action(self, game : str, action : str | None) -> ActionFunction | None:
|
||||||
"""
|
"""
|
||||||
Get an action function for a given game, if it exists.
|
Get an action function for a given game, if it exists.
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ class AgentOfChaos(Agent):
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""
|
"""
|
||||||
Create a new instance of the agent of chaos.
|
Agent that always tries to make a "random" move.
|
||||||
"""
|
"""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
name="Agent of chaos",
|
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_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:
|
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 == ""
|
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:
|
||||||
|
"""
|
||||||
|
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", [""])) }
|
||||||
|
|
|
||||||
|
|
@ -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:
|
def get_action(self, game: str, action: str | None) -> ActionFunction:
|
||||||
"""
|
"""
|
||||||
Return the mute response for any game or action.
|
Return the mute response for any game or action.
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class RemoteAgent(Agent):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
url : str
|
url : str
|
||||||
|
games : set[str]
|
||||||
|
|
||||||
def __init__(self, url : str, timeout : float = 1.0) -> None:
|
def __init__(self, url : str, timeout : float = 1.0) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
@ -57,6 +58,8 @@ class RemoteAgent(Agent):
|
||||||
profile=content,
|
profile=content,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.games = set()
|
||||||
|
|
||||||
games = content.get("games")
|
games = content.get("games")
|
||||||
if isinstance(games, dict):
|
if isinstance(games, dict):
|
||||||
if "tic-tac-toe" in games:
|
if "tic-tac-toe" in games:
|
||||||
|
|
@ -64,6 +67,13 @@ class RemoteAgent(Agent):
|
||||||
on_move=lambda x : self.poll("tic-tac-toe", "", x),
|
on_move=lambda x : self.poll("tic-tac-toe", "", x),
|
||||||
profile=games["tic-tac-toe"],
|
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:
|
def get_action(self, game: str, action: str | None) -> Callable[[dict[str, Any]], dict[str, Any]] | None:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
|
||||||
|
|
@ -30,6 +30,7 @@ type alias EloStat =
|
||||||
, losses : Int
|
, losses : Int
|
||||||
, name : String
|
, name : String
|
||||||
, url : String
|
, url : String
|
||||||
|
, version : Maybe String
|
||||||
, wins : Int
|
, wins : Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,7 +40,7 @@ type alias HealthCheck =
|
||||||
|
|
||||||
|
|
||||||
type alias Leaderboard =
|
type alias Leaderboard =
|
||||||
{ name : String, players : Players }
|
List { name : String, players : Players }
|
||||||
|
|
||||||
|
|
||||||
type alias Match =
|
type alias Match =
|
||||||
|
|
@ -63,12 +64,13 @@ type alias Players =
|
||||||
|
|
||||||
eloStatDecoder : D.Decoder EloStat
|
eloStatDecoder : D.Decoder EloStat
|
||||||
eloStatDecoder =
|
eloStatDecoder =
|
||||||
D.map6 EloStat
|
D.map7 EloStat
|
||||||
(D.field "draws" D.int)
|
(D.field "draws" D.int)
|
||||||
(D.field "elo" D.int)
|
(D.field "elo" D.int)
|
||||||
(D.field "losses" D.int)
|
(D.field "losses" D.int)
|
||||||
(D.field "name" D.string)
|
(D.field "name" D.string)
|
||||||
(D.field "url" D.string)
|
(D.field "url" D.string)
|
||||||
|
(D.maybe <| D.field "version" D.string)
|
||||||
(D.field "wins" D.int)
|
(D.field "wins" D.int)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -129,9 +131,10 @@ healthCheckDecoder =
|
||||||
|
|
||||||
leaderboardDecoder : D.Decoder Leaderboard
|
leaderboardDecoder : D.Decoder Leaderboard
|
||||||
leaderboardDecoder =
|
leaderboardDecoder =
|
||||||
D.map2 Leaderboard
|
D.map2 (\name players -> { name = name, players = players })
|
||||||
(D.field "name" D.string)
|
(D.field "name" D.string)
|
||||||
(D.field "players" playersDecoder)
|
(D.field "players" playersDecoder)
|
||||||
|
|> D.list
|
||||||
|
|
||||||
|
|
||||||
matchDecoder : D.Decoder Match
|
matchDecoder : D.Decoder Match
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,17 @@ module EloTracker exposing (main)
|
||||||
|
|
||||||
import Api.Elo as Elo
|
import Api.Elo as Elo
|
||||||
import Element exposing (Element)
|
import Element exposing (Element)
|
||||||
|
import Element.Background
|
||||||
|
import Element.Font
|
||||||
import Http
|
import Http
|
||||||
import Json.Decode as D
|
import Json.Decode as D
|
||||||
|
import Layout
|
||||||
|
import Pixels
|
||||||
import Program
|
import Program
|
||||||
|
import Screen.Leaderboard
|
||||||
|
import Theme
|
||||||
import Time
|
import Time
|
||||||
import Widget.Icon
|
import Widget.Icon
|
||||||
import Layout
|
|
||||||
import Theme
|
|
||||||
import Screen.Leaderboard
|
|
||||||
import Element.Font
|
|
||||||
import Pixels
|
|
||||||
import Element.Background
|
|
||||||
|
|
||||||
|
|
||||||
main =
|
main =
|
||||||
|
|
@ -35,12 +35,14 @@ type alias Model =
|
||||||
{ baseUrl : String
|
{ baseUrl : String
|
||||||
, leaderboard : Result Int Elo.Leaderboard
|
, leaderboard : Result Int Elo.Leaderboard
|
||||||
, matches : Result Int (List Elo.Match)
|
, matches : Result Int (List Elo.Match)
|
||||||
|
, selectedName : String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
type Msg
|
type Msg
|
||||||
= OnLeaderboard (Result Http.Error Elo.Leaderboard)
|
= OnLeaderboard (Result Http.Error Elo.Leaderboard)
|
||||||
| OnMatches (Result Http.Error (List Elo.Match))
|
| OnMatches (Result Http.Error (List Elo.Match))
|
||||||
|
| OnSelectLeaderboardName String
|
||||||
| RefreshLeaderboard
|
| RefreshLeaderboard
|
||||||
| RefreshMatches
|
| RefreshMatches
|
||||||
|
|
||||||
|
|
@ -60,6 +62,7 @@ init flag =
|
||||||
{ baseUrl = baseUrl
|
{ baseUrl = baseUrl
|
||||||
, leaderboard = Err 0
|
, leaderboard = Err 0
|
||||||
, matches = Err 0
|
, matches = Err 0
|
||||||
|
, selectedName = "tic-tac-toe"
|
||||||
}
|
}
|
||||||
in
|
in
|
||||||
( model
|
( model
|
||||||
|
|
@ -99,6 +102,9 @@ update msg model =
|
||||||
OnMatches (Ok v) ->
|
OnMatches (Ok v) ->
|
||||||
( { model | matches = Ok v }, Cmd.none )
|
( { model | matches = Ok v }, Cmd.none )
|
||||||
|
|
||||||
|
OnSelectLeaderboardName name ->
|
||||||
|
( { model | selectedName = name }, Cmd.none )
|
||||||
|
|
||||||
RefreshLeaderboard ->
|
RefreshLeaderboard ->
|
||||||
( model, getLeaderboard model )
|
( model, getLeaderboard model )
|
||||||
|
|
||||||
|
|
@ -181,7 +187,7 @@ view data =
|
||||||
, Element.spacing 10
|
, Element.spacing 10
|
||||||
]
|
]
|
||||||
[ Element.text "Loading leaderboard..."
|
[ 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 }
|
, Layout.loadingIndicator { color = Theme.lavender data.flavor }
|
||||||
|> Element.el [ Element.centerX ]
|
|> Element.el [ Element.centerX ]
|
||||||
]
|
]
|
||||||
|
|
@ -189,5 +195,11 @@ view data =
|
||||||
|
|
||||||
Ok leaderboard ->
|
Ok leaderboard ->
|
||||||
Screen.Leaderboard.view
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,83 @@ module Screen.Leaderboard exposing (..)
|
||||||
{-| Widget module for a leaderboard page.
|
{-| 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 exposing (Element)
|
||||||
|
import Element.Background
|
||||||
|
import Element.Border
|
||||||
|
import Element.Events
|
||||||
import Element.Font
|
import Element.Font
|
||||||
import Layout
|
import Layout
|
||||||
import Pixels exposing (Pixels)
|
import Pixels exposing (Pixels)
|
||||||
import Program exposing (ViewBox)
|
import Program exposing (ViewBox)
|
||||||
import Quantity exposing (Quantity)
|
import Quantity exposing (Quantity)
|
||||||
import Theme
|
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 =
|
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
|
let
|
||||||
boardWidth =
|
boardWidth =
|
||||||
data.size.width
|
data.size.width
|
||||||
|
|
@ -33,8 +95,8 @@ view data =
|
||||||
|> List.map (viewEloStat { flavor = data.flavor })
|
|> List.map (viewEloStat { flavor = data.flavor })
|
||||||
|> Element.column [ Element.centerX, boardWidth ]
|
|> Element.column [ Element.centerX, boardWidth ]
|
||||||
|> List.singleton
|
|> List.singleton
|
||||||
|> (::) (
|
|> (::)
|
||||||
Element.text data.model.name
|
(Element.text data.model.name
|
||||||
|> Element.el
|
|> Element.el
|
||||||
[ Element.centerX, Element.Font.size 50 ]
|
[ Element.centerX, Element.Font.size 50 ]
|
||||||
)
|
)
|
||||||
|
|
@ -62,7 +124,13 @@ viewEloStat data stat =
|
||||||
[ Element.Font.color (Theme.subtext0UI data.flavor)
|
[ Element.Font.color (Theme.subtext0UI data.flavor)
|
||||||
, Element.Font.size 15
|
, 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
|
-- 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 = "LOSSES", value = stat.losses, color = Theme.redUI data.flavor }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
field : { text : String, value : Int, color : Element.Color } -> Element msg
|
field : { text : String, value : Int, color : Element.Color } -> Element msg
|
||||||
field { text, value, color } =
|
field { text, value, color } =
|
||||||
Element.column
|
Element.column
|
||||||
|
|
|
||||||
9
elo.py
9
elo.py
|
|
@ -3,7 +3,7 @@
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from elo_tracker import EloTracker
|
from elo_tracker import EloTracker
|
||||||
from pyclient.games import TicTacToe
|
from pyclient.games import RoyalGameOfUr, TicTacToe
|
||||||
|
|
||||||
GAME_FILE = "games.jsonl"
|
GAME_FILE = "games.jsonl"
|
||||||
PLAYER_FILE = "known_players.json"
|
PLAYER_FILE = "known_players.json"
|
||||||
|
|
@ -21,6 +21,13 @@ def main() -> int:
|
||||||
player_count=2,
|
player_count=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tracker.start_periodic_matches(
|
||||||
|
game=RoyalGameOfUr.empty(),
|
||||||
|
interval_seconds=9 * 60,
|
||||||
|
max_turns=1_500,
|
||||||
|
player_count=2,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tracker.start_server(import_name=__name__)
|
tracker.start_server(import_name=__name__)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,9 @@ from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Sequence
|
from typing import Any, Sequence
|
||||||
|
|
||||||
import agents
|
import agents.remote
|
||||||
import pyclient
|
import pyclient
|
||||||
|
import time
|
||||||
|
|
||||||
from pyclient.games import FinishState, Game
|
from pyclient.games import FinishState, Game
|
||||||
|
|
||||||
|
|
@ -38,7 +39,7 @@ class PlayerIdentifier:
|
||||||
return (self.name, self.url, self.version)
|
return (self.name, self.url, self.version)
|
||||||
|
|
||||||
@classmethod
|
@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.
|
Gain a player identifier from an agent.
|
||||||
"""
|
"""
|
||||||
|
|
@ -176,7 +177,7 @@ class Match:
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_replay(
|
def from_replay(
|
||||||
cls,
|
cls,
|
||||||
players : list[agents.RemoteAgent],
|
players : list[agents.remote.RemoteAgent],
|
||||||
replay : pyclient.GameReplay,
|
replay : pyclient.GameReplay,
|
||||||
timestamp : str | None,
|
timestamp : str | None,
|
||||||
) -> "Match":
|
) -> "Match":
|
||||||
|
|
@ -280,7 +281,7 @@ class EloTracker:
|
||||||
# Threading variables
|
# Threading variables
|
||||||
self.__lock = threading.RLock()
|
self.__lock = threading.RLock()
|
||||||
self.__scheduler_stop = threading.Event()
|
self.__scheduler_stop = threading.Event()
|
||||||
self.__scheduler_thread: threading.Thread | None = None
|
self.__scheduler_threads: list[threading.Thread] = []
|
||||||
|
|
||||||
# Immutable values
|
# Immutable values
|
||||||
self.debug: bool = debug
|
self.debug: bool = debug
|
||||||
|
|
@ -290,9 +291,9 @@ class EloTracker:
|
||||||
|
|
||||||
# Thread-unsafe variables
|
# Thread-unsafe variables
|
||||||
# Please use a lock while doing CRUD operations on them
|
# 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.__matches: list[Match] = []
|
||||||
self.__stats: dict[PlayerIdentifier, EloStat] = {}
|
self.__stats: dict[str, dict[PlayerIdentifier, EloStat]] = {}
|
||||||
|
|
||||||
# Initialize tracker
|
# Initialize tracker
|
||||||
self.__load_matches()
|
self.__load_matches()
|
||||||
|
|
@ -309,25 +310,30 @@ class EloTracker:
|
||||||
with self.__lock:
|
with self.__lock:
|
||||||
print(f"[EloTracker] {message}")
|
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.
|
Get a player's statistics based on their player identifier.
|
||||||
|
|
||||||
If the player wasn't known, the function returns a newly
|
If the player wasn't known, the function returns a newly
|
||||||
initialized record in the database for them.
|
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.
|
:param player_id: Unique player identifier.
|
||||||
:type player_id: PlayerIdentifier
|
:type player_id: PlayerIdentifier
|
||||||
:return: Elo statistics
|
:return: Elo statistics
|
||||||
"""
|
"""
|
||||||
with self.__lock:
|
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:
|
if stat is not None:
|
||||||
return stat
|
return stat
|
||||||
|
|
||||||
|
if game_name not in self.__stats:
|
||||||
|
self.__stats[game_name] = {}
|
||||||
|
|
||||||
stat = EloStat.new(player_id=player_id)
|
stat = EloStat.new(player_id=player_id)
|
||||||
self.__stats[player_id] = stat
|
self.__stats[game_name][player_id] = stat
|
||||||
|
|
||||||
return stat
|
return stat
|
||||||
|
|
||||||
|
|
@ -387,13 +393,13 @@ class EloTracker:
|
||||||
# Do not apply them yet, in order to guarantee fair ELO shifts
|
# Do not apply them yet, in order to guarantee fair ELO shifts
|
||||||
for player_id1, result1 in m.participants:
|
for player_id1, result1 in m.participants:
|
||||||
total_k = 0.0
|
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:
|
for player_id2, result2 in m.participants:
|
||||||
if player_id1 == player_id2:
|
if player_id1 == player_id2:
|
||||||
continue
|
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))
|
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
|
# Then, apply the ELO score update + count the wins, draws & losses
|
||||||
for player_id, result in m.participants:
|
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]
|
player.elo += scores[player_id]
|
||||||
|
|
||||||
match result:
|
match result:
|
||||||
|
|
@ -430,6 +436,7 @@ class EloTracker:
|
||||||
game: Game,
|
game: Game,
|
||||||
interval_seconds: float,
|
interval_seconds: float,
|
||||||
player_count: int,
|
player_count: int,
|
||||||
|
max_turns : int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Perform a schedule in which you play a random game.
|
Perform a schedule in which you play a random game.
|
||||||
|
|
@ -440,13 +447,20 @@ class EloTracker:
|
||||||
:type interval_seconds: float
|
:type interval_seconds: float
|
||||||
:param player_count: The number of players that are supposed to participate
|
:param player_count: The number of players that are supposed to participate
|
||||||
:type player_count: int
|
: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():
|
while not self.__scheduler_stop.is_set():
|
||||||
try:
|
try:
|
||||||
self.load_players()
|
self.load_players()
|
||||||
|
|
||||||
with self.__lock:
|
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:
|
if available < player_count:
|
||||||
self.__debug(
|
self.__debug(
|
||||||
|
|
@ -455,9 +469,13 @@ class EloTracker:
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.__debug(
|
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:
|
except Exception as exc:
|
||||||
self.__debug(f"Scheduled match failed: {exc}")
|
self.__debug(f"Scheduled match failed: {exc}")
|
||||||
# raise exc
|
# raise exc
|
||||||
|
|
@ -473,7 +491,7 @@ class EloTracker:
|
||||||
:type import_name: str
|
:type import_name: str
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from flask import Flask, Response, jsonify
|
from flask import Flask, Response, jsonify, request
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"Flask is required to host the EloTracker server. "
|
"Flask is required to host the EloTracker server. "
|
||||||
|
|
@ -526,8 +544,15 @@ class EloTracker:
|
||||||
return jsonify(self.get_json_matches())
|
return jsonify(self.get_json_matches())
|
||||||
|
|
||||||
@app.get("/players")
|
@app.get("/players")
|
||||||
def players() -> Response:
|
def players() -> tuple[Response, int]:
|
||||||
return jsonify(self.get_json_players())
|
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
|
return app
|
||||||
|
|
||||||
|
|
@ -539,7 +564,11 @@ class EloTracker:
|
||||||
:rtype: bool
|
:rtype: bool
|
||||||
"""
|
"""
|
||||||
with self.__lock:
|
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:
|
def load_players(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
@ -560,13 +589,13 @@ class EloTracker:
|
||||||
"Expected `players` field to be a list of strings."
|
"Expected `players` field to be a list of strings."
|
||||||
)
|
)
|
||||||
|
|
||||||
players : list[agents.RemoteAgent] = []
|
players : list[agents.remote.RemoteAgent] = []
|
||||||
for url in urls:
|
for url in urls:
|
||||||
if not isinstance(url, str):
|
if not isinstance(url, str):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent = agents.RemoteAgent(url=url)
|
agent = agents.remote.RemoteAgent(url=url)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass # Not an available player right now
|
pass # Not an available player right now
|
||||||
else:
|
else:
|
||||||
|
|
@ -576,25 +605,44 @@ class EloTracker:
|
||||||
self.players = players
|
self.players = players
|
||||||
|
|
||||||
for agent in 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.
|
Play a single match with appointed players.
|
||||||
|
|
||||||
:param players: List of URLs that participate.
|
:param players: List of URLs that participate.
|
||||||
:type players: list[str]
|
: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.
|
:return: A summary of the game.
|
||||||
:rtype: pyclient.GameReplay
|
:rtype: pyclient.GameReplay
|
||||||
:raises ValueError: One of the URLs could not be accessed.
|
: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 ]
|
||||||
|
|
||||||
|
try:
|
||||||
replay = pyclient.PyClient(debug=self.debug).play_game(
|
replay = pyclient.PyClient(debug=self.debug).play_game(
|
||||||
players=ags,
|
players=ags,
|
||||||
start=game,
|
start=game,
|
||||||
|
max_turns=max_turns,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
return None # Exceeded turn limit
|
||||||
|
|
||||||
|
else:
|
||||||
m = Match.from_replay(players=ags, replay=replay, timestamp=Match.now())
|
m = Match.from_replay(players=ags, replay=replay, timestamp=Match.now())
|
||||||
|
|
||||||
# Record match
|
# Record match
|
||||||
|
|
@ -606,19 +654,22 @@ class EloTracker:
|
||||||
def play_random_match(
|
def play_random_match(
|
||||||
self,
|
self,
|
||||||
game: Game,
|
game: Game,
|
||||||
|
max_turns : int | None = None,
|
||||||
player_count: int | None = None,
|
player_count: int | None = None,
|
||||||
) -> pyclient.GameReplay:
|
) -> pyclient.GameReplay | None:
|
||||||
"""
|
"""
|
||||||
Play a game with any known players.
|
Play a game with any known players.
|
||||||
|
|
||||||
:param game: The game to start playing
|
:param game: The game to start playing
|
||||||
:type game: Game
|
: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.
|
:param player_count: Optional number of players to select.
|
||||||
:type player_count: int | None
|
:type player_count: int | None
|
||||||
:raises ValueError: One of the randomly chosen URLs could not be accessed.
|
:raises ValueError: One of the randomly chosen URLs could not be accessed.
|
||||||
"""
|
"""
|
||||||
with self.__lock:
|
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)
|
random.shuffle(players)
|
||||||
|
|
||||||
|
|
@ -631,6 +682,7 @@ class EloTracker:
|
||||||
self,
|
self,
|
||||||
game: Game,
|
game: Game,
|
||||||
interval_seconds: float = 300,
|
interval_seconds: float = 300,
|
||||||
|
max_turns : int | None = None,
|
||||||
player_count: int = 2,
|
player_count: int = 2,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
@ -640,6 +692,8 @@ class EloTracker:
|
||||||
:type game: pyclient.Game
|
:type game: pyclient.Game
|
||||||
:param interval_seconds: Number of seconds to sleep between games
|
:param interval_seconds: Number of seconds to sleep between games
|
||||||
:type interval_seconds: float
|
: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
|
:param player_count: The number of players that are supposed to participate
|
||||||
:type player_count: int
|
:type player_count: int
|
||||||
"""
|
"""
|
||||||
|
|
@ -649,40 +703,51 @@ class EloTracker:
|
||||||
raise ValueError("player_count must be greater than one.")
|
raise ValueError("player_count must be greater than one.")
|
||||||
|
|
||||||
with self.__lock:
|
with self.__lock:
|
||||||
if self.is_running_periodic_matches():
|
|
||||||
self.stop_periodic_matches()
|
|
||||||
|
|
||||||
self.__scheduler_stop.clear()
|
self.__scheduler_stop.clear()
|
||||||
self.__scheduler_thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=self.__scheduler_loop,
|
target=self.__scheduler_loop,
|
||||||
args=(game, interval_seconds, player_count),
|
args=(game, interval_seconds, player_count),
|
||||||
|
kwargs={"max_turns": max_turns},
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
self.__scheduler_thread.start()
|
self.__scheduler_threads.append(thread)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
def stop_periodic_matches(self) -> None:
|
def stop_periodic_matches(self) -> None:
|
||||||
"""
|
"""
|
||||||
Stop the periodic match scheduler and wait briefly for it to exit.
|
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:
|
with self.__lock:
|
||||||
self.__scheduler_stop.set()
|
self.__scheduler_stop.set()
|
||||||
thread = self.__scheduler_thread
|
to_remove = [ thread for thread in self.__scheduler_threads ]
|
||||||
|
|
||||||
|
# Then, quietly remove all existing threads
|
||||||
|
for thread in self.__scheduler_threads:
|
||||||
if thread is not None:
|
if thread is not None:
|
||||||
thread.join(timeout=5)
|
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:
|
with self.__lock:
|
||||||
if self.__scheduler_thread is thread:
|
for thread in to_remove:
|
||||||
self.__scheduler_thread = None
|
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.
|
Return known currently available players as notebook-friendly dicts.
|
||||||
"""
|
"""
|
||||||
with self.__lock:
|
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(
|
players.sort(
|
||||||
key=lambda player: (-int(player.elo), player.player_id.name)
|
key=lambda player: (-int(player.elo), player.player_id.name)
|
||||||
|
|
@ -709,14 +774,17 @@ class EloTracker:
|
||||||
|
|
||||||
return [ m.to_json() for m in matches ]
|
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 aggregate player statistics for local use or JSON APIs.
|
||||||
"""
|
"""
|
||||||
return {
|
return [
|
||||||
"name": self.name,
|
{
|
||||||
"players": self.get_json_players(),
|
"name": game_name,
|
||||||
|
"players": self.get_json_players(game_name=game_name),
|
||||||
}
|
}
|
||||||
|
for game_name in self.__stats
|
||||||
|
]
|
||||||
|
|
||||||
def start_server(
|
def start_server(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,12 @@ class PyClient:
|
||||||
|
|
||||||
debug : bool
|
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.
|
Generate a game by polling the players.
|
||||||
|
|
||||||
|
|
@ -27,12 +32,22 @@ class PyClient:
|
||||||
:type players: list[Agent]
|
:type players: list[Agent]
|
||||||
:param start: The start state of the game.
|
:param start: The start state of the game.
|
||||||
:type start: 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.
|
:return: A generator that yields turns.
|
||||||
:rtype: Generator[Turn, None, None]
|
:rtype: Generator[Turn, None, None]
|
||||||
|
:raises ValueError: The game is taking too long.
|
||||||
"""
|
"""
|
||||||
current_state = start
|
current_state = start
|
||||||
|
turns_taken = 0
|
||||||
|
|
||||||
while current_state.winner() is None:
|
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()
|
player = current_state.player_to_move()
|
||||||
|
|
||||||
if len(players) < player:
|
if len(players) < player:
|
||||||
|
|
@ -61,7 +76,12 @@ class PyClient:
|
||||||
|
|
||||||
yield Turn(action=payload, player=player, state=current_state)
|
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
|
Generate a game by polling the players. Collect all moves in a
|
||||||
summary.
|
summary.
|
||||||
|
|
@ -70,11 +90,16 @@ class PyClient:
|
||||||
:type players: list[Agent]
|
:type players: list[Agent]
|
||||||
:param start: The start state of the game.
|
:param start: The start state of the game.
|
||||||
:type start: 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.
|
:return: Summary describing how the game went.
|
||||||
:rtype: GameReplay
|
:rtype: GameReplay
|
||||||
|
:raises ValueError: The game is taking too long.
|
||||||
"""
|
"""
|
||||||
return GameReplay(
|
return GameReplay(
|
||||||
game_name=start.game_name(),
|
game_name=start.game_name(),
|
||||||
start=start,
|
start=start,
|
||||||
turns=list(self.gen_game(players=players, start=start)),
|
turns=list(self.gen_game(
|
||||||
|
players=players, start=start, max_turns=max_turns
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@
|
||||||
|
|
||||||
from .game import FinishState, Game
|
from .game import FinishState, Game
|
||||||
from .tic_tac_toe import TicTacToe
|
from .tic_tac_toe import TicTacToe
|
||||||
|
from .ur import RoyalGameOfUr
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"FinishState",
|
"FinishState",
|
||||||
"Game",
|
"Game",
|
||||||
|
"RoyalGameOfUr",
|
||||||
"TicTacToe",
|
"TicTacToe",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -19,7 +19,10 @@ RUN pip install --no-cache-dir --no-index --find-links=/wheels \
|
||||||
-r requirements-pyserver.txt && rm -rf /wheels
|
-r requirements-pyserver.txt && rm -rf /wheels
|
||||||
|
|
||||||
# Install PyServer code
|
# Install PyServer code
|
||||||
|
COPY agents/ agents/
|
||||||
COPY pyserver/ pyserver/
|
COPY pyserver/ pyserver/
|
||||||
COPY server.py .
|
COPY server.py .
|
||||||
|
|
||||||
|
ENV CONTAINERIZED=1
|
||||||
|
|
||||||
CMD ["python", "server.py"]
|
CMD ["python", "server.py"]
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
from flask import Flask, jsonify, request
|
from flask import Flask, jsonify, request
|
||||||
from agents import Agent
|
from agents import Agent
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -24,6 +26,7 @@ class PyServer:
|
||||||
:param import_name: Flask application import name
|
:param import_name: Flask application import name
|
||||||
:type import_name: str
|
:type import_name: str
|
||||||
"""
|
"""
|
||||||
|
self.containerized : bool = bool(int(os.environ.get("CONTAINERIZED", "0")))
|
||||||
self.agent = agent
|
self.agent = agent
|
||||||
self.app = Flask(import_name)
|
self.app = Flask(import_name)
|
||||||
|
|
||||||
|
|
@ -49,6 +52,7 @@ class PyServer:
|
||||||
|
|
||||||
d = { **self.agent.profile, **d }
|
d = { **self.agent.profile, **d }
|
||||||
|
|
||||||
|
d["me.noordstar.peanuts.containerized"] = self.containerized
|
||||||
d["games"] = {}
|
d["games"] = {}
|
||||||
for game, (profile, _) in self.agent.registered_games.items():
|
for game, (profile, _) in self.agent.registered_games.items():
|
||||||
d["games"][game] = profile
|
d["games"][game] = profile
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue