diff --git a/migrations/versions/516ddfaaa55f_timezones.py b/migrations/versions/516ddfaaa55f_timezones.py index 2f7b3f0..012f8d6 100644 --- a/migrations/versions/516ddfaaa55f_timezones.py +++ b/migrations/versions/516ddfaaa55f_timezones.py @@ -6,7 +6,6 @@ """ from alembic import op -import sqlalchemy as sa # revision identifiers, used by Alembic. diff --git a/migrations/versions/ecc49f9f55bc_agents.py b/migrations/versions/ecc49f9f55bc_agents.py new file mode 100644 index 0000000..81d4ce8 --- /dev/null +++ b/migrations/versions/ecc49f9f55bc_agents.py @@ -0,0 +1,60 @@ +"""agents + +Revision ID: ecc49f9f55bc +Revises: 516ddfaaa55f +Create Date: 2023-05-02 16:36:47.627765 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "ecc49f9f55bc" +down_revision = "516ddfaaa55f" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "agent_deployment", + sa.Column("agent_id", sa.BigInteger(), nullable=False), + sa.Column("url", sa.String(), nullable=False), + sa.Column("healthy", sa.Boolean(), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint( + ["agent_id"], + ["agents.id"], + ), + sa.PrimaryKeyConstraint("agent_id"), + ) + op.create_table( + "agent_history", + sa.Column("agent_id", sa.BigInteger(), nullable=False), + sa.Column("wins", sa.Integer(), nullable=False), + sa.Column("losses", sa.Integer(), nullable=False), + sa.Column("draws", sa.Integer(), nullable=False), + sa.Column("errors", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["agent_id"], + ["agents.id"], + ), + sa.PrimaryKeyConstraint("agent_id"), + ) + op.add_column( + "agents", + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + ) + op.execute("UPDATE agents SET created_at = NOW()") + op.alter_column("agents", "created_at", nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("agents", "created_at") + op.drop_table("agent_history") + op.drop_table("agent_deployment") + # ### end Alembic commands ### diff --git a/src/gameplay_computer/agents/__init__.py b/src/gameplay_computer/agents/__init__.py index 170a1f6..231fef6 100644 --- a/src/gameplay_computer/agents/__init__.py +++ b/src/gameplay_computer/agents/__init__.py @@ -1,16 +1,17 @@ -from .schemas import Agent, AgentDeployment -from .repo import ( +from .schemas import AgentDeployment, AgentHistory +from .service import ( + create_agent, get_agent_by_id, get_agent_by_username_and_agentname, get_agent_id_for_username_and_agentname, - get_agent_deployment_by_id + get_agent_action, ) __all__ = [ - "Agent", "AgentDeployment", + "AgentHistory", "get_agent_by_id", "get_agent_by_username_and_agentname", "get_agent_id_for_username_and_agentname", - "get_agent_deployment_by_id", + "get_agent_action", ] diff --git a/src/gameplay_computer/agents/repo.py b/src/gameplay_computer/agents/repo.py index fcc00b9..65f71d8 100644 --- a/src/gameplay_computer/agents/repo.py +++ b/src/gameplay_computer/agents/repo.py @@ -1,14 +1,46 @@ from databases import Database +from .schemas import AgentDeployment, AgentHistory +from . import tables +from gameplay_computer.gameplay import Agent +from gameplay_computer import users +import sqlalchemy -from .schemas import Agent, AgentDeployment -from .tables import agents -from gameplay_computer import users +async def create_agent( + database: Database, created_by_user_id: str, game: str, agentname: str, url: str +) -> int: + async with database.transaction(): + agent_id: int = await database.execute( + query=tables.agents.insert().values( + game=game, + user_id=created_by_user_id, + agentname=agentname, + created_at=sqlalchemy.func.now(), + ) + ) + await database.execute( + query=tables.agent_deployment.insert().values( + agent_id=agent_id, + url=url, + healthy=True, + active=False, + ) + ) + await database.execute( + query=tables.agent_history.insert().values( + agent_id=agent_id, + wins=0, + losses=0, + draws=0, + errors=0, + ) + ) + return agent_id async def get_agent_by_id(database: Database, agent_id: int) -> Agent | None: agent = await database.fetch_one( - query=agents.select().where(agents.c.id == agent_id) + query=tables.agents.select().where(tables.agents.c.id == agent_id) ) if agent is None: return None @@ -27,8 +59,9 @@ async def get_agent_by_username_and_agentname( user_id = await users.get_user_id_for_username(username) assert user_id is not None agent = await database.fetch_one( - query=agents.select().where( - (agents.c.user_id == user_id) & (agents.c.agentname == agentname) + query=tables.agents.select().where( + (tables.agents.c.user_id == user_id) + & (tables.agents.c.agentname == agentname) ) ) if agent is None: @@ -46,8 +79,9 @@ async def get_agent_id_for_username_and_agentname( user_id = await users.get_user_id_for_username(username) assert user_id is not None agent_id = await database.fetch_val( - query=agents.select().where( - (agents.c.user_id == user_id) & (agents.c.agentname == agentname) + query=tables.agents.select().where( + (tables.agents.c.user_id == user_id) + & (tables.agents.c.agentname == agentname) ), column="id", ) @@ -55,10 +89,19 @@ async def get_agent_id_for_username_and_agentname( return None return int(agent_id) + async def get_agent_deployment_by_id( database: Database, agent_id: int -) -> AgentDeployment: +) -> AgentDeployment | None: + agent_deployment = await database.fetch_one( + query=tables.agent_deployment.select().where( + tables.agent_deployment.c.agent_id == agent_id + ) + ) + if agent_deployment is None: + return None return AgentDeployment( - url="https://gameplay-agents.fly.dev/connect4_random", - active=True, + url=agent_deployment["url"], + healthy=agent_deployment["healthy"], + active=agent_deployment["active"], ) diff --git a/src/gameplay_computer/agents/schemas.py b/src/gameplay_computer/agents/schemas.py index eeafe8f..5916a49 100644 --- a/src/gameplay_computer/agents/schemas.py +++ b/src/gameplay_computer/agents/schemas.py @@ -1,15 +1,15 @@ from typing import Literal from pydantic import BaseModel, HttpUrl -from gameplay_computer.common import BasePlayer, Game - - -class Agent(BasePlayer): - kind: Literal["agent"] = "agent" - game: Game - username: str - agentname: str class AgentDeployment(BaseModel): url: HttpUrl active: bool + healthy: bool + + +class AgentHistory(BaseModel): + wins: int + losses: int + draws: int + errors: int diff --git a/src/gameplay_computer/agents/service.py b/src/gameplay_computer/agents/service.py new file mode 100644 index 0000000..c290f4f --- /dev/null +++ b/src/gameplay_computer/agents/service.py @@ -0,0 +1,112 @@ +from databases import Database +from fastapi import HTTPException, status +import httpx +import asyncio + +from gameplay_computer.gameplay import Match, Connect4Action, Action, Game, Agent + +from gameplay_computer import users +from . import repo + + +async def create_agent( + database: Database, + created_by_user_id: str, + game: Game, + agentname: str, + url: str, +) -> int: + created_by_user = await users.get_user_by_id(created_by_user_id) + if created_by_user is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Unknown user.", + ) + agent_id = await repo.create_agent( + database, created_by_user_id, game, agentname, url + ) + return agent_id + + +async def get_agent_by_id(database: Database, agent_id: int) -> Agent: + agent = await repo.get_agent_by_id(database, agent_id) + if agent is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Unknown agent.", + ) + return agent + + +async def get_agent_by_username_and_agentname( + database: Database, username: str, agentname: str +) -> Agent: + agent = await repo.get_agent_by_username_and_agentname( + database, username, agentname + ) + if agent is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Unknown agent.", + ) + return agent + + +async def get_agent_id_for_username_and_agentname( + database: Database, username: str, agentname: str +) -> int: + agent_id = await repo.get_agent_id_for_username_and_agentname( + database, username, agentname + ) + if agent_id is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Unknown agent.", + ) + return agent_id + + +async def get_agent_action( + database: Database, + client: httpx.AsyncClient, + agent_id: int, + match: Match, +) -> Action: + agent = await get_agent_by_id(database, agent_id) + deployment = await repo.get_agent_deployment_by_id(database, agent_id) + if deployment is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Unknown agent.", + ) + if agent.game != match.game: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Wrong game.", + ) + # We are sort of assuming right now that we know the agent is the next player and that + # somebody else checked that before calling this. tbd + + action: Connect4Action | None = None + + retries = 0 + while retries < 3: + try: + response = await client.post(deployment.url, json=match.dict()) + response.raise_for_status() + action = Connect4Action(**response.json()) + break + except httpx.HTTPError as e: + print(f"Error: {e}") + retries += 1 + await asyncio.sleep(retries) + + # todo: log errors + if action is None: + print("ERROR: too many agent errors") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Too many agent errors.", + ) + + return action diff --git a/src/gameplay_computer/agents/tables.py b/src/gameplay_computer/agents/tables.py index bbb14f0..0521526 100644 --- a/src/gameplay_computer/agents/tables.py +++ b/src/gameplay_computer/agents/tables.py @@ -9,5 +9,35 @@ sqlalchemy.Column("game", game, nullable=False, index=True), sqlalchemy.Column("user_id", sqlalchemy.String, nullable=False, index=True), sqlalchemy.Column("agentname", sqlalchemy.String, nullable=False), + sqlalchemy.Column("created_at", sqlalchemy.DateTime(timezone=True), nullable=False), sqlalchemy.UniqueConstraint("game", "user_id", "agentname"), ) + +agent_deployment = sqlalchemy.Table( + "agent_deployment", + metadata, + sqlalchemy.Column( + "agent_id", + sqlalchemy.BigInteger, + sqlalchemy.ForeignKey("agents.id"), + primary_key=True, + ), + sqlalchemy.Column("url", sqlalchemy.String, nullable=False), + sqlalchemy.Column("healthy", sqlalchemy.Boolean, nullable=False), + sqlalchemy.Column("active", sqlalchemy.Boolean, nullable=False), +) + +agent_history = sqlalchemy.Table( + "agent_history", + metadata, + sqlalchemy.Column( + "agent_id", + sqlalchemy.BigInteger, + sqlalchemy.ForeignKey("agents.id"), + primary_key=True, + ), + sqlalchemy.Column("wins", sqlalchemy.Integer, nullable=False), + sqlalchemy.Column("losses", sqlalchemy.Integer, nullable=False), + sqlalchemy.Column("draws", sqlalchemy.Integer, nullable=False), + sqlalchemy.Column("errors", sqlalchemy.Integer, nullable=False), +) diff --git a/src/gameplay_computer/common/__init__.py b/src/gameplay_computer/common/__init__.py index 3caf867..3351b0a 100644 --- a/src/gameplay_computer/common/__init__.py +++ b/src/gameplay_computer/common/__init__.py @@ -1,10 +1,17 @@ from . import tables -from .schemas import Game, BasePlayer, BaseAction, BaseState +from .schemas import ALogic +from .service import ( + serialize_state, + serialize_action, + deserialize_state, + deserialize_action, +) __all__ = [ "tables", - "Game", - "BasePlayer", - "BaseAction", - "BaseState", + "ALogic", + "serialize_state", + "serialize_action", + "deserialize_state", + "deserialize_action", ] diff --git a/src/gameplay_computer/common/schemas.py b/src/gameplay_computer/common/schemas.py index 4524bb7..6ca5d04 100644 --- a/src/gameplay_computer/common/schemas.py +++ b/src/gameplay_computer/common/schemas.py @@ -1,55 +1,24 @@ import abc -from typing import Any, Generic, Literal, Self, TypeVar +from typing import Generic, TypeVar -from pydantic import BaseModel, Json +from gameplay_computer.gameplay import BaseAction, BaseState -Game = Literal["connect4"] +A = TypeVar("A", bound=BaseAction) +S = TypeVar("S", bound=BaseState) -T = TypeVar("T", bound=Json[Any]) - - -class BaseAction(BaseModel, abc.ABC, Generic[T]): - game: Game - - @classmethod - @abc.abstractmethod - def deserialize(cls, t: T) -> Self: - ... +class ALogic(abc.ABC, Generic[A, S]): + @staticmethod @abc.abstractmethod - def serialize(self) -> T: + def initial_state() -> S: ... - -A = TypeVar("A", bound=BaseAction[Json[Any]]) -S = TypeVar("S", bound=Json[Any]) - - -class BaseState(BaseModel, abc.ABC, Generic[A, S]): - game: Game - over: bool - winner: int | None - next_player: int | None - - @classmethod + @staticmethod @abc.abstractmethod - def deserialize( - cls, over: bool, winner: int | None, next_player: int | None, s: S - ) -> Self: + def actions(s: S) -> list[A]: ... + @staticmethod @abc.abstractmethod - def serialize(self) -> S: + def turn(s: S, player: int, action: A) -> None: ... - - @abc.abstractmethod - def actions(self) -> list[A]: - ... - - @abc.abstractmethod - def turn(self, player: int, action: T) -> None: - ... - - -class BasePlayer(BaseModel): - kind: Literal["user", "agent"] diff --git a/src/gameplay_computer/common/service.py b/src/gameplay_computer/common/service.py new file mode 100644 index 0000000..f7cf4a6 --- /dev/null +++ b/src/gameplay_computer/common/service.py @@ -0,0 +1,55 @@ +from pydantic import Json +from typing import Any, assert_never + +from gameplay_computer.gameplay import ( + Action, + State, + Game, + Connect4State, + Connect4Action, +) + + +def serialize_action(action: Action) -> Json[Any]: + match action.game: + case "connect4": + assert isinstance(action, Connect4Action) + return action.column + case _game as unreachable: + assert_never(unreachable) + + +def deserialize_action(game: Game, json: Json[Any]) -> Action: + match game: + case "connect4": + assert isinstance(json, int) + assert json in range(0, 7) + return Connect4Action(column=json) + case _game as unreachable: + assert_never(unreachable) + + +def serialize_state(state: State) -> Json[Any]: + match state.game: + case "connect4": + assert isinstance(state, Connect4State) + return state.board + case _game as unreachable: + assert_never(unreachable) + + +def deserialize_state( + game: Game, over: bool, winner: int | None, next_player: int | None, json: Json[Any] +) -> State: + match game: + case "connect4": + assert isinstance(json, list) + assert len(json) == 7 + assert all(isinstance(col, list) for col in json) + assert all(len(col) == 6 for col in json) + assert all(isinstance(space, str) for col in json for space in col) + return Connect4State( + over=over, winner=winner, next_player=next_player, board=json + ) + case _game as unreachable: + assert_never(unreachable) diff --git a/src/gameplay_computer/gameplay.py b/src/gameplay_computer/gameplay.py new file mode 100644 index 0000000..3b288fb --- /dev/null +++ b/src/gameplay_computer/gameplay.py @@ -0,0 +1,71 @@ +from pydantic import BaseModel, Field +from typing import Literal, Annotated, Union +from enum import StrEnum + +Game = Literal["connect4"] + + +class User(BaseModel): + kind: Literal["user"] = "user" + username: str + + +class Agent(BaseModel): + kind: Literal["agent"] = "agent" + game: Game + username: str + agentname: str + + +Player = Annotated[Union[User, Agent], Field(discrminator="kind")] + + +class BaseAction(BaseModel): + game: Game + + +class BaseState(BaseModel): + game: Game + over: bool + winner: int | None + next_player: int | None + + +# Connect4 +class Connect4Space(StrEnum): + EMPTY = " " + BLUE = "B" + RED = "R" + + +Connect4Board = list[list[Connect4Space]] + + +class Connect4Action(BaseAction): + game: Literal["connect4"] = "connect4" + column: int + + +class Connect4State(BaseState): + game: Literal["connect4"] = "connect4" + board: Connect4Board + + +Action = Annotated[Union[Connect4Action], Field(discrminator="game")] +State = Annotated[Union[Connect4State], Field(discrminator="game")] + + +class Turn(BaseModel): + number: int + player: int | None + action: Action | None + next_player: int | None + + +class Match(BaseModel): + id: int + game: Game + players: list[Player] + turns: list[Turn] + turn: int + state: State diff --git a/src/gameplay_computer/games/__init__.py b/src/gameplay_computer/games/__init__.py index e69de29..a45c478 100644 --- a/src/gameplay_computer/games/__init__.py +++ b/src/gameplay_computer/games/__init__.py @@ -0,0 +1,3 @@ +from .connect4 import Connect4Logic + +__all__ = ["Connect4Logic"] diff --git a/src/gameplay_computer/games/connect4.py b/src/gameplay_computer/games/connect4.py index eda1eae..c939ca1 100644 --- a/src/gameplay_computer/games/connect4.py +++ b/src/gameplay_computer/games/connect4.py @@ -1,64 +1,37 @@ -from enum import IntEnum, StrEnum -from typing import Literal, Self, assert_never +from typing import Literal, assert_never -from pydantic import Json +from gameplay_computer.common import ALogic +from gameplay_computer.gameplay import ( + Connect4Action as Action, + Connect4Board as Board, + Connect4State as State, + Connect4Space as Space, +) -from gameplay_computer.common import BaseAction, BaseState - -class Player(IntEnum): - BLUE = 0 - RED = 1 - - -class Space(StrEnum): - EMPTY = " " - BLUE = "B" - RED = "R" - - -def get_player(space: Space) -> Player: +def get_player(space: Space) -> int: match space: case Space.BLUE: - return Player.BLUE + return 0 case Space.RED: - return Player.RED + return 1 case Space.EMPTY: assert None case _space as unreachable: assert_never(unreachable) -def get_space(player: Player) -> Space: +def get_space(player: int) -> Space: match player: - case Player.BLUE: + case 0: return Space.BLUE - case Player.RED: + case 1: return Space.RED case _player as unreachable: - assert_never(unreachable) - - -Result = Player | Literal["draw"] | None - + assert False, unreachable -class Action(BaseAction[Json[int]]): - game: Literal["connect4"] = "connect4" - column: int - @classmethod - def deserialize(cls, t: Json[int]) -> Self: - return cls(column=t) - - def serialize(self) -> Json[int]: - return self.column - - -Board = list[list[Space]] - - -def initial_state() -> Board: - return list([Space.EMPTY] * 6 for _ in range(7)) +Result = int | Literal["draw"] | None def check(board: Board) -> Result: @@ -120,55 +93,40 @@ def check(board: Board) -> Result: return "draw" -class State(BaseState[Action, Json[Board]]): - game: Literal["connect4"] = "connect4" - over: bool = False - winner: Player | None = None - next_player: Player | None = Player.BLUE - - board: Board = initial_state() - - @classmethod - def deserialize( - cls, - over: bool, - winner: int | None, - next_player: int | None, - json: Json[Board], - ) -> Self: - return cls(over=over, winner=winner, next_player=next_player, board=json) - - def serialize(self) -> Json[Board]: - return self.board +class Connect4Logic(ALogic[Action, State]): + @staticmethod + def initial_state() -> State: + board = list([Space.EMPTY] * 6 for _ in range(7)) + return State(over=False, winner=None, next_player=0, board=board) - def actions(self) -> list[Action]: - return [Action(column=i) for i in range(7) if self.board[i][5] == Space.EMPTY] + @staticmethod + def actions(s: State) -> list[Action]: + return [Action(column=i) for i in range(7) if s.board[i][5] == Space.EMPTY] - def turn(self, player: int, action: Action) -> None: - assert self.next_player == Player(player) - assert self.board[action.column][5] == Space.EMPTY + @staticmethod + def turn(s: State, player: int, action: Action) -> None: + assert s.next_player == player + assert s.board[action.column][5] == Space.EMPTY for i in range(6): - if self.board[action.column][i] == Space.EMPTY: - self.board[action.column][i] = get_space(Player(player)) + if s.board[action.column][i] == Space.EMPTY: + s.board[action.column][i] = get_space(player) break - result = check(self.board) + result = check(s.board) match result: - case (Player.BLUE | Player.RED) as player: - self.over = True - self.winner = player - self.next_player = None + case (0 | 1) as player: + s.over = True + s.winner = player + s.next_player = None case "draw": - self.over = True - self.winner = None - self.next_player = None + s.over = True + s.winner = None + s.next_player = None case None: - self.over = False - self.winner = None - self.next_player = ( - Player.BLUE if self.next_player == Player.RED else Player.RED - ) + s.over = False + s.winner = None + s.next_player = 0 if s.next_player == 1 else 1 case _result as unknown: - assert_never(unknown) + assert False, unknown diff --git a/src/gameplay_computer/matches/__init__.py b/src/gameplay_computer/matches/__init__.py index 67deb48..4d578a2 100644 --- a/src/gameplay_computer/matches/__init__.py +++ b/src/gameplay_computer/matches/__init__.py @@ -1,4 +1,4 @@ -from .schemas import Action, Match, MatchSummary, State, Turn +from .schemas import MatchSummary from .service import ( create_match, get_match_by_id, @@ -7,11 +7,7 @@ ) __all__ = [ - "Action", - "Match", "MatchSummary", - "State", - "Turn", "create_match", "get_match_by_id", "list_match_summaries_for_user", diff --git a/src/gameplay_computer/matches/repo.py b/src/gameplay_computer/matches/repo.py index 52a90ff..4c50890 100644 --- a/src/gameplay_computer/matches/repo.py +++ b/src/gameplay_computer/matches/repo.py @@ -3,19 +3,20 @@ import sqlalchemy from databases import Database -from gameplay_computer import users, agents -from gameplay_computer.users import User -from gameplay_computer.agents import Agent -from gameplay_computer.games.connect4 import Action as Connect4Action -from gameplay_computer.games.connect4 import State as Connect4State -from .schemas import ( - Action, - Match, - MatchSummary, +from gameplay_computer import users, agents, common +from gameplay_computer.gameplay import ( + User, + Agent, Player, - State, + Match, Turn, + Connect4State, + Connect4Action, + State, + Action, ) +from .schemas import MatchSummary + from . import tables @@ -88,7 +89,7 @@ async def create_match( """, values={ "match_id": match_id, - "state": json.dumps(state.serialize()), + "state": json.dumps(common.serialize_state(state)), "next_player": state.next_player, }, ) @@ -144,8 +145,8 @@ async def create_match_turn( "match_id": match_id, "turn": turn_number, "player": player, - "action": json.dumps(action.serialize()), - "state": json.dumps(state.serialize()), + "action": json.dumps(common.serialize_action(action)), + "state": json.dumps(common.serialize_state(state)), "next_player": state.next_player if not state.over else None, }, ) @@ -338,7 +339,7 @@ async def get_match_by_id( Turn( number=turn_r["number"], player=turn_r["player"], - action=Connect4Action.deserialize(turn_r["action"]) + action=common.deserialize_action("connect4", turn_r["action"]) if turn_r["action"] is not None else None, next_player=turn_r["next_player"], @@ -347,7 +348,8 @@ async def get_match_by_id( for turn_r in turns_r ] - state = Connect4State.deserialize( + state = common.deserialize_state( + "connect4", latest_turn_r["next_player"] is None, match_r["winner"], latest_turn_r["next_player"], @@ -357,6 +359,8 @@ async def get_match_by_id( assert False, f"Unknown game: {game}" match = Match( + id=match_r["id"], + game=match_r["game"], status=match_r["status"], created_by=created_by, created_at=match_r["created_at"], diff --git a/src/gameplay_computer/matches/schemas.py b/src/gameplay_computer/matches/schemas.py index 9f10e01..85ba4a9 100644 --- a/src/gameplay_computer/matches/schemas.py +++ b/src/gameplay_computer/matches/schemas.py @@ -1,41 +1,9 @@ from datetime import datetime -from typing import Annotated, Literal, Union +from typing import Literal -# from fastapi import Form -from pydantic import BaseModel, Field -from gameplay_computer.agents import Agent -from gameplay_computer.common import Game -from gameplay_computer.games.connect4 import Action as Connect4Action -from gameplay_computer.games.connect4 import State as Connect4State -from gameplay_computer.users import User - -Player = Annotated[Union[User, Agent], Field(discrminator="kind")] - -Action = Annotated[Union[Connect4Action], Field(discrminator="game")] -State = Annotated[Union[Connect4State], Field(discrminator="game")] - - -class Turn(BaseModel): - number: int - player: int | None - action: Action | None - next_player: int | None - created_at: datetime - - -class Match(BaseModel): - status: Literal["in_progress", "finished"] - created_by: User - created_at: datetime - finished_at: datetime | None - - players: list[Player] - turns: list[Turn] - - turn: int - updated_at: datetime - state: State +from pydantic import BaseModel +from gameplay_computer.gameplay import Game class MatchSummary(BaseModel): diff --git a/src/gameplay_computer/matches/service.py b/src/gameplay_computer/matches/service.py index d348b41..b811c43 100644 --- a/src/gameplay_computer/matches/service.py +++ b/src/gameplay_computer/matches/service.py @@ -3,12 +3,21 @@ from databases import Database from fastapi import HTTPException, status +from gameplay_computer.gameplay import ( + Game, + Player, + Match, + Action, + Connect4Action, + Connect4State, +) + from gameplay_computer import users, agents -from gameplay_computer.common import Game -from gameplay_computer.games.connect4 import State as Connect4State + +from gameplay_computer.games.connect4 import Connect4Logic from . import repo -from .schemas import Action, Match, MatchSummary, Player +from .schemas import MatchSummary async def create_match( @@ -47,7 +56,7 @@ async def create_match( detail="You cannot create a match with users unless you are one" + "of the players.", ) - state = Connect4State() + state = Connect4Logic.initial_state() match_id = await repo.create_match( database, created_by_user_id, players, state ) @@ -136,7 +145,10 @@ async def take_action( status_code=status.HTTP_403_FORBIDDEN, detail="Unknown Agent", ) - if next_player.username != agent.username or next_player.agentname != agent.agentname: + if ( + next_player.username != agent.username + or next_player.agentname != agent.agentname + ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="It is not your turn.", @@ -150,8 +162,8 @@ async def take_action( detail="Invalid action for this game.", ) - assert action in match.state.actions() - match.state.turn(player, action) + assert action in Connect4Logic.actions(match.state) + Connect4Logic.turn(match.state, player, action) added = await repo.create_match_turn( database, diff --git a/src/gameplay_computer/users/__init__.py b/src/gameplay_computer/users/__init__.py index e437cf9..9972272 100644 --- a/src/gameplay_computer/users/__init__.py +++ b/src/gameplay_computer/users/__init__.py @@ -1,4 +1,4 @@ -from .schemas import User +from .schemas import FullUser from .repo import ( get_user_by_id, get_user_by_username, @@ -7,7 +7,7 @@ ) __all__ = [ - "User", + "FullUser", "get_user_by_id", "get_user_by_username", "get_user_id_for_username", diff --git a/src/gameplay_computer/users/repo.py b/src/gameplay_computer/users/repo.py index 3e20eea..48f5357 100644 --- a/src/gameplay_computer/users/repo.py +++ b/src/gameplay_computer/users/repo.py @@ -4,7 +4,8 @@ import httpx from pydantic import BaseModel -from .schemas import User +from gameplay_computer.gameplay import User +from .schemas import FullUser class ClerkEmailAddress(BaseModel): @@ -54,10 +55,10 @@ async def _list_clerk_users(force: bool = False) -> list[ClerkUser]: return _users_cache -async def list_users(force: bool = False) -> list[User]: +async def list_users(force: bool = False) -> list[FullUser]: clerk_users = await _list_clerk_users(force) return [ - User( + FullUser( username=clerk_user.username, first_name=clerk_user.first_name, last_name=clerk_user.last_name, @@ -73,9 +74,6 @@ async def get_user_by_id(user_id: str, force: bool = False) -> User | None: if clerk_user.id == user_id: return User( username=clerk_user.username, - first_name=clerk_user.first_name, - last_name=clerk_user.last_name, - profile_image_url=clerk_user.profile_image_url, ) if not force: return await get_user_by_id(user_id, force=True) @@ -88,9 +86,6 @@ async def get_user_by_username(username: str, force: bool = False) -> User | Non if clerk_user.username == username: return User( username=clerk_user.username, - first_name=clerk_user.first_name, - last_name=clerk_user.last_name, - profile_image_url=clerk_user.profile_image_url, ) if not force: return await get_user_by_username(username, force=True) diff --git a/src/gameplay_computer/users/schemas.py b/src/gameplay_computer/users/schemas.py index 88de1da..6bc6862 100644 --- a/src/gameplay_computer/users/schemas.py +++ b/src/gameplay_computer/users/schemas.py @@ -1,11 +1,9 @@ from typing import Literal -from gameplay_computer.common import BasePlayer +from gameplay_computer.gameplay import User -class User(BasePlayer): - kind: Literal["user"] = "user" - username: str +class FullUser(User): first_name: str | None last_name: str | None profile_image_url: str | None diff --git a/src/gameplay_computer/web/app.py b/src/gameplay_computer/web/app.py index 6b623b1..3fcace0 100644 --- a/src/gameplay_computer/web/app.py +++ b/src/gameplay_computer/web/app.py @@ -24,11 +24,11 @@ from jwcrypto import jwk, jwt # type: ignore from sse_starlette.sse import EventSourceResponse -from gameplay_computer.agents import Agent -from gameplay_computer.matches import Match -from gameplay_computer.users import User from . import schemas, service +from gameplay_computer import users +from gameplay_computer.gameplay import User, Agent, Player, Action, State, Turn, Match + def session_auth(key: jwk.JWK, request: Request) -> str | None: session = request.cookies.get("__session") @@ -320,6 +320,7 @@ async def create_match( match_id = await service.create_match(user_id, database, new_match) background_tasks.add_task(run_ai_turns, database, match_id) + # await run_ai_turns(database, match_id) match = await service.get_match(database, match_id) @@ -382,6 +383,7 @@ async def create_turn( match = await service.take_turn(database, match_id, turn, user_id=user_id) background_tasks.add_task(run_ai_turns, database, match_id) + # await run_ai_turns(database, match_id) return templates.TemplateResponse( "connect4_match.html", @@ -400,55 +402,56 @@ async def watch_match_changes(request: Request, match_id: int) -> Any: fn = listener.listen(match_id) return EventSourceResponse(fn()) - @app.post("/api/v1/matches") - async def api_create_match( - background_tasks: BackgroundTasks, - request: Request, - response: Response, - new_match: schemas.MatchCreate, - user_id: str | None = Depends(auth), - ) -> Match | None: - await service.get_user(user_id) - assert user_id is not None - - match_id = await service.create_match(user_id, database, new_match) - match = await service.get_match(database, match_id) - - background_tasks.add_task(run_ai_turns, database, match_id) - - return match - - @app.get("/api/v1/matches/{match_id}") - async def api_get_match( - request: Request, - _response: Response, - match_id: int, - user_id: str | None = Depends(auth), - ) -> Match | None: - await service.get_user(user_id) - assert user_id is not None - - match = await service.get_match(database, match_id) - - return match - - # returns the match - @app.post("/api/v1/matches/{match_id}/turns") - async def api_create_turn( - background_tasks: BackgroundTasks, - request: Request, - response: Response, - match_id: int, - turn: schemas.TurnCreate, - user_id: str | None = Depends(auth), - ) -> Match | None: - await service.get_user(user_id) - assert user_id is not None - - match = await service.take_turn(database, match_id, turn, user_id=user_id) - background_tasks.add_task(run_ai_turns, database, match_id) - - return match + # @app.post("/api/v1/matches") + # async def api_create_match( + # background_tasks: BackgroundTasks, + # request: Request, + # response: Response, + # new_match: schemas.MatchCreate, + # user_id: str | None = Depends(auth), + # ) -> Match | None: + # await service.get_user(user_id) + # assert user_id is not None + # + # match_id = await service.create_match(user_id, database, new_match) + # match = await service.get_match(database, match_id) + # + # # background_tasks.add_task(run_ai_turns, database, match_id) + # await run_ai_turns(database, match_id) + # + # return match + # + # @app.get("/api/v1/matches/{match_id}") + # async def api_get_match( + # request: Request, + # _response: Response, + # match_id: int, + # user_id: str | None = Depends(auth), + # ) -> Match | None: + # await service.get_user(user_id) + # assert user_id is not None + # + # match = await service.get_match(database, match_id) + # + # return match + # + # # returns the match + # @app.post("/api/v1/matches/{match_id}/turns") + # async def api_create_turn( + # background_tasks: BackgroundTasks, + # request: Request, + # response: Response, + # match_id: int, + # turn: schemas.TurnCreate, + # user_id: str | None = Depends(auth), + # ) -> Match | None: + # await service.get_user(user_id) + # assert user_id is not None + # + # match = await service.take_turn(database, match_id, turn, user_id=user_id) + # background_tasks.add_task(run_ai_turns, database, match_id) + # + # return match return app diff --git a/src/gameplay_computer/web/schemas.py b/src/gameplay_computer/web/schemas.py index 41b2c8b..24a15a5 100644 --- a/src/gameplay_computer/web/schemas.py +++ b/src/gameplay_computer/web/schemas.py @@ -3,9 +3,6 @@ from fastapi import Form from pydantic import BaseModel -from gameplay_computer.common import Game -from gameplay_computer.matches import Action, State, Match - class MatchCreate(BaseModel): game: Literal["connect4"] @@ -43,51 +40,3 @@ class TurnCreate(BaseModel): @classmethod def as_form(cls, player: int = Form(...), column: int = Form(...)) -> Self: return cls(player=player, column=column) - -# Schemas for what we post to the agents. -# These will probably go in an external library. -class PostPlayer(BaseModel): - kind: Literal["user", "agent"] - username: str - agentname: str | None - -class PostTurn(BaseModel): - number: int - player: int | None - action: Action | None - next_player: int | None - -class PostMatch(BaseModel): - id: int - game: Game - players: list[PostPlayer] - turns: list[PostTurn] - state: State - - @classmethod - def from_match(cls, match: Match, match_id: int) -> Self: - post_game = match.state.game - post_players = [ - PostPlayer( - kind=player.kind, - username=player.username, - agentname=player.agentname if player.kind == "agent" else None - ) - for player in match.players - ] - post_turns = [ - PostTurn( - number=turn.number, - player=turn.player, - action=turn.action, - next_player=turn.next_player, - ) for turn in match.turns - ] - post_match = cls( - id=match_id, - game=post_game, - players=post_players, - turns=post_turns, - state=match.state, - ) - return post_match diff --git a/src/gameplay_computer/web/service.py b/src/gameplay_computer/web/service.py index 8a15c9c..de4e909 100644 --- a/src/gameplay_computer/web/service.py +++ b/src/gameplay_computer/web/service.py @@ -1,4 +1,3 @@ -import random import asyncio from typing import assert_never @@ -6,14 +5,14 @@ from databases import Database +from gameplay_computer.gameplay import Agent, User, Match, Connect4State, Connect4Action +from gameplay_computer import gameplay from gameplay_computer import matches, users, agents -from gameplay_computer.agents import Agent -from gameplay_computer.users import User -from gameplay_computer.games.connect4 import Action as Connect4Action -from .schemas import MatchCreate, TurnCreate, PostTurn, PostMatch, PostPlayer +from .schemas import MatchCreate, TurnCreate import httpx + # Stubs to keep web working. async def get_users() -> list[User]: return await users.list_users() @@ -79,7 +78,7 @@ async def create_match( async def get_match( database: Database, match_id: int, turn: int | None = None -) -> matches.Match: +) -> Match: match = await matches.get_match_by_id(database, match_id, turn=turn) return match @@ -88,7 +87,7 @@ async def take_ai_turn( database: Database, client: httpx.AsyncClient, match_id: int, -) -> matches.Match: +) -> Match: match = await get_match(database, match_id) assert match.state.next_player is not None @@ -102,38 +101,56 @@ async def take_ai_turn( database, agent.username, agent.agentname ) assert agent_id is not None - agent_deployment = await agents.get_agent_deployment_by_id( - database, agent_id + + post_state = gameplay.Connect4State( + over=False, + winner=None, + next_player=match.state.next_player, + board=match.state.board, ) - agent_url = agent_deployment.url - - post_match = PostMatch.from_match(match, match_id) - - action: Connect4Action | None = None - - # some sorta retry logic - retries = 0 - while retries < 3: - try: - response = await client.post(agent_url, json=post_match.dict()) - response.raise_for_status() - action = Connect4Action(**response.json()) - assert isinstance(action, Connect4Action) - break - except httpx.HTTPError as e: - print(f"Error: {e}") - retries += 1 - await asyncio.sleep(retries) - - # todo: log error and somehow get errors to the user - # todo: some way to disqualify bad agents - if action is None: - print("ERROR: too many agent errors") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Too many agent errors.", + + post_players: list[gameplay.Player] = [] + + for player in match.players: + if player.kind == "user": + post_players.append( + gameplay.User( + username=player.username, + ) + ) + elif player.kind == "agent": + post_players.append( + gameplay.Agent( + game="connect4", + username=player.username, + agentname=player.agentname, + ) + ) + + post_turns = [] + + for turn in match.turns: + post_turns.append( + gameplay.Turn( + number=turn.number, + player=turn.player, + action=turn.action, + next_player=turn.next_player, + ) ) + gp_match = gameplay.Match( + id=match.id, + game=match.game, + players=post_players, + turns=post_turns, + turn=match.turn, + state=post_state, + ) + + gp_action = await agents.get_agent_action(database, client, agent_id, gp_match) + action = Connect4Action(column=gp_action.column) + next_match = await matches.take_action( database, match_id, @@ -150,7 +167,7 @@ async def take_turn( new_turn: TurnCreate, user_id: str | None = None, agent_id: int | None = None, -) -> matches.Match: +) -> Match: return await matches.take_action( database, match_id, diff --git a/src/gameplay_computer/web/templates/connect4_match.html b/src/gameplay_computer/web/templates/connect4_match.html index b6dd882..5040a55 100644 --- a/src/gameplay_computer/web/templates/connect4_match.html +++ b/src/gameplay_computer/web/templates/connect4_match.html @@ -8,7 +8,7 @@

Connect 4

- {% if match.status == "finished" %} + {% if match.state.over is true %} {% if match.state.winner == 0 %}

Blue Wins!

{% elif match.state.winner == 1 %} @@ -56,7 +56,7 @@

Waiting for Red's Turn

- {% if match.status != "finished" + {% if match.state.over is false and match.players[match.state.next_player].kind == "user" and match.players[match.state.next_player].username == username %}