Refactor project to new Agent class
parent
5c3eb14c17
commit
d8c20fd4cc
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .agent import Agent, Payload
|
from .agent import ActionFunction, Agent, Payload
|
||||||
|
|
||||||
def respond_mute(payload : Payload) -> Payload:
|
def respond_mute(payload : Payload) -> Payload:
|
||||||
"""
|
"""
|
||||||
|
|
@ -42,4 +42,13 @@ class MuteAgent(Agent):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.add_tic_tac_toe(on_move=respond_mute, profile={})
|
def get_action(self, game: str, action: str | None) -> ActionFunction:
|
||||||
|
"""
|
||||||
|
Return the mute response for any game or action.
|
||||||
|
|
||||||
|
:param game: The game that the mute ignores.
|
||||||
|
:type game: str
|
||||||
|
:param action: The action that the mute ignores.
|
||||||
|
:type action: str
|
||||||
|
"""
|
||||||
|
return respond_mute
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import pyclient
|
import pyclient
|
||||||
|
|
||||||
from pyclient import Agent, PyClient
|
from agents import Agent, RemoteAgent
|
||||||
|
from pyclient import PyClient
|
||||||
from pyclient.games import TicTacToe
|
from pyclient.games import TicTacToe
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
|
@ -33,11 +34,11 @@ def main() -> int:
|
||||||
:return: Exit code
|
:return: Exit code
|
||||||
:rtype: int
|
:rtype: int
|
||||||
"""
|
"""
|
||||||
c = PyClient(debug=False)
|
c = PyClient(debug=True)
|
||||||
|
|
||||||
players : list[Agent] = [
|
players : list[Agent] = [
|
||||||
Agent.from_url(url="https://bmt001.noordstar.me/"),
|
RemoteAgent(url="https://bmt001.noordstar.me/"),
|
||||||
Agent.from_url(url="https://bmt001.noordstar.me/"),
|
RemoteAgent(url="https://bmt002.noordstar.me/"),
|
||||||
]
|
]
|
||||||
|
|
||||||
out = c.play_game(
|
out = c.play_game(
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Sequence
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
import agents
|
||||||
import pyclient
|
import pyclient
|
||||||
|
|
||||||
from pyclient.games import FinishState, Game
|
from pyclient.games import FinishState, Game
|
||||||
|
|
@ -37,17 +38,11 @@ class PlayerIdentifier:
|
||||||
return (self.name, self.url, self.version)
|
return (self.name, self.url, self.version)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_server_agent(cls, agent : pyclient.ServerAgent) -> "PlayerIdentifier":
|
def from_server_agent(cls, agent : agents.RemoteAgent) -> "PlayerIdentifier":
|
||||||
"""
|
"""
|
||||||
Gain a player identifier from an agent.
|
Gain a player identifier from an agent.
|
||||||
"""
|
"""
|
||||||
return cls(
|
return cls(name=agent.name, url=agent.url, version=agent.version)
|
||||||
name=agent.name,
|
|
||||||
url=agent.url,
|
|
||||||
version=agent.profile.get("version",
|
|
||||||
agent.profile.get("me.noordstar.peanuts.agent.version", None)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@dataclass()
|
@dataclass()
|
||||||
class EloStat:
|
class EloStat:
|
||||||
|
|
@ -181,7 +176,7 @@ class Match:
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_replay(
|
def from_replay(
|
||||||
cls,
|
cls,
|
||||||
players : list[pyclient.ServerAgent],
|
players : list[agents.RemoteAgent],
|
||||||
replay : pyclient.GameReplay,
|
replay : pyclient.GameReplay,
|
||||||
timestamp : str | None,
|
timestamp : str | None,
|
||||||
) -> "Match":
|
) -> "Match":
|
||||||
|
|
@ -189,7 +184,7 @@ class Match:
|
||||||
Convert a GameReplay into a match.
|
Convert a GameReplay into a match.
|
||||||
|
|
||||||
:param players: The participants of the match.
|
:param players: The participants of the match.
|
||||||
:type players: list[pyclient.ServerAgent]
|
:type players: list[agents.RemoteAgent]
|
||||||
:param replay: Game summary.
|
:param replay: Game summary.
|
||||||
:type replay: pyclient.GameReplay
|
:type replay: pyclient.GameReplay
|
||||||
:param timestamp: ISO formatted timestamp of when the game was planned.
|
:param timestamp: ISO formatted timestamp of when the game was planned.
|
||||||
|
|
@ -295,7 +290,7 @@ class EloTracker:
|
||||||
|
|
||||||
# Thread-unsafe variables
|
# Thread-unsafe variables
|
||||||
# Please use a lock while doing CRUD operations on them
|
# Please use a lock while doing CRUD operations on them
|
||||||
self.players: list[pyclient.ServerAgent] = []
|
self.players: list[agents.RemoteAgent] = []
|
||||||
self.__matches: list[Match] = []
|
self.__matches: list[Match] = []
|
||||||
self.__stats: dict[PlayerIdentifier, EloStat] = {}
|
self.__stats: dict[PlayerIdentifier, EloStat] = {}
|
||||||
|
|
||||||
|
|
@ -558,13 +553,13 @@ class EloTracker:
|
||||||
"Expected `players` field to be a list of strings."
|
"Expected `players` field to be a list of strings."
|
||||||
)
|
)
|
||||||
|
|
||||||
players : list[pyclient.ServerAgent] = []
|
players : list[agents.RemoteAgent] = []
|
||||||
for url in urls:
|
for url in urls:
|
||||||
if not isinstance(url, str):
|
if not isinstance(url, str):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent = pyclient.Agent.from_url(url, debug=self.debug)
|
agent = agents.RemoteAgent(url=url)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass # Not an available player right now
|
pass # Not an available player right now
|
||||||
else:
|
else:
|
||||||
|
|
@ -586,17 +581,14 @@ class EloTracker:
|
||||||
:rtype: pyclient.GameReplay
|
:rtype: pyclient.GameReplay
|
||||||
:raises ValueError: One of the URLs could not be accessed.
|
:raises ValueError: One of the URLs could not be accessed.
|
||||||
"""
|
"""
|
||||||
agents : list[Any] = [
|
ags : list[Any] = [ agents.RemoteAgent(url=url) for url in players ]
|
||||||
pyclient.Agent.from_url(url, debug=self.debug)
|
|
||||||
for url in players
|
|
||||||
]
|
|
||||||
|
|
||||||
replay = pyclient.PyClient(debug=self.debug).play_game(
|
replay = pyclient.PyClient(debug=self.debug).play_game(
|
||||||
players=agents,
|
players=ags,
|
||||||
start=game,
|
start=game,
|
||||||
)
|
)
|
||||||
|
|
||||||
m = Match.from_replay(players=agents, replay=replay, timestamp=Match.now())
|
m = Match.from_replay(players=ags, replay=replay, timestamp=Match.now())
|
||||||
|
|
||||||
# Record match
|
# Record match
|
||||||
m.log(self.game_file_name)
|
m.log(self.game_file_name)
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,12 @@
|
||||||
Entry points for developers who wish to use the PyClient module.
|
Entry points for developers who wish to use the PyClient module.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .agent import Agent, ServerAgent
|
|
||||||
from .client import PyClient
|
from .client import PyClient
|
||||||
from .replay import GameReplay
|
from .replay import GameReplay
|
||||||
from .transition import Transition
|
from .transition import Transition
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Agent",
|
|
||||||
"GameReplay",
|
"GameReplay",
|
||||||
"PyClient",
|
"PyClient",
|
||||||
"ServerAgent",
|
|
||||||
"Transition",
|
"Transition",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Agent:
|
|
||||||
"""
|
|
||||||
Base class of a game participant. Mostly used to inherit from.
|
|
||||||
"""
|
|
||||||
|
|
||||||
debug : bool
|
|
||||||
games : dict[str, dict[str, Any]]
|
|
||||||
profile : dict[str, Any]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
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
|
|
||||||
:raises ValueError: The server fails to reach out one of the URLs.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return ServerAgent.from_server_url(url=url, **kwargs)
|
|
||||||
except (ValueError, requests.RequestException, requests.HTTPError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
raise ValueError(
|
|
||||||
"URL did not lead to a willing agent"
|
|
||||||
)
|
|
||||||
|
|
||||||
@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: float | tuple[float, float] | None = 10.0,
|
|
||||||
debug : bool = False
|
|
||||||
) -> "ServerAgent":
|
|
||||||
"""
|
|
||||||
Create a server agent by polling its discovery endpoint.
|
|
||||||
|
|
||||||
:param url: The URL that the server can be reached at.
|
|
||||||
:type url: str
|
|
||||||
: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.
|
|
||||||
: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.
|
|
||||||
"""
|
|
||||||
response = requests.get(url.rstrip("/") + "/", timeout=timeout)
|
|
||||||
response.raise_for_status()
|
|
||||||
content = response.json()
|
|
||||||
|
|
||||||
if not isinstance(content, dict):
|
|
||||||
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(
|
|
||||||
debug=debug,
|
|
||||||
games=games,
|
|
||||||
profile=content,
|
|
||||||
url=url,
|
|
||||||
)
|
|
||||||
|
|
||||||
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]
|
|
||||||
: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)
|
|
||||||
response.raise_for_status()
|
|
||||||
content = response.json()
|
|
||||||
except (requests.exceptions.RequestException, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
if self.debug:
|
|
||||||
print(f"[DBG] Agent `{self.name}` returned:")
|
|
||||||
print(content)
|
|
||||||
|
|
||||||
return content if isinstance(content, dict) else None
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Represent the agent in the form of a dict.
|
|
||||||
|
|
||||||
:return: Dictionary representation of the ServerAgent
|
|
||||||
:rtype: dict[str, Any]
|
|
||||||
"""
|
|
||||||
return dict(
|
|
||||||
name=self.name,
|
|
||||||
games=self.games,
|
|
||||||
url=self.url,
|
|
||||||
profile=self.profile,
|
|
||||||
)
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .agent import Agent
|
from agents import Agent
|
||||||
from .games import Game
|
from .games import Game
|
||||||
from .replay import GameReplay, Turn
|
from .replay import GameReplay, Turn
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
@ -43,12 +43,17 @@ class PyClient:
|
||||||
|
|
||||||
else:
|
else:
|
||||||
agent = players[player - 1]
|
agent = players[player - 1]
|
||||||
payload = agent.poll(
|
on_move = agent.get_action(
|
||||||
game=current_state.game_name(),
|
game=current_state.game_name(),
|
||||||
payload=current_state.as_seen_by(player=player),
|
action=current_state.action_name(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate move
|
# Calculate move
|
||||||
|
if on_move is None:
|
||||||
|
payload = {}
|
||||||
|
else:
|
||||||
|
payload = on_move(current_state.as_seen_by(player=player))
|
||||||
|
|
||||||
current_state = current_state.move(payload=payload)
|
current_state = current_state.move(payload=payload)
|
||||||
|
|
||||||
yield Turn(action=payload, player=player, state=current_state)
|
yield Turn(action=payload, player=player, state=current_state)
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,11 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
from agents import Agent
|
from agents import Agent
|
||||||
from collections.abc import Callable, Mapping
|
from typing import Any
|
||||||
from flask import Flask, Response, jsonify, request
|
|
||||||
from functools import wraps
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
PayloadType = dict[str, Any]
|
PayloadType = dict[str, Any]
|
||||||
GameHandler = Callable[[PayloadType], PayloadType]
|
|
||||||
|
|
||||||
|
|
||||||
class PyServer:
|
class PyServer:
|
||||||
|
|
@ -30,34 +27,15 @@ class PyServer:
|
||||||
self.agent = agent
|
self.agent = agent
|
||||||
self.app = Flask(import_name)
|
self.app = Flask(import_name)
|
||||||
|
|
||||||
self.__add_api_endpoint("", "", lambda _ : self.__discovery())
|
@self.app.get("/")
|
||||||
|
def discovery():
|
||||||
|
return jsonify(self.__discovery())
|
||||||
|
|
||||||
# Register endpoints
|
@self.app.get("/<path:game>")
|
||||||
for name, (_, endpoints) in self.agent.registered_games.items():
|
@self.app.get("/<path:game>/<path:action>")
|
||||||
if name == "":
|
def dispatch(game: str, action: str | None = None):
|
||||||
continue
|
return jsonify(self.__dispatch(game=game, action=action))
|
||||||
|
|
||||||
for route, func in endpoints.items():
|
|
||||||
self.__add_api_endpoint(name=name, route=route, func=func)
|
|
||||||
|
|
||||||
def __add_api_endpoint(self, name : str, route : str, func : GameHandler) -> None:
|
|
||||||
"""
|
|
||||||
Create a new API endpoint.
|
|
||||||
|
|
||||||
:param name: The name of the action to undertake.
|
|
||||||
:type name: str
|
|
||||||
:param func: The player's function that determines what action to take.
|
|
||||||
:type func: Callable[[dict[str, Any]], dict[str, Any]]
|
|
||||||
:raises ValueError: The URL has already been registered.
|
|
||||||
"""
|
|
||||||
url = self.__make_url(name, route)
|
|
||||||
|
|
||||||
return self.app.add_url_rule(url,
|
|
||||||
endpoint="botman_" + name.replace("/", "_"),
|
|
||||||
view_func=self.__func_wrapper(func),
|
|
||||||
methods=["GET"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def __discovery(self) -> PayloadType:
|
def __discovery(self) -> PayloadType:
|
||||||
"""
|
"""
|
||||||
Return the server's discovery information.
|
Return the server's discovery information.
|
||||||
|
|
@ -77,21 +55,21 @@ class PyServer:
|
||||||
|
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def __func_wrapper(self, func : GameHandler) -> Callable[[], Response]:
|
def __dispatch(self, game: str, action: str | None) -> PayloadType:
|
||||||
"""
|
"""
|
||||||
Wrapper that catches an incoming request, parses it, and responds
|
Resolve a request against the agent's action lookup.
|
||||||
with a player's action response.
|
|
||||||
"""
|
"""
|
||||||
@wraps(func)
|
payload = request.get_json(silent=True) or {}
|
||||||
def exec():
|
func = self.agent.get_action(
|
||||||
payload = request.get_json(silent=True) or {}
|
game=game.strip("/"),
|
||||||
result = func(payload) or {}
|
action=action.strip("/") if isinstance(action, str) else None,
|
||||||
return jsonify(result)
|
)
|
||||||
|
|
||||||
return exec
|
|
||||||
|
|
||||||
def __make_url(self, name : str, route : str) -> str:
|
if func is None:
|
||||||
return "/" + "/".join([ name.strip("/"), route.strip("/") ]).strip("/")
|
return {}
|
||||||
|
|
||||||
|
result = func(payload) or {}
|
||||||
|
return result if isinstance(result, dict) else {}
|
||||||
|
|
||||||
def start(self, host: str = "127.0.0.1", port: int = 5000, debug: bool = False, **kwargs: Any) -> None:
|
def start(self, host: str = "127.0.0.1", port: int = 5000, debug: bool = False, **kwargs: Any) -> None:
|
||||||
"""Start the Flask development server."""
|
"""Start the Flask development server."""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue