Skip to content

Commit

Permalink
Merge pull request #28 from TINF21CS1/client_cleanup
Browse files Browse the repository at this point in the history
Client cleanup
  • Loading branch information
Petzys authored Mar 20, 2024
2 parents 69eda0b + 3772bf8 commit 358bea8
Show file tree
Hide file tree
Showing 6 changed files with 212 additions and 177 deletions.
262 changes: 139 additions & 123 deletions Client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,12 @@
from websockets.client import connect
import json
from Server.player import Player
from Server.websocket_server import Lobby
import logging
from jsonschema import validate, ValidationError
from threading import Thread
from uuid import UUID

# Set up logging
logging.basicConfig(
format='%(asctime)s %(levelname)-8s %(message)s',
level=logging.INFO,
datefmt='%Y-%m-%d %H:%M:%S')
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class GameClient:
Expand All @@ -24,36 +19,48 @@ class GameClient:
Attributes:
_ip (str): The IP address of the server.
_port (int): The port of the server.
_websocket (WebSocketClientProtocol): The websocket connection to the server.
_json_schema (dict): The JSON schema to validate incoming messages.
_player (Player): The player that is using the client.
_player_number (int): The number of the player in the game. 1 for the first player, 2 for the second player.
_symbol (str): The symbol of the player in the game. "X" for the first player, "O" for the second player.
_opponent (Player): The opponent of the player in the game.
_opponent_number (int): The number of the opponent in the game. 1 for the first player, 2 for the second player.
_current_player (Player): The player that is currently allowed to make a move.
_lobby_status (list[str]): The status of the lobby. Contains all players in the lobby.
_playfield (list[list[int]]): The status of the game. Contains the current playfield.
_statistics (dict[Player:dict[str:int]] ): The statistics of the game.
_chat_history (list[tuple[Player, str]]): The chat history of the game. Contains all messages sent in the game.
_winner (Player): The winner of the game. None if the game is not finished yet or it is a draw.
_error_history (list[str]): The error history of the game. Contains all errors that occurred for this client.
_json_schema (dict): The JSON schema that is used to validate incoming messages.
_websocket: The websocket connection to the server.
_player_number (int): The number of the player in the game. 1 or 2.
_symbol (str): The symbol of the player in the game. "X" or "O".
_kicked (bool): Whether the player has been kicked from the lobby.
_opponent (Player): The opponent of the player.
_opponent_number (int): The number of the opponent in the game. 1 or 2.
_starting_player (Player): The player that starts the game.
_current_player (Player): The player that is currently on turn.
_lobby_status (list[str]): The status of the lobby.
_playfield (list[list[int]]): The current playfield of the game.
_statistics (dict): The statistics of the game.
_chat_history (list[tuple[Player, str]]): The chat history of the game.
_winner (Player): The winner of the game.
_error_history (list[str]): The error history of the game.
Methods:
connect: Connect to the server. (This should not be called directly, use `create_game` or `join_game` instead.)
create_game: Create a new game with the given player.
connect: Connect to the server.
join_game: Join an existing game with the given player.
listen: Listen for messages from the server. (This should not be called directly, use `create_game` or `join_game` instead.)
get_player_by_uuid: Get a player by its UUID.
listen: Listen for messages from the server.
get_player_by_uuid: Get a player by its UUID.
_preprocess_message: Preprocess a message from the server.
_message_handler: Handle a message from the server.
join_lobby: Join the lobby of the server.
lobby_ready: Set the player ready in the lobby.
lobby_ready: Set the ready status of the player in the lobby.
lobby_kick: Kick a player from the lobby.
game_make_move: Make a move in the game.
chat_message: Send a chat message.
close: Close the connection to the server.
close: Close the websocket connection. Terminate the thread.
terminate: Terminate the game.
"""

def __init__(self, ip:str, port:int, player:Player) -> None:
"""Initialize the game client with the given IP, port and player.
Args:
ip (str): The IP address of the server.
port (int): The port of the server.
player (Player): The player that wants to join the game.
"""
self._ip: str = ip
self._port: int = port

Expand Down Expand Up @@ -81,6 +88,7 @@ def __init__(self, ip:str, port:int, player:Player) -> None:
self._json_schema = json.load(f)

async def connect(self):
"""Connect to the server."""
# Try 5 times to connect to the server
for i in range(5):
try:
Expand All @@ -90,52 +98,19 @@ async def connect(self):
logger.error(f"Could not connect to server. Attempt {i+1}/5. Retrying in 0.5 seconds...")
await asyncio.sleep(0.5)

@classmethod
async def create_game(cls, player: Player, port:int = 8765) -> tuple[GameClient, asyncio.Task, Thread]:
"""Start a new game with the given player. Therefore, create a new server thread and connect to it.
This function __has__ to be run in an asyncio event loop. Wrap it in an async function which you then call with `asyncio.run(my_wrapper_function())`. Reminder: `asyncio.run()` can only be called once in a program. Calling `asyncio.run(GameClient.create_game())` directly will not terminate since the server thread is not joined and the listening task is not awaited.
For an example see `my_example()` and `my_handler()`.
Args:
player (Player): The player that wants to start a new game.
port (int, optional): The port to start the server on. Defaults to 8765.
Returns:
GameClient: The game client that is connected to the server and can be used to send messages to the server. (methods are documented in the class itself)
asyncio.Task: The listening task that listens for messages from the server. It should be awaited at the end of the program.
Thread: The thread that runs the server. It should be joined at the end of the program.
"""

lobby = Lobby(port = port, admin = player)
server_thread = Thread(target=lobby.run, daemon=True)
server_thread.start()

client = cls("localhost", port, player)
await client.connect()
listening_task = asyncio.create_task(client.listen())
await asyncio.create_task(client.join_lobby())

return client, listening_task, server_thread

@classmethod
async def join_game(cls, player: Player, ip:str, port:int = 8765) -> tuple[GameClient, asyncio.Task]:
"""Join an existing game with the given player and message handler.
This function __has__ to be run in an asyncio event loop. Wrap it in an async function which you then call with `asyncio.run(my_wrapper_function())`. Reminder: `asyncio.run()` can only be called once in a program. Calling `asyncio.run(join_game())` directly will not terminate since the listening task is not awaited.
For an example see `my_example()` and `my_handler()`.
Args:
player (Player): The player that wants to join the game.
message_handler ([type]): The message handler that is called whenever a message is received from the server.
ip (str): The IP address of the server.
port (int): The port of the server.
port (int): The port of the server. Default is 8765.
Returns:
GameClient: The game client that is connected to the server and can be used to send messages to the server. (methods are documented in the class itself)
asyncio.Task: The listening task that listens for messages from the server. It should be awaited at the end of the program.
tuple[GameClient, asyncio.Task]: A tuple containing the game client and the listening task.
"""

client = cls(ip, port, player)
Expand All @@ -147,6 +122,7 @@ async def join_game(cls, player: Player, ip:str, port:int = 8765) -> tuple[GameC
return client, listening_task

async def listen(self):
"""Listen for messages from the server."""
async for message in self._websocket:
logger.info(f"Received: {message}")

Expand All @@ -166,75 +142,91 @@ async def listen(self):
break

def get_player_by_uuid(self, uuid:str) -> Player:
"""Get a player by its UUID.
Args:
uuid (str): The UUID of the player.
Returns:
Player: The player with the given UUID.
"""
for player in self._lobby_status:
if str(player.uuid) == uuid:
return player
return None

async def _preprocess_message(self, message:str) -> str:
message_json = json.loads(message)

try:
validate(instance=message_json, schema=self._json_schema)
except ValidationError as e:
logger.error(e)
raise ValidationError(e)

match message_json["message_type"]:
case "lobby/status":
self._lobby_status = [Player.from_dict(player_dict) for player_dict in message_json["players"]]
case "game/start":
if len(self._lobby_status) != 2:
logger.error("Game start message received, but lobby does not contain 2 players. This should not happen and should be investigated.")
raise ValidationError("Game start message received, but lobby does not contain 2 players. This should not happen and should be investigated.")


self._opponent = self._lobby_status[0] if self._lobby_status[0] != self._player else self._lobby_status[1]

if self._opponent == self._player:
logger.error("player and opponent are equal")


if str(self._player.uuid) == message_json["starting_player_uuid"]:
self._current_player = self._player
self._starting_player = self._player
self._symbol = "X"
self._player_number = 1
self._opponent_number = 2
else:
self._current_player = self._opponent
self._starting_player = self._opponent
self._symbol = "O"
self._opponent_number = 1
self._player_number = 2
case "game/end":
self._winner = self.get_player_by_uuid(message_json["winner_uuid"])
logger.info(f"Game ended. Winner: {self._winner.display_name if self._winner else 'Draw'}")
self._playfield = message_json["final_playfield"]
case "game/turn":
self._playfield = message_json["updated_playfield"]
self._current_player = self.get_player_by_uuid(message_json["next_player_uuid"])
case "statistics/statistics":
sorted_statistics = sorted(message_json["server_statistics"], key=lambda x: x["player"]["display_name"])
for entry in sorted_statistics:
self._statistics[Player(**entry["player"])] = entry["statistics"]
case "game/error":
self._error_history.append(message_json["error_message"])
logger.error(f"Game error: {message_json['error_message']}")
case "chat/receive":
sender = self.get_player_by_uuid(message_json["sender_uuid"])
self._chat_history.append((sender, message_json["message"]))
case "lobby/ping":
await self.join_lobby()
case "lobby/kick":
if str(self._player.uuid) == message_json["kick_player_uuid"]:
logger.info("You have been kicked from the lobby. Closing after processing the message...")
self._kicked = True
case _:
logger.error(f"Unknown message type: {message_json['message_type']}")
"""Preprocess a message from the server.
Args:
message (str): The message from the server.
Returns:
str: The type of the message.
"""
message_json = json.loads(message)

try:
validate(instance=message_json, schema=self._json_schema)
except ValidationError as e:
logger.error(e)
raise ValidationError(e)

match message_json["message_type"]:
case "lobby/status":
self._lobby_status = [Player.from_dict(player_dict) for player_dict in message_json["players"]]
case "game/start":
if len(self._lobby_status) != 2:
logger.error("Game start message received, but lobby does not contain 2 players. This should not happen and should be investigated.")
raise ValidationError("Game start message received, but lobby does not contain 2 players. This should not happen and should be investigated.")

return message_json["message_type"]


self._opponent = self._lobby_status[0] if self._lobby_status[0] != self._player else self._lobby_status[1]

if self._opponent == self._player:
logger.error("player and opponent are equal")


if str(self._player.uuid) == message_json["starting_player_uuid"]:
self._current_player = self._player
self._starting_player = self._player
self._symbol = "X"
self._player_number = 1
self._opponent_number = 2
else:
self._current_player = self._opponent
self._starting_player = self._opponent
self._symbol = "O"
self._opponent_number = 1
self._player_number = 2
case "game/end":
self._winner = self.get_player_by_uuid(message_json["winner_uuid"])
logger.info(f"Game ended. Winner: {self._winner.display_name if self._winner else 'Draw'}")
self._playfield = message_json["final_playfield"]
case "game/turn":
self._playfield = message_json["updated_playfield"]
self._current_player = self.get_player_by_uuid(message_json["next_player_uuid"])
case "statistics/statistics":
sorted_statistics = sorted(message_json["server_statistics"], key=lambda x: x["player"]["display_name"])
for entry in sorted_statistics:
self._statistics[Player(**entry["player"])] = entry["statistics"]
case "game/error":
self._error_history.append(message_json["error_message"])
logger.error(f"Game error: {message_json['error_message']}")
case "chat/receive":
sender = self.get_player_by_uuid(message_json["sender_uuid"])
self._chat_history.append((sender, message_json["message"]))
case "lobby/ping":
await self.join_lobby()
case "lobby/kick":
if str(self._player.uuid) == message_json["kick_player_uuid"]:
logger.info("You have been kicked from the lobby. Closing after processing the message...")
self._kicked = True
case _:
logger.error(f"Unknown message type: {message_json['message_type']}")
raise ValidationError("Game start message received, but lobby does not contain 2 players. This should not happen and should be investigated.")

return message_json["message_type"]

async def _message_handler(self, message_type:str):
"""Example handler for the game client. This function is called whenever a message is received from the server.
Expand Down Expand Up @@ -275,13 +267,19 @@ async def _message_handler(self, message_type:str):
return

async def join_lobby(self):
"""Join the lobby of the server."""
msg = {
"message_type": "lobby/join",
"profile": self._player.as_dict()
}
await self._websocket.send(json.dumps(msg))

async def lobby_ready(self, ready:bool = True):
"""Set the ready status of the player in the lobby.
Args:
ready (bool, optional): The ready status of the player. Defaults to True.
"""
msg = {
"message_type": "lobby/ready",
"player_uuid": str(self._player.uuid),
Expand All @@ -290,13 +288,24 @@ async def lobby_ready(self, ready:bool = True):
await self._websocket.send(json.dumps(msg))

async def lobby_kick(self, player_to_kick:UUID):
"""Kick a player from the lobby.
Args:
player_to_kick (UUID): The UUID of the player to kick.
"""
msg = {
"message_type": "lobby/kick",
"kick_player_uuid": str(player_to_kick)
}
await self._websocket.send(json.dumps(msg))

async def game_make_move(self, x:int, y:int):
"""Make a move in the game.
Args:
x (int): The x-coordinate of the move.
y (int): The y-coordinate of the move.
"""
msg = {
"message_type": "game/make_move",
"player_uuid": str(self._player.uuid),
Expand All @@ -308,6 +317,11 @@ async def game_make_move(self, x:int, y:int):
await self._websocket.send(json.dumps(msg))

async def chat_message(self, message:str):
"""Send a chat message.
Args:
message (str): The message to send.
"""
msg = {
"message_type": "chat/message",
"player_uuid": str(self._player.uuid),
Expand All @@ -316,10 +330,12 @@ async def chat_message(self, message:str):
await self._websocket.send(json.dumps(msg))

async def close(self):
"""Close the websocket connection. Terminate the thread."""
await self._websocket.close()
exit()

async def terminate(self):
"""Try to terminate the game."""
msg = {
"message_type": "server/terminate",
"player_uuid": str(self._player.uuid)
Expand Down
Loading

0 comments on commit 358bea8

Please sign in to comment.