Merge main updates into web-client branch

bram/mute
Bram van den Heuvel 2026-06-20 15:04:37 +02:00
commit c3fd825876
16 changed files with 885 additions and 184 deletions

179
.dockerignore Normal file
View File

@ -0,0 +1,179 @@
# Ignore Markdown documentation
spec/
*.md
# ----------------------------
# ---> GITIGNORE CONFIGURATION
# ----------------------------
# Repository-specific virtual environments
.venv-pyserver
.venv-pyclient
# ---> Elm
# elm-package generated files
elm-stuff
# elm-repl generated files
repl-temp-*
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

4
.gitignore vendored
View File

@ -1,3 +1,7 @@
# Repository-specific virtual environments
.venv-pyserver
.venv-pyclient
# ---> Elm
# elm-package generated files
elm-stuff

92
client.py Normal file
View File

@ -0,0 +1,92 @@
"""
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
from pyclient import Agent, PyClient
from pyclient.games import TicTacToe
def main() -> int:
"""
Start a client, then use it to analyze one or more matches.
:return: Exit code
:rtype: int
"""
c = PyClient(debug=False)
players : list[Agent] = [
Agent.from_url(url="https://bmt001.noordstar.me/"),
Agent.from_url(url="https://bmt001.noordstar.me/"),
]
out = c.play_game(
players=players,
start=TicTacToe.empty(),
)
inspect_game(out)
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())

View File

@ -1,111 +1,16 @@
"""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.
:param hosts: URLs of servers that can participate.
:type hosts: List[str]
:raises ValueError: If no reachable servers are provided.
Entry points for developers who wish to use the PyClient module.
"""
self.agents: List[ServerAgent] = []
self.debug = debug
self.hosts: List[str] = []
for host in hosts:
agent = self.__discover_host(host)
if agent is not None:
self.agents.append(agent)
self.hosts.append(agent.url)
from .agent import Agent, ServerAgent
from .client import PyClient
from .replay import GameReplay
from .transition import Transition
if len(self.hosts) <= 0:
raise ValueError(
"No valid hosts found! Check your internet connection or verify the URLs are correct."
)
def __discover_host(self, url: str) -> Optional[ServerAgent]:
try:
return ServerAgent.from_url(url, debug=self.debug)
except (requests.exceptions.RequestException, ValueError):
return None
def gen_game(self, name : str, game : Any, urls : list[str], timeout : float = 1.0, move_default_if_nonexistent : bool = False) -> Generator[tuple[int, dict[str, Any], Any], None, None]:
"""
Play game. Return the results as they've been calculated.
:param name: Unique identifier string name for the game
:type name: str
:param game: Game to be played
:type game: Any
:param urls: List of URLs where players are located
:type urls: list[str]
:param timeout: Maximum time in seconds to wait for a player to respond
:type timeout: float
:param move_default_if_nonexistent: If a required player doesn't exist, raise an error. When this is set to true, however, the program will instead assume a player that always takes the default option.
:type move_default_if_nonexistent: bool
:return: Generator of each turn consisting of a player, what action it took and what that resulted in.
:rtype: Generator[tuple[int, dict[str, Any], Any], None, None]
:raises KeyError: The number of URLs provided is too low, the game requires more players.
"""
agents = [
ServerAgent(url=url, name="", games={}, debug=self.debug)
for url in urls
__all__ = [
"Agent",
"GameReplay",
"PyClient",
"ServerAgent",
"Transition",
]
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"]

View File

@ -1,58 +1,93 @@
"""Discovery and polling helpers for Bot-Man-Toe servers."""
"""
This module hosts various agents that can participate in games.
Examples of possible agents could be:
- An online server
- A locally running neural network
- A hacker who participates from the terminal
- A user interface that allows users to play against their own creations
"""
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union
import requests
import time
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,
profile : dict[str, Any] = {}
) -> None:
@dataclass(frozen=True)
class Agent:
"""
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]]
:param debug: Whether to enable debug mode.
:type debug: bool
:param profile: Custom user profile containing a user's details.
:type profile: dict[str, Any]
Base class of a game participant. Mostly used to inherit from.
"""
self.debug = debug
self.games = games
self.name = name
self.profile = profile
self.url = url.strip("/")
debug : bool
games : dict[str, dict[str, Any]]
profile : dict[str, Any]
@classmethod
def from_url(
def from_url(cls, url : str, **kwargs) -> "ServerAgent":
"""
Create an agent based on a URL.
:param url: The URL where the agent can be accessed.
:type url: str
:return: An agent that contacts a server when polled.
:rtype: ServerAgent
"""
return ServerAgent.from_server_url(url=url, **kwargs)
@property
def name(self) -> str:
"""
The name by which the agent calls itself.
"""
return str(self.profile.get("name", "Nameless agent"))
def poll(
self,
game : str,
payload : dict[str, Any],
**kwargs
) -> Optional[dict[str, Any]]:
"""
Ask the agent to make a move.
:param game: The game the ServerAgent is asked to play.
:type game: str
:param payload: The JSON payload that represents the game's state.
:type payload: dict[str, Any]
:return: The agent's response, or None if the agent doesn't respond.
:rtype: Optional[dict[str, Any]]
"""
print(f"WARNING: The `poll` method is not defined on the {self.__class__.__name__} class")
return None
@dataclass(frozen=True)
class ServerAgent(Agent):
"""
Agent that reaches out to the internet to poll for moves.
"""
url : str
@classmethod
def from_server_url(
cls,
url: str,
timeout: Optional[Union[float, Tuple[float, float]]] = 10.0,
timeout: float | tuple[float, float] | None = 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.
:param timeout: Request timeout.
:type timeout: float | tuple[float, float] | None
:param debug: Enables debug mode
:type debug: bool
:return: The server's representation as an agent.
:rtype: ServerAgent
:raises requests.exceptions.HTTPError: If the server returns a
non-success HTTP status code.
@ -71,7 +106,7 @@ class ServerAgent:
raw_name = content.get("name", "")
name = "" if raw_name is None else str(raw_name)
games: Dict[str, Dict[str, Any]] = {}
games: dict[str, dict[str, Any]] = {}
raw_games = content.get("games", {})
if raw_games is not None:
if not isinstance(raw_games, dict):
@ -81,28 +116,31 @@ class ServerAgent:
if isinstance(profile, dict):
games[str(game_name)] = profile
profile: dict[str, Any] = {}
for k, v in content.items():
if k not in [ "name", "games" ]:
profile[k] = v
return cls(
debug=debug,
games=games,
profile=content,
url=url,
)
return cls(url=url, name=name, games=games, debug=debug, profile=profile)
def poll(self, game: str, payload: Dict[str, Any], timeout: float = 1.0) -> Optional[Dict[str, Any]]:
def poll(
self,
game : str,
payload : dict[str, Any],
**kwargs
) -> Optional[dict[str, Any]]:
"""
Inquire a game to make a move.
:param game: The game the ServerAgent is asked to play.
:type game: str
:param payload: The JSON payload that represents the game's state.
:type payload: Dict[str, Any]
: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]]
:type payload: dict[str, Any]
:return: The server's response, or None if the server did not respond.
:rtype: Optional[dict[str, Any]]
"""
url = f"{self.url.rstrip('/')}/{game.lstrip('/')}"
timeout = float(kwargs.get("timeout", 1.0))
try:
response = requests.get(url, json=payload, timeout=timeout)
@ -130,4 +168,3 @@ class ServerAgent:
url=self.url,
profile=self.profile,
)

71
pyclient/client.py Normal file
View File

@ -0,0 +1,71 @@
"""
This module hosts the PyClient class. You can use this class to simulate
games among mulltiple agents.
"""
from __future__ import annotations
from .agent 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) -> 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
:return: A generator that yields turns.
:rtype: Generator[Turn, None, None]
"""
current_state = start
while current_state.winner() is None:
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]
payload = agent.poll(
game=current_state.game_name(),
payload=current_state.as_seen_by(player=player),
)
# Calculate move
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) -> 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
:return: Summary describing how the game went.
:rtype: GameReplay
"""
return GameReplay(
start=start,
turns=list(self.gen_game(players=players, start=start)),
)

View File

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

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

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

View File

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

92
pyclient/replay.py Normal file
View File

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

110
pyclient/transition.py Normal file
View File

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

25
pyserver/Containerfile Normal file
View File

@ -0,0 +1,25 @@
FROM python:3.10-alpine AS builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache gcc musl-dev python3-dev
COPY requirements-pyserver.txt .
# Create wheels for faster installation
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements-pyserver.txt
FROM python:3.10-alpine
WORKDIR /app
# Install from pre-built wheels
COPY --from=builder /wheels /wheels
COPY requirements-pyserver.txt .
RUN pip install --no-cache-dir --no-index --find-links=/wheels \
-r requirements-pyserver.txt && rm -rf /wheels
# Install PyServer code
COPY pyserver/ pyserver/
COPY server.py .
CMD ["python", "server.py"]

View File

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

View File

@ -0,0 +1,8 @@
blinker==1.9.0
click==8.4.1
colorama==0.4.6
Flask==3.1.3
itsdangerous==2.2.0
Jinja2==3.1.6
MarkupSafe==3.0.3
Werkzeug==3.1.8

View File

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

View File

@ -2,13 +2,20 @@
This module enables a user to host a server that is able to play games.
"""
from __future__ import annotations
import random
from pyserver import PyServer
from pyclient import PyClient
from pyclient.games.tic_tac_toe import TicTacToe
from typing import Any
def main():
def main() -> int:
"""
Start a server.
:return: Exit code
:rtype: int
"""
player = PyServer(
# Customize this to whatever you'd like to call your player
name="My super smart robot player",
@ -20,13 +27,18 @@ def main():
import_name=__name__,
)
# Register games! Comment out any you don't want your player to play.
player.add_tic_tac_toe(on_move=play_tic_tac_toe, profile={})
player.start(port=5001)
# Start listening for games
player.start(
host="0.0.0.0", # Comment out when using only locally
port=5000,
)
return 0
def play_tic_tac_toe(payload):
def play_tic_tac_toe(payload : dict[str, Any]) -> dict[str, Any]:
"""
Play a game of tic-tac-toe.
@ -52,6 +64,11 @@ def play_tic_tac_toe(payload):
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!
@ -80,10 +97,3 @@ def play_tic_tac_toe(payload):
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)