Compare commits

...

32 Commits

Author SHA1 Message Date
Bram van den Heuvel 94ea1ae2f0 HOTFIX: Enforce player's supported list of games 2026-07-01 23:10:16 +02:00
Bram van den Heuvel c9899c5d65 Merge branch 'bram/deployment' into elo 2026-07-01 22:51:46 +02:00
Bram van den Heuvel 220c956ba0 Improve AgentOfChaos documentation 2026-07-01 22:45:39 +02:00
Bram van den Heuvel 194f962746 Introduce maximum number of turns 2026-07-01 22:45:13 +02:00
Bram van den Heuvel d7ac20a6dd Apply hotfixes from branch bram/sneaky-hotfix 2026-07-01 22:09:01 +02:00
Bram van den Heuvel 1d78cdfd4d Ignore data files 2026-07-01 21:57:23 +02:00
Bram van den Heuvel 36d5aa30fc Restrict participation to clients that want to participate 2026-07-01 21:54:24 +02:00
Bram van den Heuvel ca7bfdc50a Allow EloTracker to run matches for multiple games 2026-07-01 17:23:14 +02:00
Bram van den Heuvel e2daa9a205 Update EloTracker API to support multiple games 2026-07-01 17:01:59 +02:00
Bram van den Heuvel da26f5c016 Implement royal game of Ur 2026-07-01 16:14:31 +02:00
Bram van den Heuvel 90d2bea528 Convert all links to relative links 2026-06-28 23:16:39 +02:00
Bram van den Heuvel 73be372a30 Add improved documentation 2026-06-28 23:09:55 +02:00
Bram van den Heuvel 87525238ba Prevent exceptions from destroying the ELO tracker scheduled loop 2026-06-28 13:48:42 +02:00
Bram van den Heuvel c004d9d3e7 Update PyServer Containerfile 2026-06-28 13:24:39 +02:00
Bram van den Heuvel 6cd1ec9c97 Fix flag -> flags misspelling 2026-06-27 17:44:28 +02:00
Bram van den Heuvel b09bc370c4 Hotfix: Update input flag base URL 2026-06-27 17:35:24 +02:00
Bram van den Heuvel 317b5eeabf Update localhost address to empty string 2026-06-27 17:32:25 +02:00
Bram van den Heuvel 2ace9688ff Create MVP ELO tracker with front-end app & Containerfile 2026-06-27 17:21:16 +02:00
Bram van den Heuvel d8c20fd4cc Refactor project to new Agent class 2026-06-24 13:31:24 +02:00
Bram van den Heuvel 5c3eb14c17 Create MVP RemoteAgent 2026-06-24 09:11:21 +02:00
Bram van den Heuvel b563c8422f Create proof-of-concept update for mute & chaos agents 2026-06-23 23:45:33 +02:00
Bram van den Heuvel 7b0f147a89 Create generalized Agent class 2026-06-23 23:24:32 +02:00
Bram van den Heuvel bb3bb36665 Create functional ELO tracker 2026-06-23 19:42:18 +02:00
Bram van den Heuvel eabf15ddbe Create ELO tracker MVP 2026-06-23 16:31:38 +02:00
Bram van den Heuvel c3fd825876 Merge main updates into web-client branch 2026-06-20 15:04:37 +02:00
Bram van den Heuvel 8aaacfc83c Remove unnecessary imports from client.py 2026-06-20 14:59:58 +02:00
Bram van den Heuvel 04e21632ea Refactor PyClient module 2026-06-20 14:53:51 +02:00
Bram van den Heuvel 78c461d9b2 Create client script with diagnostics 2026-06-19 15:46:03 +02:00
Bram van den Heuvel e2a11ae0d1 Add draft CreateGame screen 2026-06-19 13:46:13 +02:00
Bram van den Heuvel ecc69034f3 Create separate Program module 2026-06-19 11:52:44 +02:00
Bram van den Heuvel bf8e33ee1c Add back-end service to webclient 2026-06-18 23:20:19 +02:00
Bram van den Heuvel 8f030efa9a Add MVP front-end framework 2026-06-18 23:19:12 +02:00
54 changed files with 6088 additions and 546 deletions

View File

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

7
.gitignore vendored
View File

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

View File

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

70
agents/README.md Normal file
View File

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

18
agents/__init__.py Normal file
View File

@ -0,0 +1,18 @@
"""
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 Normal file
View File

@ -0,0 +1,154 @@
"""
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)

78
agents/chaos.py Normal file
View File

@ -0,0 +1,78 @@
"""
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", [""])) }

104
agents/example.py Normal file
View File

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

57
agents/mute.py Normal file
View File

@ -0,0 +1,57 @@
"""
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 Normal file
View File

