Create MVP PyServer
parent
99e5cafa89
commit
815f2d0d35
|
|
@ -12,3 +12,4 @@ are programs or browsers that mediate matches between servers.
|
||||||
|
|
||||||
- The discovery contract is documented in `spec/README.md`.
|
- The discovery contract is documented in `spec/README.md`.
|
||||||
- Python client helpers live under `pyclient/`.
|
- Python client helpers live under `pyclient/`.
|
||||||
|
- Python server helpers live under `pyserver/`.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""
|
||||||
|
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({}, play_tic_tac_toe)
|
||||||
|
|
||||||
|
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://localhost:5001",
|
||||||
|
"http://localhost:5002"
|
||||||
|
], debug=True)
|
||||||
|
|
||||||
|
out = c.play_game("/tic-tac-toe", TicTacToe)
|
||||||
|
|
@ -10,7 +10,7 @@ from .poll import ServerAgent
|
||||||
class PyClient:
|
class PyClient:
|
||||||
"""Host games between discovered server agents."""
|
"""Host games between discovered server agents."""
|
||||||
|
|
||||||
def __init__(self, hosts: List[str]) -> None:
|
def __init__(self, hosts: List[str], debug : bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
Create a PyClient.
|
Create a PyClient.
|
||||||
|
|
||||||
|
|
@ -19,6 +19,7 @@ class PyClient:
|
||||||
:raises ValueError: If no reachable servers are provided.
|
:raises ValueError: If no reachable servers are provided.
|
||||||
"""
|
"""
|
||||||
self.agents: List[ServerAgent] = []
|
self.agents: List[ServerAgent] = []
|
||||||
|
self.debug = debug
|
||||||
self.hosts: List[str] = []
|
self.hosts: List[str] = []
|
||||||
|
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
|
|
@ -34,7 +35,7 @@ class PyClient:
|
||||||
|
|
||||||
def __discover_host(self, url: str) -> Optional[ServerAgent]:
|
def __discover_host(self, url: str) -> Optional[ServerAgent]:
|
||||||
try:
|
try:
|
||||||
return ServerAgent.from_url(url)
|
return ServerAgent.from_url(url, debug=self.debug)
|
||||||
except (requests.exceptions.RequestException, ValueError):
|
except (requests.exceptions.RequestException, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ class TicTacToe:
|
||||||
raise KeyError(
|
raise KeyError(
|
||||||
f"Index to write to should be 1-9, not {index}"
|
f"Index to write to should be 1-9, not {index}"
|
||||||
)
|
)
|
||||||
elif current_value != Field.empty:
|
elif current_value != str(Field.empty):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Field should be empty, not {current_value}"
|
f"Field should be empty, not {current_value}"
|
||||||
)
|
)
|
||||||
|
|
@ -170,7 +170,7 @@ class TicTacToe:
|
||||||
)
|
)
|
||||||
|
|
||||||
def action_name(self):
|
def action_name(self):
|
||||||
return "tic-tac-toe"
|
return "/tic-tac-toe"
|
||||||
|
|
||||||
def count_o(self) -> int:
|
def count_o(self) -> int:
|
||||||
"""
|
"""
|
||||||
|
|
@ -234,20 +234,21 @@ class TicTacToe:
|
||||||
# Extract field to place symbol at
|
# Extract field to place symbol at
|
||||||
move = 1
|
move = 1
|
||||||
if isinstance(payload, dict):
|
if isinstance(payload, dict):
|
||||||
move = payload.get("move", 1)
|
move = payload.get("move", None)
|
||||||
|
|
||||||
if not isinstance(move, int):
|
if not isinstance(move, int):
|
||||||
move = 1
|
move = None
|
||||||
|
else:
|
||||||
|
move = move if 1 <= move <= 9 else None
|
||||||
|
|
||||||
move = min(9, max(1, move))
|
if move is not None:
|
||||||
|
try:
|
||||||
try:
|
out = self.__write(move, symbol)
|
||||||
out = self.__write(move, symbol)
|
except ValueError:
|
||||||
except ValueError:
|
# Invalid move! We'll try any other move.
|
||||||
# Invalid move! We'll try any other move.
|
pass
|
||||||
pass
|
else:
|
||||||
else:
|
return out
|
||||||
return out
|
|
||||||
|
|
||||||
# The player chose an invalid move.
|
# The player chose an invalid move.
|
||||||
return self.move_default()
|
return self.move_default()
|
||||||
|
|
@ -292,13 +293,13 @@ class TicTacToe:
|
||||||
|
|
||||||
d = self.to_dict()
|
d = self.to_dict()
|
||||||
|
|
||||||
for player, symbol in [ ( 1, Field.X ), ( 2, 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
|
return player
|
||||||
else:
|
else:
|
||||||
# Check for draw
|
# Check for draw
|
||||||
if all(item != Field.empty for item in d.values()):
|
if all(item != "" for item in d.values()):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ def _poll_request(
|
||||||
class ServerAgent:
|
class ServerAgent:
|
||||||
"""Representation of a server that can host one or more games."""
|
"""Representation of a server that can host one or more games."""
|
||||||
|
|
||||||
def __init__(self, url: str, name: str, games: Dict[str, Dict[str, Any]]) -> None:
|
def __init__(self, url: str, name: str, games: Dict[str, Dict[str, Any]], debug : bool = False) -> None:
|
||||||
"""
|
"""
|
||||||
Create a server representation.
|
Create a server representation.
|
||||||
|
|
||||||
|
|
@ -73,15 +73,17 @@ class ServerAgent:
|
||||||
:param games: Games the server is willing to play.
|
:param games: Games the server is willing to play.
|
||||||
:type games: Dict[str, Dict[str, Any]]
|
:type games: Dict[str, Dict[str, Any]]
|
||||||
"""
|
"""
|
||||||
self.url = url.strip("/")
|
self.debug = debug
|
||||||
self.name = name
|
|
||||||
self.games = games
|
self.games = games
|
||||||
|
self.name = name
|
||||||
|
self.url = url.strip("/")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_url(
|
def from_url(
|
||||||
cls,
|
cls,
|
||||||
url: str,
|
url: str,
|
||||||
timeout: Optional[Union[float, Tuple[float, float]]] = 10.0,
|
timeout: Optional[Union[float, Tuple[float, float]]] = 10.0,
|
||||||
|
debug : bool = False
|
||||||
) -> "ServerAgent":
|
) -> "ServerAgent":
|
||||||
"""
|
"""
|
||||||
Create a server agent by polling its discovery endpoint.
|
Create a server agent by polling its discovery endpoint.
|
||||||
|
|
@ -130,7 +132,7 @@ class ServerAgent:
|
||||||
if isinstance(profile, dict):
|
if isinstance(profile, dict):
|
||||||
games[str(game_name)] = profile
|
games[str(game_name)] = profile
|
||||||
|
|
||||||
return cls(url=url, name=name, games=games)
|
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]]:
|
def poll(self, game: str, payload: Dict[str, Any], timeout: float = 1.0) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -148,7 +150,11 @@ class ServerAgent:
|
||||||
"""
|
"""
|
||||||
url = f"{self.url.rstrip('/')}/{game.lstrip('/')}"
|
url = f"{self.url.rstrip('/')}/{game.lstrip('/')}"
|
||||||
|
|
||||||
status, content = _poll_request(url, payload, timeout, timeout)
|
status, content = _poll_request(url, payload, timeout, 3.0)
|
||||||
|
|
||||||
|
if self.debug:
|
||||||
|
print(f"[DBG] Agent `{self.name}` returned:")
|
||||||
|
print(( status, content ))
|
||||||
|
|
||||||
if status != "ok":
|
if status != "ok":
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Public server entry points."""
|
||||||
|
|
||||||
|
from .server import PyServer
|
||||||
|
|
||||||
|
__all__ = ["PyServer"]
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
"""Stateless Flask server helpers for Bot-Man-Toe games."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
|
||||||
|
|
||||||
|
GameHandler = Callable[[Any], Any]
|
||||||
|
|
||||||
|
|
||||||
|
class PyServer:
|
||||||
|
"""A tiny stateless Flask app that serves discovery and game routes."""
|
||||||
|
|
||||||
|
def __init__(self, name: str, profile: Optional[Mapping[str, Any]] = None, import_name: str = __name__) -> None:
|
||||||
|
"""
|
||||||
|
Create a PyServer.
|
||||||
|
|
||||||
|
:param name: Preferred display name for discovery.
|
||||||
|
:param profile: Additional root-level discovery metadata.
|
||||||
|
:param import_name: Flask import name.
|
||||||
|
"""
|
||||||
|
self.name = name
|
||||||
|
self.profile = dict(profile or {})
|
||||||
|
|
||||||
|
if "name" in self.profile:
|
||||||
|
raise ValueError("Root profile metadata must not define 'name'.")
|
||||||
|
if "games" in self.profile:
|
||||||
|
raise ValueError("Root profile metadata must not define 'games'.")
|
||||||
|
|
||||||
|
self.app = Flask(import_name)
|
||||||
|
self._games: dict[str, dict[str, Any]] = {}
|
||||||
|
self._registered_routes: set[str] = set()
|
||||||
|
|
||||||
|
self._register_root()
|
||||||
|
|
||||||
|
def _register_root(self) -> None:
|
||||||
|
def discovery() -> Any:
|
||||||
|
payload = {"name": self.name, **self.profile, "games": dict(self._games)}
|
||||||
|
return jsonify(payload)
|
||||||
|
|
||||||
|
self.app.add_url_rule("/", endpoint="botman_discovery", view_func=discovery, methods=["GET"])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_segment(segment: str) -> str:
|
||||||
|
normalized = segment.strip("/")
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("Route segments must not be empty.")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _compose_route(self, game_name: str, action_name: str) -> str:
|
||||||
|
normalized_game = self._normalize_segment(game_name)
|
||||||
|
normalized_action = action_name.strip("/")
|
||||||
|
return f"/{normalized_game}" if not normalized_action else f"/{normalized_game}/{normalized_action}"
|
||||||
|
|
||||||
|
def _register_action(self, route: str, handler: GameHandler) -> None:
|
||||||
|
if route in self._registered_routes:
|
||||||
|
raise ValueError(f"Route already registered: {route}")
|
||||||
|
|
||||||
|
endpoint = f"botman_{len(self._registered_routes)}"
|
||||||
|
|
||||||
|
def view() -> Any:
|
||||||
|
payload = request.get_json(silent=True)
|
||||||
|
return jsonify(handler(payload))
|
||||||
|
|
||||||
|
self.app.add_url_rule(route, endpoint=endpoint, view_func=view, methods=["GET"])
|
||||||
|
self._registered_routes.add(route)
|
||||||
|
|
||||||
|
def add_game(
|
||||||
|
self,
|
||||||
|
game_name: str,
|
||||||
|
custom_profile: Mapping[str, Any],
|
||||||
|
actions: Mapping[str, GameHandler],
|
||||||
|
required_actions: Optional[tuple[str, ...]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Register a stateless game with one or more action routes.
|
||||||
|
|
||||||
|
:param game_name: Base route name for the game.
|
||||||
|
:param custom_profile: Game-specific discovery metadata.
|
||||||
|
:param actions: Mapping of action subpaths to handlers.
|
||||||
|
:param required_actions: Optional completeness check. If provided, all
|
||||||
|
required action names must exist in ``actions`` and map to callables.
|
||||||
|
"""
|
||||||
|
if not isinstance(custom_profile, Mapping):
|
||||||
|
raise TypeError("custom_profile must be a mapping.")
|
||||||
|
if not isinstance(actions, Mapping) or not actions:
|
||||||
|
raise ValueError("actions must be a non-empty mapping.")
|
||||||
|
|
||||||
|
normalized_profile = dict(custom_profile)
|
||||||
|
|
||||||
|
if required_actions is not None:
|
||||||
|
missing_actions = [action_name for action_name in required_actions if action_name not in actions]
|
||||||
|
if missing_actions:
|
||||||
|
raise ValueError(f"Missing required action handlers: {', '.join(sorted(missing_actions))}")
|
||||||
|
|
||||||
|
prepared_routes: list[tuple[str, GameHandler]] = []
|
||||||
|
for action_name, handler in actions.items():
|
||||||
|
if not isinstance(action_name, str):
|
||||||
|
raise TypeError("Action names must be strings.")
|
||||||
|
if handler is None or not callable(handler):
|
||||||
|
raise TypeError(f"Action handler for '{action_name}' must be callable.")
|
||||||
|
prepared_routes.append((self._compose_route(game_name, action_name), handler))
|
||||||
|
|
||||||
|
normalized_routes = [route for route, _ in prepared_routes]
|
||||||
|
if len(set(normalized_routes)) != len(normalized_routes):
|
||||||
|
raise ValueError("Action routes must be unique after normalization.")
|
||||||
|
|
||||||
|
duplicate_routes = [route for route, _ in prepared_routes if route in self._registered_routes or route in self._games]
|
||||||
|
if duplicate_routes:
|
||||||
|
raise ValueError(f"Route already registered: {duplicate_routes[0]}")
|
||||||
|
|
||||||
|
for route, handler in prepared_routes:
|
||||||
|
self._register_action(route, handler)
|
||||||
|
self._games[route] = dict(normalized_profile)
|
||||||
|
|
||||||
|
def add_tic_tac_toe(self, custom_profile: Mapping[str, Any], on_move: GameHandler) -> None:
|
||||||
|
"""
|
||||||
|
Convenience registration for tic-tac-toe.
|
||||||
|
|
||||||
|
The game is exposed at ``/tic-tac-toe``.
|
||||||
|
"""
|
||||||
|
self.add_game("tic-tac-toe", custom_profile, {"": on_move})
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
|
blinker==1.9.0
|
||||||
certifi==2026.5.20
|
certifi==2026.5.20
|
||||||
charset-normalizer==3.4.7
|
charset-normalizer==3.4.7
|
||||||
|
click==8.4.1
|
||||||
|
colorama==0.4.6
|
||||||
|
Flask==3.1.3
|
||||||
idna==3.18
|
idna==3.18
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
Jinja2==3.1.6
|
||||||
|
MarkupSafe==3.0.3
|
||||||
requests==2.34.2
|
requests==2.34.2
|
||||||
urllib3==2.7.0
|
urllib3==2.7.0
|
||||||
|
Werkzeug==3.1.8
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue