Compare commits

..

14 Commits

26 changed files with 891 additions and 111 deletions

View File

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

4
.gitignore vendored
View File

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

View File

@ -1,15 +1,24 @@
# Bot-Man-Toe # Bot-Man-Toe
Bot-Man-Toe is an attempt to create a way for players to play games against Write a script that plays tic-tac-toe, and see how well it performs against
themselves, other players, or self-trained AI players. other programs!
## Technology stack - [🚀 Write your own agent](agents/README.md)
- [🖊 Compare how well your agent performs](elo_tracker/README.md)
- [🌐 Publish your agent to the internet](pyserver/README.md)
Counterintuitively, the **servers** are participants to a game. The **clients** ## Get started
are programs or browsers that mediate matches between servers.
## More 1. Clone this repository.
2. Run `python client.py` in the terminal and let two random agents play
against each other.
3. Copy the example agent and [create your own](agents/README.md).
4. Play against the AgentOfChaos while you improve your strategy.
5. Publish your agent. _(optional)_
6. Compare it against others with the Elo tracker. _(optional)_
- The discovery contract is documented in `spec/README.md`.
- Python client helpers live under `pyclient/`. ## Links
- Python server helpers live under `pyserver/`.
- [📜 API specification](pyserver/spec.md)
- [🏆 Online ELO tracker](https://elo.noordstar.me/)

70
agents/README.md Normal file
View File

@ -0,0 +1,70 @@
# 🚀 Write your own agent!
An **agent** is a Python class that knows how to play one or more games.
Don't worry about writing a perfect strategy. Start with something that works,
print the incoming game state, and improve it one step at a time.
## Quick start
1. Copy [example.py](example.py).
2. Rename the file and the `ExampleAgent` class.
3. Update the agent's name, author and version.
4. Implement `play_tic_tac_toe()`.
5. Import your agent in `client.py` and let it play a game.
That's enough to get started.
## Understanding the game
Whenever your agent has to make a move, it receives the current game state as
a Python dictionary.
Start by printing it:
```py
print(payload)
```
Run a few games and watch how the payload changes after every move. Once you
understand what you're receiving, you can start writing your own strategy!
Your function should return:
```py
{"move": 7}
```
where the number is the square you want to play.
## Testing your agent
Open [client.py](../client.py) and replace one of the players with your own agent.
For example:
```py
from agents.my_agent import MyAgent
from agents.chaos import AgentOfChaos
players = [
MyAgent(),
AgentOfChaos(),
]
```
The repository includes `AgentOfChaos`, a very simple opponent that plays
random moves. It's useful for testing your own agent while you're developing it.
> ⚠️ Don't try to build the perfect player immediately! Agents are easy to
> improve while you test against other agents. Plus, imperfect agents are
> typically the most interesting.
Run the client, inspect the output, tweak your algorithm, and repeat. You don't
need to understand the rest of the project before you can start experimenting.
## What's next?
Once you're happy with your agent, you can:
- [🖊 Compare your agent against other agents with the ELO tracker](../elo_tracker/README.md)
- [🌐 Publish your agent so other people can play against it](../pyserver/README.md)

View File

@ -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",
] ]

View File

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

View File

@ -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:
@ -61,4 +62,17 @@ def play_tic_tac_toe(payload : Payload) -> Payload:
for k, v in dict(payload).items() for k, v in dict(payload).items()
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", [""])) }

104
agents/example.py Normal file
View File

@ -0,0 +1,104 @@
"""
This module contains an example agent that you can use to create your own!
Please copy this file, rename it, and then build it the way you see fit.
"""
from __future__ import annotations
import random
from .agent import Agent, Payload
class ExampleAgent(Agent):
"""
Describe here what your agent does and how it behaves! This will help
others understand how your agent works.
"""
def __init__(self) -> None:
"""
Create a custom instance of your agent. This function allows you
to define which games your agent can play.
You may add parameters to the function if your agent requires more
information to be able to operate.
"""
super().__init__(
# Give your bot a name to display in leaderboards
name="MY super smart agent",
# Your name, to give you credit
author="Unknown programmer",
# 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:
"""
Play a game of tic-tac-toe.
You receive a payload that looks like this:
{
"1": "X", "2": "", "3": "O",
"4": "X", "5": "O", "6": "",
"7": "", "8": "", "9": "",
"your_token": "X"
}
And you're expected to return a response of which field you'd like to
place your piece in. For example, if you wish to place your token in
field 7, your response should look like this:
{ "move": 7 }
The board is arranged as follows:
1 | 2 | 3
---+---+---
4 | 5 | 6
---+---+---
7 | 8 | 9
:param payload: The incoming JSON that contains the game state.
:type payload: dict[str, Any]
:return: The move you wish to take.
:rtype: dict[str, Any]
"""
# Try printing the payload to see what it looks like!
print(payload)
options = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, ]
# 1. Try filtering out the impossible moves!
# If an X or O was already placed at a field, remove it from the options
#
# 2. Try finding two in a row! If possible, you can try to place the third
# item on the board and get 3 in a row.
#
# 3. Perhaps you can block the opponent from getting 3 in a row?
#
# Now, pick any of the remaining options.
# This is just a simple implementation. Naturally, you're welcome to try
# your own algorithm.
return { "move": random.choice(options) }

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

View File

@ -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:
""" """
@ -93,7 +103,7 @@ class RemoteAgent(Agent):
response = requests.get(url, json=payload, timeout=self.timeout) response = requests.get(url, json=payload, timeout=self.timeout)
response.raise_for_status() response.raise_for_status()
content = response.json() content = response.json()
except (requests.RequestException, requests.HTTPError, ValueError): except (requests.ConnectTimeout, requests.RequestException, requests.HTTPError, ValueError):
return {} return {}
else: else:
return content if isinstance(content, dict) else {} return content if isinstance(content, dict) else {}

View File

@ -23,9 +23,14 @@ from __future__ import annotations
import json import json
import pyclient import pyclient
from agents import Agent, RemoteAgent # Import your game(s) here
from pyclient import PyClient
from pyclient.games import TicTacToe from pyclient.games import TicTacToe
# ...
# Import your agent(s) here
from agents import Agent, AgentOfChaos
# from agents.my_agent import MyAgent
# ...
def main() -> int: def main() -> int:
""" """
@ -34,22 +39,29 @@ def main() -> int:
:return: Exit code :return: Exit code
:rtype: int :rtype: int
""" """
c = PyClient(debug=True)
# Start the game engine
c = pyclient.PyClient(debug=True)
# Mix and match any agents you'd like.
# During development it's usually easiest to play against AgentOfChaos().
players : list[Agent] = [ players : list[Agent] = [
RemoteAgent(url="https://bmt001.noordstar.me/"), AgentOfChaos(),
RemoteAgent(url="https://bmt002.noordstar.me/"), AgentOfChaos(),
] ]
out = c.play_game( # Play a given game with your players
result = c.play_game(
players=players, players=players,
start=TicTacToe.empty(), start=TicTacToe.empty(),
) )
inspect_game(out) # Print the game results to the terminal!
inspect_game(result)
return 0 return 0
def inspect_game(game : pyclient.GameReplay) -> None: def inspect_game(game : pyclient.GameReplay) -> None:
""" """
Print a diagnostic of a played game to the terminal. Print a diagnostic of a played game to the terminal.
@ -89,5 +101,6 @@ def inspect_game(game : pyclient.GameReplay) -> None:
print(f"Total turns taken: {len(game.turns)}") print(f"Total turns taken: {len(game.turns)}")
print(f"Result: {final_state.winner()}") print(f"Result: {final_state.winner()}")
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())

View File

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

View File

@ -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
@ -98,10 +101,13 @@ 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 )
RefreshMatches -> RefreshMatches ->
( model, getMatches model ) ( model, getMatches model )
@ -142,7 +148,7 @@ title model =
case model.leaderboard of case model.leaderboard of
Err _ -> Err _ ->
"EloTracker | Loading..." "EloTracker | Loading..."
Ok _ -> Ok _ ->
"EloTracker | Leaderboard" "EloTracker | Leaderboard"
@ -167,7 +173,7 @@ view data =
|> Element.el [ Element.centerX ] |> Element.el [ Element.centerX ]
] ]
] ]
Err n -> Err n ->
Element.column Element.column
[ Element.width <| Element.px <| Pixels.inPixels data.size.width [ Element.width <| Element.px <| Pixels.inPixels data.size.width
@ -181,13 +187,19 @@ 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 ]
] ]
] ]
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
}

View File

@ -3,26 +3,88 @@ 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
|> Quantity.toFloatQuantity |> Quantity.toFloatQuantity
|> Quantity.multiplyBy (2/3) |> Quantity.multiplyBy (2 / 3)
|> Quantity.floor |> Quantity.floor
|> Quantity.clamp (Pixels.pixels 600) (Pixels.pixels 1800) |> Quantity.clamp (Pixels.pixels 600) (Pixels.pixels 1800)
|> Pixels.inPixels |> Pixels.inPixels
@ -33,15 +95,15 @@ 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 ]
) )
|> Element.column |> Element.column
[ Element.padding 15 [ Element.padding 15
, Element.spacing 30 , Element.spacing 30
, Element.width <| Element.px <| Pixels.inPixels data.size.width , Element.width <| Element.px <| Pixels.inPixels data.size.width
] ]
@ -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
View File

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

49
elo_tracker/README.md Normal file
View File

