Create PyClient for hosting games
commit
e4c40d2d97
|
|
@ -0,0 +1,2 @@
|
||||||
|
.env/
|
||||||
|
__pycache__/
|
||||||
|
|
@ -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/`.
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
certifi==2026.5.20
|
||||||
|
charset-normalizer==3.4.7
|
||||||
|
idna==3.18
|
||||||
|
requests==2.34.2
|
||||||
|
urllib3==2.7.0
|
||||||
|
|
@ -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": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Loading…
Reference in New Issue