@ -0,0 +1,109 @@
"""
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 {}

106
client.py Normal file
View File

@ -0,0 +1,106 @@
"""
This module offers a small place to start building game clients from.
You can use this for:
- Learning how the system works.
- Debugging a game server.
- Debugging a game that behaves weirdly.
This module lets you host a game, let online servers participate in it,
and then analyze the outcome.
You can build several things with this:
1. You can build an AI trainer that trains on existing players.
2. You can build an ELO evaluator that compares the performance of various
strategies.
"""
from __future__ import annotations
import json
import pyclient
# Import your game(s) here
from pyclient.games import TicTacToe
# ...
# Import your agent(s) here
from agents import Agent, AgentOfChaos
# from agents.my_agent import MyAgent
# ...
def main() -> int:
"""
Start a client, then use it to analyze one or more matches.
:return: Exit code
:rtype: int
"""
# 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] = [
AgentOfChaos(),
AgentOfChaos(),
]
# Play a given game with your players
result = c.play_game(
players=players,
start=TicTacToe.empty(),
)
# Print the game results to the terminal!
inspect_game(result)
return 0
def inspect_game(game : pyclient.GameReplay) -> None:
"""
Print a diagnostic of a played game to the terminal.
:param game: The results of a played game.
:type game: pyclient.GameReplay
"""
max_width = 40
hbar = max_width * '%'
def title(s : str) -> None:
"""
Show a title nice and clean in the middle
:param s: The title to display
:type s: str
"""
w = (max_width - len(s)) // 2
print(hbar)
print((w * ' ') + s.upper() + (w * ' '))
print(hbar)
# Show all moves made throughout the game
title("Turns taken")
for turn in game.turns:
print(f"Player {turn.player} : " + json.dumps(turn.action)[:max_width-12])
# Show all remaining variables in the finishing state of the game
title("Final state")
final_state = game.turns[-1].state
for k, v in final_state.to_dict().items():
print(f"{k} => {json.dumps(v)}")
# Some final (usually the most relevant) statistics
title("Result")
print(f"Total turns taken: {len(game.turns)}")
print(f"Result: {final_state.winner()}")
if __name__ == "__main__":
raise SystemExit(main())

39
elm.json Normal file
View File

@ -0,0 +1,39 @@
{
"type": "application",
"source-directories": [
"elm"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"Orasund/elm-ui-widgets": "3.4.0",
"avh4/elm-color": "1.0.0",
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.1",
"elm/http": "2.0.0",
"elm/json": "1.1.4",
"elm/svg": "1.0.1",
"elm/time": "1.0.0",
"ianmackenzie/elm-units": "2.10.0",
"icidasset/elm-material-icons": "11.0.0",
"mdgriffith/elm-ui": "1.1.8",
"noordstar/elm-palette": "1.0.0"
},
"indirect": {
"elm/bytes": "1.0.8",
"elm/file": "1.0.5",
"elm/regex": "1.0.0",
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.5",
"elm-community/intdict": "3.1.0",
"fredcy/elm-parseint": "2.0.1",
"noahzgordon/elm-color-extra": "1.0.2",
"turboMaCk/queue": "1.2.0"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

167
elm/Api.elm Normal file
View File

@ -0,0 +1,167 @@
module Api exposing
( GameDetails
, Player
, gameDetails
, profile
, startGame
)
import Dict exposing (Dict)
import Http
import Json.Decode as D
import Json.Encode as E
endpointGameDetails =
"/game-details"
endpointGetProfile =
"/profile"
endpointStartGame =
"/start-game"
{-| Full report on how a game is going.
-}
type alias GameDetails gameState =
{ name : String
, turns : List { player : Int, action : E.Value, state : gameState }
, winner : Maybe Int
}
{-| General format of a player who's allowed to participate.
-}
type alias Player =
{ name : String
, games : Dict String (Dict String E.Value)
, profile : Dict String E.Value
, url : String
}
{-| Builds a generalized API call to the webclient server.
-}
callWebClient :
{ body : Maybe D.Value
, decoder : D.Decoder a
, method : String
, toMsg : Result Http.Error a -> msg
, url : String
}
-> Cmd msg
callWebClient data =
Http.request
{ method = data.method
, headers = []
, url = data.url
, body =
data.body
|> Maybe.map Http.jsonBody
|> Maybe.withDefault Http.emptyBody
, expect = Http.expectJson data.toMsg data.decoder
, timeout = Nothing
, tracker = Nothing
}
{-| Retrieves all the latest details about a game. A game might still be
ongoing and therefore might be incomplete.
-}
gameDetails :
{ baseUrl : String
, decoder : D.Decoder gameState
, gameId : String
, toMsg : Result Http.Error (GameDetails gameState) -> msg
}
-> Cmd msg
gameDetails data =
callWebClient
{ body = Just (E.object [ ( "game_id", E.string data.gameId ) ])
, decoder = gameDetailsDecoder data.decoder
, method = "GET"
, toMsg = data.toMsg
, url = data.baseUrl ++ endpointGameDetails
}
{-| Decodes a game's details from JSON.
-}
gameDetailsDecoder : D.Decoder gameState -> D.Decoder (GameDetails gameState)
gameDetailsDecoder decoder =
D.map3 GameDetails
(D.field "name" D.string)
(D.map3
(\action player state ->
{ player = player, action = action, state = state }
)
(D.field "action" D.value)
(D.field "player" D.int)
(D.field "state" decoder)
|> D.list
|> D.field "turns"
)
(D.field "winner" <| D.oneOf [ D.map Just D.int, D.null Nothing ])
{-| Decodes a Player from JSON.
-}
playerDecoder : D.Decoder Player
playerDecoder =
D.map4 Player
(D.field "name" D.string)
(D.field "games" <| D.dict <| D.dict D.value)
(D.field "profile" <| D.dict D.value)
(D.field "url" D.string)
{-| Gets the profile of a given player with a given URL.
-}
profile :
{ baseUrl : String
, playerUrl : String
, toMsg : Result Http.Error Player -> msg
}
-> Cmd msg
profile data =
callWebClient
{ body = Just (E.object [ ( "url", E.string data.playerUrl ) ])
, decoder = playerDecoder
, method = "GET"
, toMsg = data.toMsg
, url = data.baseUrl ++ endpointGetProfile
}
{-| Instructs the server to start a game with the PyClient. The players list
provides a set of URLs that should be considered as its players, even if the
players haven't been verified (yet).
The server responds with a unique identifier for the game. This allows the
front-end to query updates about the game while it's still being processed.
-}
startGame :
{ baseUrl : String
, game : String
, players : List String
, toMsg : Result Http.Error String -> msg
}
-> Cmd msg
startGame data =
callWebClient
{ body =
Just
(E.object
[ ( "game", E.string data.game )
, ( "players", E.list E.string data.players )
]
)
, decoder = D.string
, method = "POST"
, toMsg = data.toMsg
, url = data.baseUrl ++ endpointStartGame
}

159
elm/Api/Elo.elm Normal file
View File

@ -0,0 +1,159 @@
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

205
elm/EloTracker.elm Normal file
View File

@ -0,0 +1,205 @@
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
}

238
elm/GameList.elm Normal file
View File

@ -0,0 +1,238 @@
module GameList exposing (..)
import Dict exposing (Dict)
import Duration
import Element exposing (Element)
import Element.Background
import Element.Events
import Games.TicTacToe as TicTacToe exposing (TicTacToe)
import Layout
import Match exposing (Match)
import Material.Icons as Icons
import Pixels exposing (Pixels)
import Quantity exposing (Quantity)
import Theme
-- MODEL
type CreateGameType
= CreateTicTacToe
type Game
= GameTicTacToe String (Match TicTacToe)
type GameList
= GameList
{ ticTacToe : Dict String (Match TicTacToe)
}
type Msg
= AddTicTacToe { baseUrl : String, matchId : String }
| OnTicTacToe String (Match.Msg TicTacToe)
init : {} -> ( GameList, Cmd Msg )
init _ =
( GameList
{ ticTacToe = Dict.empty
}
, Cmd.none
)
-- UPDATE
update : Msg -> GameList -> ( GameList, Cmd Msg )
update msg ((GameList data) as model) =
case msg of
AddTicTacToe newGame ->
let
( newMdl, newM ) =
Match.init
{ autoScroll = Just (Duration.seconds 0.75)
, baseUrl = newGame.baseUrl
, decoder = TicTacToe.decoder
, empty = TicTacToe.empty
, matchId = newGame.matchId
}
in
( GameList { data | ticTacToe = Dict.insert newGame.matchId newMdl data.ticTacToe }
, Cmd.map (OnTicTacToe newGame.matchId) newM
)
OnTicTacToe key m ->
case Dict.get key data.ticTacToe of
Nothing ->
( model, Cmd.none )
Just mdl ->
case Match.update m mdl of
( newMdl, newM ) ->
( GameList { data | ticTacToe = Dict.insert key newMdl data.ticTacToe }
, Cmd.map (OnTicTacToe key) newM
)
-- SUBSCRIPTIONS
subscriptions : GameList -> Sub Msg
subscriptions (GameList data) =
Sub.batch
[ Dict.toList data.ticTacToe
|> List.map
(\( key, mdl ) ->
Sub.map (OnTicTacToe key) (Match.subscriptions mdl)
)
|> Sub.batch
]
-- VIEW
createGameToString : CreateGameType -> String
createGameToString cg =
case cg of
CreateTicTacToe ->
"tic-tac-toe"
viewCreateGame :
{ baseUrl : String
, flavor : Theme.Flavor
, height : Quantity Int Pixels
, onBaseUrl : String -> msg
, onPlayers : List String -> msg
, players : List String
, width : Quantity Int Pixels
}
-> Element msg
viewCreateGame data =
Element.none
viewGame :
{ flavor : Theme.Flavor
, game : Game
, height : Quantity Int Pixels
, onNavigateBack : msg
, toMsg : Msg -> msg
, width : Quantity Int Pixels
}
-> Element msg
viewGame data =
let
navBarHeight =
Pixels.pixels 200
showNavBar =
Quantity.ratio (Quantity.toFloatQuantity data.height) navBarHeight <= 3
gameHeight =
if showNavBar then
data.height |> Quantity.minus navBarHeight
else
data.height
in
Element.column
[ Element.height <| Element.px <| Pixels.inPixels data.height
, Element.width <| Element.px <| Pixels.inPixels data.width
]
[ if showNavBar then
Element.row
[ Element.Background.color (Theme.blueUI data.flavor)
, Element.height <| Element.px <| Pixels.inPixels navBarHeight
, Element.width <| Element.px <| Pixels.inPixels data.width
]
[ Layout.iconAsElement
{ color = Theme.blue data.flavor
, height = Pixels.inPixels navBarHeight
, icon = Icons.arrow_back
, width = Pixels.inPixels data.width
}
|> Element.el [ Element.Events.onClick data.onNavigateBack ]
]
else
Element.none
, viewMatch
{ flavor = data.flavor
, game = data.game
, height = gameHeight
, width = data.width
}
|> Element.map data.toMsg
]
viewMatch :
{ flavor : Theme.Flavor
, game : Game
, height : Quantity Int Pixels
, width : Quantity Int Pixels
}
-> Element Msg
viewMatch data =
case data.game of
GameTicTacToe key match ->
Match.view
{ flavor = data.flavor
, height = data.height
, match = match
, toMsg = OnTicTacToe key
, viewGame = TicTacToe.view
, width = data.width
}
viewSelection :
{ flavor : Theme.Flavor
, height : Quantity Int Pixels
, model : GameList
, onCreateGame : msg
, onNavigateToGame : Game -> msg
, width : Quantity Int Pixels
}
-> Element msg
viewSelection data =
case data.model of
GameList model ->
[ Layout.itemWithSubtext
{ color = Theme.mantle data.flavor
, leftIcon = always Element.none
, onPress = Just data.onCreateGame
, rightIcon = always Element.none
, text = "Create new game"
, title = "CREATE"
}
[]
|> List.singleton
, model.ticTacToe
|> Dict.toList
|> List.map
(\( key, match ) ->
Match.viewListItem
{ flavor = data.flavor
, height = Pixels.pixels 80
, match = match
, onPress = Just <| data.onNavigateToGame <| GameTicTacToe key match
, width = data.width
}
)
]
|> List.concat
|> Element.column
[ Element.centerX
]

201
elm/Games/TicTacToe.elm Normal file
View File

@ -0,0 +1,201 @@
module Games.TicTacToe exposing (..)
{-| This module exposes a library for the simple game of tic-tac-toe.
-}
import Color
import Element exposing (Element)
import Json.Decode as D
import Layout
import Pixels exposing (Pixels)
import Quantity exposing (Quantity)
import Svg
import Svg.Attributes
import Theme
-- MODEL
type Field
= X
| O
| Empty
type alias TicTacToe =
{ field_1 : Field
, field_2 : Field
, field_3 : Field
, field_4 : Field
, field_5 : Field
, field_6 : Field
, field_7 : Field
, field_8 : Field
, field_9 : Field
}
decoder : D.Decoder TicTacToe
decoder =
D.map3
(\( a, b, c ) ( d, e, f ) ( g, h, i ) ->
TicTacToe a b c d e f g h i
)
(D.map3 (\a b c -> ( a, b, c )) fieldDecoder fieldDecoder fieldDecoder)
(D.map3 (\a b c -> ( a, b, c )) fieldDecoder fieldDecoder fieldDecoder)
(D.map3 (\a b c -> ( a, b, c )) fieldDecoder fieldDecoder fieldDecoder)
empty : TicTacToe
empty =
{ field_1 = Empty
, field_2 = Empty
, field_3 = Empty
, field_4 = Empty
, field_5 = Empty
, field_6 = Empty
, field_7 = Empty
, field_8 = Empty
, field_9 = Empty
}
fieldDecoder : D.Decoder Field
fieldDecoder =
D.andThen
(\s ->
case s of
"X" ->
D.succeed X
"O" ->
D.succeed O
"" ->
D.succeed Empty
_ ->
D.fail "Unknown field type"
)
D.string
-- VIEW
view :
{ flavor : Theme.Flavor
, game : TicTacToe
, height : Quantity Int Pixels
, width : Quantity Int Pixels
}
-> Element msg
view data =
Layout.svg
{ aspectRatio = 1 / 1
, height = Pixels.inPixels data.height
, width = Pixels.inPixels data.width
, viewMinX = 0
, viewMaxX = 300
, viewMinY = 0
, viewMaxY = 300
, svg =
Svg.g
[ Svg.Attributes.strokeLinecap "round"
]
[ svgField { field = data.game.field_1, flavor = data.flavor, offsetX = 0, offsetY = 0 }
, svgField { field = data.game.field_2, flavor = data.flavor, offsetX = 1, offsetY = 0 }
, svgField { field = data.game.field_3, flavor = data.flavor, offsetX = 2, offsetY = 0 }
, svgField { field = data.game.field_4, flavor = data.flavor, offsetX = 0, offsetY = 1 }
, svgField { field = data.game.field_5, flavor = data.flavor, offsetX = 1, offsetY = 1 }
, svgField { field = data.game.field_6, flavor = data.flavor, offsetX = 2, offsetY = 1 }
, svgField { field = data.game.field_7, flavor = data.flavor, offsetX = 0, offsetY = 2 }
, svgField { field = data.game.field_8, flavor = data.flavor, offsetX = 1, offsetY = 2 }
, svgField { field = data.game.field_9, flavor = data.flavor, offsetX = 2, offsetY = 2 }
, Svg.g
[ Svg.Attributes.fill <| Color.toCssString <| Theme.text data.flavor
, Svg.Attributes.strokeWidth "7.5"
]
[ Svg.line
[ Svg.Attributes.x1 "100"
, Svg.Attributes.x1 "100"
, Svg.Attributes.y1 "20"
, Svg.Attributes.y2 "280"
]
[]
, Svg.line
[ Svg.Attributes.x1 "200"
, Svg.Attributes.x1 "200"
, Svg.Attributes.y1 "20"
, Svg.Attributes.y2 "280"
]
[]
, Svg.line
[ Svg.Attributes.x1 "20"
, Svg.Attributes.x1 "280"
, Svg.Attributes.y1 "100"
, Svg.Attributes.y2 "100"
]
[]
, Svg.line
[ Svg.Attributes.x1 "20"
, Svg.Attributes.x1 "280"
, Svg.Attributes.y1 "200"
, Svg.Attributes.y2 "200"
]
[]
]
]
}
svgField :
{ field : Field
, flavor : Theme.Flavor
, offsetX : Int
, offsetY : Int
}
-> Svg.Svg svg
svgField data =
let
radius =
35
in
Svg.g
[ Svg.Attributes.fill <| Color.toCssString <| Theme.subtext0 data.flavor
, Svg.Attributes.strokeWidth "10"
]
[ case data.field of
Empty ->
Svg.g [] []
O ->
Svg.circle
[ Svg.Attributes.cx (String.fromInt (50 + 100 * data.offsetX))
, Svg.Attributes.cy (String.fromInt (50 + 100 * data.offsetY))
, Svg.Attributes.r (String.fromInt radius)
]
[]
X ->
Svg.g
[]
[ Svg.line
[ Svg.Attributes.x1 (String.fromInt (50 - radius))
, Svg.Attributes.x2 (String.fromInt (50 + radius))
, Svg.Attributes.y1 (String.fromInt (50 - radius))
, Svg.Attributes.y2 (String.fromInt (50 + radius))
]
[]
, Svg.line
[ Svg.Attributes.x1 (String.fromInt (50 - radius))
, Svg.Attributes.x2 (String.fromInt (50 + radius))
, Svg.Attributes.y1 (String.fromInt (50 + radius))
, Svg.Attributes.y2 (String.fromInt (50 - radius))
]
[]
]
]

515
elm/Layout.elm Normal file
View File

@ -0,0 +1,515 @@
module Layout exposing
( twoBlocks
, tab, sideIconBar
, iconAsElement, iconAsIcon
, containedButton, outlinedButton, textButton
, textInput, passwordInput
, header, stdText
, itemWithSubtext
, sideList, radioButtons
, loadingIndicator, svg
)
{-|
# Layout
The layout module exposes some boilerplate functions that have produce a
beautiful Material design Elm webpage.
## Screen layout
@docs twoBlocks
## Elements
@docs tab, sideIconBar
## Icons
@docs iconAsElement, iconAsIcon
## Buttons
@docs containedButton, outlinedButton, textButton
## Text fields
@docs textInput, passwordInput
## Text
@docs header, stdText
## Items in a list
@docs itemWithSubtext
## Lists
@docs sideList, radioButtons
## Other elements
@docs loadingIndicator, svg
-}
import Color exposing (Color)
import Element exposing (Element)
import Element.Background
import Element.Events
import Element.Font
import Element.Input
import Html.Attributes
import Material.Icons.Types
import Svg exposing (Svg)
import Svg.Attributes
import Theme
import Widget
import Widget.Customize as Customize
import Widget.Icon exposing (Icon)
import Widget.Material as Material
import Widget.Material.Typography
{-| A contained button representing the most important action of a group.
-}
containedButton :
{ buttonColor : Color
, clickColor : Color
, icon : Icon msg
, onPress : Maybe msg
, text : String
}
-> Element msg
containedButton data =
Widget.button
({ primary = data.buttonColor, onPrimary = data.clickColor }
|> singlePalette
|> Material.containedButton
|> Customize.elementButton [ Element.width Element.fill ]
)
{ text = data.text, icon = data.icon, onPress = data.onPress }
header : String -> Element msg
header =
Element.text
>> Element.el Widget.Material.Typography.h1
>> List.singleton
>> Element.paragraph []
iconAsElement :
{ color : Color
, height : Int
, icon : Material.Icons.Types.Icon msg
, width : Int
}
-> Element msg
iconAsElement data =
data.icon
|> iconAsIcon
|> (|>) { size = Basics.min data.height data.width, color = data.color }
|> Element.el [ Element.centerX, Element.centerY ]
|> Element.el
[ Element.height (Element.px data.height)
, Element.width (Element.px data.width)
]
iconAsIcon : Material.Icons.Types.Icon msg -> Widget.Icon.Icon msg
iconAsIcon =
Widget.Icon.elmMaterialIcons Material.Icons.Types.Color
{-| Multiline item
-}
itemWithSubtext :
{ color : Color
, leftIcon : Widget.Icon.Icon msg
, onPress : Maybe msg
, rightIcon : Widget.Icon.Icon msg
, text : String
, title : String
}
-> Widget.Item msg
itemWithSubtext data =
Widget.multiLineItem
({ primary = data.color, onPrimary = data.color }
|> singlePalette
|> Material.multiLineItem
)
{ content = data.rightIcon
, icon = data.leftIcon
, onPress = data.onPress
, title = data.title
, text = data.text
}
{-| Circular loading bar indicator
-}
loadingIndicator :
{ color : Color
}
-> Element msg
loadingIndicator data =
Widget.circularProgressIndicator
({ primary = data.color, onPrimary = data.color }
|> singlePalette
|> Material.progressIndicator
)
Nothing
{-| An outlined button representing an important action within a group.
-}
outlinedButton :
{ color : Color
, icon : Icon msg
, onPress : Maybe msg
, text : String
}
-> Element msg
outlinedButton data =
Widget.button
({ primary = data.color, onPrimary = data.color }
|> singlePalette
|> Material.outlinedButton
)
{ text = data.text, icon = data.icon, onPress = data.onPress }
{-| Show a password field
-}
passwordInput :
{ color : Color
, label : String
, onChange : String -> msg
, placeholder : Maybe String
, show : Bool
, text : String
}
-> Element msg
passwordInput data =
Widget.currentPasswordInputV2
({ primary = data.color, onPrimary = data.color }
|> singlePalette
|> Material.passwordInput
|> Customize.elementRow [ Element.width Element.fill ]
)
{ label = data.label
, onChange = data.onChange
, placeholder =
data.placeholder
|> Maybe.map Element.text
|> Maybe.map (Element.Input.placeholder [])
, show = data.show
, text = data.text
}
{-| Redio buttons are side-by-side buttons that only allowed up to one to be
selected.
-}
radioButtons :
{ color : Color
, items : List ( Bool, a )
, toIcon : a -> Icon msg
, toString : a -> String
, onChange : a -> msg
}
-> Element msg
radioButtons data =
data.items
|> List.map
(Tuple.mapSecond
(\item ->
{ text = data.toString item
, icon =
\{ size, color } ->
Element.text (data.toString item)
, onPress = Just (data.onChange item)
}
)
)
|> Widget.toggleRow
{ elementRow = Material.toggleRow
, content =
{ primary = data.color, onPrimary = data.color }
|> singlePalette
|> Material.toggleButton
}
{-| Create a simple palette.
-}
singlePalette : { primary : Color, onPrimary : Color } -> Material.Palette
singlePalette { primary, onPrimary } =
{ primary = primary
, secondary = primary
, background = primary
, surface = primary
, error = primary
, on =
{ primary = onPrimary
, secondary = onPrimary
, background = onPrimary
, surface = onPrimary
, error = onPrimary
}
}
sideIconBar :
{ colorBackground : Color
, colorText : Color
, height : Int
, items : List { icon : Widget.Icon.Icon msg, onPress : msg, text : String }
, width : Int
}
-> Element msg
sideIconBar data =
let
buttonHeight =
round (toFloat data.width * 1.618)
fontSize =
data.width // 6
iconSize =
data.width * 3 // 5
in
data.items
|> List.map
(\item ->
[ item.icon { size = iconSize, color = data.colorText }
|> Element.el [ Element.centerX ]
, Element.paragraph [] [ Element.text item.text ]
]
|> Element.column
[ Element.centerX
, Element.centerY
, Element.Font.bold
, Element.Font.center
, Element.Font.size fontSize
, Element.htmlAttribute (Html.Attributes.style "cursor" "pointer")
]
|> Element.el
[ Element.centerY
, Element.Events.onClick item.onPress
, Element.height (Element.px data.width)
, Element.width (Element.px data.width)
]
|> Element.el
[ Element.height (Element.px buttonHeight)
]
)
|> Element.column
[ Element.Background.color (Theme.toElmUiColor data.colorBackground)
, Element.height (Element.px data.height)
, Element.scrollbarY
, Element.width (Element.px data.width)
]
sideList : { color : Color, items : List (Widget.Item msg), width : Int } -> Element msg
sideList data =
let
width px =
Element.width (Element.px px)
in
Widget.itemList
({ primary = data.color, onPrimary = data.color }
|> singlePalette
|> Material.sideSheet
)
data.items
|> Element.el [ Element.centerX, width (Basics.min 360 data.width) ]
|> Element.el [ width data.width ]
stdText : String -> Element msg
stdText =
Element.text >> List.singleton >> Element.paragraph []
svg :
{ aspectRatio : Float
, height : Int
, svg : Svg msg
, width : Int
, viewMinX : Float
, viewMaxX : Float
, viewMinY : Float
, viewMaxY : Float
}
-> Element msg
svg data =
let
givenWidth =
toFloat data.width
givenHeight =
toFloat data.height
scaleFactorWidth =
givenHeight / givenWidth
innerWidth =
if scaleFactorWidth > data.aspectRatio then
givenWidth
else
givenHeight / data.aspectRatio
innerHeight =
if scaleFactorWidth > data.aspectRatio then
givenWidth * data.aspectRatio
else
givenHeight
in
Svg.svg
[ [ data.viewMinX, data.viewMinY, data.viewMaxX - data.viewMinX, data.viewMaxY - data.viewMinY ]
|> List.map String.fromFloat
|> String.join " "
|> Svg.Attributes.viewBox
, Svg.Attributes.width (String.fromFloat innerWidth)
, Svg.Attributes.height (String.fromFloat innerHeight)
]
[ data.svg ]
|> Element.html
|> Element.el [ Element.centerX, Element.centerY ]
|> Element.el
[ Element.height (Element.px data.height)
, Element.width (Element.px data.width)
]
{-| A tab selector that always has an item selected.
-}
tab :
{ color : Color
, content : Int -> Element msg
, items : List { text : String, icon : Icon msg }
, onSelect : Int -> msg
, selected : Int
}
-> Element msg
tab data =
Widget.tab
({ primary = data.color, onPrimary = data.color }
|> singlePalette
|> Material.tab
)
{ tabs =
{ onSelect = data.onSelect >> Just
, options = data.items
, selected = Just data.selected
}
, content = \_ -> data.content data.selected
}
{-| A text button representing an important action within a group.
-}
textButton :
{ icon : Icon msg
, onPress : Maybe msg
, text : String
, color : Color
}
-> Element msg
textButton data =
Widget.button
({ primary = data.color, onPrimary = data.color }
|> singlePalette
|> Material.textButton
)
{ text = data.text, icon = data.icon, onPress = data.onPress }
{-| Text input element.
-}
textInput :
{ color : Color
, label : String
, onChange : String -> msg
, placeholder : Maybe String
, text : String
}
-> Element msg
textInput data =
Widget.textInput
({ primary = data.color, onPrimary = data.color }
|> singlePalette
|> Material.textInput
|> Customize.elementRow [ Element.width Element.fill ]
)
{ chips = []
, text = data.text
, placeholder =
data.placeholder
|> Maybe.map Element.text
|> Maybe.map (Element.Input.placeholder [])
, label = data.label
, onChange = data.onChange
}
{-| Two blocks either next to each other or below each other, depending on the
screen shape.
-}
twoBlocks :
{ height : Int
, el1 : { height : Int, width : Int } -> Element msg
, el2 : { height : Int, width : Int } -> Element msg
, width : Int
}
-> Element msg
twoBlocks data =
let
goesVertical =
2 * data.width <= 3 * data.height
direction =
if goesVertical then
Element.column
else
Element.row
width =
if goesVertical then
data.width
else
data.width // 2
height =
if goesVertical then
data.height // 2
else
data.height
in
direction
[ Element.height (Element.px data.height)
, Element.width (Element.px data.width)
]
[ data.el1 { height = height, width = width }
, data.el2 { height = height, width = width }
]

134
elm/Main.elm Normal file
View File

@ -0,0 +1,134 @@
module Main exposing (main)
import Element exposing (Element)
import GameList exposing (Game, GameList)
import Json.Decode as D
import Layout
import Material.Icons as Icons
import Program
import Widget.Icon
main =
Program.document
{ flagsDecoder = D.string
, headers = headers
, init = init
, subscriptions = subscriptions
, title = always "Coolio!" -- TODO
, update = update
, view = view
}
-- MODEL
type alias Model =
{ baseUrl : String
, games : GameList
, screen : Screen
}
type Screen
= ViewCreateGame
| ViewGameSelectionMenu
| ViewGame Game
type Msg
= OnGameList GameList.Msg
| OnScreen Screen
init : Result D.Error String -> ( Model, Cmd Msg )
init baseUrl =
let
( gmdl, gmsg ) =
GameList.init {}
in
( { baseUrl =
case baseUrl of
Ok s ->
s
Err _ ->
"http://localhost:5000"
, games = gmdl
, screen = ViewGameSelectionMenu
}
, Cmd.map OnGameList gmsg
)
-- UPDATE
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
OnGameList m ->
case GameList.update m model.games of
( newMdl, newM ) ->
( { model | games = newMdl }, Cmd.map OnGameList newM )
OnScreen screen ->
( { model | screen = screen }, Cmd.none )
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.map OnGameList (GameList.subscriptions model.games)
-- VIEW
headers : Model -> List { icon : Widget.Icon.Icon Msg, onPress : Msg }
headers model =
case model.screen of
ViewCreateGame ->
[ { icon = Layout.iconAsIcon Icons.arrow_back, onPress = OnScreen ViewGameSelectionMenu }
]
ViewGameSelectionMenu ->
[]
ViewGame _ ->
[ { icon = Layout.iconAsIcon Icons.arrow_back, onPress = OnScreen ViewGameSelectionMenu }
]
view : Program.ViewBox Model -> Element Msg
view data =
case data.model.screen of
ViewCreateGame ->
Element.text "Create game menu!"
ViewGameSelectionMenu ->
GameList.viewSelection
{ flavor = data.flavor
, height = data.size.height
, model = data.model.games
, onCreateGame = OnScreen ViewCreateGame
, onNavigateToGame = OnScreen << ViewGame
, width = data.size.width
}
ViewGame game ->
GameList.viewGame
{ flavor = data.flavor
, game = game
, height = data.size.height
, onNavigateBack = OnScreen ViewGameSelectionMenu
, toMsg = OnGameList
, width = data.size.width
}

255
elm/Match.elm Normal file
View File

@ -0,0 +1,255 @@
module Match exposing (..)
{-| A match describes a game's history. It shows what took place.
-}
import Api
import Duration exposing (Duration)
import Element exposing (Element)
import Http
import Json.Decode as D
import Layout
import Pixels exposing (Pixels)
import Quantity exposing (Quantity)
import Theme
import Time
import Zipper exposing (Zipper)
-- MODEL
type Match gameState
= Match
{ autoScroll : Maybe Duration
, baseUrl : String
, decoder : D.Decoder gameState
, empty : gameState
, matchId : String
, turns : Zipper gameState
, winner : Maybe Int
}
type Msg gameState
= AskUpdate
| Autoscroll
| OnUpdate (Result Http.Error (Api.GameDetails gameState))
| PageEnd
| PageNext
| PagePrev
| PageStart
init :
{ autoScroll : Maybe Duration
, baseUrl : String
, decoder : D.Decoder gameState
, empty : gameState
, matchId : String
}
-> ( Match gameState, Cmd (Msg gameState) )
init data =
( Match
{ autoScroll = data.autoScroll
, baseUrl = data.baseUrl
, decoder = data.decoder
, empty = data.empty
, matchId = data.matchId
, turns = Zipper.init data.empty
, winner = Nothing
}
, Cmd.none
)
-- UPDATE
update : Msg gameState -> Match gameState -> ( Match gameState, Cmd (Msg gameState) )
update msg (Match data) =
case msg of
AskUpdate ->
( Match data
, Api.gameDetails
{ baseUrl = data.baseUrl
, decoder = data.decoder
, gameId = data.matchId
, toMsg = OnUpdate
}
)
Autoscroll ->
( Match { data | turns = Zipper.next data.turns }
, Cmd.none
)
OnUpdate (Err _) ->
-- For now, do nothing with failed API requests
( Match data, Cmd.none )
OnUpdate (Ok details) ->
( Match
{ data
| turns =
details.turns
|> List.map .state
|> Zipper.fromList data.empty
|> Zipper.samePageAs data.turns
, winner = details.winner
}
, Cmd.none
)
PageEnd ->
( Match
{ data
| autoScroll = Nothing
, turns = Zipper.toEnd data.turns
}
, Cmd.none
)
PageNext ->
( Match
{ data
| autoScroll = Nothing
, turns = Zipper.next data.turns
}
, Cmd.none
)
PagePrev ->
( Match
{ data
| autoScroll = Nothing
, turns = Zipper.prev data.turns
}
, Cmd.none
)
PageStart ->
( Match
{ data
| autoScroll = Nothing
, turns = Zipper.toStart data.turns
}
, Cmd.none
)
-- SUBSCRIPTIONS
subscriptions : Match gameState -> Sub (Msg gameState)
subscriptions (Match data) =
Sub.batch
[ case data.autoScroll of
Just duration ->
Time.every (Duration.inMilliseconds duration) (always Autoscroll)
Nothing ->
Sub.none
, case data.winner of
Just _ ->
Sub.none
Nothing ->
Time.every 550 (always AskUpdate)
]
-- VIEW
view :
{ flavor : Theme.Flavor
, height : Quantity Int Pixels
, match : Match gameState
, toMsg : Msg gameState -> msg
, width : Quantity Int Pixels
, viewGame :
{ flavor : Theme.Flavor
, game : gameState
, height : Quantity Int Pixels
, width : Quantity Int Pixels
}
-> Element msg
}
-> Element msg
view data =
let
menuHeight =
data.height
|> Quantity.toFloatQuantity
|> Quantity.multiplyBy (1 / 8)
|> Quantity.floor
tinyScreen =
data.height |> Quantity.lessThan (Pixels.pixels 300)
gameHeight =
if tinyScreen then
data.height
|> Quantity.minus menuHeight
else
data.height
in
Element.column
[ Element.height (Element.px (Pixels.inPixels data.height))
, Element.width (Element.px (Pixels.inPixels data.width))
]
[ case data.match of
Match { turns } ->
data.viewGame
{ flavor = data.flavor
, game = Zipper.current turns
, height = gameHeight
, width = data.width
}
, if tinyScreen then
viewMenu
{ height = menuHeight
, width = data.width
}
|> Element.map data.toMsg
else
Element.none
]
viewListItem :
{ flavor : Theme.Flavor
, height : Quantity Int Pixels
, match : Match gameState
, onPress : Maybe msg
, width : Quantity Int Pixels
}
-> Element msg
viewListItem data =
case data.match of
Match match ->
Layout.itemWithSubtext
{ color = Theme.mantle data.flavor
, leftIcon = always Element.none
, onPress = data.onPress
, rightIcon = always Element.none
, text = "Subtext"
, title = match.matchId
}
[]
viewMenu :
{ height : Quantity Int Pixels
, width : Quantity Int Pixels
}
-> Element (Msg gameState)
viewMenu _ =
Element.none

311
elm/Program.elm Normal file
View File

@ -0,0 +1,311 @@
module Program exposing (Px, ViewBox, document, element)
import Browser
import Color
import Element exposing (Element)
import Element.Background
import Element.Events
import Element.Font
import Html
import Json.Decode as D
import Layout
import Pixels exposing (Pixels)
import Quantity exposing (Quantity)
import ScreenSize exposing (ScreenSize)
import Svg
import Svg.Attributes
import Theme
import Widget.Icon
type alias Model model =
{ content : model
, flavor : Theme.Flavor
, size : ScreenSize
}
type Msg msg
= OnContent msg
| OnFlavor Theme.Flavor
| OnScreenSize ScreenSize
type alias Px =
Quantity Int Pixels
type alias ViewBox model =
{ flavor : Theme.Flavor, model : model, size : ScreenSize }
element :
{ flagsDecoder : D.Decoder flags
, init : Result D.Error flags -> ( model, Cmd msg )
, subscriptions : model -> Sub msg
, update : msg -> model -> ( model, Cmd msg )
, view : ViewBox model -> Element msg
}
-> Program D.Value (Model model) (Msg msg)
element data =
Browser.element
{ init = init { f = data.init, d = data.flagsDecoder }
, subscriptions = subscriptions data.subscriptions
, update = update data.update
, view =
view
{ body = data.view
, headers = always []
}
}
document :
{ flagsDecoder : D.Decoder flags
, headers : model -> List { icon : Widget.Icon.Icon msg, onPress : msg }
, init : Result D.Error flags -> ( model, Cmd msg )
, subscriptions : model -> Sub msg
, title : model -> String
, update : msg -> model -> ( model, Cmd msg )
, view : ViewBox model -> Element msg
}
-> Program D.Value (Model model) (Msg msg)
document data =
Browser.document
{ init = init { f = data.init, d = data.flagsDecoder }
, subscriptions = subscriptions data.subscriptions
, update = update data.update
, view =
\model ->
{ title = data.title model.content
, body = [ view { body = data.view, headers = data.headers } model ]
}
}
-- INIT
init :
{ f : Result D.Error flags -> ( model, Cmd msg )
, d : D.Decoder flags
}
-> D.Value
-> ( Model model, Cmd (Msg msg) )
init data blob =
case data.f (D.decodeValue data.d blob) of
( mdl, msg ) ->
( { content = mdl
, flavor = Theme.Frappe
, size = ScreenSize.init
}
, Cmd.batch
[ Cmd.map OnContent msg
, ScreenSize.updateScreenSize OnScreenSize
]
)
-- UPDATE
update :
(msg -> model -> ( model, Cmd msg ))
-> Msg msg
-> Model model
-> ( Model model, Cmd (Msg msg) )
update f msg model =
case msg of
OnContent m ->
case f m model.content of
( newMdl, newMsg ) ->
( { model | content = newMdl }
, Cmd.map OnContent newMsg
)
OnFlavor flavor ->
( { model | flavor = flavor }, Cmd.none )
OnScreenSize size ->
( { model | size = size }, Cmd.none )
-- SUBSCRIPTIONS
subscriptions : (model -> Sub msg) -> Model model -> Sub (Msg msg)
subscriptions f model =
Sub.batch
[ Sub.map OnContent <| f model.content
, ScreenSize.onResize OnScreenSize
]
-- VIEW
view :
{ body : ViewBox model -> Element msg
, headers : model -> List { icon : Widget.Icon.Icon msg, onPress : msg }
}
-> Model model
-> Html.Html (Msg msg)
view data model =
let
navBarIconHeight =
Pixels.pixels 40
navBarHeight =
Quantity.twice navBarIconHeight
showNavBar =
navBarHeight
|> Quantity.multiplyBy 6
|> Quantity.lessThanOrEqualTo model.size.height
contentHeight =
if showNavBar then
model.size.height |> Quantity.minus navBarHeight
else
model.size.height
in
[ if showNavBar then
viewNavBar
{ headers = data.headers model.content
, iconHeight = navBarIconHeight
, model = model
}
else
Element.none
, data.body
{ flavor = model.flavor
, model = model.content
, size =
{ height = contentHeight --|> Quantity.minus (Pixels.pixels 25)
, width = model.size.width --|> Quantity.minus (Pixels.pixels 25)
}
}
|> Element.map OnContent
|> Element.el []
]
|> Element.column [ Element.width Element.fill ]
|> Element.layout
[ Element.Background.color (Theme.baseUI model.flavor)
, Element.Font.color (Theme.textUI model.flavor)
, Element.width <| Element.px <| Pixels.inPixels model.size.width
]
viewFlavorPicker :
{ currentFlavor : Theme.Flavor
, flavorToPick : Theme.Flavor
, onClick : Theme.Flavor -> msg
, size : Px
}
-> Element msg
viewFlavorPicker data =
Layout.svg
{ aspectRatio = 1 / 1
, height = Pixels.inPixels data.size
, svg =
Svg.circle
[ Svg.Attributes.cx "5"
, Svg.Attributes.cy "5"
, Svg.Attributes.r "4"
, Svg.Attributes.strokeWidth "1"
, Svg.Attributes.fill (Color.toCssString <| Theme.base data.flavorToPick)
, Svg.Attributes.stroke (Color.toCssString <| Theme.crust data.currentFlavor)
]
[]
, viewMinY = 0
, viewMaxY = 10
, viewMinX = 0
, viewMaxX = 10
, width = Pixels.inPixels data.size
}
|> (if data.currentFlavor /= data.flavorToPick then
Element.el [ Element.Events.onClick (data.onClick data.flavorToPick) ]
else
identity
)
viewNavBar :
{ headers : List { icon : Widget.Icon.Icon msg, onPress : msg }
, iconHeight : Px
, model : Model model
}
-> Element (Msg msg)
viewNavBar data =
let
heightAttr =
Quantity.twice data.iconHeight
|> Pixels.inPixels
|> Element.px
|> Element.height
widthAttr =
Quantity.twice data.iconHeight
|> Pixels.inPixels
|> Element.px
|> Element.width
in
Element.row
[ Element.Background.color <| Theme.mantleUI data.model.flavor
, Element.width Element.fill
]
[ data.headers
|> List.map
(viewNavBarIcon
{ flavor = data.model.flavor
, height = Quantity.twice data.iconHeight
, heightIcon = data.iconHeight
}
)
|> Element.row []
, Element.el [ heightAttr, Element.width Element.fill ] Element.none
, [ Theme.Latte, Theme.Frappe, Theme.Macchiato, Theme.Mocha ]
|> List.map
(\flavor ->
viewFlavorPicker
{ currentFlavor = data.model.flavor
, flavorToPick = flavor
, onClick = OnFlavor
, size = data.iconHeight
}
|> Element.el [ Element.centerX, Element.centerY ]
|> Element.el [ heightAttr, widthAttr ]
)
|> Element.row []
]
viewNavBarIcon :
{ flavor : Theme.Flavor
, height : Px
, heightIcon : Px
}
-> { icon : Widget.Icon.Icon msg, onPress : msg }
-> Element (Msg msg)
viewNavBarIcon { flavor, height, heightIcon } { icon, onPress } =
-- TODO: Implement coloring for hover + onclick
icon { color = Theme.text flavor, size = Pixels.inPixels heightIcon }
|> Element.el
[ Element.centerX
, Element.centerY
, Element.height <| Element.px <| Pixels.inPixels heightIcon
, Element.width <| Element.px <| Pixels.inPixels heightIcon
]
|> Element.el
[ Element.Events.onClick onPress
, Element.height <| Element.px <| Pixels.inPixels height
, Element.width <| Element.px <| Pixels.inPixels height
]
|> Element.map OnContent

75
elm/Screen/CreateGame.elm Normal file
View File

@ -0,0 +1,75 @@
module Screen.CreateGame exposing (..)
-- MODEL
type alias Model =
{ baseUrl : String
, players : List String
}
type Msg
= OnBaseUrl String
| OnPlayer Int String
| RemovePlayer Int
-- UPDATE
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
OnBaseUrl url ->
( { model | baseUrl = url }, Cmd.none )
OnPlayer n p ->
let
newIndex =
List.length model.players == n
newPlayers =
if newIndex && mayCreateNewPlayer model.players then
List.append model.players [ p ]
else
List.indexedMap
(\i player ->
if n == i then
p
else
player
)
model.players
in
( { model | players = newPlayers }, Cmd.none )
RemovePlayer n ->
( { model
| players =
model.players
|> List.indexedMap
(\i player ->
if n == i then
Nothing
else
Just player
)
|> List.filterMap identity
}
, Cmd.none
)
-- SUBSCRIPTIONS
-- VIEW
mayCreateNewPlayer : List String -> Bool
mayCreateNewPlayer =
List.all (not << String.isEmpty)

159
elm/Screen/Leaderboard.elm Normal file
View File

@ -0,0 +1,159 @@
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 ]
]

41
elm/ScreenSize.elm Normal file
View File

@ -0,0 +1,41 @@
module ScreenSize exposing (..)
import Browser.Dom
import Browser.Events
import Pixels exposing (Pixels)
import Quantity exposing (Quantity)
import Task
type alias ScreenSize =
{ height : Quantity Int Pixels
, width : Quantity Int Pixels
}
init : ScreenSize
init =
{ width = Pixels.pixels 960, height = Pixels.pixels 480 }
onResize : (ScreenSize -> msg) -> Sub msg
onResize toMsg =
Browser.Events.onResize
(\w h ->
toMsg
{ height = Pixels.pixels h
, width = Pixels.pixels w
}
)
updateScreenSize : (ScreenSize -> msg) -> Cmd msg
updateScreenSize toMsg =
Browser.Dom.getViewport
|> Task.map
(\viewport ->
{ height = Pixels.pixels (floor viewport.viewport.height)
, width = Pixels.pixels (floor viewport.viewport.width)
}
)
|> Task.perform toMsg

606
elm/Theme.elm Normal file
View File

@ -0,0 +1,606 @@
module Theme exposing (..)
{-|
# Theme
The Theme helps pick colors from different color schemes.
-}
import Catppuccin.Frappe as CF
import Catppuccin.Latte as CL
import Catppuccin.Macchiato as CA
import Catppuccin.Mocha as CO
import Color
import Element
import Element.Background
{-| Catppuccin flavor used for display.
-}
type Flavor
= Frappe
| Latte
| Macchiato
| Mocha
background : (Flavor -> Color.Color) -> Flavor -> Element.Attribute msg
background toColor =
toColor >> toElmUiColor >> Element.Background.color
toElmUiColor : Color.Color -> Element.Color
toElmUiColor =
Color.toRgba >> Element.fromRgb
rosewater : Flavor -> Color.Color
rosewater flavor =
case flavor of
Frappe ->
CF.rosewater
Latte ->
CL.rosewater
Macchiato ->
CA.rosewater
Mocha ->
CO.rosewater
rosewaterUI : Flavor -> Element.Color
rosewaterUI =
rosewater >> toElmUiColor
flamingo : Flavor -> Color.Color
flamingo flavor =
case flavor of
Frappe ->
CF.flamingo
Latte ->
CL.flamingo
Macchiato ->
CA.flamingo
Mocha ->
CO.flamingo
flamingoUI : Flavor -> Element.Color
flamingoUI =
flamingo >> toElmUiColor
pink : Flavor -> Color.Color
pink flavor =
case flavor of
Frappe ->
CF.pink
Latte ->
CL.pink
Macchiato ->
CA.pink
Mocha ->
CO.pink
pinkUI : Flavor -> Element.Color
pinkUI =
pink >> toElmUiColor
mauve : Flavor -> Color.Color
mauve flavor =
case flavor of
Frappe ->
CF.mauve
Latte ->
CL.mauve
Macchiato ->
CA.mauve
Mocha ->
CO.mauve
mauveUI : Flavor -> Element.Color
mauveUI =
mauve >> toElmUiColor
red : Flavor -> Color.Color
red flavor =
case flavor of
Frappe ->
CF.red
Latte ->
CL.red
Macchiato ->
CA.red
Mocha ->
CO.red
redUI : Flavor -> Element.Color
redUI =
red >> toElmUiColor
maroon : Flavor -> Color.Color
maroon flavor =
case flavor of
Frappe ->
CF.maroon
Latte ->
CL.maroon
Macchiato ->
CA.maroon
Mocha ->
CO.maroon
maroonUI : Flavor -> Element.Color
maroonUI =
maroon >> toElmUiColor
peach : Flavor -> Color.Color
peach flavor =
case flavor of
Frappe ->
CF.peach
Latte ->
CL.peach
Macchiato ->
CA.peach
Mocha ->
CO.peach
peachUI : Flavor -> Element.Color
peachUI =
peach >> toElmUiColor
yellow : Flavor -> Color.Color
yellow flavor =
case flavor of
Frappe ->
CF.yellow
Latte ->
CL.yellow
Macchiato ->
CA.yellow
Mocha ->
CO.yellow
yellowUI : Flavor -> Element.Color
yellowUI =
yellow >> toElmUiColor
green : Flavor -> Color.Color
green flavor =
case flavor of
Frappe ->
CF.green
Latte ->
CL.green
Macchiato ->
CA.green
Mocha ->
CO.green
greenUI : Flavor -> Element.Color
greenUI =
green >> toElmUiColor
teal : Flavor -> Color.Color
teal flavor =
case flavor of
Frappe ->
CF.teal
Latte ->
CL.teal
Macchiato ->
CA.teal
Mocha ->
CO.teal
tealUI : Flavor -> Element.Color
tealUI =
teal >> toElmUiColor
sky : Flavor -> Color.Color
sky flavor =
case flavor of
Frappe ->
CF.sky
Latte ->
CL.sky
Macchiato ->
CA.sky
Mocha ->
CO.sky
skyUI : Flavor -> Element.Color
skyUI =
sky >> toElmUiColor
sapphire : Flavor -> Color.Color
sapphire flavor =
case flavor of
Frappe ->
CF.sapphire
Latte ->
CL.sapphire
Macchiato ->
CA.sapphire
Mocha ->
CO.sapphire
sapphireUI : Flavor -> Element.Color
sapphireUI =
sapphire >> toElmUiColor
blue : Flavor -> Color.Color
blue flavor =
case flavor of
Frappe ->
CF.blue
Latte ->
CL.blue
Macchiato ->
CA.blue
Mocha ->
CO.blue
blueUI : Flavor -> Element.Color
blueUI =
blue >> toElmUiColor
lavender : Flavor -> Color.Color
lavender flavor =
case flavor of
Frappe ->
CF.lavender
Latte ->
CL.lavender
Macchiato ->
CA.lavender
Mocha ->
CO.lavender
lavenderUI : Flavor -> Element.Color
lavenderUI =
lavender >> toElmUiColor
text : Flavor -> Color.Color
text flavor =
case flavor of
Frappe ->
CF.text
Latte ->
CL.text
Macchiato ->
CA.text
Mocha ->
CO.text
textUI : Flavor -> Element.Color
textUI =
text >> toElmUiColor
subtext1 : Flavor -> Color.Color
subtext1 flavor =
case flavor of
Frappe ->
CF.subtext1
Latte ->
CL.subtext1
Macchiato ->
CA.subtext1
Mocha ->
CO.subtext1
subtext1UI : Flavor -> Element.Color
subtext1UI =
subtext1 >> toElmUiColor
subtext0 : Flavor -> Color.Color
subtext0 flavor =
case flavor of
Frappe ->
CF.subtext0
Latte ->
CL.subtext0
Macchiato ->
CA.subtext0
Mocha ->
CO.subtext0
subtext0UI : Flavor -> Element.Color
subtext0UI =
subtext0 >> toElmUiColor
overlay2 : Flavor -> Color.Color
overlay2 flavor =
case flavor of
Frappe ->
CF.overlay2
Latte ->
CL.overlay2
Macchiato ->
CA.overlay2
Mocha ->
CO.overlay2
overlay2UI : Flavor -> Element.Color
overlay2UI =
overlay2 >> toElmUiColor
overlay1 : Flavor -> Color.Color
overlay1 flavor =
case flavor of
Frappe ->
CF.overlay1
Latte ->
CL.overlay1
Macchiato ->
CA.overlay1
Mocha ->
CO.overlay1
overlay1UI : Flavor -> Element.Color
overlay1UI =
overlay1 >> toElmUiColor
overlay0 : Flavor -> Color.Color
overlay0 flavor =
case flavor of
Frappe ->
CF.overlay0
Latte ->
CL.overlay0
Macchiato ->
CA.overlay0
Mocha ->
CO.overlay0
overlay0UI : Flavor -> Element.Color
overlay0UI =
overlay0 >> toElmUiColor
surface2 : Flavor -> Color.Color
surface2 flavor =
case flavor of
Frappe ->
CF.surface2
Latte ->
CL.surface2
Macchiato ->
CA.surface2
Mocha ->
CO.surface2
surface2UI : Flavor -> Element.Color
surface2UI =
surface2 >> toElmUiColor
surface1 : Flavor -> Color.Color
surface1 flavor =
case flavor of
Frappe ->
CF.surface1
Latte ->
CL.surface1
Macchiato ->
CA.surface1
Mocha ->
CO.surface1
surface1UI : Flavor -> Element.Color
surface1UI =
surface1 >> toElmUiColor
surface0 : Flavor -> Color.Color
surface0 flavor =
case flavor of
Frappe ->
CF.surface0
Latte ->
CL.surface0
Macchiato ->
CA.surface0
Mocha ->
CO.surface0
surface0UI : Flavor -> Element.Color
surface0UI =
surface0 >> toElmUiColor
base : Flavor -> Color.Color
base flavor =
case flavor of
Frappe ->
CF.base
Latte ->
CL.base
Macchiato ->
CA.base
Mocha ->
CO.base
baseUI : Flavor -> Element.Color
baseUI =
base >> toElmUiColor
mantle : Flavor -> Color.Color
mantle flavor =
case flavor of
Frappe ->
CF.mantle
Latte ->
CL.mantle
Macchiato ->
CA.mantle
Mocha ->
CO.mantle
mantleUI : Flavor -> Element.Color
mantleUI =
mantle >> toElmUiColor
crust : Flavor -> Color.Color
crust flavor =
case flavor of
Frappe ->
CF.crust
Latte ->
CL.crust
Macchiato ->
CA.crust
Mocha ->
CO.crust
crustUI : Flavor -> Element.Color
crustUI =
crust >> toElmUiColor
brown : Flavor -> Color.Color
brown flavor =
case flavor of
Frappe ->
Color.rgb 165 42 42
-- Example RGB for a brown color
Latte ->
Color.rgb 139 69 19
-- Example RGB for another shade of brown
Macchiato ->
Color.rgb 160 82 45
Mocha ->
Color.rgb 101 67 33
brownUI : Flavor -> Element.Color
brownUI =
brown >> toElmUiColor

143
elm/Zipper.elm Normal file
View File

@ -0,0 +1,143 @@
module Zipper exposing (Zipper, current, currentPage, fromList, init, isAtEnd, isAtStart, length, next, prev, samePageAs, toEnd, toStart)
{-| The Zipper allows to dynamically paginate between items.
-}
type Zipper a
= Zipper
{ prev : List a
, current : a
, next : List a
}
{-| Gets the current item in the zipper.
-}
current : Zipper a -> a
current (Zipper data) =
data.current
{-| If counting from 1, determines the number corresponding to the currently selected item.
-}
currentPage : Zipper a -> Int
currentPage (Zipper data) =
List.length data.prev + 1
{-| Builds a zipper from a list of items.
-}
fromList : a -> List a -> Zipper a
fromList head tail =
Zipper { prev = [], current = head, next = tail }
{-| Create a new zipper from nothing.
-}
init : a -> Zipper a
init x =
Zipper { prev = [], current = x, next = [] }
{-| Determines whether the zipper is at the end.
-}
isAtEnd : Zipper a -> Bool
isAtEnd (Zipper data) =
List.isEmpty data.next
{-| Determines whether the zipper is at the start.
-}
isAtStart : Zipper a -> Bool
isAtStart (Zipper data) =
List.isEmpty data.prev
{-| Determine the total number of items in the zipper.
-}
length : Zipper a -> Int
length (Zipper data) =
List.length data.prev + List.length data.next + 1
{-| Paginates one further in the zipper.
-}
next : Zipper a -> Zipper a
next (Zipper data) =
case data.next of
[] ->
Zipper data
head :: tail ->
Zipper
{ prev = data.current :: data.prev
, current = head
, next = tail
}
{-| Paginates one back in the zipper.
-}
prev : Zipper a -> Zipper a
prev (Zipper data) =
case data.prev of
[] ->
Zipper data
head :: tail ->
Zipper
{ prev = tail
, current = head
, next = data.current :: data.next
}
{-| Synchronize a zipper to be at the same page as another, if possible.
-}
samePageAs : Zipper a -> Zipper a -> Zipper a
samePageAs goal z =
let
cp =
currentPage z
tp =
currentPage goal
in
if cp == tp then
z
else if cp < tp then
if isAtEnd z then
z
else
samePageAs goal (next z)
else if isAtStart z then
z
else
samePageAs goal (prev z)
{-| Navigate all the way to the end of the zipper.
-}
toEnd : Zipper a -> Zipper a
toEnd z =
if isAtEnd z then
z
else
toEnd (next z)
{-| Navigate all the way to the start of the zipper.
-}
toStart : Zipper a -> Zipper a
toStart z =
if isAtStart z then
z
else
toStart (prev z)

40
elo.py Normal file
View File

@ -0,0 +1,40 @@
"""
Create an ELO tracker that compares various server agents out there.
"""
from elo_tracker import EloTracker
from pyclient.games import RoyalGameOfUr, TicTacToe
GAME_FILE = "games.jsonl"
PLAYER_FILE = "known_players.json"
def main() -> int:
tracker = EloTracker(
game_file_name=GAME_FILE,
player_file_name=PLAYER_FILE,
debug=True,
)
tracker.start_periodic_matches(
game=TicTacToe.empty(),
interval_seconds=10 * 60,
player_count=2,
)
tracker.start_periodic_matches(
game=RoyalGameOfUr.empty(),
interval_seconds=9 * 60,
max_turns=1_500,
player_count=2,
)
try:
tracker.start_server(import_name=__name__)
except KeyboardInterrupt:
print("Noticed KeyboardInterrupt, stopping match daemon...")
tracker.stop_periodic_matches()
return 0
if __name__ == "__main__":
raise SystemExit(main())

60
elo_tracker/Containerfile Normal file
View File

@ -0,0 +1,60 @@
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"]

49
elo_tracker/README.md Normal file
View File

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

5
elo_tracker/__init__.py Normal file
View File

@ -0,0 +1,5 @@
from .app import EloTracker
__all__ = [
"EloTracker",
]

804
elo_tracker/app.py Normal file
View File

@ -0,0 +1,804 @@
"""
This app hosts the client that'll perform the ELO tracking.
"""
from __future__ import annotations
import copy
import json
import os
import random
import threading
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Sequence
import agents.remote
import pyclient
import time
from pyclient.games import FinishState, Game
DEFAULT_ELO = 1000
STD_DEV_DIFF = 400
ELO_K_FACTOR = 32
@dataclass(frozen=True)
class PlayerIdentifier:
"""
The Player Identifier uniquely identifies each player for the ELO
tracker.
"""
name : str
url : str
version : str | None
def __key__(self) -> tuple[str, str, str | None]:
return (self.name, self.url, self.version)
@classmethod
def from_server_agent(cls, agent : agents.remote.RemoteAgent) -> "PlayerIdentifier":
"""
Gain a player identifier from an agent.
"""
return cls(name=agent.name, url=agent.url, version=agent.version)
@dataclass()
class EloStat:
"""
The EloStat records the ELO statistics of a single player.
What's their score, and how much did they win?
"""
# Identity
player_id : PlayerIdentifier
# Statistics
losses : int
draws : int
wins : int
elo : float
@classmethod
def new(cls, player_id : PlayerIdentifier) -> "EloStat":
"""
Create a new ELO type based on a player.
:param player_id: Unique player identifier
:type player_id: PlayerIdentifier
:return: New empty Elo statistics for the player
:rtype: EloStat
"""
return cls(
player_id=player_id, losses=0, draws=0, wins=0, elo=DEFAULT_ELO,
)
def to_json(self) -> dict[str, Any]:
"""
Convert EloStat to a JSON-formatted dictionary
:return: The EloStat in JSON format
:rtype: dict[str, Any]
"""
d = dict(
name=self.player_id.name,
url=self.player_id.url,
losses=self.losses,
draws=self.draws,
wins=self.wins,
elo=int(self.elo),
)
if self.player_id.version is not None:
d["version"] = self.player_id.version
return d
@dataclass(frozen=True)
class Match:
"""
A Match represents a game written to disk in JSONL format.
"""
game_name : str
participants : list[tuple[PlayerIdentifier, FinishState]]
timestamp : str
@staticmethod
def now() -> str:
"""
Get a timestamp of now.
:return: Timestamp in ISO format
:rtype: str
"""
return datetime.now(tz=timezone.utc).isoformat()
@classmethod
def from_json_record(cls, record : dict[str, Any]) -> "Match":
"""
Create a new match from a decoded JSON object.
:param participants: Decoded JSON object
:type participants: dict[str, Any]
:return: An initialized match
:rtype: Match
:raises KeyError: The JSON is missing required keys.
:raises ValueError: The JSON is formatted improperly.
"""
participants : list[dict[str, Any]] = record["participants"]
if not isinstance(participants, list):
raise ValueError(
"Key `participants` must be list of objects"
)
game_name : str = str(record["name"])
timestamp : str = str(record["timestamp"]) # TODO: Perhaps verify ISO format?
new_participants : list[tuple[PlayerIdentifier, FinishState]] = []
for i, participant in enumerate(participants):
# Sanity assertions
if not isinstance(participant, dict):
raise ValueError(
f"Participant #{i+1} must be dictionary"
)
for key in ["name", "url", "result"]:
if not isinstance(participant[key], str):
raise ValueError(
f"Participant #{i+1} must have the `{key}` key as string"
)
# Initialize participant
name : str = participant["name"]
url : str = participant["url"]
result = FinishState.from_str(participant["result"])
version : str | None = participant.get("version", None)
if version is not None:
version = str(version)
new_participants.append((
PlayerIdentifier(name=name, url=url, version=version),
result,
))
if len(new_participants) < 2:
raise ValueError(
"Expected at least 2 participants in a game for which ELO can be tracked"
)
return cls(
game_name=game_name,
participants=new_participants,
timestamp=timestamp,
)
@classmethod
def from_replay(
cls,
players : list[agents.remote.RemoteAgent],
replay : pyclient.GameReplay,
timestamp : str | None,
) -> "Match":
"""
Convert a GameReplay into a match.
:param players: The participants of the match.
:type players: list[agents.RemoteAgent]
:param replay: Game summary.
:type replay: pyclient.GameReplay
:param timestamp: ISO formatted timestamp of when the game was planned.
:type timestamp: str
:return: An initialized match.
:rtype: Match
:raises ValueError: The replay shows an unfinished match.
"""
results = replay.turns[-1].state.winner()
if results is None:
raise ValueError(
"Game hasn't finished yet."
)
participants : list[tuple[PlayerIdentifier, FinishState]] = []
for i, agent in enumerate(players):
finish_state = results.get(i + 1, None)
if finish_state is None:
continue
participants.append((
PlayerIdentifier.from_server_agent(agent=agent),
finish_state,
))
return cls(
game_name=replay.game_name,
participants=participants,
timestamp=timestamp or cls.now()
)
def log(self, file_name : str) -> None:
"""
Log the current match to disk.
:param file_name: File name to write the match to.
:type file_name: str
"""
with open(file_name, "a", encoding="utf-8") as wp:
wp.write(json.dumps(self.to_json(), sort_keys=True) + "\n")
def to_json(self) -> dict[str, Any]:
"""
Convert the Match back to JSON.
:return: The Match in a dictionary that's can be converted to JSON.
:rtype: dict[str, Any]
"""
participants : list[dict[str, str]] = []
for player_id, result in self.participants:
d : dict[str, str]= dict(
name=player_id.name,
url=player_id.url,
result=result.name,
)
if player_id.version is not None:
d["version"] = player_id.version
participants.append(d)
return dict(
name=self.game_name,
participants=participants,
timestamp=self.timestamp,
)
class EloTracker:
"""
The Elo tracker tracks matches between URLs that it is familiar with.
"""
def __init__(
self,
game_file_name: str,
player_file_name: str,
debug: bool = False,
name: str = "Bot-Man-Toe Elo Tracker",
) -> None:
"""
Create an EloTracker.
:param game_file_name: The file name to write game results to.
:type game_file_name: str
:param player_file_name: The file name to read player URLs from.
:type player_file_name: str
:param debug: Whether to print scheduler errors.
:type debug: bool
:param name: Display name for the leaderboard.
:type name: str
"""
# Threading variables
self.__lock = threading.RLock()
self.__scheduler_stop = threading.Event()
self.__scheduler_threads: list[threading.Thread] = []
# Immutable values
self.debug: bool = debug
self.game_file_name: str = game_file_name
self.player_file_name: str = player_file_name
self.name: str = name
# Thread-unsafe variables
# Please use a lock while doing CRUD operations on them
self.players: list[agents.remote.RemoteAgent] = []
self.__matches: list[Match] = []
self.__stats: dict[str, dict[PlayerIdentifier, EloStat]] = {}
# Initialize tracker
self.__load_matches()
self.load_players()
def __debug(self, message: str) -> None:
"""
Send a debug message to stdout. Ignored when not in debug mode.
:param message: The message to debug log
:type message: str
"""
if self.debug:
with self.__lock:
print(f"[EloTracker] {message}")
def __get_stat(self, game_name : str, player_id : PlayerIdentifier) -> EloStat:
"""
Get a player's statistics based on their player identifier.
If the player wasn't known, the function returns a newly
initialized record in the database for them.
:param game_name: Name of the relevant game
:type game_name: str
:param player_id: Unique player identifier.
:type player_id: PlayerIdentifier
:return: Elo statistics
"""
with self.__lock:
stat = self.__stats.get(game_name, {}).get(player_id, None)
if stat is not None:
return stat
if game_name not in self.__stats:
self.__stats[game_name] = {}
stat = EloStat.new(player_id=player_id)
self.__stats[game_name][player_id] = stat
return stat
def __load_matches(self) -> None:
"""
Load persisted JSONL records and rebuild in-memory statistics.
"""
if not os.path.exists(self.game_file_name):
return
with self.__lock:
self.__matches = []
self.__stats = {}
with open(self.game_file_name, encoding="utf-8") as fp:
for line_no, line in enumerate(fp, start=1):
line = line.strip()
if line == "":
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
self.__debug(
f"Skipping malformed match record on line {line_no}."
)
continue
if not isinstance(record, dict):
self.__debug(
f"Skipping non-object match record on line {line_no}."
)
continue
try:
m = Match.from_json_record(record=record)
except ( KeyError, ValueError ):
self.__debug(
f"Skipping malformed JSON object on line {line_no}."
)
else:
self.__matches.append(m)
self.__register_match(m)
def __register_match(self, m : Match) -> None:
"""
Apply a newly registered match to the aggregate statistics.
:param m: Newly created match with results & outcomes.
:type m: Match
"""
effective_k = ELO_K_FACTOR / (len(m.participants) - 1)
scores : dict[PlayerIdentifier, float] = {}
# First, calculate the pairwise ELO results
# Do not apply them yet, in order to guarantee fair ELO shifts
for player_id1, result1 in m.participants:
total_k = 0.0
rating_1 = self.__get_stat(game_name=m.game_name, player_id=player_id1).elo
for player_id2, result2 in m.participants:
if player_id1 == player_id2:
continue
rating_2 = self.__get_stat(game_name=m.game_name, player_id=player_id2).elo
expected_score = 1 / (1 + 10 ** ((rating_2 - rating_1) / STD_DEV_DIFF))
actual_score = 0
if result1.score() > 0.0:
actual_score = result1.score() / (result1.score() + result2.score())
total_k += effective_k * (actual_score - expected_score)
scores[player_id1] = total_k
all_scores = sum(scores.values())
if 0.001 <= abs(all_scores):
self.__debug(
f"In total, all ELO score changes added together are {all_scores} (should be 0.0)"
)
# Then, apply the ELO score update + count the wins, draws & losses
for player_id, result in m.participants:
player = self.__get_stat(game_name=m.game_name, player_id=player_id)
player.elo += scores[player_id]
match result:
case FinishState.draw:
player.draws += 1
case FinishState.loss:
player.losses += 1
case FinishState.win:
player.wins += 1
def __scheduler_loop(
self,
game: Game,
interval_seconds: float,
player_count: int,
max_turns : int | None = None,
) -> None:
"""
Perform a schedule in which you play a random game.
:param game: Game to play.
:type game: pyclient.Game
:param interval_seconds: Number of seconds to sleep between games
:type interval_seconds: float
:param player_count: The number of players that are supposed to participate
:type player_count: int
:param max_turns: Optional maximum number of turns to allow
:type max_turns: int | None
"""
while not self.__scheduler_stop.is_set():
try:
self.load_players()
with self.__lock:
players = [
player for player in self.players
if game.game_name() in player.games
]
available = len(players)
if available < player_count:
self.__debug(
f"Skipping scheduled match: {available} players available, "
f"{player_count} required."
)
else:
self.__debug(
f"Playing a new scheduled match of {game.game_name()}"
)
self.play_random_match(
game=game,
max_turns=max_turns,
player_count=player_count,
)
except Exception as exc:
self.__debug(f"Scheduled match failed: {exc}")
# raise exc
if self.__scheduler_stop.wait(interval_seconds):
break
def create_flask_app(self, import_name : str) -> Any:
"""
Create a Flask app that exposes tracker statistics.
:param import_name: The name of the application package.
:type import_name: str
"""
try:
from flask import Flask, Response, jsonify, request
except ImportError as exc:
raise ImportError(
"Flask is required to host the EloTracker server. "
"Install the project requirements before calling create_app()."
) from exc
app = Flask(__name__)
@app.get("/")
def index() -> Response:
return Response(
"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Bot-Man-Toe Elo Tracker</title>
<script src="/static/elo_tracker.js"></script>
</head>
<body>
<main id="main-block">
<h1>Bot-Man-Toe Elo Tracker</h1>
<p>The JSON API is available at /leaderboard, /matches, /players, and /health.</p>
</main>
</body>
<script>
var app = Elm.EloTracker.init({
node: document.body,
flags: { baseUrl: "" }
});
</script>
</html>
""".strip(), # TODO: Make base URL more explicit
mimetype="text/html",
)
@app.get("/health")
def health() -> Response:
return jsonify({
"ok": True,
"periodic_matches": self.is_running_periodic_matches(),
})
@app.get("/leaderboard")
def leaderboard() -> Response:
return jsonify(self.get_json_leaderboard())
@app.get("/matches")
def matches() -> Response:
return jsonify(self.get_json_matches())
@app.get("/players")
def players() -> tuple[Response, int]:
payload = request.get_json(silent=True) or {}
game = payload.get("game", None)
if isinstance(game, str):
return jsonify(self.get_json_players(game_name=game)), 200
else:
return jsonify({ "error": "No `game` parameter was given" }), 400
return app
def is_running_periodic_matches(self) -> bool:
"""
Return whether the scheduler thread is currently alive.
:return: Whether the scheduler thread is currently alive.
:rtype: bool
"""
with self.__lock:
for thread in self.__scheduler_threads:
if thread.is_alive():
return True
else:
return False
def load_players(self) -> None:
"""
Load the known players from disk.
:raises ValueError: File was not properly JSON-formatted.
"""
with open(self.player_file_name, encoding="utf-8") as fp:
obj = json.load(fp)
if not isinstance(obj, dict):
raise ValueError(
"Expected list of URLs in player file."
)
urls = obj.get("players", [])
if not isinstance(urls, list):
raise ValueError(
"Expected `players` field to be a list of strings."
)
players : list[agents.remote.RemoteAgent] = []
for url in urls:
if not isinstance(url, str):
continue
try:
agent = agents.remote.RemoteAgent(url=url)
except ValueError:
pass # Not an available player right now
else:
players.append(agent)
with self.__lock:
self.players = players
for agent in players:
for game_name in self.__stats:
self.__get_stat(
game_name=game_name,
player_id=PlayerIdentifier.from_server_agent(agent)
)
def play_match(
self,
players: list[str],
game: Game,
max_turns : int | None = None,
) -> pyclient.GameReplay | None:
"""
Play a single match with appointed players.
:param players: List of URLs that participate.
:type players: list[str]
:param game: The game to start playing
:type game: Game
:param max_turns: Optional maximum number of turns to allow
:type max_turns: int | None
:return: A summary of the game.
:rtype: pyclient.GameReplay
:raises ValueError: One of the URLs could not be accessed.
"""
ags : list[Any] = [ agents.remote.RemoteAgent(url=url) for url in players ]
try:
replay = pyclient.PyClient(debug=self.debug).play_game(
players=ags,
start=game,
max_turns=max_turns,
)
except ValueError:
return None # Exceeded turn limit
else:
m = Match.from_replay(players=ags, replay=replay, timestamp=Match.now())
# Record match
m.log(self.game_file_name)
self.__register_match(m)
return replay
def play_random_match(
self,
game: Game,
max_turns : int | None = None,
player_count: int | None = None,
) -> pyclient.GameReplay | None:
"""
Play a game with any known players.
:param game: The game to start playing
:type game: Game
:param max_turns: Optional maximum number of turns to allow
:type max_turns: int | None
:param player_count: Optional number of players to select.
:type player_count: int | None
:raises ValueError: One of the randomly chosen URLs could not be accessed.
"""
with self.__lock:
players = [agent.url for agent in self.players if game.game_name() in agent.games]
random.shuffle(players)
if player_count is not None:
players = players[:player_count]
return self.play_match(players=players, game=game)
def start_periodic_matches(
self,
game: Game,
interval_seconds: float = 300,
max_turns : int | None = None,
player_count: int = 2,
) -> None:
"""
Start running matches periodically in a daemon thread.
:param game: Game to play.
:type game: pyclient.Game
:param interval_seconds: Number of seconds to sleep between games
:type interval_seconds: float
:param max_turns: Optional maximum number of turns to allow
:type max_turns: int | None
:param player_count: The number of players that are supposed to participate
:type player_count: int
"""
if interval_seconds <= 0:
raise ValueError("interval_seconds must be greater than zero.")
if player_count <= 1:
raise ValueError("player_count must be greater than one.")
with self.__lock:
self.__scheduler_stop.clear()
thread = threading.Thread(
target=self.__scheduler_loop,
args=(game, interval_seconds, player_count),
kwargs={"max_turns": max_turns},
daemon=True,
)
self.__scheduler_threads.append(thread)
thread.start()
def stop_periodic_matches(self) -> None:
"""
Stop the periodic match scheduler and wait briefly for it to exit.
"""
max_wait_time_s = 5
now = time.time()
time_left = lambda : max(0.1, now + max_wait_time_s - time.time())
to_remove : list[threading.Thread] = []
# Announce that all threads need to be stopped
with self.__lock:
self.__scheduler_stop.set()
to_remove = [ thread for thread in self.__scheduler_threads ]
# Then, quietly remove all existing threads
for thread in self.__scheduler_threads:
if thread is not None:
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:
for thread in to_remove:
self.__scheduler_threads.remove(thread)
def get_json_players(self, game_name : str) -> list[dict[str, Any]]:
"""
Return known currently available players as notebook-friendly dicts.
"""
with self.__lock:
players : list[EloStat] = [
player for player in self.__stats.get(game_name, {}).values()
if abs(player.wins + player.draws + player.losses) > 0
]
players.sort(
key=lambda player: (-int(player.elo), player.player_id.name)
)
return [ stat.to_json() for stat in players ]
def get_json_matches(self, limit: int | None = None) -> list[dict[str, Any]]:
"""
Return persisted match records, newest last unless limited.
:param limit: Maximum number of most recent matches
:type limit: int | None
:return: A list of most recent matches
:rtype: list[dict[str, Any]]
"""
with self.__lock:
if limit is None:
matches = self.__matches
elif limit <= 0:
matches = []
else:
matches = self.__matches[-limit:]
return [ m.to_json() for m in matches ]
def get_json_leaderboard(self) -> list[dict[str, Any]]:
"""
Return aggregate player statistics for local use or JSON APIs.
"""
return [
{
"name": game_name,
"players": self.get_json_players(game_name=game_name),
}
for game_name in self.__stats
]
def start_server(
self,
host: str = "0.0.0.0",
import_name : str = __name__,
port: int = 5000,
debug: bool = False,
**kwargs: Any,
) -> None:
"""
Start a Flask development server from which the ELO scores can be
viewed interactively.
"""
return (
self.create_flask_app(import_name=import_name)
.run(host=host, port=port, debug=debug, **kwargs)
)

0
games.jsonl Normal file
View File

7
known_players.json Normal file
View File

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

89
main.py
View File

@ -1,89 +0,0 @@
"""
This module enables a user to host a server that is able to play games.
"""
import random
from pyserver import PyServer
from pyclient import PyClient
from pyclient.games.tic_tac_toe import TicTacToe
def main():
player = PyServer(
# Customize this to whatever you'd like to call your player
name="My super smart robot player",
# Custom information that you can use to tell people about this player
profile={},
# Unless you know what you're doing, don't touch this.
import_name=__name__,
)
player.add_tic_tac_toe(on_move=play_tic_tac_toe, profile={})
player.start(port=5001)
return 0
def play_tic_tac_toe(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
"""
# 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 logic.
return { "move": random.choice(options) }
if __name__ == "__main__":
raise SystemExit(main())
c = PyClient([
"http://127.0.0.1:5001",
"http://127.0.0.1:5002",
], debug=True)
out = c.play_game("tic-tac-toe", TicTacToe)

View File

@ -1,111 +1,13 @@
"""Public client entry points."""
"""
Entry points for developers who wish to use the PyClient module.
"""
from typing import Any, Generator, List, Optional
from .client import PyClient
from .replay import GameReplay
from .transition import Transition
import requests
from .poll import ServerAgent
class PyClient:
"""Host games between discovered server agents."""
def __init__(self, hosts: List[str], debug : bool = False) -> None:
"""
Create a PyClient.
:param hosts: URLs of servers that can participate.
:type hosts: List[str]
:raises ValueError: If no reachable servers are provided.
"""
self.agents: List[ServerAgent] = []
self.debug = debug
self.hosts: List[str] = []
for host in hosts:
agent = self.__discover_host(host)
if agent is not None:
self.agents.append(agent)
self.hosts.append(agent.url)
if len(self.hosts) <= 0:
raise ValueError(
"No valid hosts found! Check your internet connection or verify the URLs are correct."
)
def __discover_host(self, url: str) -> Optional[ServerAgent]:
try:
return ServerAgent.from_url(url, debug=self.debug)
except (requests.exceptions.RequestException, ValueError):
return None
def gen_game(self, name : str, game : Any, urls : list[str], timeout : float = 1.0, move_default_if_nonexistent : bool = False) -> Generator[tuple[int, dict[str, Any], Any], None, None]:
"""
Play game. Return the results as they've been calculated.
:param name: Unique identifier string name for the game
:type name: str
:param game: Game to be played
:type game: Any
:param urls: List of URLs where players are located
:type urls: list[str]
:param timeout: Maximum time in seconds to wait for a player to respond
:type timeout: float
:param move_default_if_nonexistent: If a required player doesn't exist, raise an error. When this is set to true, however, the program will instead assume a player that always takes the default option.
:type move_default_if_nonexistent: bool
:return: Generator of each turn consisting of a player, what action it took and what that resulted in.
:rtype: Generator[tuple[int, dict[str, Any], Any], None, None]
:raises KeyError: The number of URLs provided is too low, the game requires more players.
"""
agents = [
ServerAgent(url=url, name="", games={}, debug=self.debug)
for url in urls
]
current_state = game.empty()
while current_state.winner() is None:
player : int = current_state.player_to_move()
if len(agents) < player:
if not move_default_if_nonexistent:
raise KeyError(
f"Game requires at least {player} players to exist, found only {len(agents)}"
)
current_state = current_state.move_default()
yield player, {}, current_state
else:
agent = agents[player-1]
payload = agent.poll(
game=(name + "/" + current_state.action_name()).strip("/"),
payload=current_state.as_seen_by(player),
timeout=timeout,
)
# Calculate move
current_state = current_state.move(payload)
yield player, payload or {}, current_state
def play_game(self, name : str, game, timeout : float = 1.0) -> list[tuple[int, dict[str, Any], Any]]:
"""
Play a given game. Ask the registered agents to participate.
"""
urls = [ agent.url for agent in self.agents if name in agent.games ]
return list(self.gen_game(
name=name, game=game, urls=urls, timeout=timeout,
move_default_if_nonexistent=False,
))
def verify_host(self, url: str) -> bool:
"""
Verify whether the URL seems to contain a link to a playable server.
"""
return self.__discover_host(url) is not None
__all__ = ["PyClient", "ServerAgent"]
__all__ = [
"GameReplay",
"PyClient",
"Transition",
]

105
pyclient/client.py Normal file
View File

@ -0,0 +1,105 @@
"""
This module hosts the PyClient class. You can use this class to simulate
games among mulltiple agents.
"""
from __future__ import annotations
from agents import Agent
from .games import Game
from .replay import GameReplay, Turn
from dataclasses import dataclass
from typing import Any, Generator
@dataclass(frozen=True)
class PyClient:
"""
Host games among multiple agents.
"""
debug : bool
def gen_game(
self,
players : list[Agent],
start : Game,
max_turns : int | None = None,
) -> Generator[Turn, None, None]:
"""
Generate a game by polling the players.
:param players: All players that wish to participate.
:type players: list[Agent]
:param start: The start state of the game.
:type start: Game
:param max_turns: The maximum number of turns a game may take
:type max_turns: int
:return: A generator that yields turns.
:rtype: Generator[Turn, None, None]
:raises ValueError: The game is taking too long.
"""
current_state = start
turns_taken = 0
while current_state.winner() is None:
if max_turns is not None and turns_taken >= max_turns:
raise ValueError(
f"Turn limit exceeded: played for {turns_taken} turns and still needed more."
)
turns_taken += 1
player = current_state.player_to_move()
if len(players) < player:
# Player not found! Make a default move.
current_state = current_state.move_default()
yield Turn(action={}, player=player, state=current_state)
else:
agent = players[player - 1]
on_move = agent.get_action(
game=current_state.game_name(),
action=current_state.action_name(),
)
# 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)
yield Turn(action=payload, player=player, state=current_state)
def play_game(
self,
players : list[Agent],
start : Game,
max_turns : int | None = None,
) -> GameReplay:
"""
Generate a game by polling the players. Collect all moves in a
summary.
:param players: All players that wish to participate.
:type players: list[Agent]
:param start: The start state of the game.
:type start: Game
:param max_turns: The maximum number of turns a game may take
:type max_turns: int
:return: Summary describing how the game went.
:rtype: GameReplay
:raises ValueError: The game is taking too long.
"""
return GameReplay(
game_name=start.game_name(),
start=start,
turns=list(self.gen_game(
players=players, start=start, max_turns=max_turns
)),
)

View File

@ -0,0 +1,14 @@
"""
Entry point for collecting all known games
"""
from .game import FinishState, Game
from .tic_tac_toe import TicTacToe
from .ur import RoyalGameOfUr
__all__ = [
"FinishState",
"Game",
"RoyalGameOfUr",
"TicTacToe",
]

172
pyclient/games/game.py Normal file
View File

@ -0,0 +1,172 @@
"""
This module contains the base class for any game.
If you'd like to create a new game, please copy this module and fill in
all the necessary methods in order to have a functioning game.
"""
from __future__ import annotations
from enum import Enum, auto
from typing import Any
class FinishState(Enum):
draw = auto()
loss = auto()
win = auto()
@classmethod
def from_str(cls, s : str) -> "FinishState":
"""
Convert the finish state from a string.
:param s: String to convert.
:type s: str
:return: Finish state
:rtype: FinishState
:raises ValueError: Invalid string value.
"""
for option in FinishState:
if s == option.name:
return option
else:
raise ValueError(
f"Unknown finish state `{s}`"
)
def score(self) -> float:
"""
As a score between 0 and 1, convert how "good" an outcome is.
:return: A score determining how beneficial a finish state is.
:rtype: float
"""
match self:
case FinishState.draw:
return 0.5
case FinishState.loss:
return 0.0
case FinishState.win:
return 1.0
class Game:
"""
Base class for all games.
A game is always a snapshot of a game that is paused because it waits
for a user to make a move.
"""
def action_name(self) -> str:
"""
Return the type of action to take. Please only use alphanumeric
characters.
This helps the player understand what they are supposed to do.
For example, in Risk, you can use this to explain whether they are
expected to "recruit" or "attack" or "move" with their troops.
If all moves require the same action (such as with tic-tac-toe),
you can leave this to return an empty string.
"""
return ""
def as_seen_by(self, player : int) -> dict[str, Any]:
"""
From the perspective of a given player, return the game's state.
Note that it is very common in games for players to not have ALL
the details available. For example, in card games, you are
generally unable to see the cards in your opponents' hands.
The game's state is formatted in a way that makes it easy to
convert it to JSON or a tensor.
:param player: Player. Counting starts from one.
:type player: int
:return: The game's state from the perspective of the player.
:rtype: dict[str, Any]
"""
return {}
@classmethod
def empty(cls) -> "Game":
"""
Create a new game.
Use this method to shuffle the cards, to arrange pieces on the
board, and set up the state in a way where the first player can
start to make its move.
:return: A game that's ready to start.
:rtype: Game
"""
return cls()
def game_name(self) -> str:
"""
Return a non-empty string that uniquely identifies the game.
When creating a new unspecified game, please respect the Java
package naming convention.
"""
return "default-game"
def move_default(self) -> "Game":
"""
Fallback move option for when a user isn't available or cannot
decide. Program this move to be predictable, and resemble an
attempted skip as much as possible. For example:"
- In Go, place a tile in an empty space near the top-left corner
- In Monopoly, choose not to buy anything
- In poker, check. (Or fold after a raise.)
- In a maze, choose the left-most path
"""
# NOTE: Games are meant to be immutable values!
# As such, you should always consider creating a deep copy of oneself,
# do not simply return self if it might update.
return self
def move(self, payload : dict[str, Any] | None = None) -> "Game":
"""
Make a move on behalf of whose turn it is.
:param payload: Dictionary containing the player's response.
:type payload: dict[str, Any] | None
:return: A new instance of the game where a new action is required.
:rtype: Game
"""
if not isinstance(payload, dict):
return self.move_default()
# NOTE: Games are meant to be immutable values!
# As such, you should always consider creating a deep copy of oneself,
# do not simply return self if it might update.
return self
def player_to_move(self) -> int:
"""
Return which player is currently meant to move.
:return: Which player to move. Counting starts from one.
:rtype: int
"""
return 1
def to_dict(self) -> dict[str, Any]:
"""
Return a dictionary containing the full game state.
:return: The current game's state.
:rtype: dict[str, Any]
"""
return {}
def winner(self) -> dict[int, FinishState] | None:
"""
Determine whether the game has ended.
:return: A list detailing which players have won, lost or drawn - or None if the game hasn't finished.
:rtype: dict[int, FinishState] | None
"""
return {}

View File

@ -1,6 +1,14 @@
"""
This module hosts the game of tic-tac-toe, the traditional game where
you're trying to place 3 items in a row in a 3x3-grid.
"""
from __future__ import annotations
from .game import FinishState, Game
from dataclasses import dataclass
from enum import Enum, auto
from typing import Any, Dict, List, Optional
from typing import Any, Optional
class Field(Enum):
X = auto()
@ -20,7 +28,7 @@ class Field(Enum):
return ""
@dataclass(frozen=True)
class TicTacToe:
class TicTacToe(Game):
field_1 : Field
field_2 : Field
field_3 : Field
@ -172,7 +180,7 @@ class TicTacToe:
def action_name(self) -> str:
return ""
def as_seen_by(self, player : int) -> Dict[str, Any]:
def as_seen_by(self, player : int) -> dict[str, Any]:
"""
Return the view of the game from the perspective of a given player.
@ -202,6 +210,9 @@ class TicTacToe:
field_4=Field.empty, field_5=Field.empty, field_6=Field.empty,
field_7=Field.empty, field_8=Field.empty, field_9=Field.empty,
)
def game_name(self) -> str:
return "tic-tac-toe"
def move_default(self) -> "TicTacToe":
"""
@ -229,7 +240,7 @@ class TicTacToe:
"No legal moves exist anymore on this tic-tac-toe board."
)
def move(self, payload : Optional[Dict[str, Any]] = None) -> "TicTacToe":
def move(self, payload : Optional[dict[str, Any]] = None) -> "TicTacToe":
"""
Have a player make a move. Based on this information, update the
game.
@ -271,7 +282,7 @@ class TicTacToe:
"""
return 1 if self.count_x() <= self.count_o() else 2
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> dict[str, Any]:
return {
"1": str(self.field_1),
"2": str(self.field_2),
@ -284,12 +295,12 @@ class TicTacToe:
"9": str(self.field_9),
}
def winner(self) -> int | None:
def winner(self) -> dict[int, FinishState] | None:
"""
Returns whether the board indicates that there's a winner.
:return: The winning player, zero in case of a tie, or None if there's no winner yet.
:rtype: int | None
:rtype: dict[int, FinishState] | None
"""
win_lines = [
[ 1, 2, 3, ],
@ -303,15 +314,17 @@ class TicTacToe:
]
d = self.to_dict()
out = { 1 : FinishState.loss, 2 : FinishState.loss, }
for player, symbol in [ ( 1, str(Field.X) ), ( 2, str(Field.O) ) ]:
for win_line in win_lines:
if all(d[str(w)] == symbol for w in win_line):
return player
out[player] = FinishState.win
return out
else:
# Check for draw
if all(item != "" for item in d.values()):
return 0
return { 1 : FinishState.draw, 2 : FinishState.draw, }
return None

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

@ -0,0 +1,235 @@
"""
The Royal game of Ur is supposedly one of the world's oldest known board
games. Players race 7 horses to the finish line while trying to sabotage
each other.
"""
from __future__ import annotations
from typing import Any, Generator
import random
from .game import FinishState, Game
from dataclasses import dataclass
# On these fields, you can safely place your token without getting it removed
# if the opponent plays a token on their respective spot.
SAFE_FIELDS = set([ 0, 1, 2, 3, 4, 13, 14, 15 ])
# Whenever you land a token on this piece, you may play another turn.
STAR_FIELDS = set([ 4, 8, 14 ])
# You may place multiple pieces on these fields.
STACK_FIELDS = set([ 0, 15 ])
# Length of the board
BOARD_LENGTH = 1 + 4 + (4 + 2 + 2) + 2 + 1
@dataclass(frozen=True)
class RoyalGameOfUr(Game):
"""
The Royal Game of Ur is a game where players roll dice to get to the
finish line.
"""
board : list[tuple[int, int]]
mover : int
roll : int
def __updated_board(self, start : int) -> list[tuple[int, int]]:
new_board = []
assert self.roll > 0
assert 0 <= start < BOARD_LENGTH
assert start + self.roll < BOARD_LENGTH
elim = False
for i, (a, b) in enumerate(self.board):
if i == start:
if self.player_to_move() == 1:
new_board.append(( a - 1, b ))
else:
new_board.append(( a, b - 1 ))
elif i == start + self.roll:
if self.player_to_move() == 1:
if b > 0 and i not in SAFE_FIELDS:
elim = True
b = 0
new_board.append(( a + 1, b ))
else:
if a > 0 and i not in SAFE_FIELDS:
elim = True
a = 0
new_board.append(( a, b + 1 ))
else:
new_board.append(( a, b ))
if elim:
if self.player_to_move() == 1:
new_board[0] = ( new_board[0][0], new_board[0][1] + 1)
else:
new_board[0] = ( new_board[0][0] + 1, new_board[0][1] )
return new_board
def __valid_moves(self) -> Generator[int, None, None]:
for i in range(BOARD_LENGTH - self.roll):
dest = i + self.roll
if self.board[i][self.mover] > 0:
if dest in STACK_FIELDS or self.board[dest][self.mover] == 0:
yield i
def action_name(self) -> str:
return "" # Players can only move in this game
def as_seen_by(self, player: int) -> dict[str, Any]:
my_index = player - 1
ot_index = 1 - my_index
d : dict[str, Any] = dict(
enemies_at_start=self.board[0][ot_index],
enemies_at_finish=self.board[15][ot_index],
safe_fields=list(SAFE_FIELDS),
stack_fields=list(STACK_FIELDS),
star_fields=list(STAR_FIELDS),
tokens_at_start=self.board[0][my_index],
tokens_at_finish=self.board[15][my_index],
valid_moves=list(self.__valid_moves()),
)
for i in range(BOARD_LENGTH):
d[str(i)] = (
"YOU" if self.board[i][my_index] > 0 else (
"ENEMY" if my_index not in SAFE_FIELDS and self.board[i][ot_index] > 0 else (
""
)))
for i in SAFE_FIELDS:
d[f"{i}_enemy"] = (
"ENEMY" if self.board[i][ot_index] > 0 else ""
)
return d
@classmethod
def empty(cls) -> RoyalGameOfUr:
"""
Create a new game.
:return: Start state of the board
:rtype: RoyalGameOfUr
"""
times, roll = cls.roll_repeatedly()
return cls(
board=[ (7, 7) ] + ((BOARD_LENGTH - 1) * [ (0, 0 ) ]),
mover=(1 + times) % 2,
roll=roll,
)
def game_name(self) -> str:
return "royal-game-of-ur"
def move_default(self) -> Game:
"""
Fallback move option for when a user isn't available or cannot
decide. In this case, the player attempts to move whatever is the
furthest in the back.
"""
player = self.player_to_move()
for i in self.__valid_moves():
new_board = self.__updated_board(start=i)
new_mover = self.mover
if i + self.roll not in STAR_FIELDS:
new_mover += 1
return RoyalGameOfUr(
board=new_board, mover=new_mover % 2, roll=self.roll_once(),
).skip_if_possible()
else:
raise ValueError(
"Player has no valid moves even though those should've been erased."
)
def move(self, payload : dict[str, Any] | None = None):
if not isinstance(payload, dict):
return self.move_default()
key = str(payload.get("move", ""))
for i in self.__valid_moves():
if str(i) == key:
new_board = self.__updated_board(start=i)
new_mover = self.mover
if i + self.roll not in STAR_FIELDS:
new_mover += 1
return RoyalGameOfUr(
board=new_board, mover=new_mover % 2, roll=self.roll_once(),
).skip_if_possible()
else:
return self.move_default()
def player_to_move(self) -> int:
return self.mover + 1
@staticmethod
def roll_once() -> int:
tot = 0
for _ in range(4):
tot += random.randint(0, 1)
return tot
@staticmethod
def roll_repeatedly() -> tuple[int, int]:
"""
Roll until you don't roll a zero.
"""
n, roll = 0, 0
while roll == 0:
n += 1
roll = RoyalGameOfUr.roll_once()
return n, roll
def skip_if_possible(self) -> RoyalGameOfUr:
"""
Skip this turn. Allow the next player to make a move.
Players are not allowed to skip if they can make a valid move.
"""
# Exit if at least one valid move exists
if self.roll > 0:
for _ in self.__valid_moves():
return self
# Exit if game has already finished
if self.winner() is not None:
return self
return RoyalGameOfUr(
board=self.board,
mover=(self.mover + 1) % 2,
roll=self.roll_once()
).skip_if_possible()
def to_dict(self) -> dict[str, Any]:
return self.as_seen_by(player=1)
def winner(self) -> dict[int, FinishState] | None:
a, b = self.board[-1]
if a == 7:
return {
1 : FinishState.win,
2 : FinishState.loss,
}
elif b == 7:
return {
1 : FinishState.loss,
2 : FinishState.win,
}
else:
return None

View File

@ -1,102 +0,0 @@
"""Discovery and polling helpers for Bot-Man-Toe servers."""
from typing import Any, Dict, Optional, Tuple, Union
import requests
import time
class ServerAgent:
"""Representation of a server that can host one or more games."""
def __init__(self, url: str, name: str, games: Dict[str, Dict[str, Any]], debug : bool = False) -> None:
"""
Create a server representation.
:param url: The URL used to discover the server.
:type url: str
:param name: Name of the server's player.
:type name: str
:param games: Games the server is willing to play.
:type games: Dict[str, Dict[str, Any]]
"""
self.debug = debug
self.games = games
self.name = name
self.url = url.strip("/")
@classmethod
def from_url(
cls,
url: str,
timeout: Optional[Union[float, Tuple[float, float]]] = 10.0,
debug : bool = False
) -> "ServerAgent":
"""
Create a server agent by polling its discovery endpoint.
The root endpoint is expected to return a JSON object with an optional
``name`` field and an optional ``games`` mapping.
:param url: The URL that the server can be reached at.
:type url: str
:param timeout: Request timeout passed to ``requests.get``.
:type timeout: Optional[Union[float, Tuple[float, float]]]
:return: The server's representation.
: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(url=url, name=name, games=games, debug=debug)
def poll(self, game: str, payload: Dict[str, Any], timeout: float = 1.0) -> 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]
:param timeout: Maximum number of seconds to wait for a move.
:type timeout: float
:return: The server's response, or None if the server did not respond
in time or returned an invalid response.
:rtype: Optional[Dict[str, Any]]
"""
url = f"{self.url.rstrip('/')}/{game.lstrip('/')}"
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

93
pyclient/replay.py Normal file
View File

@ -0,0 +1,93 @@
"""
This module helps describe & evaluate previously played games.
"""
from __future__ import annotations
from .games import Game
from .transition import Transition
from dataclasses import dataclass
from typing import Any, Generator
@dataclass(frozen=True)
class GameReplay:
"""
Game replay detailing the events of what happened in a previously
played game.
"""
game_name : str
start : Game
turns : list[Turn]
def gen_transitions(self) -> Generator[Transition, None, None]:
"""
Generate transitions from the game's summary.
:return: Transitions that describe consequences throughout the game.
:rtype: Generator[Transition, None, None]
"""
prev_state : Game = self.start
seen_participants : dict[int, tuple[Game, dict[str, Any]]] = {}
for turn in self.turns:
# If a player has made a move before, show the current state as
# the result of their previous turn's action.
state, action = seen_participants.get(turn.player, ( None, {} ))
if state is not None:
yield Transition(
full_state_after=prev_state,
full_state_before=state,
move=action,
player=turn.player,
)
# Save the player's current action for the future.
# We'll see how this action worked out!
seen_participants[turn.player] = ( prev_state, turn.action )
prev_state = turn.state
else:
# Record to all players that the game has finished.
for player, ( state, action ) in seen_participants.items():
yield Transition(
full_state_after=prev_state,
full_state_before=state,
move=action,
player=player,
)
def to_transitions(self, player : int | None = None) -> list[Transition]:
"""
Convert the GameSummary into a list of transitions that can be
fed to a system that can reflect on past moves.
:param player: Filter to only show transitions of a given player.
:type player: int | None
:return: A list of transitions extracted from a game.
:rtype: list[Transition]
"""
if player is None:
return list(self.gen_transitions())
else:
return [ t for t in self.gen_transitions() if t.player == player ]
@dataclass(frozen=True)
class Turn:
"""
A turn is a snapshot of a moment where a user was prompted to take an
action. A turn consists of three values:
1. The player (number) that was prompted for an action
2. The action that that player has decided to take
3. The game's state after the action was prompted and before the next
prompted move.
Note that turns might not correlate with in-game turns. A user might
take multiple actions in a Risk game (recruit, attack, move) and each
action is registered as an independent move.
"""
player : int
action : dict[str, Any]
state : Game

110
pyclient/transition.py Normal file
View File

@ -0,0 +1,110 @@
"""
The transition module allows agents to reflect on the choices they
(and others) have made during recent games.
Agents are generally intended to be stateless, so this allows them to
later understand how well certain moves turned out.
"""
from __future__ import annotations
from .games import FinishState, Game
from dataclasses import dataclass
from typing import Any, Generator
@dataclass(frozen=True)
class Transition:
"""
A transition is a simplified view on a user's single move.
The transition contains the game state, the move they made,
and what the game ended up looking like the next time they were
prompted for a move. If the game ends, the `state_after` field contains
the final state.
"""
full_state_after : Game
full_state_before : Game
move : dict[str, Any]
player : int
@property
def end_result(self) -> FinishState | None:
"""
Return the result of this move if it caused the game to end,
or None if the game still continues after this.
:return: The player's result in the game with this move.
:rtype: FinishState | None
"""
w = self.full_state_after.winner()
if w is None:
return None
return w[self.player]
@property
def game_has_ended(self) -> bool:
"""
Shorthand property for whether this was the player's last move.
Tip: Use the methods `move_caused_draw`, `move_caused_loss` and
`move_caused_win` to determine the outcome.
"""
return self.full_state_after.winner() is not None
@property
def move_caused_draw(self) -> bool:
"""
Shorthand property for whether this move caused the player to draw.
:rtype: bool
"""
return self.end_result == FinishState.draw
@property
def move_caused_loss(self) -> bool:
"""
Shorthand property for whether this move caused the player to lose.
:rtype: bool
"""
return self.end_result == FinishState.loss
@property
def move_caused_win(self) -> bool:
"""
Shorthand property for whether this move caused the player to win.
:rtype: bool
"""
return self.end_result == FinishState.win
@property
def state_after(self) -> dict[str, Any]:
"""
The initial state that the agent was shown after they were asked for
a move.
Keep in mind that this state does not necessarily contain
ALL information, as players are often partially kept in the dark
about a game's state.
:rtype: dict[str, Any]
"""
return self.full_state_after.as_seen_by(self.player)
@property
def state_before(self) -> dict[str, Any]:
"""
The initial state that the agent was shown when they were asked for
a move.
Keep in mind that this state does not necessarily contain
ALL information, as players are often partially kept in the dark
about a game's state.
:rtype: dict[str, Any]
"""
return self.full_state_before.as_seen_by(self.player)

View File

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

36
pyserver/README.md Normal file
View File

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

View File

@ -2,86 +2,43 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Any, Optional
import os
from flask import Flask, Response, jsonify, request
from functools import wraps
from flask import Flask, jsonify, request
from agents import Agent
from typing import Any
PayloadType = dict[str, Any]
GameHandler = Callable[[PayloadType], PayloadType]
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,
name: str,
import_name: str = __name__,
profile: Optional[PayloadType] = None,
subpath : str = "",
) -> None:
def __init__(self, agent : Agent, import_name : str = __name__) -> None:
"""
Create a PyServer.
Create a PyServer that serves the behavior of an Agent.
:param name: Preferred display name for discovery.
:type name: str
:param import_name: Flask import name.
:param agent: The agent that one can communicate with.
:type agent: Agent
:param import_name: Flask application import name
: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.name = name
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.containerized : bool = bool(int(os.environ.get("CONTAINERIZED", "0")))
self.agent = agent
self.app = Flask(import_name)
self.__games: dict[str, PayloadType] = {}
self.__registered_routes: set[str] = set()
# Register the root
self.__add_api_endpoint("", "", lambda _ : 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("/")
def discovery():
return jsonify(self.__discovery())
:param name: The name of the action to undertake.
:type name: str
:param func: The player's function that determines what action to take.
:type func: Callable[[dict[str, Any]], dict[str, Any]]
:raises ValueError: The URL has already been registered.
"""
url = self.__make_url(name, route)
@self.app.get("/<path:game>")
@self.app.get("/<path:game>/<path:action>")
def dispatch(game: str, action: str | None = None):
return jsonify(self.__dispatch(game=game, action=action))
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:
"""
Return the server's discovery information.
@ -89,104 +46,35 @@ class PyServer:
:return: The personal discovery information.
:rtype: dict[str, Any]
"""
return {
"name": self.name,
"games": dict(self.__games),
**self.profile,
}
def __func_wrapper(self, func : GameHandler) -> Callable[[], Response]:
"""
Wrapper that catches an incoming request, parses it, and responds
with a player's action response.
"""
@wraps(func)
def exec():
payload = request.get_json(silent=True) or {}
result = func(payload) or {}
return jsonify(result)
d = dict(name=self.agent.name, author=self.agent.author)
if self.agent.version is not None:
d["version"] = self.agent.version
return exec
d = { **self.agent.profile, **d }
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:
"""
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.
"""
# Verify that all required actions are present
if required_actions is not 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))}"
)
d["me.noordstar.peanuts.containerized"] = self.containerized
d["games"] = {}
for game, (profile, _) in self.agent.registered_games.items():
d["games"][game] = profile
# 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()
return d
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:
def __dispatch(self, game: str, action: str | None) -> PayloadType:
"""
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:
Resolve a request against the agent's action lookup.
"""
self.add_game(
name="tic-tac-toe",
profile=profile,
actions={"": on_move},
required_actions=[""],
payload = request.get_json(silent=True) or {}
func = self.agent.get_action(
game=game.strip("/"),
action=action.strip("/") if isinstance(action, str) else None,
)
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:
"""Start the Flask development server."""
self.app.run(host=host, port=port, debug=debug, **kwargs)

View File

@ -3,6 +3,10 @@
This document describes how a Bot-Man-Toe server or client is supposed to
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
A **server** is a REST API server that hosts a player willing to play games.
@ -65,3 +69,13 @@ package namespace guidelines.
}
```
There's a few optional data fields that are specified here:
| Field | Type | Description |
| ----- | ---- | ----------- |
| author | string | The name of the person who designed the agent. |
| version | string | The version of the agent, in case there's an update. |
| me.noordstar.peanuts.containerized | bool | Whether the agent is running in a container. |
| me.noordstar.peanuts.is_ai | bool | Whether the agent runs on a trained deep learning model or some artificial intelligence. |
| me.noordstar.peanuts.agent.version | string | **Deprecated.** Experimental value to demonstrate an agent's version. Please use `version` instead. |
| me.noordstar.peanuts.author | string | **Deprecated.** Experimental value to demonstrate an agent's author. Please use `author` instead. |

13
requirements-elo.txt Normal file
View File

@ -0,0 +1,13 @@
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

View File

@ -0,0 +1,5 @@
certifi==2026.6.17
charset-normalizer==3.4.7
idna==3.18
requests==2.34.2
urllib3==2.7.0

View File

@ -1,5 +1,5 @@
blinker==1.9.0
certifi==2026.5.20
certifi==2026.6.17
charset-normalizer==3.4.7
click==8.4.1
colorama==0.4.6

View File

@ -4,10 +4,9 @@
from __future__ import annotations
import random
import agents
from pyserver import PyServer
from typing import Any
def main() -> int:
"""
@ -16,19 +15,7 @@ def main() -> int:
:return: Exit code
:rtype: int
"""
player = PyServer(
# Customize this to whatever you'd like to call your player
name="My super smart robot player",
# Custom information that you can use to tell people about this player
profile={},
# 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=play_tic_tac_toe, profile={})
player = PyServer(agents.AgentOfChaos())
# Start listening for games
player.start(
@ -38,62 +25,5 @@ def main() -> int:
return 0
def play_tic_tac_toe(payload : dict[str, Any]) -> dict[str, Any]:
"""
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 logic.
return { "move": random.choice(options) }
if __name__ == "__main__":
raise SystemExit(main())

16
web.py Normal file
View File

@ -0,0 +1,16 @@
"""Entry point for the web client Flask server."""
from webclient import WebClient
web_client = WebClient(import_name=__name__)
app = web_client.app
def main() -> int:
web_client.start()
return 0
if __name__ == "__main__":
raise SystemExit(main())

5
webclient/__init__.py Normal file
View File

@ -0,0 +1,5 @@
"""Public server entry points."""
from .app import WebClient
__all__ = ["WebClient"]

247
webclient/app.py Normal file
View File

@ -0,0 +1,247 @@
"""
Flask server that enables a user to run Bot-Man-Toe games as a client from
a web browser interface.
"""
from __future__ import annotations
import threading
import uuid
from typing import Any, Optional
from urllib.parse import urlparse, urlunparse
from dataclasses import dataclass
import requests
from flask import Flask, jsonify, request
from pyclient.games.tic_tac_toe import TicTacToe
from pyclient import PyClient, ServerAgent
@dataclass(frozen=True)
class Match:
name : str
turns : list[tuple[int, Any, Any]]
winner : int | None
def append_turn(self, player : int, action : Any, state : Any) -> "Match":
"""
Create a new match value that contains a new turn.
:param player: The player taking the action
:type player: int
:param action: The action taken by the player
:type action: Any
:param state: The new game state based on the action
:type state: Any
:return: A new match type
:rtype: Match
"""
return Match(
name=self.name,
turns=self.turns + [( player, action, state )],
winner=state.winner(),
)
@classmethod
def new(cls, name : str) -> "Match":
"""
Initialize a new match.
:param name: The name of the game being played.
:type name: str
:return: Initialized match
:rtype: Match
"""
return cls(name=name, turns=[], winner=None)
def to_dict(self) -> dict[str, Any]:
"""
Create a dictionary representation of the match.
"""
return dict(
name=self.name,
turns=[
dict(player=player, action=action, state=state.to_dict())
for player, action, state in self.turns
],
winner=self.winner,
)
class WebClient:
"""
A small Flask application aimed at running Bot-Man-Toe games.
"""
def __init__(
self,
debug : bool = False,
import_name: str = __name__,
) -> None:
"""
Create a WebClient.
:param debug: Debug mode
:type debug: bool
:param import_name: Flask import name
:type import_name: str
"""
self.app = Flask(import_name)
self.debug = debug
self.__lock = threading.RLock()
self.__matches : dict[str, Match] = {}
self.__games : dict[str, Any] = {}
@self.app.route("/game-details", methods=["GET"])
def game_details():
payload = request.get_json(silent=True) or {}
game_id : str | None = payload.get("game_id")
if not isinstance(game_id, str):
return jsonify({
"error": "Expected field `game` as string"
}), 400
match : Match | None = self.__find_match(match_id=game_id)
if not isinstance(match, Match):
return jsonify({
"error": f"Could not find match with id `{game_id}`",
}), 400
return jsonify(match.to_dict())
@self.app.route("/profile", methods=["GET"])
def profile():
payload = request.get_json(silent=True) or {}
url = payload.get("url")
if not isinstance(url, str):
return jsonify({"error": "A string 'url' is required."}), 400
try:
agent = ServerAgent.from_url(url, debug=self.debug)
except (requests.HTTPError, requests.RequestException, ValueError) as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(agent.to_dict())
@self.app.route("/start-game", methods=["GET"])
def start_game():
payload = request.get_json(silent=True) or {}
game : str | None = payload.get("game", None)
ps : list | None = payload.get("players", None)
if not isinstance(game, str):
return jsonify({
"error": "Field `game` must be a string",
}), 400
if not isinstance(ps, list):
return jsonify({
"error": "Field `players` must be a list of URLs"
}), 400
players : list[str] = [ str(player) for player in ps ]
game_cls : Any | None = self.__games.get(game, None)
if game_cls is None:
return jsonify({
"error": "Unknown game type"
}), 400
match_id = self.__register_match(game)
runner = threading.Thread(
target=self.__run_match,
args=(match_id, game, players),
daemon=True,
)
runner.start()
return match_id
def __find_match(self, match_id : str) -> Match | None:
"""
Find a match. Uses a threading lock for a safe read.
"""
with self.__lock:
return self.__matches.get(match_id, None)
def __register_match(self, name : str) -> str:
"""
Create a new match in a given game.
:param name: The name of the game to be played.
:type name: str
:param game: Game class
:type game: Any
:return: Unique match identifier
:rtype: str
"""
match_id = uuid.uuid4().hex
with self.__lock:
# Ensure uuid uniqueness
while match_id in self.__matches:
print("WARNING: Found duplicate uuid `{match_id}` in existing matches")
match_id = uuid.uuid4().hex
self.__matches[match_id] = Match.new(name)
return match_id
def __run_match(self, match_id : str, game : str, players : list[str]) -> None:
"""
Run a match. This function is usually run in a separate thread.
:param match_id: The match to process
:type match_id: str
:param game: The name of the game type to play
:type game: str
:param players: List of URLs to use for processing
:type players: list[str]
:raises KeyError: The game is not recognized or the match isn't found
:raises ValueError: None of the players were accessible
"""
game_cls = self.__games[game]
match = self.__find_match(match_id=match_id)
if match is None:
raise KeyError(
f"Could not find match with id `{match_id}`"
)
c = PyClient(hosts=players, debug=self.debug)
for player, action, state in c.gen_game(name=game, game=game_cls, urls=players, move_default_if_nonexistent=True):
match = match.append_turn(player=player, action=action, state=state)
with self.__lock:
self.__matches[match_id] = match
def register_game(self, game_name: str, game_type: type[Any]) -> None:
"""
Register a supported game.
:param game_name: The string name of the game
:type game_name: str
:param game_type: The game's object
:type game_type: Any
:raises ValueError:
"""
name = game_name.strip().strip("/")
if name == "":
raise ValueError("Game name cannot be empty.")
self.__games[name] = game_type
def start(
self,
host: str = "127.0.0.1",
port: int = 5000,
debug: bool = False,
**kwargs: Any,
) -> None:
"""Start the Flask development server."""
self.app.run(host=host, port=port, debug=debug, **kwargs)