commit e4c40d2d97303078d2b9ef89d356ae2cf2271d07 Author: Bram van den Heuvel Date: Fri Jun 5 16:48:18 2026 +0200 Create PyClient for hosting games diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ee270a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env/ +__pycache__/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..692f74b --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Bot-Man-Toe + +Bot-Man-Toe is an attempt to create a way for players to play games against +themselves, other players, or self-trained AI players. + +## Technology stack + +Counterintuitively, the **servers** are participants to a game. The **clients** +are programs or browsers that mediate matches between servers. + +## More + +- The discovery contract is documented in `spec/README.md`. +- Python client helpers live under `pyclient/`. diff --git a/pyclient/__init__.py b/pyclient/__init__.py new file mode 100644 index 0000000..d246d77 --- /dev/null +++ b/pyclient/__init__.py @@ -0,0 +1,80 @@ +"""Public client entry points.""" + +from typing import List, Optional + +import requests + +from .poll import ServerAgent + + +class PyClient: + """Host games between discovered server agents.""" + + def __init__(self, hosts: List[str]) -> None: + """ + Create a PyClient. + + :param hosts: URLs of servers that can participate. + :type hosts: List[str] + :raises ValueError: If no reachable servers are provided. + """ + self.agents: List[ServerAgent] = [] + self.hosts: List[str] = [] + + for host in hosts: + agent = self.__discover_host(host) + if agent is not None: + self.agents.append(agent) + self.hosts.append(agent.url) + + if len(self.hosts) <= 0: + raise ValueError( + "No valid hosts found! Check your internet connection or verify the URLs are correct." + ) + + def __discover_host(self, url: str) -> Optional[ServerAgent]: + try: + return ServerAgent.from_url(url) + except (requests.exceptions.RequestException, ValueError): + return None + + def play_game(self, name : str, game, timeout : float = 1.0): + """ + Play a given game. Ask the registered agents to participate. + """ + states = [] + + current_state = game.empty() + + while current_state.winner() is None: + player : int = current_state.player_to_move() + + if len(self.agents) < player: + raise KeyError( + f"The client does not have access to {player} players" + ) + + # Ask for move + agent = self.agents[player] + payload = agent.poll( + game=current_state.action_name(), + payload=current_state.to_dict(), + timeout=timeout, + ) + + # Calculate move + current_state = current_state.move(payload) + + # Save the results + states.append((player, payload, current_state)) + + return states + + 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"] diff --git a/pyclient/games/tic_tac_toe.py b/pyclient/games/tic_tac_toe.py new file mode 100644 index 0000000..d1cb471 --- /dev/null +++ b/pyclient/games/tic_tac_toe.py @@ -0,0 +1,301 @@ +from dataclasses import dataclass +from enum import Enum, auto +from typing import Any, Dict, List, Optional + +class Field(Enum): + X = auto() + O = auto() + empty = auto() + + def __str__(self): + """ + Convert the field to a string. + """ + match self: + case Field.X: + return "X" + case Field.O: + return "O" + case Field.empty: + return "" + +@dataclass(frozen=True) +class 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 + + def __write(self, index : int, value : Field) -> "TicTacToe": + """ + Create a new copy where one field has been written with any value. + + :param index: The field number to replace. (1-9) + :type index: int + :param value: The field value to write. + :type value: Field + :return: A new copy where the value was written. + :rtype: TicTacToe + :raises ValueError: The field index has already been written to. + :raises KeyError: The index is an invalid number. + """ + current_value = self.to_dict().get(str(index), None) + + if current_value is None: + raise KeyError( + f"Index to write to should be 1-9, not {index}" + ) + elif current_value != Field.empty: + raise ValueError( + f"Field should be empty, not {current_value}" + ) + + match index: + case 1: + return TicTacToe( + field_1=value, + field_2=self.field_2, + field_3=self.field_3, + field_4=self.field_4, + field_5=self.field_5, + field_6=self.field_6, + field_7=self.field_7, + field_8=self.field_8, + field_9=self.field_9, + ) + case 2: + return TicTacToe( + field_1=self.field_1, + field_2=value, + field_3=self.field_3, + field_4=self.field_4, + field_5=self.field_5, + field_6=self.field_6, + field_7=self.field_7, + field_8=self.field_8, + field_9=self.field_9, + ) + case 3: + return TicTacToe( + field_1=self.field_1, + field_2=self.field_2, + field_3=value, + field_4=self.field_4, + field_5=self.field_5, + field_6=self.field_6, + field_7=self.field_7, + field_8=self.field_8, + field_9=self.field_9, + ) + case 4: + return TicTacToe( + field_1=self.field_1, + field_2=self.field_2, + field_3=self.field_3, + field_4=value, + field_5=self.field_5, + field_6=self.field_6, + field_7=self.field_7, + field_8=self.field_8, + field_9=self.field_9, + ) + case 5: + return TicTacToe( + field_1=self.field_1, + field_2=self.field_2, + field_3=self.field_3, + field_4=self.field_4, + field_5=value, + field_6=self.field_6, + field_7=self.field_7, + field_8=self.field_8, + field_9=self.field_9, + ) + case 6: + return TicTacToe( + field_1=self.field_1, + field_2=self.field_2, + field_3=self.field_3, + field_4=self.field_4, + field_5=self.field_5, + field_6=value, + field_7=self.field_7, + field_8=self.field_8, + field_9=self.field_9, + ) + case 7: + return TicTacToe( + field_1=self.field_1, + field_2=self.field_2, + field_3=self.field_3, + field_4=self.field_4, + field_5=self.field_5, + field_6=self.field_6, + field_7=value, + field_8=self.field_8, + field_9=self.field_9, + ) + case 8: + return TicTacToe( + field_1=self.field_1, + field_2=self.field_2, + field_3=self.field_3, + field_4=self.field_4, + field_5=self.field_5, + field_6=self.field_6, + field_7=self.field_7, + field_8=value, + field_9=self.field_9, + ) + case 9: + return TicTacToe( + field_1=self.field_1, + field_2=self.field_2, + field_3=self.field_3, + field_4=self.field_4, + field_5=self.field_5, + field_6=self.field_6, + field_7=self.field_7, + field_8=self.field_8, + field_9=value, + ) + case _: + raise KeyError( + f"Index to write to should be 1-9, not {index}" + ) + + def action_name(self): + return "tic-tac-toe" + + def count_o(self) -> int: + """ + Count the number of O's on the board. + """ + return list(self.to_dict().values()).count(str(Field.O)) + + def count_x(self) -> int: + """ + Count the number of X's on the board. + """ + return list(self.to_dict().values()).count(str(Field.X)) + + @classmethod + def empty(cls): + return cls( + field_1=Field.empty, field_2=Field.empty, field_3=Field.empty, + field_4=Field.empty, field_5=Field.empty, field_6=Field.empty, + field_7=Field.empty, field_8=Field.empty, field_9=Field.empty, + ) + + def move_default(self) -> "TicTacToe": + """ + Have a player take a "default" move. They'll take this move + whenever their response is invalid, or when they take too long + to decide, or when they're no longer accessible. + + :return: New state of the board. + :rtype: TicTacToe + :raises ValueError: When no more moves exist. By this point, someone should've already won. + """ + symbol = Field.X if self.player_to_move() == 1 else Field.O + + for i in range(9): + try: + out = self.__write(i+1, symbol) + except ValueError: + # Field already occupied! Move to the next option. + pass + else: + return out + else: + # All moves seem invalid! + raise ValueError( + "No legal moves exist anymore on this tic-tac-toe board." + ) + + def move(self, payload : Optional[Dict[str, Any]] = None) -> "TicTacToe": + """ + Have a player make a move. Based on this information, update the + game. + + :param payload: Dictionary containing the player's response. + :type payload: Optional[Dict[str, Any]] + :return: New state of the board. + :rtype: TicTacToe + :raises ValueError: When no more moves exist. By this point, someone should've already won. + """ + symbol = Field.X if self.player_to_move() == 1 else Field.O + + # Extract field to place symbol at + move = 1 + if isinstance(payload, dict): + move = payload.get("move", 1) + + if not isinstance(move, int): + move = 1 + + 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 + + # The player chose an invalid move. + return self.move_default() + + + def player_to_move(self) -> int: + """ + Return which player needs to move. + """ + return 1 if self.count_x() <= self.count_o() else 2 + + def to_dict(self) -> Dict[str, Any]: + return { + "1": str(self.field_1), + "2": str(self.field_2), + "3": str(self.field_3), + "4": str(self.field_4), + "5": str(self.field_5), + "6": str(self.field_6), + "7": str(self.field_7), + "8": str(self.field_8), + "9": str(self.field_9), + } + + def winner(self) -> int | None: + """ + Returns whether the board indicates that there's a winner. + + :return: The winning player, or None if there's no winner yet. + :rtype: int | None + """ + win_lines = [ + [ 1, 2, 3, ], + [ 4, 5, 6, ], + [ 7, 8, 9, ], + [ 1, 4, 7, ], + [ 2, 5, 8, ], + [ 3, 6, 9, ], + [ 1, 5, 9, ], + [ 3, 5, 7, ], + ] + + d = self.to_dict() + + for player, symbol in [ ( 1, Field.X ), ( 2, Field.O ) ]: + for win_line in win_lines: + if all(d[str(w)] == symbol for w in win_line): + return player + else: + return None + diff --git a/pyclient/poll.py b/pyclient/poll.py new file mode 100644 index 0000000..82426f3 --- /dev/null +++ b/pyclient/poll.py @@ -0,0 +1,156 @@ +"""Discovery and polling helpers for Bot-Man-Toe servers.""" + +import multiprocessing +from queue import Empty +from typing import Any, Dict, Optional, Tuple, Union + +import requests + + +def _poll_worker(url: str, payload: Dict[str, Any], timeout: Optional[Union[float, Tuple[float, float]]], queue: multiprocessing.Queue) -> None: + """Run a polling request in a separate process.""" + try: + response = requests.get(url, json=payload, timeout=timeout) + response.raise_for_status() + content = response.json() + if isinstance(content, dict): + queue.put(("ok", content)) + return + except requests.exceptions.RequestException: + queue.put(("request_error", None)) + return + except ValueError: + queue.put(("invalid", None)) + return + + queue.put(("invalid", None)) + + +def _poll_request( + url: str, + payload: Dict[str, Any], + request_timeout: Optional[Union[float, Tuple[float, float]]], + deadline: float, +) -> tuple[str, Optional[Dict[str, Any]]]: + """Execute a bounded JSON request and classify the result.""" + context = multiprocessing.get_context("spawn") + queue: multiprocessing.Queue = context.Queue(maxsize=1) + process = context.Process(target=_poll_worker, args=(url, payload, request_timeout, queue)) + process.daemon = True + process.start() + + process.join(deadline) + if process.is_alive(): + process.terminate() + process.join() + queue.close() + queue.join_thread() + return ("timeout", None) + + try: + status, content = queue.get(timeout=0.1) + except Empty: + queue.close() + queue.join_thread() + return ("invalid", None) + + queue.close() + queue.join_thread() + return status, content + + +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: + """ + 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.url = url.strip("/") + self.name = name + self.games = games + + @classmethod + def from_url( + cls, + url: str, + timeout: Optional[Union[float, Tuple[float, float]]] = 10.0, + ) -> "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. + """ + if timeout is None: + deadline = 10.0 + elif isinstance(timeout, (int, float)): + deadline = float(timeout) + else: + deadline = sum(timeout) + status, content = _poll_request(url.rstrip("/") + "/", {}, timeout, deadline) + + if status == "timeout": + raise requests.exceptions.RequestException("Server discovery request timed out.") + if status == "request_error": + raise requests.exceptions.RequestException("Server discovery request failed.") + if status != "ok" or content is None: + 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) + + 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('/')}" + + status, content = _poll_request(url, payload, timeout, timeout) + + if status != "ok": + return None + + return content diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..263afb3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +certifi==2026.5.20 +charset-normalizer==3.4.7 +idna==3.18 +requests==2.34.2 +urllib3==2.7.0 diff --git a/spec/README.md b/spec/README.md new file mode 100644 index 0000000..156d3b9 --- /dev/null +++ b/spec/README.md @@ -0,0 +1,67 @@ +# Bot-Man-Toe Specification + +This document describes how a Bot-Man-Toe server or client is supposed to +behave. + +## Terminology + +A **server** is a REST API server that hosts a player willing to play games. +A **client** can feed it a game's state through a request with a JSON body, +and the server may respond with an action they wish to take. + +While each API request is completely stateless and memoryless, it is typical +for clients to use the server to play full games. + +## Discovery + +Each server has a uniquely identifying URL at which they can be addressed. + +### / + +At the root of the URL, the client may send a GET request. The server should +ignore the JSON body, and respond with a profile detailing which game(s) the +server is willing to play. + +#### 200 Response + +The server issues this response to indicate that it's a Bot-Man-Toe server. +It'll offer some details, preferences and clarifications about itself that the +client may use. + +For example, they may choose a favourite color they'd like to be displayed in, +indicate their favourite type of food or restrict what kind of roles it can +play in certain games. (e.g. it can only play as white in chess) + +| Field | Type | Description | +| ----- | ---- | ----------- | +| name | string | The preferred name of the server in games. Clients may use this to create scoreboards. | +| games | {string:Game} | Collection of supported games. The key is the game's identifying API endpoint route, and the value is the server's profile information for that game. | + +Notes: + +- Clients should treat any unrecognized top-level fields as opaque metadata. +- Clients should ignore malformed entries inside `games` rather than failing the whole discovery step, unless the root payload itself is invalid. +- Discovery requests should be made against the root endpoint with a plain `GET`; the server may ignore the request body if one is present. + +Some other metadata might be provided. When providing custom information, you +are recommended to provide this information in a way that respects the Java +package namespace guidelines. + +```json +{ + "name": "My self-built AI!!!1!", + "games": { + "/snake": { + "snake_color": "red", + "snake_face": "silly" + }, + "/tic-tac-toe": {}, + "/chess": { + "preferred_color": "white" + } + }, + "org.example.my-custom-data": "foobarbaz", + "org.example.some-more-data": {} +} +``` +