diff --git a/README.md b/README.md index 692f74b..aaf82d5 100644 --- a/README.md +++ b/README.md @@ -12,3 +12,4 @@ are programs or browsers that mediate matches between servers. - The discovery contract is documented in `spec/README.md`. - Python client helpers live under `pyclient/`. +- Python server helpers live under `pyserver/`. diff --git a/main.py b/main.py new file mode 100644 index 0000000..a8f5f50 --- /dev/null +++ b/main.py @@ -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) diff --git a/pyclient/__init__.py b/pyclient/__init__.py index 0e7e37f..b80fe32 100644 --- a/pyclient/__init__.py +++ b/pyclient/__init__.py @@ -10,7 +10,7 @@ from .poll import ServerAgent class PyClient: """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. @@ -19,6 +19,7 @@ class PyClient: :raises ValueError: If no reachable servers are provided. """ self.agents: List[ServerAgent] = [] + self.debug = debug self.hosts: List[str] = [] for host in hosts: @@ -34,7 +35,7 @@ class PyClient: def __discover_host(self, url: str) -> Optional[ServerAgent]: try: - return ServerAgent.from_url(url) + return ServerAgent.from_url(url, debug=self.debug) except (requests.exceptions.RequestException, ValueError): return None diff --git a/pyclient/games/tic_tac_toe.py b/pyclient/games/tic_tac_toe.py index 76b2c9f..8e109ae 100644 --- a/pyclient/games/tic_tac_toe.py +++ b/pyclient/games/tic_tac_toe.py @@ -50,7 +50,7 @@ class TicTacToe: raise KeyError( f"Index to write to should be 1-9, not {index}" ) - elif current_value != Field.empty: + elif current_value != str(Field.empty): raise ValueError( f"Field should be empty, not {current_value}" ) @@ -170,7 +170,7 @@ class TicTacToe: ) def action_name(self): - return "tic-tac-toe" + return "/tic-tac-toe" def count_o(self) -> int: """ @@ -234,20 +234,21 @@ class TicTacToe: # Extract field to place symbol at move = 1 if isinstance(payload, dict): - move = payload.get("move", 1) + move = payload.get("move", None) if not isinstance(move, int): - move = 1 + move = None + else: + move = move if 1 <= move <= 9 else None - move = min(9, max(1, move)) - - try: - out = self.__write(move, symbol) - except ValueError: - # Invalid move! We'll try any other move. - pass - else: - return out + if move is not None: + try: + out = self.__write(move, symbol) + except ValueError: + # Invalid move! We'll try any other move. + pass + else: + return out # The player chose an invalid move. return self.move_default() @@ -292,13 +293,13 @@ class TicTacToe: 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: if all(d[str(w)] == symbol for w in win_line): return player else: # Check for draw - if all(item != Field.empty for item in d.values()): + if all(item != "" for item in d.values()): return 0 return None diff --git a/pyclient/poll.py b/pyclient/poll.py index 82426f3..c99e0a7 100644 --- a/pyclient/poll.py +++ b/pyclient/poll.py @@ -62,7 +62,7 @@ def _poll_request( 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]]) -> None: + def __init__(self, url: str, name: str, games: Dict[str, Dict[str, Any]], debug : bool = False) -> None: """ Create a server representation. @@ -73,15 +73,17 @@ class ServerAgent: :param games: Games the server is willing to play. :type games: Dict[str, Dict[str, Any]] """ - self.url = url.strip("/") - self.name = name + 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. @@ -130,7 +132,7 @@ class ServerAgent: if isinstance(profile, dict): 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]]: """ @@ -148,7 +150,11 @@ class ServerAgent: """ 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": return None diff --git a/pyserver/__init__.py b/pyserver/__init__.py new file mode 100644 index 0000000..7b067aa --- /dev/null +++ b/pyserver/__init__.py @@ -0,0 +1,5 @@ +"""Public server entry points.""" + +from .server import PyServer + +__all__ = ["PyServer"] diff --git a/pyserver/server.py b/pyserver/server.py new file mode 100644 index 0000000..288323d --- /dev/null +++ b/pyserver/server.py @@ -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) diff --git a/requirements.txt b/requirements.txt index 263afb3..ceebf86 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,13 @@ +blinker==1.9.0 certifi==2026.5.20 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