Compare commits
1 Commits
main
...
agent/mute
| Author | SHA1 | Date |
|---|---|---|
|
|
e9a25c37e1 |
|
|
@ -10,13 +10,6 @@ spec/
|
||||||
.venv-pyserver
|
.venv-pyserver
|
||||||
.venv-pyclient
|
.venv-pyclient
|
||||||
|
|
||||||
# Compiled elm files
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,6 @@
|
||||||
.venv-pyserver
|
.venv-pyserver
|
||||||
.venv-pyclient
|
.venv-pyclient
|
||||||
|
|
||||||
# Compiled elm files
|
|
||||||
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
|
||||||
|
|
|
||||||
27
README.md
27
README.md
|
|
@ -1,24 +1,15 @@
|
||||||
# Bot-Man-Toe
|
# Bot-Man-Toe
|
||||||
|
|
||||||
Write a script that plays tic-tac-toe, and see how well it performs against
|
Bot-Man-Toe is an attempt to create a way for players to play games against
|
||||||
other programs!
|
themselves, other players, or self-trained AI players.
|
||||||
|
|
||||||
- [🚀 Write your own agent](agents/README.md)
|
## Technology stack
|
||||||
- [🖊 Compare how well your agent performs](elo_tracker/README.md)
|
|
||||||
- [🌐 Publish your agent to the internet](pyserver/README.md)
|
|
||||||
|
|
||||||
## Get started
|
Counterintuitively, the **servers** are participants to a game. The **clients**
|
||||||
|
are programs or browsers that mediate matches between servers.
|
||||||
|
|
||||||
1. Clone this repository.
|
## More
|
||||||
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`.
|
||||||
## Links
|
- Python client helpers live under `pyclient/`.
|
||||||
|
- Python server helpers live under `pyserver/`.
|
||||||
- [📜 API specification](pyserver/spec.md)
|
|
||||||
- [🏆 Online ELO tracker](https://elo.noordstar.me/)
|
|
||||||
|
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
# 🚀 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)
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
"""
|
|
||||||
Agents are players that can participate in a game. THey contain a unified
|
|
||||||
API that allows other systems to apply to them for communication.
|
|
||||||
For example:
|
|
||||||
|
|
||||||
1. The PyClient can locally run a game with agents as participants.
|
|
||||||
2. A PyServer can host a web server and asks a specific agent to respond.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .agent import Agent
|
|
||||||
from .chaos import AgentOfChaos
|
|
||||||
from .mute import MuteAgent
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"Agent",
|
|
||||||
"AgentOfChaos",
|
|
||||||
"MuteAgent",
|
|
||||||
]
|
|
||||||
154
agents/agent.py
154
agents/agent.py
|
|
@ -1,154 +0,0 @@
|
||||||
"""
|
|
||||||
The agent module hosts the base class of all agents. It dictates how
|
|
||||||
agents are expected to behave.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
# Communication type that the players communicate in
|
|
||||||
Payload = dict[str, Any]
|
|
||||||
|
|
||||||
# Function that the agent is expected to communicate with.
|
|
||||||
# The ActionFunction takes a Payload as input and returns a Payload of its own.
|
|
||||||
ActionFunction = Callable[[Payload], Payload]
|
|
||||||
|
|
||||||
GameInfo = tuple[Payload, dict[str, ActionFunction]]
|
|
||||||
|
|
||||||
class Agent:
|
|
||||||
"""
|
|
||||||
Base class for all agents to communicate in.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Local variables
|
|
||||||
author : str
|
|
||||||
name : str
|
|
||||||
profile : Payload
|
|
||||||
registered_games : dict[str, tuple[Payload, dict[str, ActionFunction]]]
|
|
||||||
version : str | None
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name : str,
|
|
||||||
author : str,
|
|
||||||
version : str | None = None,
|
|
||||||
profile : Payload = {},
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Create an instance of the agent.
|
|
||||||
|
|
||||||
Note that the base class expects quite a lot of variables for the
|
|
||||||
initialization, whereas the subclasses are expected to require
|
|
||||||
no inputs for initialization. It is the subclasses' responsibility
|
|
||||||
to override the current function by inserting its own values.
|
|
||||||
|
|
||||||
:param name: String value giving the player a name.
|
|
||||||
:type name: str
|
|
||||||
:param author: String value representing the player's designer or programmer.
|
|
||||||
:type author: str
|
|
||||||
:param version: Version of the player, in case an update changes its behavior.
|
|
||||||
:type version: str | None
|
|
||||||
:param profile: Custom profile pertaining to the player.
|
|
||||||
:type profile: dict[str, Any]
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Register basic (required) variables
|
|
||||||
self.author = author
|
|
||||||
self.name = name
|
|
||||||
self.profile = profile
|
|
||||||
self.version = version
|
|
||||||
|
|
||||||
# Have the subclass register games
|
|
||||||
self.registered_games = {}
|
|
||||||
|
|
||||||
def add_game(
|
|
||||||
self,
|
|
||||||
name : str,
|
|
||||||
actions : dict[str, ActionFunction],
|
|
||||||
profile : Payload,
|
|
||||||
required_actions : list[str] = [],
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Add a new game to the player. Replaces any prior definition for the
|
|
||||||
game.
|
|
||||||
|
|
||||||
:param name: Game name
|
|
||||||
:type name: str
|
|
||||||
:param actions: Dictionary containing all actions the player is willing to take.
|
|
||||||
:type actions: dict[str, Callable[[dict[str, Any]], dict[str, Any]]]
|
|
||||||
:param profile: Custom profile describing some details about a game.
|
|
||||||
:type profile: dict[str, Any]
|
|
||||||
:param required_actions: Optional completeness check. If provided,
|
|
||||||
all required action names must exist in `actions` and map to
|
|
||||||
callables.
|
|
||||||
:type required_actions: list[str]
|
|
||||||
:raises AssertionError: Some of the required actions aren't present.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Verify that all required actions exist
|
|
||||||
for ra in required_actions:
|
|
||||||
if ra not in actions.keys():
|
|
||||||
raise AssertionError(
|
|
||||||
f"Missing required action `{ra}` in action mapping."
|
|
||||||
)
|
|
||||||
|
|
||||||
self.registered_games[name] = ( profile, actions )
|
|
||||||
|
|
||||||
def add_tic_tac_toe(
|
|
||||||
self,
|
|
||||||
on_move : ActionFunction,
|
|
||||||
profile : Payload = {},
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Convenience registration for the tic-tac-toe game.
|
|
||||||
|
|
||||||
: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="tic-tac-toe",
|
|
||||||
actions={"": on_move},
|
|
||||||
profile=profile,
|
|
||||||
required_actions=[""],
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_royal_game_of_ur(
|
|
||||||
self,
|
|
||||||
on_move : ActionFunction,
|
|
||||||
profile : Payload = {},
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Convenience registration for the royal game of Ur.
|
|
||||||
|
|
||||||
:param on_move: Called function when the player is to make a move.
|
|
||||||
:type on_move: Callable[[dict[str, Any]], dict[str, Any]]
|
|
||||||
:param profile: Custom details to share about the game.
|
|
||||||
:type profile: dict[str, Any]
|
|
||||||
"""
|
|
||||||
return self.add_game(
|
|
||||||
name="royal-game-of-ur",
|
|
||||||
actions={"": on_move},
|
|
||||||
profile=profile,
|
|
||||||
required_actions=[""],
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_action(self, game : str, action : str | None) -> ActionFunction | None:
|
|
||||||
"""
|
|
||||||
Get an action function for a given game, if it exists.
|
|
||||||
|
|
||||||
:param game: The name of the game.
|
|
||||||
:type game: str
|
|
||||||
:param action: The name of the action, or None if the game only has one action.
|
|
||||||
:type action: str | None
|
|
||||||
:return: The action with which the agent would like to act, if at all.
|
|
||||||
:rtype: Callable[[dict[str, Any]], dict[str, Any]] | None
|
|
||||||
"""
|
|
||||||
_, actions = self.registered_games.get(game, ({}, {}))
|
|
||||||
|
|
||||||
if action is None:
|
|
||||||
return list(actions.values())[0] if len(actions) == 1 else None
|
|
||||||
else:
|
|
||||||
return actions.get(action, None)
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
"""
|
|
||||||
The agent of chaos is an agent that always attempts to return a random
|
|
||||||
response.
|
|
||||||
|
|
||||||
In most games, the Agent of chaos makes two considerations:
|
|
||||||
|
|
||||||
1. Which moves are valid? Since invalid moves fallback to "default" moves,
|
|
||||||
this can destabilize the probability distribution, making some moves
|
|
||||||
more likely to be chosen than others. As a result, we refrain from
|
|
||||||
"invalid" moves and take the effort to filter them out.
|
|
||||||
|
|
||||||
2. What's a reasonable probability distribution? Sometimes, a uniform
|
|
||||||
distribution across all options doesn't make a lot of sense. For
|
|
||||||
example, imagine asking the agent every turn whether they want to
|
|
||||||
use their super duper special single-use ability, and leaving that to
|
|
||||||
a 50/50 call every turn. It feels much more random if such an ability
|
|
||||||
is more randomly used throughout the GAME, than to have every choice
|
|
||||||
be a uniformly distributed decision.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import random
|
|
||||||
|
|
||||||
from .agent import Agent, Payload
|
|
||||||
|
|
||||||
|
|
||||||
class AgentOfChaos(Agent):
|
|
||||||
"""
|
|
||||||
The agent of chaos always aims to deliver a random response.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
"""
|
|
||||||
Agent that always tries to make a "random" move.
|
|
||||||
"""
|
|
||||||
super().__init__(
|
|
||||||
name="Agent of chaos",
|
|
||||||
author="Bram",
|
|
||||||
version="1.0.1",
|
|
||||||
profile={
|
|
||||||
"me.noordstar.peanuts.is_ai": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
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:
|
|
||||||
"""
|
|
||||||
In tic-tac-toe, the agent of chaos makes uniformly distributed choices
|
|
||||||
on unclaimed tiles.
|
|
||||||
|
|
||||||
:param payload: The incoming game state.
|
|
||||||
:type payload: dict[str, Any]
|
|
||||||
:return: The agent of chaos' random choice
|
|
||||||
:rtype: dict[str, Any]
|
|
||||||
"""
|
|
||||||
options = [
|
|
||||||
int(k)
|
|
||||||
for k, v in dict(payload).items()
|
|
||||||
if k in "0123456789" and v == ""
|
|
||||||
]
|
|
||||||
return { "move": random.choice(options) }
|
|
||||||
|
|
||||||
def play_royal_game_of_ur(payload : Payload) -> Payload:
|
|
||||||
"""
|
|
||||||
The royal game of Ur already returns a set of valid moves.
|
|
||||||
|
|
||||||
We can lazily just pick one of these options and return it.
|
|
||||||
|
|
||||||
:param payload: The incoming game state.
|
|
||||||
:type payload: dict[str, Any]
|
|
||||||
:return: The agent of chaos' random choice
|
|
||||||
:rtype: dict[str, Any]
|
|
||||||
"""
|
|
||||||
return { "move": random.choice(payload.get("valid_moves", [""])) }
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
"""
|
|
||||||
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) }
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
"""
|
|
||||||
The mute agent is a proof-of-concept agent of an agent that simply doesn't
|
|
||||||
respond - it always responds with an empty object.
|
|
||||||
|
|
||||||
This is the simplest agent to implement, and it can be used as a test to
|
|
||||||
make sure that the "default" move can be picked properly each turn.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .agent import ActionFunction, Agent, Payload
|
|
||||||
|
|
||||||
def respond_mute(payload : Payload) -> Payload:
|
|
||||||
"""
|
|
||||||
Standard response from the mute. Returns an empty object, always.
|
|
||||||
|
|
||||||
:param payload: Incoming game state.
|
|
||||||
:type payload: dict[str, Any]
|
|
||||||
:return: The empty object.
|
|
||||||
:rtype: dict[str, Any]
|
|
||||||
"""
|
|
||||||
return {}
|
|
||||||
|
|
||||||
class MuteAgent(Agent):
|
|
||||||
"""
|
|
||||||
The mute agent class refuses to respond to any incoming game states,
|
|
||||||
and simply responds with an empty dictionary. For most games,
|
|
||||||
this means that the mute player takes the "default" option as a result
|
|
||||||
of not responding.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
"""
|
|
||||||
Create a new instance of the mute agent.
|
|
||||||
"""
|
|
||||||
super().__init__(
|
|
||||||
name="Mute",
|
|
||||||
author="Bram",
|
|
||||||
version="1.0.1",
|
|
||||||
profile={
|
|
||||||
"me.noordstar.peanuts.is_ai": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.add_tic_tac_toe(on_move=respond_mute)
|
|
||||||
self.add_royal_game_of_ur(on_move=respond_mute)
|
|
||||||
|
|
||||||
def get_action(self, game: str, action: str | None) -> ActionFunction:
|
|
||||||
"""
|
|
||||||
Return the mute response for any game or action.
|
|
||||||
|
|
||||||
:param game: The game that the mute ignores.
|
|
||||||
:type game: str
|
|
||||||
:param action: The action that the mute ignores.
|
|
||||||
:type action: str
|
|
||||||
"""
|
|
||||||
return respond_mute
|
|
||||||
109
agents/remote.py
109
agents/remote.py
|
|
@ -1,109 +0,0 @@
|
||||||
"""
|
|
||||||
The remote agent is the agent that is accessible on the internet.
|
|
||||||
|
|
||||||
The common implementation of the back-end is the PyClient, although the
|
|
||||||
remote agent communicates through an API so the agent is
|
|
||||||
implementation-agnostic.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from .agent import Agent, Payload
|
|
||||||
|
|
||||||
class RemoteAgent(Agent):
|
|
||||||
"""
|
|
||||||
The RemoteAgent is the agent that is remotely accessible on an external
|
|
||||||
URL through the specified protocol.
|
|
||||||
"""
|
|
||||||
|
|
||||||
url : str
|
|
||||||
games : set[str]
|
|
||||||
|
|
||||||
def __init__(self, url : str, timeout : float = 1.0) -> None:
|
|
||||||
"""
|
|
||||||
Create a new instance of the remote agent.
|
|
||||||
|
|
||||||
:param url: URL at which the agent can be accessed.
|
|
||||||
:type url: str
|
|
||||||
:param timeout: The maximum time the client is allowed to respond.
|
|
||||||
:type timeout: float
|
|
||||||
:raises requests.HTTPError: The server could not be reached.
|
|
||||||
:raises ValueError: The server returned an invalid response.
|
|
||||||
"""
|
|
||||||
self.url = url.strip("/")
|
|
||||||
self.timeout = timeout
|
|
||||||
|
|
||||||
# Get discovery from URL
|
|
||||||
response = requests.get(self.url.strip("/") + "/", timeout=self.timeout)
|
|
||||||
response.raise_for_status()
|
|
||||||
content = response.json()
|
|
||||||
|
|
||||||
if not isinstance(content, dict):
|
|
||||||
raise ValueError(
|
|
||||||
"Remote agent's discovery response must be a JSON object."
|
|
||||||
)
|
|
||||||
|
|
||||||
raw_author = content.get("author", content.get("me.noordstar.peanuts.author", ""))
|
|
||||||
raw_name = content.get("name", "")
|
|
||||||
raw_version = content.get("version", content.get("me.noordstar.peanuts.agent.version", None))
|
|
||||||
|
|
||||||
# Initialize agent based on given information
|
|
||||||
super().__init__(
|
|
||||||
author="" if raw_author is None else str(raw_author),
|
|
||||||
name="" if raw_name is None else str(raw_name),
|
|
||||||
version=None if raw_version is None else str(raw_version),
|
|
||||||
profile=content,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.games = set()
|
|
||||||
|
|
||||||
games = content.get("games")
|
|
||||||
if isinstance(games, dict):
|
|
||||||
if "tic-tac-toe" in games:
|
|
||||||
self.add_tic_tac_toe(
|
|
||||||
on_move=lambda x : self.poll("tic-tac-toe", "", x),
|
|
||||||
profile=games["tic-tac-toe"],
|
|
||||||
)
|
|
||||||
if "royal-game-of-ur" in games:
|
|
||||||
self.add_royal_game_of_ur(
|
|
||||||
on_move=lambda x : self.poll("royal-game-of-ur", "", x),
|
|
||||||
profile=games["royal-game-of-ur"],
|
|
||||||
)
|
|
||||||
|
|
||||||
self.games = set(games.keys())
|
|
||||||
|
|
||||||
def get_action(self, game: str, action: str | None) -> Callable[[dict[str, Any]], dict[str, Any]] | None:
|
|
||||||
"""
|
|
||||||
Get an action function for any given game.
|
|
||||||
|
|
||||||
:param game: The name of the game.
|
|
||||||
:type game: str
|
|
||||||
:param action: The name of the action, or None if the game only has one action.
|
|
||||||
:type action: str | None
|
|
||||||
:return: The action with which the agent would like to act, if at all.
|
|
||||||
:rtype: Callable[[dict[str, Any]], dict[str, Any]] | None
|
|
||||||
"""
|
|
||||||
return lambda x : self.poll(game=game, action=action or "", payload=x)
|
|
||||||
|
|
||||||
def poll(
|
|
||||||
self,
|
|
||||||
game : str,
|
|
||||||
action : str,
|
|
||||||
payload : Payload
|
|
||||||
) -> Payload:
|
|
||||||
"""
|
|
||||||
Inquire the remote agent for a response.
|
|
||||||
"""
|
|
||||||
url = "/".join([self.url, game.strip("/"), action.strip("/")]).strip("/")
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.get(url, json=payload, timeout=self.timeout)
|
|
||||||
response.raise_for_status()
|
|
||||||
content = response.json()
|
|
||||||
except (requests.ConnectTimeout, requests.RequestException, requests.HTTPError, ValueError):
|
|
||||||
return {}
|
|
||||||
else:
|
|
||||||
return content if isinstance(content, dict) else {}
|
|
||||||
26
client.py
26
client.py
|
|
@ -23,14 +23,8 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import pyclient
|
import pyclient
|
||||||
|
|
||||||
# Import your game(s) here
|
from pyclient import Agent, 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:
|
||||||
"""
|
"""
|
||||||
|
|
@ -39,29 +33,22 @@ def main() -> int:
|
||||||
:return: Exit code
|
:return: Exit code
|
||||||
:rtype: int
|
:rtype: int
|
||||||
"""
|
"""
|
||||||
|
c = PyClient(debug=False)
|
||||||
|
|
||||||
# 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] = [
|
||||||
AgentOfChaos(),
|
Agent.from_url(url="https://bmt001.noordstar.me/"),
|
||||||
AgentOfChaos(),
|
Agent.from_url(url="https://bmt001.noordstar.me/"),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Play a given game with your players
|
out = c.play_game(
|
||||||
result = c.play_game(
|
|
||||||
players=players,
|
players=players,
|
||||||
start=TicTacToe.empty(),
|
start=TicTacToe.empty(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Print the game results to the terminal!
|
inspect_game(out)
|
||||||
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.
|
||||||
|
|
@ -101,6 +88,5 @@ 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())
|
||||||
|
|
|
||||||
159
elm/Api/Elo.elm
159
elm/Api/Elo.elm
|
|
@ -1,159 +0,0 @@
|
||||||
module Api.Elo exposing
|
|
||||||
( EloStat
|
|
||||||
, HealthCheck
|
|
||||||
, Leaderboard
|
|
||||||
, Match
|
|
||||||
, Participant
|
|
||||||
, Players
|
|
||||||
, getHealth
|
|
||||||
, getLeaderboard
|
|
||||||
, getMatches
|
|
||||||
, getPlayers
|
|
||||||
)
|
|
||||||
|
|
||||||
{-|
|
|
||||||
|
|
||||||
|
|
||||||
# ELO API
|
|
||||||
|
|
||||||
This module contains functions to address the API of the ELO tracker.
|
|
||||||
|
|
||||||
-}
|
|
||||||
|
|
||||||
import Http
|
|
||||||
import Json.Decode as D
|
|
||||||
|
|
||||||
|
|
||||||
type alias EloStat =
|
|
||||||
{ draws : Int
|
|
||||||
, elo : Int
|
|
||||||
, losses : Int
|
|
||||||
, name : String
|
|
||||||
, url : String
|
|
||||||
, version : Maybe String
|
|
||||||
, wins : Int
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias HealthCheck =
|
|
||||||
{ ok : Bool, periodicMatches : Bool }
|
|
||||||
|
|
||||||
|
|
||||||
type alias Leaderboard =
|
|
||||||
List { name : String, players : Players }
|
|
||||||
|
|
||||||
|
|
||||||
type alias Match =
|
|
||||||
{ name : String
|
|
||||||
, participants : List Participant
|
|
||||||
, timestamp : String
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias Participant =
|
|
||||||
{ name : String
|
|
||||||
, result : String
|
|
||||||
, url : String
|
|
||||||
, version : Maybe String
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias Players =
|
|
||||||
List EloStat
|
|
||||||
|
|
||||||
|
|
||||||
eloStatDecoder : D.Decoder EloStat
|
|
||||||
eloStatDecoder =
|
|
||||||
D.map7 EloStat
|
|
||||||
(D.field "draws" D.int)
|
|
||||||
(D.field "elo" D.int)
|
|
||||||
(D.field "losses" D.int)
|
|
||||||
(D.field "name" D.string)
|
|
||||||
(D.field "url" D.string)
|
|
||||||
(D.maybe <| D.field "version" D.string)
|
|
||||||
(D.field "wins" D.int)
|
|
||||||
|
|
||||||
|
|
||||||
getHealth :
|
|
||||||
{ baseUrl : String
|
|
||||||
, toMsg : Result Http.Error HealthCheck -> msg
|
|
||||||
}
|
|
||||||
-> Cmd msg
|
|
||||||
getHealth data =
|
|
||||||
Http.get
|
|
||||||
{ url = data.baseUrl ++ "/health"
|
|
||||||
, expect = Http.expectJson data.toMsg healthCheckDecoder
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
getLeaderboard :
|
|
||||||
{ baseUrl : String
|
|
||||||
, toMsg : Result Http.Error Leaderboard -> msg
|
|
||||||
}
|
|
||||||
-> Cmd msg
|
|
||||||
getLeaderboard data =
|
|
||||||
Http.get
|
|
||||||
{ url = data.baseUrl ++ "/leaderboard"
|
|
||||||
, expect = Http.expectJson data.toMsg leaderboardDecoder
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
getMatches :
|
|
||||||
{ baseUrl : String
|
|
||||||
, toMsg : Result Http.Error (List Match) -> msg
|
|
||||||
}
|
|
||||||
-> Cmd msg
|
|
||||||
getMatches data =
|
|
||||||
Http.get
|
|
||||||
{ url = data.baseUrl ++ "/matches"
|
|
||||||
, expect = Http.expectJson data.toMsg (D.list matchDecoder)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
getPlayers :
|
|
||||||
{ baseUrl : String
|
|
||||||
, toMsg : Result Http.Error Players -> msg
|
|
||||||
}
|
|
||||||
-> Cmd msg
|
|
||||||
getPlayers data =
|
|
||||||
Http.get
|
|
||||||
{ url = data.baseUrl ++ "/players"
|
|
||||||
, expect = Http.expectJson data.toMsg playersDecoder
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
healthCheckDecoder : D.Decoder HealthCheck
|
|
||||||
healthCheckDecoder =
|
|
||||||
D.map2 HealthCheck
|
|
||||||
(D.field "ok" D.bool)
|
|
||||||
(D.field "periodic_matches" D.bool)
|
|
||||||
|
|
||||||
|
|
||||||
leaderboardDecoder : D.Decoder Leaderboard
|
|
||||||
leaderboardDecoder =
|
|
||||||
D.map2 (\name players -> { name = name, players = players })
|
|
||||||
(D.field "name" D.string)
|
|
||||||
(D.field "players" playersDecoder)
|
|
||||||
|> D.list
|
|
||||||
|
|
||||||
|
|
||||||
matchDecoder : D.Decoder Match
|
|
||||||
matchDecoder =
|
|
||||||
D.map3 Match
|
|
||||||
(D.field "name" D.string)
|
|
||||||
(D.field "participants" <| D.list participantDecoder)
|
|
||||||
(D.field "timestamp" D.string)
|
|
||||||
|
|
||||||
|
|
||||||
participantDecoder : D.Decoder Participant
|
|
||||||
participantDecoder =
|
|
||||||
D.map4 Participant
|
|
||||||
(D.field "name" D.string)
|
|
||||||
(D.field "result" D.string)
|
|
||||||
(D.field "url" D.string)
|
|
||||||
(D.maybe <| D.field "version" D.string)
|
|
||||||
|
|
||||||
|
|
||||||
playersDecoder : D.Decoder Players
|
|
||||||
playersDecoder =
|
|
||||||
D.list eloStatDecoder
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
module EloTracker exposing (main)
|
|
||||||
|
|
||||||
import Api.Elo as Elo
|
|
||||||
import Element exposing (Element)
|
|
||||||
import Element.Background
|
|
||||||
import Element.Font
|
|
||||||
import Http
|
|
||||||
import Json.Decode as D
|
|
||||||
import Layout
|
|
||||||
import Pixels
|
|
||||||
import Program
|
|
||||||
import Screen.Leaderboard
|
|
||||||
import Theme
|
|
||||||
import Time
|
|
||||||
import Widget.Icon
|
|
||||||
|
|
||||||
|
|
||||||
main =
|
|
||||||
Program.document
|
|
||||||
{ flagsDecoder = D.field "baseUrl" D.string
|
|
||||||
, headers = headers
|
|
||||||
, init = init
|
|
||||||
, subscriptions = subscriptions
|
|
||||||
, title = title
|
|
||||||
, update = update
|
|
||||||
, view = view
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- MODEL
|
|
||||||
|
|
||||||
|
|
||||||
type alias Model =
|
|
||||||
{ baseUrl : String
|
|
||||||
, leaderboard : Result Int Elo.Leaderboard
|
|
||||||
, matches : Result Int (List Elo.Match)
|
|
||||||
, selectedName : String
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type Msg
|
|
||||||
= OnLeaderboard (Result Http.Error Elo.Leaderboard)
|
|
||||||
| OnMatches (Result Http.Error (List Elo.Match))
|
|
||||||
| OnSelectLeaderboardName String
|
|
||||||
| RefreshLeaderboard
|
|
||||||
| RefreshMatches
|
|
||||||
|
|
||||||
|
|
||||||
init : Result D.Error String -> ( Model, Cmd Msg )
|
|
||||||
init flag =
|
|
||||||
let
|
|
||||||
baseUrl =
|
|
||||||
case flag of
|
|
||||||
Err _ ->
|
|
||||||
"http://localhost:5000"
|
|
||||||
|
|
||||||
Ok v ->
|
|
||||||
v
|
|
||||||
|
|
||||||
model =
|
|
||||||
{ baseUrl = baseUrl
|
|
||||||
, leaderboard = Err 0
|
|
||||||
, matches = Err 0
|
|
||||||
, selectedName = "tic-tac-toe"
|
|
||||||
}
|
|
||||||
in
|
|
||||||
( model
|
|
||||||
, Cmd.batch
|
|
||||||
[ getLeaderboard model
|
|
||||||
, getMatches model
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- UPDATE
|
|
||||||
|
|
||||||
|
|
||||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
|
||||||
update msg model =
|
|
||||||
case msg of
|
|
||||||
OnLeaderboard (Err _) ->
|
|
||||||
case model.leaderboard of
|
|
||||||
Err n ->
|
|
||||||
( { model | leaderboard = Err (n + 1) }, Cmd.none )
|
|
||||||
|
|
||||||
Ok _ ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
OnLeaderboard (Ok v) ->
|
|
||||||
( { model | leaderboard = Ok v }, Cmd.none )
|
|
||||||
|
|
||||||
OnMatches (Err _) ->
|
|
||||||
case model.matches of
|
|
||||||
Err n ->
|
|
||||||
( { model | matches = Err (n + 1) }, Cmd.none )
|
|
||||||
|
|
||||||
Ok _ ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
OnMatches (Ok v) ->
|
|
||||||
( { model | matches = Ok v }, Cmd.none )
|
|
||||||
|
|
||||||
OnSelectLeaderboardName name ->
|
|
||||||
( { model | selectedName = name }, Cmd.none )
|
|
||||||
|
|
||||||
RefreshLeaderboard ->
|
|
||||||
( model, getLeaderboard model )
|
|
||||||
|
|
||||||
RefreshMatches ->
|
|
||||||
( model, getMatches model )
|
|
||||||
|
|
||||||
|
|
||||||
getLeaderboard : Model -> Cmd Msg
|
|
||||||
getLeaderboard model =
|
|
||||||
Elo.getLeaderboard { baseUrl = model.baseUrl, toMsg = OnLeaderboard }
|
|
||||||
|
|
||||||
|
|
||||||
getMatches : Model -> Cmd Msg
|
|
||||||
getMatches model =
|
|
||||||
Elo.getMatches { baseUrl = model.baseUrl, toMsg = OnMatches }
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- SUBSCRIPTIONS
|
|
||||||
|
|
||||||
|
|
||||||
subscriptions : Model -> Sub Msg
|
|
||||||
subscriptions _ =
|
|
||||||
Sub.batch
|
|
||||||
[ Time.every (5 * 1000) (always RefreshLeaderboard)
|
|
||||||
, Time.every (1 * 60 * 1000) (always RefreshMatches)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- VIEW
|
|
||||||
|
|
||||||
|
|
||||||
headers : Model -> List { icon : Widget.Icon.Icon msg, onPress : msg }
|
|
||||||
headers _ =
|
|
||||||
[]
|
|
||||||
|
|
||||||
|
|
||||||
title : Model -> String
|
|
||||||
title model =
|
|
||||||
case model.leaderboard of
|
|
||||||
Err _ ->
|
|
||||||
"EloTracker | Loading..."
|
|
||||||
|
|
||||||
Ok _ ->
|
|
||||||
"EloTracker | Leaderboard"
|
|
||||||
|
|
||||||
|
|
||||||
view : Program.ViewBox Model -> Element Msg
|
|
||||||
view data =
|
|
||||||
case data.model.leaderboard of
|
|
||||||
Err 0 ->
|
|
||||||
Element.column
|
|
||||||
[ Element.width <| Element.px <| Pixels.inPixels data.size.width
|
|
||||||
, Element.height <| Element.px <| Pixels.inPixels data.size.height
|
|
||||||
]
|
|
||||||
[ Element.column
|
|
||||||
[ Element.centerX
|
|
||||||
, Element.centerY
|
|
||||||
, Element.Background.color (Theme.mantleUI data.flavor)
|
|
||||||
, Element.padding 15
|
|
||||||
, Element.spacing 10
|
|
||||||
]
|
|
||||||
[ Element.text "Loading leaderboard..."
|
|
||||||
, Layout.loadingIndicator { color = Theme.lavender data.flavor }
|
|
||||||
|> Element.el [ Element.centerX ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
Err n ->
|
|
||||||
Element.column
|
|
||||||
[ Element.width <| Element.px <| Pixels.inPixels data.size.width
|
|
||||||
, Element.height <| Element.px <| Pixels.inPixels data.size.height
|
|
||||||
]
|
|
||||||
[ Element.column
|
|
||||||
[ Element.centerX
|
|
||||||
, Element.centerY
|
|
||||||
, Element.Background.color (Theme.mantleUI data.flavor)
|
|
||||||
, Element.padding 15
|
|
||||||
, Element.spacing 10
|
|
||||||
]
|
|
||||||
[ Element.text "Loading leaderboard..."
|
|
||||||
, Element.el [ Element.centerX, Element.Font.color (Theme.redUI data.flavor) ] (Element.text ("Failed " ++ String.fromInt n ++ " times"))
|
|
||||||
, Layout.loadingIndicator { color = Theme.lavender data.flavor }
|
|
||||||
|> Element.el [ Element.centerX ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
Ok leaderboard ->
|
|
||||||
Screen.Leaderboard.view
|
|
||||||
{ flavor = data.flavor
|
|
||||||
, model =
|
|
||||||
{ board = leaderboard
|
|
||||||
, onSelect = OnSelectLeaderboardName
|
|
||||||
, selectedName = data.model.selectedName
|
|
||||||
}
|
|
||||||
, size = data.size
|
|
||||||
}
|
|
||||||
|
|
@ -156,43 +156,32 @@ view :
|
||||||
-> Html.Html (Msg msg)
|
-> Html.Html (Msg msg)
|
||||||
view data model =
|
view data model =
|
||||||
let
|
let
|
||||||
navBarIconHeight =
|
preferredNavBarHeight =
|
||||||
Pixels.pixels 40
|
Pixels.pixels 40
|
||||||
|
|
||||||
navBarHeight =
|
|
||||||
Quantity.twice navBarIconHeight
|
|
||||||
|
|
||||||
showNavBar =
|
showNavBar =
|
||||||
navBarHeight
|
preferredNavBarHeight
|
||||||
|> Quantity.multiplyBy 6
|
|> Quantity.multiplyBy 6
|
||||||
|> Quantity.lessThanOrEqualTo model.size.height
|
|> Quantity.lessThanOrEqualTo model.size.height
|
||||||
|
|
||||||
contentHeight =
|
contentHeight =
|
||||||
if showNavBar then
|
if showNavBar then
|
||||||
model.size.height |> Quantity.minus navBarHeight
|
model.size.height |> Quantity.minus preferredNavBarHeight
|
||||||
|
|
||||||
else
|
else
|
||||||
model.size.height
|
model.size.height
|
||||||
in
|
in
|
||||||
[ if showNavBar then
|
[ viewNavBar
|
||||||
viewNavBar
|
{ headers = data.headers model.content
|
||||||
{ headers = data.headers model.content
|
, iconHeight = preferredNavBarHeight
|
||||||
, iconHeight = navBarIconHeight
|
, model = model
|
||||||
, model = model
|
}
|
||||||
}
|
|
||||||
|
|
||||||
else
|
|
||||||
Element.none
|
|
||||||
, data.body
|
, data.body
|
||||||
{ flavor = model.flavor
|
{ flavor = model.flavor
|
||||||
, model = model.content
|
, model = model.content
|
||||||
, size =
|
, size = { height = contentHeight, width = model.size.width }
|
||||||
{ height = contentHeight --|> Quantity.minus (Pixels.pixels 25)
|
|
||||||
, width = model.size.width --|> Quantity.minus (Pixels.pixels 25)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|> Element.map OnContent
|
|> Element.map OnContent
|
||||||
|> Element.el []
|
|
||||||
]
|
]
|
||||||
|> Element.column [ Element.width Element.fill ]
|
|> Element.column [ Element.width Element.fill ]
|
||||||
|> Element.layout
|
|> Element.layout
|
||||||
|
|
|
||||||
|
|
@ -2,60 +2,52 @@ module Screen.CreateGame exposing (..)
|
||||||
|
|
||||||
-- MODEL
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
type alias Model =
|
type alias Model =
|
||||||
{ baseUrl : String
|
{ baseUrl : String
|
||||||
, players : List String
|
, players : List String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
type Msg
|
type Msg
|
||||||
= OnBaseUrl String
|
= OnBaseUrl String
|
||||||
| OnPlayer Int String
|
| OnPlayer Int String
|
||||||
| RemovePlayer Int
|
| RemovePlayer Int
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- UPDATE
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
update msg model =
|
update msg model =
|
||||||
case msg of
|
case msg of
|
||||||
OnBaseUrl url ->
|
OnBaseUrl url ->
|
||||||
( { model | baseUrl = url }, Cmd.none )
|
( { model | baseUrl = url }, Cmd.none )
|
||||||
|
|
||||||
OnPlayer n p ->
|
OnPlayer n p ->
|
||||||
let
|
let
|
||||||
newIndex =
|
newIndex = List.length model.players == n
|
||||||
List.length model.players == n
|
|
||||||
|
|
||||||
newPlayers =
|
newPlayers =
|
||||||
if newIndex && mayCreateNewPlayer model.players then
|
if newIndex && mayCreateNewPlayer model.players then
|
||||||
List.append model.players [ p ]
|
List.append model.players [ p ]
|
||||||
|
|
||||||
else
|
else
|
||||||
List.indexedMap
|
List.indexedMap
|
||||||
(\i player ->
|
(\i player ->
|
||||||
if n == i then
|
if n == i then
|
||||||
p
|
p
|
||||||
|
|
||||||
else
|
else
|
||||||
player
|
player
|
||||||
)
|
)
|
||||||
model.players
|
model.players
|
||||||
in
|
|
||||||
( { model | players = newPlayers }, Cmd.none )
|
|
||||||
|
|
||||||
|
in
|
||||||
|
( { model | players = newPlayers }, Cmd.none )
|
||||||
|
|
||||||
RemovePlayer n ->
|
RemovePlayer n ->
|
||||||
( { model
|
( { model
|
||||||
| players =
|
| players =
|
||||||
model.players
|
model.players
|
||||||
|> List.indexedMap
|
|> List.indexedMap
|
||||||
(\i player ->
|
(\i player ->
|
||||||
if n == i then
|
if n == i then
|
||||||
Nothing
|
Nothing
|
||||||
|
|
||||||
else
|
else
|
||||||
Just player
|
Just player
|
||||||
)
|
)
|
||||||
|
|
@ -64,11 +56,9 @@ update msg model =
|
||||||
, Cmd.none
|
, Cmd.none
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- SUBSCRIPTIONS
|
-- SUBSCRIPTIONS
|
||||||
-- VIEW
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
mayCreateNewPlayer : List String -> Bool
|
mayCreateNewPlayer : List String -> Bool
|
||||||
mayCreateNewPlayer =
|
mayCreateNewPlayer =
|
||||||
|
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
module Screen.Leaderboard exposing (..)
|
|
||||||
|
|
||||||
{-| Widget module for a leaderboard page.
|
|
||||||
-}
|
|
||||||
|
|
||||||
import Api.Elo exposing (EloStat, Leaderboard, Players)
|
|
||||||
import Color
|
|
||||||
import Element exposing (Element)
|
|
||||||
import Element.Background
|
|
||||||
import Element.Border
|
|
||||||
import Element.Events
|
|
||||||
import Element.Font
|
|
||||||
import Layout
|
|
||||||
import Pixels exposing (Pixels)
|
|
||||||
import Program exposing (ViewBox)
|
|
||||||
import Quantity exposing (Quantity)
|
|
||||||
import Theme
|
|
||||||
|
|
||||||
|
|
||||||
view : ViewBox { board : Leaderboard, onSelect : String -> msg, selectedName : String } -> Element msg
|
|
||||||
view data =
|
|
||||||
Element.column
|
|
||||||
[]
|
|
||||||
[ selectBoard
|
|
||||||
{ flavor = data.flavor
|
|
||||||
, leaderboard = data.model.board
|
|
||||||
, onSelect = data.model.onSelect
|
|
||||||
, selectedName = data.model.selectedName
|
|
||||||
, width = data.size.width
|
|
||||||
}
|
|
||||||
, data.model.board
|
|
||||||
|> List.map
|
|
||||||
(\l ->
|
|
||||||
if l.name == data.model.selectedName then
|
|
||||||
viewSingleBoard
|
|
||||||
{ flavor = data.flavor
|
|
||||||
, model = l
|
|
||||||
, size = data.size
|
|
||||||
}
|
|
||||||
|
|
||||||
else
|
|
||||||
Element.none
|
|
||||||
)
|
|
||||||
|> Element.column []
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
selectBoard :
|
|
||||||
{ flavor : Theme.Flavor
|
|
||||||
, leaderboard : Leaderboard
|
|
||||||
, onSelect : String -> msg
|
|
||||||
, selectedName : String
|
|
||||||
, width : Quantity Int Pixels
|
|
||||||
}
|
|
||||||
-> Element msg
|
|
||||||
selectBoard data =
|
|
||||||
data.leaderboard
|
|
||||||
|> List.map .name
|
|
||||||
|> List.map
|
|
||||||
(\s ->
|
|
||||||
Element.el
|
|
||||||
[ if data.selectedName == s then
|
|
||||||
Element.Background.color (Theme.subtext0UI data.flavor)
|
|
||||||
|
|
||||||
else
|
|
||||||
Element.Background.color (Theme.surface1UI data.flavor)
|
|
||||||
, Element.Border.rounded 25
|
|
||||||
, Element.centerX
|
|
||||||
, Element.Events.onClick (data.onSelect s)
|
|
||||||
, Element.padding 10
|
|
||||||
]
|
|
||||||
(Element.text s)
|
|
||||||
)
|
|
||||||
|> Element.wrappedRow
|
|
||||||
[ Element.padding 20
|
|
||||||
, Element.spacing 10
|
|
||||||
, Element.width <| Element.px <| Pixels.inPixels data.width
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
viewSingleBoard : ViewBox { name : String, players : Players } -> Element msg
|
|
||||||
viewSingleBoard data =
|
|
||||||
let
|
|
||||||
boardWidth =
|
|
||||||
data.size.width
|
|
||||||
|> Quantity.toFloatQuantity
|
|
||||||
|> Quantity.multiplyBy (2 / 3)
|
|
||||||
|> Quantity.floor
|
|
||||||
|> Quantity.clamp (Pixels.pixels 600) (Pixels.pixels 1800)
|
|
||||||
|> Pixels.inPixels
|
|
||||||
|> Element.px
|
|
||||||
|> Element.width
|
|
||||||
in
|
|
||||||
data.model.players
|
|
||||||
|> List.map (viewEloStat { flavor = data.flavor })
|
|
||||||
|> Element.column [ Element.centerX, boardWidth ]
|
|
||||||
|> List.singleton
|
|
||||||
|> (::)
|
|
||||||
(Element.text data.model.name
|
|
||||||
|> Element.el
|
|
||||||
[ Element.centerX, Element.Font.size 50 ]
|
|
||||||
)
|
|
||||||
|> Element.column
|
|
||||||
[ Element.padding 15
|
|
||||||
, Element.spacing 30
|
|
||||||
, Element.width <| Element.px <| Pixels.inPixels data.size.width
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
viewEloStat : { flavor : Theme.Flavor } -> EloStat -> Element msg
|
|
||||||
viewEloStat data stat =
|
|
||||||
Element.row
|
|
||||||
[ Element.Background.color (Theme.mantleUI data.flavor)
|
|
||||||
, Element.Border.rounded 5
|
|
||||||
, Element.padding 10
|
|
||||||
, Element.spacing 5
|
|
||||||
, Element.width Element.fill
|
|
||||||
]
|
|
||||||
[ Element.column
|
|
||||||
[ Element.centerY, Element.spacing 5, Element.width Element.fill ]
|
|
||||||
[ Element.text stat.name
|
|
||||||
|> Element.el [ Element.Font.size 30 ]
|
|
||||||
, Element.el
|
|
||||||
[ Element.Font.color (Theme.subtext0UI data.flavor)
|
|
||||||
, Element.Font.size 15
|
|
||||||
]
|
|
||||||
(case stat.version of
|
|
||||||
Nothing ->
|
|
||||||
Element.text stat.url
|
|
||||||
|
|
||||||
Just v ->
|
|
||||||
Element.text (stat.url ++ " | " ++ v)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
-- Bar to push the rest to the right
|
|
||||||
, Element.column [ Element.width Element.fill ] []
|
|
||||||
, field { text = "ELO", value = stat.elo, color = Theme.lavenderUI data.flavor }
|
|
||||||
|> Element.el [ Element.paddingEach { top = 0, bottom = 0, left = 0, right = 15 } ]
|
|
||||||
, field { text = "WINS", value = stat.wins, color = Theme.greenUI data.flavor }
|
|
||||||
, field { text = "DRAWS", value = stat.draws, color = Theme.subtext0UI 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, value, color } =
|
|
||||||
Element.column
|
|
||||||
[ Element.Font.color color
|
|
||||||
, Element.spacing 3
|
|
||||||
, Element.width (Element.px 70)
|
|
||||||
]
|
|
||||||
[ String.fromInt value
|
|
||||||
|> Element.text
|
|
||||||
|> Element.el [ Element.centerX, Element.Font.size 25 ]
|
|
||||||
, text
|
|
||||||
|> Element.text
|
|
||||||
|> Element.el [ Element.centerX, Element.Font.size 15 ]
|
|
||||||
]
|
|
||||||
18
elo.py
18
elo.py
|
|
@ -3,7 +3,10 @@
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from elo_tracker import EloTracker
|
from elo_tracker import EloTracker
|
||||||
from pyclient.games import RoyalGameOfUr, TicTacToe
|
from pyclient.games import TicTacToe
|
||||||
|
|
||||||
|
import pyclient
|
||||||
|
import time
|
||||||
|
|
||||||
GAME_FILE = "games.jsonl"
|
GAME_FILE = "games.jsonl"
|
||||||
PLAYER_FILE = "known_players.json"
|
PLAYER_FILE = "known_players.json"
|
||||||
|
|
@ -17,21 +20,14 @@ def main() -> int:
|
||||||
|
|
||||||
tracker.start_periodic_matches(
|
tracker.start_periodic_matches(
|
||||||
game=TicTacToe.empty(),
|
game=TicTacToe.empty(),
|
||||||
interval_seconds=10 * 60,
|
interval_seconds=60,
|
||||||
player_count=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
tracker.start_periodic_matches(
|
|
||||||
game=RoyalGameOfUr.empty(),
|
|
||||||
interval_seconds=9 * 60,
|
|
||||||
max_turns=1_500,
|
|
||||||
player_count=2,
|
player_count=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tracker.start_server(import_name=__name__)
|
while True:
|
||||||
|
pass
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("Noticed KeyboardInterrupt, stopping match daemon...")
|
|
||||||
tracker.stop_periodic_matches()
|
tracker.stop_periodic_matches()
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
FROM alpine:3.10 AS elm-builder
|
|
||||||
|
|
||||||
ARG ELM_VERSION=0.19.1
|
|
||||||
ARG ELM_URL=https://github.com/elm/compiler/releases/download/${ELM_VERSION}/binary-for-linux-64-bit.gz
|
|
||||||
|
|
||||||
# Download internet packages + Node & npm
|
|
||||||
RUN apk add --no-cache ca-certificates curl gzip bash nodejs npm
|
|
||||||
|
|
||||||
# Download & install Elm
|
|
||||||
RUN curl -L ${ELM_URL} \
|
|
||||||
| gunzip > /usr/local/bin/elm \
|
|
||||||
&& chmod +x /usr/local/bin/elm
|
|
||||||
|
|
||||||
# Install uglify-js globally
|
|
||||||
RUN npm install -g uglify-js
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy Elm code
|
|
||||||
COPY elm.json .
|
|
||||||
COPY elm/ elm/
|
|
||||||
|
|
||||||
# Compile module
|
|
||||||
RUN elm make --output=/app/elm.js --optimize elm/EloTracker.elm
|
|
||||||
|
|
||||||
# Optimize & Minify compiled JS
|
|
||||||
RUN uglifyjs elm.js \
|
|
||||||
--compress "pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters,keep_fargs=false,unsafe_comps,unsafe" \
|
|
||||||
| uglifyjs --mangle --output elo_tracker.js
|
|
||||||
|
|
||||||
FROM python:3.10-alpine AS python-builder
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install build dependencies
|
|
||||||
RUN apk add --no-cache gcc musl-dev python3-dev
|
|
||||||
COPY requirements-elo.txt .
|
|
||||||
|
|
||||||
# Create wheels for faster installation
|
|
||||||
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements-elo.txt
|
|
||||||
|
|
||||||
FROM python:3.10-alpine
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install from pre-built wheels
|
|
||||||
COPY --from=python-builder /wheels /wheels
|
|
||||||
COPY requirements-elo.txt .
|
|
||||||
RUN pip install --no-cache-dir --no-index --find-links=/wheels \
|
|
||||||
-r requirements-elo.txt && rm -rf /wheels
|
|
||||||
|
|
||||||
# Install Elm front-end JS
|
|
||||||
COPY --from=elm-builder /app/elo_tracker.js /app/elo_tracker/static/elo_tracker.js
|
|
||||||
|
|
||||||
# Install ELO tracker code
|
|
||||||
COPY agents/ agents/
|
|
||||||
COPY elo_tracker/ elo_tracker/
|
|
||||||
COPY pyclient/ pyclient/
|
|
||||||
COPY pyserver/ pyserver/
|
|
||||||
COPY elo.py .
|
|
||||||
|
|
||||||
CMD ["python", "elo.py"]
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
# 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>.
|
|
||||||
|
|
@ -14,9 +14,7 @@ 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.remote
|
|
||||||
import pyclient
|
import pyclient
|
||||||
import time
|
|
||||||
|
|
||||||
from pyclient.games import FinishState, Game
|
from pyclient.games import FinishState, Game
|
||||||
|
|
||||||
|
|
@ -39,11 +37,17 @@ 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.remote.RemoteAgent) -> "PlayerIdentifier":
|
def from_server_agent(cls, agent : pyclient.ServerAgent) -> "PlayerIdentifier":
|
||||||
"""
|
"""
|
||||||
Gain a player identifier from an agent.
|
Gain a player identifier from an agent.
|
||||||
"""
|
"""
|
||||||
return cls(name=agent.name, url=agent.url, version=agent.version)
|
return cls(
|
||||||
|
name=agent.name,
|
||||||
|
url=agent.url,
|
||||||
|
version=agent.profile.get("version",
|
||||||
|
agent.profile.get("me.noordstar.peanuts.agent.version", None)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@dataclass()
|
@dataclass()
|
||||||
class EloStat:
|
class EloStat:
|
||||||
|
|
@ -177,7 +181,7 @@ class Match:
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_replay(
|
def from_replay(
|
||||||
cls,
|
cls,
|
||||||
players : list[agents.remote.RemoteAgent],
|
players : list[pyclient.ServerAgent],
|
||||||
replay : pyclient.GameReplay,
|
replay : pyclient.GameReplay,
|
||||||
timestamp : str | None,
|
timestamp : str | None,
|
||||||
) -> "Match":
|
) -> "Match":
|
||||||
|
|
@ -185,7 +189,7 @@ class Match:
|
||||||
Convert a GameReplay into a match.
|
Convert a GameReplay into a match.
|
||||||
|
|
||||||
:param players: The participants of the match.
|
:param players: The participants of the match.
|
||||||
:type players: list[agents.RemoteAgent]
|
:type players: list[pyclient.ServerAgent]
|
||||||
:param replay: Game summary.
|
:param replay: Game summary.
|
||||||
:type replay: pyclient.GameReplay
|
:type replay: pyclient.GameReplay
|
||||||
:param timestamp: ISO formatted timestamp of when the game was planned.
|
:param timestamp: ISO formatted timestamp of when the game was planned.
|
||||||
|
|
@ -202,7 +206,7 @@ class Match:
|
||||||
|
|
||||||
participants : list[tuple[PlayerIdentifier, FinishState]] = []
|
participants : list[tuple[PlayerIdentifier, FinishState]] = []
|
||||||
for i, agent in enumerate(players):
|
for i, agent in enumerate(players):
|
||||||
finish_state = results.get(i + 1, None)
|
finish_state = results.get(i, None)
|
||||||
if finish_state is None:
|
if finish_state is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -281,7 +285,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_threads: list[threading.Thread] = []
|
self.__scheduler_thread: threading.Thread | None = None
|
||||||
|
|
||||||
# Immutable values
|
# Immutable values
|
||||||
self.debug: bool = debug
|
self.debug: bool = debug
|
||||||
|
|
@ -291,9 +295,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.remote.RemoteAgent] = []
|
self.players: list[pyclient.ServerAgent] = []
|
||||||
self.__matches: list[Match] = []
|
self.__matches: list[Match] = []
|
||||||
self.__stats: dict[str, dict[PlayerIdentifier, EloStat]] = {}
|
self.__stats: dict[PlayerIdentifier, EloStat] = {}
|
||||||
|
|
||||||
# Initialize tracker
|
# Initialize tracker
|
||||||
self.__load_matches()
|
self.__load_matches()
|
||||||
|
|
@ -310,30 +314,25 @@ class EloTracker:
|
||||||
with self.__lock:
|
with self.__lock:
|
||||||
print(f"[EloTracker] {message}")
|
print(f"[EloTracker] {message}")
|
||||||
|
|
||||||
def __get_stat(self, game_name : str, player_id : PlayerIdentifier) -> EloStat:
|
def __get_stat(self, 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(game_name, {}).get(player_id, None)
|
stat = self.__stats.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[game_name][player_id] = stat
|
self.__stats[player_id] = stat
|
||||||
|
|
||||||
return stat
|
return stat
|
||||||
|
|
||||||
|
|
@ -393,13 +392,10 @@ 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(game_name=m.game_name, player_id=player_id1).elo
|
rating_1 = self.__get_stat(player_id=player_id1).elo
|
||||||
|
|
||||||
for player_id2, result2 in m.participants:
|
for player_id2, result2 in m.participants:
|
||||||
if player_id1 == player_id2:
|
rating_2 = self.__get_stat(player_id=player_id2).elo
|
||||||
continue
|
|
||||||
|
|
||||||
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))
|
||||||
|
|
||||||
|
|
@ -420,7 +416,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(game_name=m.game_name, player_id=player_id)
|
player = self.__get_stat(player_id=player_id)
|
||||||
player.elo += scores[player_id]
|
player.elo += scores[player_id]
|
||||||
|
|
||||||
match result:
|
match result:
|
||||||
|
|
@ -436,7 +432,6 @@ 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.
|
||||||
|
|
@ -447,20 +442,13 @@ 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:
|
||||||
players = [
|
available = len(self.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(
|
||||||
|
|
@ -469,16 +457,12 @@ class EloTracker:
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.__debug(
|
self.__debug(
|
||||||
f"Playing a new scheduled match of {game.game_name()}"
|
"Playing a new scheduled match"
|
||||||
)
|
|
||||||
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
|
||||||
|
|
@ -491,7 +475,7 @@ class EloTracker:
|
||||||
:type import_name: str
|
:type import_name: str
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from flask import Flask, Response, jsonify, request
|
from flask import Flask, Response, jsonify
|
||||||
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. "
|
||||||
|
|
@ -509,22 +493,15 @@ class EloTracker:
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Bot-Man-Toe Elo Tracker</title>
|
<title>Bot-Man-Toe Elo Tracker</title>
|
||||||
<script src="/static/elo_tracker.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main id="main-block">
|
<main>
|
||||||
<h1>Bot-Man-Toe Elo Tracker</h1>
|
<h1>Bot-Man-Toe Elo Tracker</h1>
|
||||||
<p>The JSON API is available at /leaderboard, /matches, /players, and /health.</p>
|
<p>The JSON API is available at /leaderboard, /matches, /players, and /health.</p>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
<script>
|
|
||||||
var app = Elm.EloTracker.init({
|
|
||||||
node: document.body,
|
|
||||||
flags: { baseUrl: "" }
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</html>
|
</html>
|
||||||
""".strip(), # TODO: Make base URL more explicit
|
""".strip(),
|
||||||
mimetype="text/html",
|
mimetype="text/html",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -544,15 +521,8 @@ class EloTracker:
|
||||||
return jsonify(self.get_json_matches())
|
return jsonify(self.get_json_matches())
|
||||||
|
|
||||||
@app.get("/players")
|
@app.get("/players")
|
||||||
def players() -> tuple[Response, int]:
|
def players() -> Response:
|
||||||
payload = request.get_json(silent=True) or {}
|
return jsonify(self.get_json_players())
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -564,11 +534,7 @@ class EloTracker:
|
||||||
:rtype: bool
|
:rtype: bool
|
||||||
"""
|
"""
|
||||||
with self.__lock:
|
with self.__lock:
|
||||||
for thread in self.__scheduler_threads:
|
return self.__scheduler_thread is not None and self.__scheduler_thread.is_alive()
|
||||||
if thread.is_alive():
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def load_players(self) -> None:
|
def load_players(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|
@ -589,13 +555,13 @@ class EloTracker:
|
||||||
"Expected `players` field to be a list of strings."
|
"Expected `players` field to be a list of strings."
|
||||||
)
|
)
|
||||||
|
|
||||||
players : list[agents.remote.RemoteAgent] = []
|
players : list[pyclient.ServerAgent] = []
|
||||||
for url in urls:
|
for url in urls:
|
||||||
if not isinstance(url, str):
|
if not isinstance(url, str):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent = agents.remote.RemoteAgent(url=url)
|
agent = pyclient.Agent.from_url(url, debug=self.debug)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass # Not an available player right now
|
pass # Not an available player right now
|
||||||
else:
|
else:
|
||||||
|
|
@ -605,71 +571,52 @@ class EloTracker:
|
||||||
self.players = players
|
self.players = players
|
||||||
|
|
||||||
for agent in players:
|
for agent in players:
|
||||||
for game_name in self.__stats:
|
self.__get_stat(PlayerIdentifier.from_server_agent(agent))
|
||||||
self.__get_stat(
|
|
||||||
game_name=game_name,
|
|
||||||
player_id=PlayerIdentifier.from_server_agent(agent)
|
|
||||||
)
|
|
||||||
|
|
||||||
def play_match(
|
def play_match(self, players: list[str], game: Game) -> pyclient.GameReplay:
|
||||||
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.remote.RemoteAgent(url=url) for url in players ]
|
agents = [
|
||||||
|
pyclient.Agent.from_url(url, debug=self.debug)
|
||||||
|
for url in players
|
||||||
|
]
|
||||||
|
|
||||||
try:
|
replay = pyclient.PyClient(debug=self.debug).play_game(
|
||||||
replay = pyclient.PyClient(debug=self.debug).play_game(
|
players=agents,
|
||||||
players=ags,
|
start=game,
|
||||||
start=game,
|
)
|
||||||
max_turns=max_turns,
|
|
||||||
)
|
|
||||||
|
|
||||||
except ValueError:
|
m = Match.from_replay(players=agents, replay=replay, timestamp=Match.now())
|
||||||
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 | None:
|
) -> pyclient.GameReplay:
|
||||||
"""
|
"""
|
||||||
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 if game.game_name() in agent.games]
|
players = [agent.url for agent in self.players]
|
||||||
|
|
||||||
random.shuffle(players)
|
random.shuffle(players)
|
||||||
|
|
||||||
|
|
@ -682,7 +629,6 @@ 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:
|
||||||
"""
|
"""
|
||||||
|
|
@ -692,8 +638,6 @@ 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
|
||||||
"""
|
"""
|
||||||
|
|
@ -703,51 +647,40 @@ 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()
|
||||||
thread = threading.Thread(
|
self.__scheduler_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_threads.append(thread)
|
self.__scheduler_thread.start()
|
||||||
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.
|
||||||
"""
|
"""
|
||||||
max_wait_time_s = 5
|
thread: threading.Thread | None
|
||||||
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()
|
||||||
to_remove = [ thread for thread in self.__scheduler_threads ]
|
thread = self.__scheduler_thread
|
||||||
|
|
||||||
# Then, quietly remove all existing threads
|
if thread is not None:
|
||||||
for thread in self.__scheduler_threads:
|
thread.join(timeout=5)
|
||||||
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:
|
||||||
for thread in to_remove:
|
if self.__scheduler_thread is thread:
|
||||||
self.__scheduler_threads.remove(thread)
|
self.__scheduler_thread = None
|
||||||
|
|
||||||
def get_json_players(self, game_name : str) -> list[dict[str, Any]]:
|
def get_json_players(self) -> 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] = [
|
players : list[EloStat] = list(self.__stats.values())
|
||||||
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)
|
||||||
|
|
@ -774,21 +707,18 @@ class EloTracker:
|
||||||
|
|
||||||
return [ m.to_json() for m in matches ]
|
return [ m.to_json() for m in matches ]
|
||||||
|
|
||||||
def get_json_leaderboard(self) -> list[dict[str, Any]]:
|
def get_json_leaderboard(self) -> 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,
|
||||||
"name": game_name,
|
"players": self.get_json_players(),
|
||||||
"players": self.get_json_players(game_name=game_name),
|
}
|
||||||
}
|
|
||||||
for game_name in self.__stats
|
|
||||||
]
|
|
||||||
|
|
||||||
def start_server(
|
def start_server(
|
||||||
self,
|
self,
|
||||||
host: str = "0.0.0.0",
|
host: str = "127.0.0.1",
|
||||||
import_name : str = __name__,
|
import_name : str = __name__,
|
||||||
port: int = 5000,
|
port: int = 5000,
|
||||||
debug: bool = False,
|
debug: bool = False,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
{
|
{
|
||||||
"players": [
|
"players": [
|
||||||
"https://bmt001.noordstar.me",
|
"https://bmt001.noordstar.me"
|
||||||
"https://bmt002.noordstar.me",
|
|
||||||
"https://bmt003.noordstar.me"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -2,12 +2,15 @@
|
||||||
Entry points for developers who wish to use the PyClient module.
|
Entry points for developers who wish to use the PyClient module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from .agent import Agent, ServerAgent
|
||||||
from .client import PyClient
|
from .client import PyClient
|
||||||
from .replay import GameReplay
|
from .replay import GameReplay
|
||||||
from .transition import Transition
|
from .transition import Transition
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"Agent",
|
||||||
"GameReplay",
|
"GameReplay",
|
||||||
"PyClient",
|
"PyClient",
|
||||||
|
"ServerAgent",
|
||||||
"Transition",
|
"Transition",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
"""
|
||||||
|
This module hosts various agents that can participate in games.
|
||||||
|
|
||||||
|
Examples of possible agents could be:
|
||||||
|
|
||||||
|
- An online server
|
||||||
|
- A locally running neural network
|
||||||
|
- A hacker who participates from the terminal
|
||||||
|
- A user interface that allows users to play against their own creations
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, Optional, Tuple, Union
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Agent:
|
||||||
|
"""
|
||||||
|
Base class of a game participant. Mostly used to inherit from.
|
||||||
|
"""
|
||||||
|
|
||||||
|
debug : bool
|
||||||
|
games : dict[str, dict[str, Any]]
|
||||||
|
profile : dict[str, Any]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_url(cls, url : str, **kwargs) -> "ServerAgent":
|
||||||
|
"""
|
||||||
|
Create an agent based on a URL.
|
||||||
|
|
||||||
|
:param url: The URL where the agent can be accessed.
|
||||||
|
:type url: str
|
||||||
|
:return: An agent that contacts a server when polled.
|
||||||
|
:rtype: ServerAgent
|
||||||
|
:raises ValueError: The server fails to reach out one of the URLs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return ServerAgent.from_server_url(url=url, **kwargs)
|
||||||
|
except (ValueError, requests.RequestException, requests.HTTPError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
"URL did not lead to a willing agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""
|
||||||
|
The name by which the agent calls itself.
|
||||||
|
"""
|
||||||
|
return str(self.profile.get("name", "Nameless agent"))
|
||||||
|
|
||||||
|
def poll(
|
||||||
|
self,
|
||||||
|
game : str,
|
||||||
|
payload : dict[str, Any],
|
||||||
|
**kwargs
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Ask the agent to make a move.
|
||||||
|
|
||||||
|
:param game: The game the ServerAgent is asked to play.
|
||||||
|
:type game: str
|
||||||
|
:param payload: The JSON payload that represents the game's state.
|
||||||
|
:type payload: dict[str, Any]
|
||||||
|
:return: The agent's response, or None if the agent doesn't respond.
|
||||||
|
:rtype: Optional[dict[str, Any]]
|
||||||
|
"""
|
||||||
|
print(f"WARNING: The `poll` method is not defined on the {self.__class__.__name__} class")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ServerAgent(Agent):
|
||||||
|
"""
|
||||||
|
Agent that reaches out to the internet to poll for moves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url : str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_server_url(
|
||||||
|
cls,
|
||||||
|
url: str,
|
||||||
|
timeout: float | tuple[float, float] | None = 10.0,
|
||||||
|
debug : bool = False
|
||||||
|
) -> "ServerAgent":
|
||||||
|
"""
|
||||||
|
Create a server agent by polling its discovery endpoint.
|
||||||
|
|
||||||
|
:param url: The URL that the server can be reached at.
|
||||||
|
:type url: str
|
||||||
|
:param timeout: Request timeout.
|
||||||
|
:type timeout: float | tuple[float, float] | None
|
||||||
|
:param debug: Enables debug mode
|
||||||
|
:type debug: bool
|
||||||
|
:return: The server's representation as an agent.
|
||||||
|
:rtype: ServerAgent
|
||||||
|
:raises requests.exceptions.HTTPError: If the server returns a
|
||||||
|
non-success HTTP status code.
|
||||||
|
:raises requests.exceptions.RequestException: If the request fails
|
||||||
|
before a response is received.
|
||||||
|
:raises ValueError: If the response body is not a JSON object or if the
|
||||||
|
payload contains malformed discovery fields.
|
||||||
|
"""
|
||||||
|
response = requests.get(url.rstrip("/") + "/", timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
content = response.json()
|
||||||
|
|
||||||
|
if not isinstance(content, dict):
|
||||||
|
raise ValueError("Server discovery responses must be JSON objects.")
|
||||||
|
|
||||||
|
raw_name = content.get("name", "")
|
||||||
|
name = "" if raw_name is None else str(raw_name)
|
||||||
|
|
||||||
|
games: dict[str, dict[str, Any]] = {}
|
||||||
|
raw_games = content.get("games", {})
|
||||||
|
if raw_games is not None:
|
||||||
|
if not isinstance(raw_games, dict):
|
||||||
|
raise ValueError("The 'games' field must be a JSON object when provided.")
|
||||||
|
|
||||||
|
for game_name, profile in raw_games.items():
|
||||||
|
if isinstance(profile, dict):
|
||||||
|
games[str(game_name)] = profile
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
debug=debug,
|
||||||
|
games=games,
|
||||||
|
profile=content,
|
||||||
|
url=url,
|
||||||
|
)
|
||||||
|
|
||||||
|
def poll(
|
||||||
|
self,
|
||||||
|
game : str,
|
||||||
|
payload : dict[str, Any],
|
||||||
|
**kwargs
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Inquire a game to make a move.
|
||||||
|
|
||||||
|
:param game: The game the ServerAgent is asked to play.
|
||||||
|
:type game: str
|
||||||
|
:param payload: The JSON payload that represents the game's state.
|
||||||
|
:type payload: dict[str, Any]
|
||||||
|
:return: The server's response, or None if the server did not respond.
|
||||||
|
:rtype: Optional[dict[str, Any]]
|
||||||
|
"""
|
||||||
|
url = f"{self.url.rstrip('/')}/{game.lstrip('/')}"
|
||||||
|
timeout = float(kwargs.get("timeout", 1.0))
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, json=payload, timeout=timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
content = response.json()
|
||||||
|
except (requests.exceptions.RequestException, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.debug:
|
||||||
|
print(f"[DBG] Agent `{self.name}` returned:")
|
||||||
|
print(content)
|
||||||
|
|
||||||
|
return content if isinstance(content, dict) else None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Represent the agent in the form of a dict.
|
||||||
|
|
||||||
|
:return: Dictionary representation of the ServerAgent
|
||||||
|
:rtype: dict[str, Any]
|
||||||
|
"""
|
||||||
|
return dict(
|
||||||
|
name=self.name,
|
||||||
|
games=self.games,
|
||||||
|
url=self.url,
|
||||||
|
profile=self.profile,
|
||||||
|
)
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from agents import Agent
|
from .agent import Agent
|
||||||
from .games import Game
|
from .games import Game
|
||||||
from .replay import GameReplay, Turn
|
from .replay import GameReplay, Turn
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
@ -19,12 +19,7 @@ class PyClient:
|
||||||
|
|
||||||
debug : bool
|
debug : bool
|
||||||
|
|
||||||
def gen_game(
|
def gen_game(self, players : list[Agent], start : Game) -> Generator[Turn, None, None]:
|
||||||
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.
|
||||||
|
|
||||||
|
|
@ -32,22 +27,12 @@ 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:
|
||||||
|
|
@ -58,30 +43,17 @@ class PyClient:
|
||||||
|
|
||||||
else:
|
else:
|
||||||
agent = players[player - 1]
|
agent = players[player - 1]
|
||||||
on_move = agent.get_action(
|
payload = agent.poll(
|
||||||
game=current_state.game_name(),
|
game=current_state.game_name(),
|
||||||
action=current_state.action_name(),
|
payload=current_state.as_seen_by(player=player),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate move
|
# Calculate move
|
||||||
if on_move is None:
|
|
||||||
payload = {}
|
|
||||||
else:
|
|
||||||
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(
|
def play_game(self, players : list[Agent], start : Game) -> GameReplay:
|
||||||
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.
|
||||||
|
|
@ -90,16 +62,11 @@ 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(
|
turns=list(self.gen_game(players=players, start=start)),
|
||||||
players=players, start=start, max_turns=max_turns
|
|
||||||
)),
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,9 @@
|
||||||
|
|
||||||
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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,235 +0,0 @@
|
||||||
"""
|
|
||||||
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,10 +19,7 @@ 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"]
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
# 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).
|
|
||||||
|
|
@ -2,43 +2,86 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
from collections.abc import Callable, Mapping
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
from flask import Flask, jsonify, request
|
from flask import Flask, Response, jsonify, request
|
||||||
from agents import Agent
|
from functools import wraps
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
PayloadType = dict[str, Any]
|
PayloadType = dict[str, Any]
|
||||||
|
GameHandler = Callable[[PayloadType], PayloadType]
|
||||||
|
|
||||||
|
|
||||||
class PyServer:
|
class PyServer:
|
||||||
"""
|
"""A tiny stateless Flask app that serves discovery and game routes."""
|
||||||
A tiny stateless Flask app that serves discovery and routes
|
|
||||||
game behavior to an agent.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, agent : Agent, import_name : str = __name__) -> None:
|
def __init__(self,
|
||||||
|
name: str,
|
||||||
|
import_name: str = __name__,
|
||||||
|
profile: Optional[PayloadType] = None,
|
||||||
|
subpath : str = "",
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Create a PyServer that serves the behavior of an Agent.
|
Create a PyServer.
|
||||||
|
|
||||||
:param agent: The agent that one can communicate with.
|
:param name: Preferred display name for discovery.
|
||||||
:type agent: Agent
|
:type name: str
|
||||||
:param import_name: Flask application import name
|
:param import_name: Flask import name.
|
||||||
:type import_name: str
|
:type import_name: str
|
||||||
|
:param profile: Additional root-level discovery metadata.
|
||||||
|
:type profile: Optional[Dict[str, Any]]
|
||||||
|
:type subpath: str
|
||||||
|
:raises ValueError: The input contains invalid information.
|
||||||
"""
|
"""
|
||||||
self.containerized : bool = bool(int(os.environ.get("CONTAINERIZED", "0")))
|
self.name = name
|
||||||
self.agent = agent
|
self.profile = dict(profile or {})
|
||||||
|
|
||||||
|
if "name" in self.profile:
|
||||||
|
raise ValueError(
|
||||||
|
"Root profile metadata must not define 'name'."
|
||||||
|
)
|
||||||
|
if "games" in self.profile:
|
||||||
|
raise ValueError(
|
||||||
|
"Root profile metadata must not define 'games'."
|
||||||
|
)
|
||||||
|
|
||||||
self.app = Flask(import_name)
|
self.app = Flask(import_name)
|
||||||
|
self.__games: dict[str, PayloadType] = {}
|
||||||
|
self.__registered_routes: set[str] = set()
|
||||||
|
|
||||||
@self.app.get("/")
|
# Register the root
|
||||||
def discovery():
|
self.__add_api_endpoint("", "", lambda _ : self.__discovery())
|
||||||
return jsonify(self.__discovery())
|
# self.app.add_url_rule("/",
|
||||||
|
# endpoint="botman_discovery",
|
||||||
|
# view_func=self.__discovery,
|
||||||
|
# methods=["GET"]
|
||||||
|
# )
|
||||||
|
|
||||||
|
def __add_api_endpoint(self, name : str, route : str, func : GameHandler) -> None:
|
||||||
|
"""
|
||||||
|
Create a new API endpoint.
|
||||||
|
|
||||||
@self.app.get("/<path:game>")
|
:param name: The name of the action to undertake.
|
||||||
@self.app.get("/<path:game>/<path:action>")
|
:type name: str
|
||||||
def dispatch(game: str, action: str | None = None):
|
:param func: The player's function that determines what action to take.
|
||||||
return jsonify(self.__dispatch(game=game, action=action))
|
:type func: Callable[[dict[str, Any]], dict[str, Any]]
|
||||||
|
:raises ValueError: The URL has already been registered.
|
||||||
|
"""
|
||||||
|
url = self.__make_url(name, route)
|
||||||
|
|
||||||
|
if url in self.__registered_routes:
|
||||||
|
raise ValueError(
|
||||||
|
f"Route already registered: {url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.__registered_routes.add(url)
|
||||||
|
|
||||||
|
return self.app.add_url_rule(url,
|
||||||
|
endpoint="botman_" + name.replace("/", "_"),
|
||||||
|
view_func=self.__func_wrapper(func),
|
||||||
|
methods=["GET"],
|
||||||
|
)
|
||||||
|
|
||||||
def __discovery(self) -> PayloadType:
|
def __discovery(self) -> PayloadType:
|
||||||
"""
|
"""
|
||||||
Return the server's discovery information.
|
Return the server's discovery information.
|
||||||
|
|
@ -46,35 +89,104 @@ class PyServer:
|
||||||
:return: The personal discovery information.
|
:return: The personal discovery information.
|
||||||
:rtype: dict[str, Any]
|
:rtype: dict[str, Any]
|
||||||
"""
|
"""
|
||||||
d = dict(name=self.agent.name, author=self.agent.author)
|
return {
|
||||||
if self.agent.version is not None:
|
"name": self.name,
|
||||||
d["version"] = self.agent.version
|
"games": dict(self.__games),
|
||||||
|
**self.profile,
|
||||||
d = { **self.agent.profile, **d }
|
}
|
||||||
|
|
||||||
d["me.noordstar.peanuts.containerized"] = self.containerized
|
def __func_wrapper(self, func : GameHandler) -> Callable[[], Response]:
|
||||||
d["games"] = {}
|
"""
|
||||||
for game, (profile, _) in self.agent.registered_games.items():
|
Wrapper that catches an incoming request, parses it, and responds
|
||||||
d["games"][game] = profile
|
with a player's action response.
|
||||||
|
"""
|
||||||
|
@wraps(func)
|
||||||
|
def exec():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
result = func(payload) or {}
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
return d
|
return exec
|
||||||
|
|
||||||
def __dispatch(self, game: str, action: str | None) -> PayloadType:
|
def __make_url(self, name : str, route : str) -> str:
|
||||||
|
return "/" + "/".join([ name.strip("/"), route.strip("/") ]).strip("/")
|
||||||
|
|
||||||
|
def add_game(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
profile: PayloadType,
|
||||||
|
actions: dict[str, GameHandler],
|
||||||
|
required_actions: Optional[list[str]] = None,
|
||||||
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Resolve a request against the agent's action lookup.
|
Register a stateless game with one or more action routes.
|
||||||
|
|
||||||
|
:param name: Base route name for the game.
|
||||||
|
:type name: str
|
||||||
|
:param profile: Game-specific discovery metadata.
|
||||||
|
:type profile: dict[str, Any]
|
||||||
|
:param actions: Mapping of action subpaths to handlers.
|
||||||
|
:type actions: dict[str, Callable[[dict[str, Any]], dict[str, Any]]]
|
||||||
|
:param required_actions: Optional completeness check. If provided, all
|
||||||
|
required action names must exist in ``actions`` and map to callables.
|
||||||
|
:type required_actions: Optional[list[str]]
|
||||||
|
:raises AssertionError: Some of the required actions aren't present.
|
||||||
|
:raises ValueError: Some of the requested URL paths are already occupied.
|
||||||
"""
|
"""
|
||||||
payload = request.get_json(silent=True) or {}
|
|
||||||
func = self.agent.get_action(
|
# Verify that all required actions are present
|
||||||
game=game.strip("/"),
|
if required_actions is not None:
|
||||||
action=action.strip("/") if isinstance(action, str) else None,
|
missing_actions = [ action for action in required_actions if action not in actions ]
|
||||||
|
|
||||||
|
if len(missing_actions) > 0:
|
||||||
|
raise AssertionError(
|
||||||
|
f"Missing required action handlers: {', '.join(sorted(missing_actions))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify that there are no duplicate URLs being registered
|
||||||
|
# Even though this will automatically be checked later, checking this
|
||||||
|
# now ensures that the operation is atomic and doesn't add
|
||||||
|
# games partially.
|
||||||
|
new_routes : set[str] = set()
|
||||||
|
|
||||||
|
for route in actions:
|
||||||
|
url = self.__make_url(name, route)
|
||||||
|
|
||||||
|
if url in self.__registered_routes:
|
||||||
|
raise ValueError(
|
||||||
|
"Route {url} was already registered"
|
||||||
|
)
|
||||||
|
if url in new_routes:
|
||||||
|
raise ValueError(
|
||||||
|
"Route {url} was registered twice by the same function call"
|
||||||
|
)
|
||||||
|
|
||||||
|
new_routes.add(url)
|
||||||
|
|
||||||
|
# Register all actions
|
||||||
|
for route, func in actions.items():
|
||||||
|
self.__add_api_endpoint(name=name, route=route, func=func)
|
||||||
|
|
||||||
|
# Add profile data
|
||||||
|
self.__games[name] = profile
|
||||||
|
|
||||||
|
def add_tic_tac_toe(self, on_move: GameHandler, profile: PayloadType = {}) -> None:
|
||||||
|
"""
|
||||||
|
Convenience registration for tic-tac-toe.
|
||||||
|
|
||||||
|
The game is exposed at `/tic-tac-toe`.
|
||||||
|
|
||||||
|
:param profile: The player's custom profile.
|
||||||
|
:type profile: dict[str, Any]
|
||||||
|
:param on_move:
|
||||||
|
"""
|
||||||
|
self.add_game(
|
||||||
|
name="tic-tac-toe",
|
||||||
|
profile=profile,
|
||||||
|
actions={"": on_move},
|
||||||
|
required_actions=[""],
|
||||||
)
|
)
|
||||||
|
|
||||||
if func is None:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
result = func(payload) or {}
|
|
||||||
return result if isinstance(result, dict) else {}
|
|
||||||
|
|
||||||
def start(self, host: str = "127.0.0.1", port: int = 5000, debug: bool = False, **kwargs: Any) -> None:
|
def start(self, host: str = "127.0.0.1", port: int = 5000, debug: bool = False, **kwargs: Any) -> None:
|
||||||
"""Start the Flask development server."""
|
"""Start the Flask development server."""
|
||||||
self.app.run(host=host, port=port, debug=debug, **kwargs)
|
self.app.run(host=host, port=port, debug=debug, **kwargs)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
blinker==1.9.0
|
|
||||||
certifi==2026.6.17
|
|
||||||
charset-normalizer==3.4.7
|
|
||||||
click==8.4.1
|
|
||||||
colorama==0.4.6
|
|
||||||
Flask==3.1.3
|
|
||||||
idna==3.18
|
|
||||||
itsdangerous==2.2.0
|
|
||||||
Jinja2==3.1.6
|
|
||||||
MarkupSafe==3.0.3
|
|
||||||
requests==2.34.2
|
|
||||||
urllib3==2.7.0
|
|
||||||
Werkzeug==3.1.8
|
|
||||||
37
server.py
37
server.py
|
|
@ -4,9 +4,10 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import agents
|
import random
|
||||||
|
|
||||||
from pyserver import PyServer
|
from pyserver import PyServer
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
"""
|
"""
|
||||||
|
|
@ -15,7 +16,25 @@ def main() -> int:
|
||||||
:return: Exit code
|
:return: Exit code
|
||||||
:rtype: int
|
:rtype: int
|
||||||
"""
|
"""
|
||||||
player = PyServer(agents.AgentOfChaos())
|
player = PyServer(
|
||||||
|
# Customize this to whatever you'd like to call your player
|
||||||
|
name="Mute",
|
||||||
|
|
||||||
|
# Custom information that you can use to tell people about this player
|
||||||
|
profile={
|
||||||
|
"me.noordstar.peanuts.agent.version": "1.0.0",
|
||||||
|
"me.noordstar.peanuts.is_ai": False,
|
||||||
|
"me.noordstar.peanuts.author": "Bram",
|
||||||
|
"me.noordstar.peanuts.containerized": True,
|
||||||
|
"version": "1.0.0",
|
||||||
|
},
|
||||||
|
|
||||||
|
# Unless you know what you're doing, don't touch this.
|
||||||
|
import_name=__name__,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register games! Comment out any you don't want your player to play.
|
||||||
|
player.add_tic_tac_toe(on_move=respond_mute, profile={})
|
||||||
|
|
||||||
# Start listening for games
|
# Start listening for games
|
||||||
player.start(
|
player.start(
|
||||||
|
|
@ -25,5 +44,19 @@ def main() -> int:
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
def respond_mute(payload : dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Always respond with an empty dictionary. This means the user should
|
||||||
|
always take the "default" move.
|
||||||
|
|
||||||
|
A well-programmed game must NOT raise an error as a result of this.
|
||||||
|
|
||||||
|
:param payload: The game state which is completely ignored.
|
||||||
|
:type payload: dict[str, Any]
|
||||||
|
:return: An empty dictionary
|
||||||
|
:rtype: dict[str, Any]
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,6 @@
|
||||||
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.
|
||||||
|
|
@ -69,13 +65,3 @@ 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. |
|
|
||||||
Loading…
Reference in New Issue