@ -0,0 +1,49 @@
# ELO tracker
The ELO tracker lets your agent play many games and assigns every participant
an Elo rating.
## Before you start
The tracker only plays against **remote agents**. Remote agents are available
through a URL, and usually run on the internet.
If your agent only exists as a local Python class,
[publish it first](../pyserver/README.md).
## Add players
Open `known_players.json` and add the URLs you want to include.
For example:
```json
{
"players": [
"https://my-agent.example",
"https://bmt001.noordstar.me"
]
}
```
Every listed URL is considered a participant. The ELO tracker will compare
these players with one another.
## Start the tracker
Run:
```sh
python elo.py
```
The tracker will continuously schedule new matches until you stop it.
## Results
Two files are updated while the tracker runs:
* `games.jsonl` stores the played games.
* `known_players.json` stores the list of participants.
You can stop the tracker with <kbd>Ctrl</kbd>+<kbd>C</kbd>.

View File

@ -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,12 +469,16 @@ 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
if self.__scheduler_stop.wait(interval_seconds): if self.__scheduler_stop.wait(interval_seconds):
break break
@ -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,49 +605,71 @@ 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 ]
replay = pyclient.PyClient(debug=self.debug).play_game( try:
players=ags, replay = pyclient.PyClient(debug=self.debug).play_game(
start=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
else:
m = Match.from_replay(players=ags, replay=replay, timestamp=Match.now())
# Record match # Record match
m.log(self.game_file_name) m.log(self.game_file_name)
self.__register_match(m) self.__register_match(m)
return replay return replay
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 ]
if thread is not None: # Then, quietly remove all existing threads
thread.join(timeout=5) 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: 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,

View File

@ -1,6 +1,7 @@
{ {
"players": [ "players": [
"https://bmt001.noordstar.me", "https://bmt001.noordstar.me",
"https://bmt002.noordstar.me" "https://bmt002.noordstar.me",
"https://bmt003.noordstar.me"
] ]
} }

View File

@ -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:
@ -52,13 +67,21 @@ class PyClient:
if on_move is None: if on_move is None:
payload = {} payload = {}
else: else:
payload = on_move(current_state.as_seen_by(player=player)) try:
payload = on_move(current_state.as_seen_by(player=player))
except Exception:
payload = {}
current_state = current_state.move(payload=payload) current_state = current_state.move(payload=payload)
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.
@ -67,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
)),
) )

View File

@ -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",
] ]

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,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"]

36
pyserver/README.md Normal file
View File

@ -0,0 +1,36 @@
# Publishing your agent
Once your agent behaves the way you want, you can expose it over HTTP so other clients can play against it.
You only need this if you want other programs to connect to your agent.
## Use this repository
1. Open `server.py`.
2. Replace the agent passed to `PyServer` with your own. For example:
```py
from agents.my_agent import MyAgent
player = PyServer(MyAgent())
```
3. Start the server. Run:
```sh
python server.py
```
By default, the server listens on port `5000`.
4. Open your browser and visit `http://localhost:5000/`. You should receive
a JSON document describing your agent and the games it supports.
If that works, your agent is ready to receive game requests.
## Writing your own server
The included server is the easiest way to publish a Python agent.
If you want to implement the protocol yourself
_(for example in another language)_ you can use the specification in
[the protocol specification](./spec.md).

View File

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

View File

@ -3,6 +3,10 @@
This document describes how a Bot-Man-Toe server or client is supposed to This document describes how a Bot-Man-Toe server or client is supposed to
behave. behave.
**This document requires familiarity with setting up an HTTP server.** Please
use [the easy implementation](../pyserver/README.md) if you're building
[a simple agent](../agents/README.md) in this repository.
## Terminology ## Terminology
A **server** is a REST API server that hosts a player willing to play games. A **server** is a REST API server that hosts a player willing to play games.
@ -65,3 +69,13 @@ package namespace guidelines.
} }
``` ```
There's a few optional data fields that are specified here:
| Field | Type | Description |
| ----- | ---- | ----------- |
| author | string | The name of the person who designed the agent. |
| version | string | The version of the agent, in case there's an update. |
| me.noordstar.peanuts.containerized | bool | Whether the agent is running in a container. |
| me.noordstar.peanuts.is_ai | bool | Whether the agent runs on a trained deep learning model or some artificial intelligence. |
| me.noordstar.peanuts.agent.version | string | **Deprecated.** Experimental value to demonstrate an agent's version. Please use `version` instead. |
| me.noordstar.peanuts.author | string | **Deprecated.** Experimental value to demonstrate an agent's author. Please use `author` instead. |

View File

@ -15,7 +15,7 @@ def main() -> int:
:return: Exit code :return: Exit code
:rtype: int :rtype: int
""" """
player = PyServer(agents.RemoteAgent(url="https://bmt001.noordstar.me/")) player = PyServer(agents.AgentOfChaos())
# Start listening for games # Start listening for games
player.start( player.start(