Compare commits
32 Commits
agent/agen
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
94ea1ae2f0 | |
|
|
c9899c5d65 | |
|
|
220c956ba0 | |
|
|
194f962746 | |
|
|
d7ac20a6dd | |
|
|
1d78cdfd4d | |
|
|
36d5aa30fc | |
|
|
ca7bfdc50a | |
|
|
e2daa9a205 | |
|
|
da26f5c016 | |
|
|
90d2bea528 | |
|
|
73be372a30 | |
|
|
87525238ba | |
|
|
c004d9d3e7 | |
|
|
6cd1ec9c97 | |
|
|
b09bc370c4 | |
|
|
317b5eeabf | |
|
|
2ace9688ff | |
|
|
d8c20fd4cc | |
|
|
5c3eb14c17 | |
|
|
b563c8422f | |
|
|
7b0f147a89 | |
|
|
bb3bb36665 | |
|
|
eabf15ddbe | |
|
|
c3fd825876 | |
|
|
8aaacfc83c | |
|
|
04e21632ea | |
|
|
78c461d9b2 | |
|
|
e2a11ae0d1 | |
|
|
ecc69034f3 | |
|
|
bf8e33ee1c | |
|
|
8f030efa9a |
|
|
@ -10,6 +10,13 @@ spec/
|
||||||
.venv-pyserver
|
.venv-pyserver
|
||||||
.venv-pyclient
|
.venv-pyclient
|
||||||
|
|
||||||
|
# Compiled elm files
|
||||||
|
elo_tracker/static/elo_tracker.js
|
||||||
|
|
||||||
|
# Data files
|
||||||
|
games.jsonl
|
||||||
|
known_players.json
|
||||||
|
|
||||||
# ---> Elm
|
# ---> Elm
|
||||||
# elm-package generated files
|
# elm-package generated files
|
||||||
elm-stuff
|
elm-stuff
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,13 @@
|
||||||
.venv-pyserver
|
.venv-pyserver
|
||||||
.venv-pyclient
|
.venv-pyclient
|
||||||
|
|
||||||
|
# Compiled elm files
|
||||||
|
elo_tracker/static/elo_tracker.js
|
||||||
|
|
||||||
|
# Data files
|
||||||
|
games.jsonl
|
||||||
|
known_players.json
|
||||||
|
|
||||||
# ---> Elm
|
# ---> Elm
|
||||||
# elm-package generated files
|
# elm-package generated files
|
||||||
elm-stuff
|
elm-stuff
|
||||||
|
|
|
||||||
27
README.md
27
README.md
|
|
@ -1,15 +1,24 @@
|
||||||
# Bot-Man-Toe
|
# Bot-Man-Toe
|
||||||
|
|
||||||
Bot-Man-Toe is an attempt to create a way for players to play games against
|
Write a script that plays tic-tac-toe, and see how well it performs against
|
||||||
themselves, other players, or self-trained AI players.
|
other programs!
|
||||||
|
|
||||||
## Technology stack
|
- [🚀 Write your own agent](agents/README.md)
|
||||||
|
- [🖊 Compare how well your agent performs](elo_tracker/README.md)
|
||||||
|
- [🌐 Publish your agent to the internet](pyserver/README.md)
|
||||||
|
|
||||||
Counterintuitively, the **servers** are participants to a game. The **clients**
|
## Get started
|
||||||
are programs or browsers that mediate matches between servers.
|
|
||||||
|
|
||||||
## More
|
1. Clone this repository.
|
||||||
|
2. Run `python client.py` in the terminal and let two random agents play
|
||||||
|
against each other.
|
||||||
|
3. Copy the example agent and [create your own](agents/README.md).
|
||||||
|
4. Play against the AgentOfChaos while you improve your strategy.
|
||||||
|
5. Publish your agent. _(optional)_
|
||||||
|
6. Compare it against others with the Elo tracker. _(optional)_
|
||||||
|
|
||||||
- The discovery contract is documented in `spec/README.md`.
|
|
||||||
- Python client helpers live under `pyclient/`.
|
## Links
|
||||||
- Python server helpers live under `pyserver/`.
|
|
||||||
|
- [📜 API specification](pyserver/spec.md)
|
||||||
|
- [🏆 Online ELO tracker](https://elo.noordstar.me/)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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", [""])) }
|
||||||
|
|
@ -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) }
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
]
|
||||||
|
|
@ -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))
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
@ -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 }
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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 ]
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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>.
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
from .app import EloTracker
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EloTracker",
|
||||||
|
]
|
||||||
|
|
@ -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,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"players": [
|
||||||
|
"https://bmt001.noordstar.me",
|
||||||
|
"https://bmt002.noordstar.me",
|
||||||
|
"https://bmt003.noordstar.me"
|
||||||
|
]
|
||||||
|
}
|
||||||
89
main.py
89
main.py
|
|
@ -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)
|
|
||||||
|
|
@ -1,111 +1,13 @@
|
||||||
"""Public client entry points."""
|
|
||||||
|
|
||||||
from typing import Any, Generator, List, Optional
|
|
||||||
|
|
||||||
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.
|
Entry points for developers who wish to use the PyClient module.
|
||||||
|
|
||||||
: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:
|
from .client import PyClient
|
||||||
agent = self.__discover_host(host)
|
from .replay import GameReplay
|
||||||
if agent is not None:
|
from .transition import Transition
|
||||||
self.agents.append(agent)
|
|
||||||
self.hosts.append(agent.url)
|
|
||||||
|
|
||||||
if len(self.hosts) <= 0:
|
__all__ = [
|
||||||
raise ValueError(
|
"GameReplay",
|
||||||
"No valid hosts found! Check your internet connection or verify the URLs are correct."
|
"PyClient",
|
||||||
)
|
"Transition",
|
||||||
|
|
||||||
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"]
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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 dataclasses import dataclass
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
class Field(Enum):
|
class Field(Enum):
|
||||||
X = auto()
|
X = auto()
|
||||||
|
|
@ -20,7 +28,7 @@ class Field(Enum):
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TicTacToe:
|
class TicTacToe(Game):
|
||||||
field_1 : Field
|
field_1 : Field
|
||||||
field_2 : Field
|
field_2 : Field
|
||||||
field_3 : Field
|
field_3 : Field
|
||||||
|
|
@ -172,7 +180,7 @@ class TicTacToe:
|
||||||
def action_name(self) -> str:
|
def action_name(self) -> str:
|
||||||
return ""
|
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.
|
Return the view of the game from the perspective of a given player.
|
||||||
|
|
||||||
|
|
@ -203,6 +211,9 @@ class TicTacToe:
|
||||||
field_7=Field.empty, field_8=Field.empty, field_9=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":
|
def move_default(self) -> "TicTacToe":
|
||||||
"""
|
"""
|
||||||
Have a player take a "default" move. They'll take this move
|
Have a player take a "default" move. They'll take this move
|
||||||
|
|
@ -229,7 +240,7 @@ class TicTacToe:
|
||||||
"No legal moves exist anymore on this tic-tac-toe board."
|
"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
|
Have a player make a move. Based on this information, update the
|
||||||
game.
|
game.
|
||||||
|
|
@ -271,7 +282,7 @@ class TicTacToe:
|
||||||
"""
|
"""
|
||||||
return 1 if self.count_x() <= self.count_o() else 2
|
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 {
|
return {
|
||||||
"1": str(self.field_1),
|
"1": str(self.field_1),
|
||||||
"2": str(self.field_2),
|
"2": str(self.field_2),
|
||||||
|
|
@ -284,12 +295,12 @@ class TicTacToe:
|
||||||
"9": str(self.field_9),
|
"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.
|
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.
|
: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 = [
|
win_lines = [
|
||||||
[ 1, 2, 3, ],
|
[ 1, 2, 3, ],
|
||||||
|
|
@ -303,15 +314,17 @@ class TicTacToe:
|
||||||
]
|
]
|
||||||
|
|
||||||
d = self.to_dict()
|
d = self.to_dict()
|
||||||
|
out = { 1 : FinishState.loss, 2 : FinishState.loss, }
|
||||||
|
|
||||||
for player, symbol in [ ( 1, str(Field.X) ), ( 2, str(Field.O) ) ]:
|
for player, symbol in [ ( 1, str(Field.X) ), ( 2, str(Field.O) ) ]:
|
||||||
for win_line in win_lines:
|
for win_line in win_lines:
|
||||||
if all(d[str(w)] == symbol for w in win_line):
|
if all(d[str(w)] == symbol for w in win_line):
|
||||||
return player
|
out[player] = FinishState.win
|
||||||
|
return out
|
||||||
else:
|
else:
|
||||||
# Check for draw
|
# Check for draw
|
||||||
if all(item != "" for item in d.values()):
|
if all(item != "" for item in d.values()):
|
||||||
return 0
|
return { 1 : FinishState.draw, 2 : FinishState.draw, }
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
102
pyclient/poll.py
102
pyclient/poll.py
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -19,7 +19,10 @@ RUN pip install --no-cache-dir --no-index --find-links=/wheels \
|
||||||
-r requirements-pyserver.txt && rm -rf /wheels
|
-r requirements-pyserver.txt && rm -rf /wheels
|
||||||
|
|
||||||
# Install PyServer code
|
# Install PyServer code
|
||||||
|
COPY agents/ agents/
|
||||||
COPY pyserver/ pyserver/
|
COPY pyserver/ pyserver/
|
||||||
COPY server.py .
|
COPY server.py .
|
||||||
|
|
||||||
|
ENV CONTAINERIZED=1
|
||||||
|
|
||||||
CMD ["python", "server.py"]
|
CMD ["python", "server.py"]
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -2,85 +2,42 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, Mapping
|
import os
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from flask import Flask, Response, jsonify, request
|
from flask import Flask, jsonify, request
|
||||||
from functools import wraps
|
from agents import Agent
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
PayloadType = dict[str, Any]
|
PayloadType = dict[str, Any]
|
||||||
GameHandler = Callable[[PayloadType], PayloadType]
|
|
||||||
|
|
||||||
|
|
||||||
class PyServer:
|
class PyServer:
|
||||||
"""A tiny stateless Flask app that serves discovery and game routes."""
|
|
||||||
|
|
||||||
def __init__(self,
|
|
||||||
name: str,
|
|
||||||
import_name: str = __name__,
|
|
||||||
profile: Optional[PayloadType] = None,
|
|
||||||
subpath : str = "",
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Create a PyServer.
|
A tiny stateless Flask app that serves discovery and routes
|
||||||
|
game behavior to an agent.
|
||||||
|
"""
|
||||||
|
|
||||||
:param name: Preferred display name for discovery.
|
def __init__(self, agent : Agent, import_name : str = __name__) -> None:
|
||||||
:type name: str
|
"""
|
||||||
:param import_name: Flask import name.
|
Create a PyServer that serves the behavior of an Agent.
|
||||||
|
|
||||||
|
:param agent: The agent that one can communicate with.
|
||||||
|
:type agent: Agent
|
||||||
|
:param import_name: Flask application import name
|
||||||
:type import_name: str
|
:type import_name: str
|
||||||
:param profile: Additional root-level discovery metadata.
|
|
||||||
:type profile: Optional[Dict[str, Any]]
|
|
||||||
:type subpath: str
|
|
||||||
:raises ValueError: The input contains invalid information.
|
|
||||||
"""
|
"""
|
||||||
self.name = name
|
self.containerized : bool = bool(int(os.environ.get("CONTAINERIZED", "0")))
|
||||||
self.profile = dict(profile or {})
|
self.agent = agent
|
||||||
|
|
||||||
if "name" in self.profile:
|
|
||||||
raise ValueError(
|
|
||||||
"Root profile metadata must not define 'name'."
|
|
||||||
)
|
|
||||||
if "games" in self.profile:
|
|
||||||
raise ValueError(
|
|
||||||
"Root profile metadata must not define 'games'."
|
|
||||||
)
|
|
||||||
|
|
||||||
self.app = Flask(import_name)
|
self.app = Flask(import_name)
|
||||||
self.__games: dict[str, PayloadType] = {}
|
|
||||||
self.__registered_routes: set[str] = set()
|
|
||||||
|
|
||||||
# Register the root
|
@self.app.get("/")
|
||||||
self.__add_api_endpoint("", "", lambda _ : self.__discovery())
|
def discovery():
|
||||||
# self.app.add_url_rule("/",
|
return jsonify(self.__discovery())
|
||||||
# endpoint="botman_discovery",
|
|
||||||
# view_func=self.__discovery,
|
|
||||||
# methods=["GET"]
|
|
||||||
# )
|
|
||||||
|
|
||||||
def __add_api_endpoint(self, name : str, route : str, func : GameHandler) -> None:
|
@self.app.get("/<path:game>")
|
||||||
"""
|
@self.app.get("/<path:game>/<path:action>")
|
||||||
Create a new API endpoint.
|
def dispatch(game: str, action: str | None = None):
|
||||||
|
return jsonify(self.__dispatch(game=game, action=action))
|
||||||
: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)
|
|
||||||
|
|
||||||
if url in self.__registered_routes:
|
|
||||||
raise ValueError(
|
|
||||||
f"Route already registered: {url}"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.__registered_routes.add(url)
|
|
||||||
|
|
||||||
return self.app.add_url_rule(url,
|
|
||||||
endpoint="botman_" + name.replace("/", "_"),
|
|
||||||
view_func=self.__func_wrapper(func),
|
|
||||||
methods=["GET"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def __discovery(self) -> PayloadType:
|
def __discovery(self) -> PayloadType:
|
||||||
"""
|
"""
|
||||||
|
|
@ -89,103 +46,34 @@ class PyServer:
|
||||||
:return: The personal discovery information.
|
:return: The personal discovery information.
|
||||||
:rtype: dict[str, Any]
|
:rtype: dict[str, Any]
|
||||||
"""
|
"""
|
||||||
return {
|
d = dict(name=self.agent.name, author=self.agent.author)
|
||||||
"name": self.name,
|
if self.agent.version is not None:
|
||||||
"games": dict(self.__games),
|
d["version"] = self.agent.version
|
||||||
**self.profile,
|
|
||||||
}
|
|
||||||
|
|
||||||
def __func_wrapper(self, func : GameHandler) -> Callable[[], Response]:
|
d = { **self.agent.profile, **d }
|
||||||
|
|
||||||
|
d["me.noordstar.peanuts.containerized"] = self.containerized
|
||||||
|
d["games"] = {}
|
||||||
|
for game, (profile, _) in self.agent.registered_games.items():
|
||||||
|
d["games"][game] = profile
|
||||||
|
|
||||||
|
return d
|
||||||
|
|
||||||
|
def __dispatch(self, game: str, action: str | None) -> PayloadType:
|
||||||
"""
|
"""
|
||||||
Wrapper that catches an incoming request, parses it, and responds
|
Resolve a request against the agent's action lookup.
|
||||||
with a player's action response.
|
|
||||||
"""
|
"""
|
||||||
@wraps(func)
|
|
||||||
def exec():
|
|
||||||
payload = request.get_json(silent=True) or {}
|
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 {}
|
result = func(payload) or {}
|
||||||
return jsonify(result)
|
return result if isinstance(result, dict) else {}
|
||||||
|
|
||||||
return exec
|
|
||||||
|
|
||||||
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))}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify that there are no duplicate URLs being registered
|
|
||||||
# Even though this will automatically be checked later, checking this
|
|
||||||
# now ensures that the operation is atomic and doesn't add
|
|
||||||
# games partially.
|
|
||||||
new_routes : set[str] = set()
|
|
||||||
|
|
||||||
for route in actions:
|
|
||||||
url = self.__make_url(name, route)
|
|
||||||
|
|
||||||
if url in self.__registered_routes:
|
|
||||||
raise ValueError(
|
|
||||||
"Route {url} was already registered"
|
|
||||||
)
|
|
||||||
if url in new_routes:
|
|
||||||
raise ValueError(
|
|
||||||
"Route {url} was registered twice by the same function call"
|
|
||||||
)
|
|
||||||
|
|
||||||
new_routes.add(url)
|
|
||||||
|
|
||||||
# Register all actions
|
|
||||||
for route, func in actions.items():
|
|
||||||
self.__add_api_endpoint(name=name, route=route, func=func)
|
|
||||||
|
|
||||||
# Add profile data
|
|
||||||
self.__games[name] = profile
|
|
||||||
|
|
||||||
def add_tic_tac_toe(self, on_move: GameHandler, profile: PayloadType = {}) -> None:
|
|
||||||
"""
|
|
||||||
Convenience registration for tic-tac-toe.
|
|
||||||
|
|
||||||
The game is exposed at `/tic-tac-toe`.
|
|
||||||
|
|
||||||
:param profile: The player's custom profile.
|
|
||||||
:type profile: dict[str, Any]
|
|
||||||
:param on_move:
|
|
||||||
"""
|
|
||||||
self.add_game(
|
|
||||||
name="tic-tac-toe",
|
|
||||||
profile=profile,
|
|
||||||
actions={"": on_move},
|
|
||||||
required_actions=[""],
|
|
||||||
)
|
|
||||||
|
|
||||||
def start(self, host: str = "127.0.0.1", port: int = 5000, debug: bool = False, **kwargs: Any) -> None:
|
def start(self, host: str = "127.0.0.1", port: int = 5000, debug: bool = False, **kwargs: Any) -> None:
|
||||||
"""Start the Flask development server."""
|
"""Start the Flask development server."""
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@
|
||||||
This document describes how a Bot-Man-Toe server or client is supposed to
|
This document describes how a Bot-Man-Toe server or client is supposed to
|
||||||
behave.
|
behave.
|
||||||
|
|
||||||
|
**This document requires familiarity with setting up an HTTP server.** Please
|
||||||
|
use [the easy implementation](../pyserver/README.md) if you're building
|
||||||
|
[a simple agent](../agents/README.md) in this repository.
|
||||||
|
|
||||||
## Terminology
|
## Terminology
|
||||||
|
|
||||||
A **server** is a REST API server that hosts a player willing to play games.
|
A **server** is a REST API server that hosts a player willing to play games.
|
||||||
|
|
@ -65,3 +69,13 @@ package namespace guidelines.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
There's a few optional data fields that are specified here:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ----- | ---- | ----------- |
|
||||||
|
| author | string | The name of the person who designed the agent. |
|
||||||
|
| version | string | The version of the agent, in case there's an update. |
|
||||||
|
| me.noordstar.peanuts.containerized | bool | Whether the agent is running in a container. |
|
||||||
|
| me.noordstar.peanuts.is_ai | bool | Whether the agent runs on a trained deep learning model or some artificial intelligence. |
|
||||||
|
| me.noordstar.peanuts.agent.version | string | **Deprecated.** Experimental value to demonstrate an agent's version. Please use `version` instead. |
|
||||||
|
| me.noordstar.peanuts.author | string | **Deprecated.** Experimental value to demonstrate an agent's author. Please use `author` instead. |
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
certifi==2026.6.17
|
||||||
|
charset-normalizer==3.4.7
|
||||||
|
idna==3.18
|
||||||
|
requests==2.34.2
|
||||||
|
urllib3==2.7.0
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
blinker==1.9.0
|
blinker==1.9.0
|
||||||
certifi==2026.5.20
|
certifi==2026.6.17
|
||||||
charset-normalizer==3.4.7
|
charset-normalizer==3.4.7
|
||||||
click==8.4.1
|
click==8.4.1
|
||||||
colorama==0.4.6
|
colorama==0.4.6
|
||||||
|
|
|
||||||
53
server.py
53
server.py
|
|
@ -1,29 +1,12 @@
|
||||||
"""
|
"""
|
||||||
This implementation describes the Agent of chaos. This agent mostly uses
|
This module enables a user to host a server that is able to play games.
|
||||||
randomness to determine which action to take.
|
|
||||||
|
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import random
|
import agents
|
||||||
|
|
||||||
from pyserver import PyServer
|
from pyserver import PyServer
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
"""
|
"""
|
||||||
|
|
@ -32,41 +15,15 @@ def main() -> int:
|
||||||
:return: Exit code
|
:return: Exit code
|
||||||
:rtype: int
|
:rtype: int
|
||||||
"""
|
"""
|
||||||
player = PyServer(
|
player = PyServer(agents.AgentOfChaos())
|
||||||
name="Agent of chaos",
|
|
||||||
profile={
|
|
||||||
"me.noordstar.peanuts.author": "Bram",
|
|
||||||
"me.noordstar.peanuts.containerized": True,
|
|
||||||
"me.noordstar.peanuts.agent.version" : "1.0.0",
|
|
||||||
},
|
|
||||||
import_name=__name__,
|
|
||||||
)
|
|
||||||
|
|
||||||
player.add_tic_tac_toe(on_move=play_tic_tac_toe, profile={})
|
|
||||||
|
|
||||||
|
# Start listening for games
|
||||||
player.start(
|
player.start(
|
||||||
host="0.0.0.0",
|
host="0.0.0.0", # Comment out when using only locally
|
||||||
port=5000,
|
port=5000,
|
||||||
)
|
)
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def play_tic_tac_toe(payload : dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
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) }
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Public server entry points."""
|
||||||
|
|
||||||
|
from .app import WebClient
|
||||||
|
|
||||||
|
__all__ = ["WebClient"]
|
||||||
|
|
@ -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)
|
||||||
Loading…
Reference in New Issue