From 5abd9cf01b6ceb3bf3b8cb1c142fa6428c5966c2 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 May 2023 20:54:00 +0200 Subject: [PATCH 01/15] feat(penguins): Add dataclass for Field and Board This commit adds dataclasses for the Field and Board classes in socha/api/plugin/penguins/board.py. The dataclasses allow us to have more concise and readable code while also taking advantage of immutability and hashability, which are important properties for the game logic. Additionally, the commit replaces some method names with more descriptive ones and removes unnecessary comments and type hints for constructor parameters. The changes include: Add @dataclass decorator to Field and Board classes Remove constructor from Field class and replace with a more concise dataclass syntax Replace board constructor parameter in Board class with board dataclass parameter Rename _is_destination_valid method to is_destination_valid Add Generator and Iterator type hints to method signatures when appropriate Change some variable names to be more consistent with PEP 8 conventions --- socha/api/plugin/penguins/board.py | 133 ++++++++++++++++++----------- 1 file changed, 81 insertions(+), 52 deletions(-) diff --git a/socha/api/plugin/penguins/board.py b/socha/api/plugin/penguins/board.py index 31c42b0..66857fb 100644 --- a/socha/api/plugin/penguins/board.py +++ b/socha/api/plugin/penguins/board.py @@ -1,32 +1,24 @@ import _pickle as pickle +import dataclasses import logging -import warnings +from dataclasses import dataclass from itertools import chain, takewhile from operator import attrgetter -from typing import List, Union, Optional +from typing import List, Union, Optional, Generator, Iterator from socha.api.plugin.penguins.coordinate import HexCoordinate, Vector, CartesianCoordinate from socha.api.plugin.penguins.team import Penguin, TeamEnum, Move +@dataclass(frozen=True, order=True, unsafe_hash=True) class Field: """ Represents a field in the game. """ - def __init__(self, coordinate: HexCoordinate, penguin: Optional[Penguin], fish: int): - """ - The Field represents a field on the game board. - It says what state itself it has and where it is on the board. - - Args: - coordinate: - penguin: - fish: - """ - self.coordinate = coordinate - self.penguin = penguin - self.fish = fish + coordinate: HexCoordinate + penguin: Optional[Penguin] + fish: int def is_empty(self) -> bool: """ @@ -86,27 +78,14 @@ def get_direction(self, destination: HexCoordinate) -> Vector: """ return destination.subtract_vector(self.coordinate.to_vector()).to_vector() - def __eq__(self, other): - if isinstance(other, self.__class__): - return self.coordinate == other.coordinate and self.penguin == other.penguin and self.fish == other.fish - return False - - def __repr__(self): - return f"Field({self.coordinate}, {self.penguin}, Fish({self.fish}))" - +@dataclass(frozen=True, order=True, unsafe_hash=True) class Board: """ Class which represents a game board. Consisting of a two-dimensional array of fields. """ - def __init__(self, board: List[List[Field]]): - """ - The Board shows the state where each field is, how many fish and which team is on each field. - - :param board: The game field as a two-dimensional array of fields. - """ - self.board = board + board: List[List[Field]] def get_empty_fields(self) -> List[Field]: """ @@ -128,8 +107,8 @@ def is_valid(self, coordinates: HexCoordinate) -> bool: :param coordinates: The coordinates of the field. :return: True if the field is valid, false otherwise. """ - arrayCoordinates = coordinates.to_cartesian() - return 0 <= arrayCoordinates.x < self.width() and 0 <= arrayCoordinates.y < self.height() + array_coordinates = coordinates.to_cartesian() + return 0 <= array_coordinates.x < self.width() and 0 <= array_coordinates.y < self.height() def width(self) -> int: """ @@ -206,7 +185,7 @@ def compare_to(self, other: 'Board') -> List[Field]: Compares two boards and returns a list of the Fields that are different. :param other: The other board to compare to. - :return: A list of Fields that are different or a empty list if the boards are equal. + :return: A list of Fields that are different or an empty list if the boards are equal. """ if not isinstance(other, Board): raise TypeError("Can only compare to another Board object") @@ -254,17 +233,30 @@ def get_moves_in_direction(self, origin: HexCoordinate, direction: Vector, team_ def valid_destination(i): destination = origin.add_vector(direction.scalar_product(i)) - return self._is_destination_valid(destination) + return self.is_destination_valid(destination) moves = [Move(team_enum=team_enum, from_value=origin, to_value=origin.add_vector(direction.scalar_product(i))) for i in takewhile(valid_destination, range(1, self.width()))] return moves - def _is_destination_valid(self, field: HexCoordinate) -> bool: + def is_destination_valid(self, field: HexCoordinate) -> bool: + """ + Checks if the given field is a valid destination for a move. + It checks if the destination is on the board, if it is not occupied and if it is not empty. + + Args: + field: The field to check for. + + Returns: + bool: True if the field is a valid destination, False otherwise. + """ return self.is_valid(field) and not self.is_occupied(field) and not \ self.get_field(field).is_empty() + def _is_destination_valid(self, field: HexCoordinate) -> bool: + return self.is_destination_valid(field) + def possible_moves_from(self, position: HexCoordinate, team_enum: Optional[TeamEnum] = None) -> List[Move]: """ Returns a list of all possible moves from the given position. That are all moves in all hexagonal directions. @@ -302,8 +294,8 @@ def get_teams_penguins(self, team: TeamEnum) -> List[Penguin]: :param team: The team_enum to search for. :return: A list of all coordinates that are occupied by a penguin of the given team_enum. """ - is_team_penguin = lambda field: field.penguin and field.penguin.team_enum == team - penguins = filter(is_team_penguin, (field for row in self.board for field in row)) + penguins = filter(lambda field: field.penguin and field.penguin.team_enum == team, + (field for row in self.board for field in row)) return list(map(attrgetter('penguin'), penguins)) def get_most_fish(self) -> List[Field]: @@ -334,10 +326,53 @@ def get_fields_intersection(self, other: List[Field]) -> List[Field]: """ return [field for field in self.get_all_fields() if field in other] - def _move(self, move: Move) -> 'Board': - warnings.warn("'_move' is deprecated and will be removed in a future version. Use 'move' instead.", - DeprecationWarning) - return self.move(move) + def get_neighbor_fields(self, field: Field) -> Iterator[Field]: + """ + Returns a generator of all neighbor fields of the given field. + + Args: + field: The field to get the neighbors of. + + Returns: + Generator[Field, None, None]: A generator of all neighbor fields of the given field. + """ + return (self.get_field(each) for each in field.coordinate.get_neighbors() if self.is_valid(each)) + + def get_neighbor_fields_coordinate(self, coordinate: HexCoordinate) -> Iterator[Field]: + """ + Returns a generator of all neighbor fields of the given coordinate. + + Args: + coordinate: The coordinate to get the neighbors of. + + Returns: + Generator[Field, None, None]: A generator of all neighbor fields of the given coordinate. + """ + return (self.get_field(each) for each in coordinate.get_neighbors() if self.is_valid(each)) + + def get_valid_neighbor_fields(self, field: Field) -> Iterator[Field]: + """ + Returns a generator of all neighbor fields of the given field. + + Args: + field: The field to get the neighbors of. + + Returns: + Generator[Field, None, None]: A generator of all neighbor fields of the given field. + """ + return (self.get_field(each) for each in field.coordinate.get_neighbors() if self.is_destination_valid(each)) + + def get_valid_neighbor_fields_coordinate(self, coordinate: HexCoordinate) -> Iterator[Field]: + """ + Returns a generator of all neighbor fields of the given coordinate. + + Args: + coordinate: The coordinate to get the neighbors of. + + Returns: + Generator[Field, None, None]: A generator of all neighbor fields of the given coordinate. + """ + return (self.get_field(each) for each in coordinate.get_neighbors() if self.is_destination_valid(each)) def move(self, move: Move) -> 'Board': """ @@ -349,7 +384,6 @@ def move(self, move: Move) -> 'Board': :return: The new board with the moved penguin. """ board_state = pickle.loads(pickle.dumps(self.board, protocol=-1)) - updated_board = Board(board_state) moving_penguin = Penguin(team_enum=move.team_enum, coordinate=move.to_value) if move.from_value: if not self.get_field(move.from_value).penguin: @@ -357,13 +391,13 @@ def move(self, move: Move) -> 'Board': return self origin_field_coordinate = move.from_value.to_cartesian() moving_penguin = board_state[origin_field_coordinate.y][origin_field_coordinate.x].penguin - moving_penguin.coordinate = move.to_value + moving_penguin = dataclasses.replace(moving_penguin, coordinate=move.to_value) board_state[origin_field_coordinate.y][origin_field_coordinate.x] = Field(coordinate=move.from_value, penguin=None, fish=0) - destination_field = updated_board.get_field(move.to_value) - destination_field.penguin = moving_penguin - destination_field.fish = 0 - return updated_board + + destination_field = Field(coordinate=move.to_value, penguin=moving_penguin, fish=0) + board_state[move.to_value.to_cartesian().y][move.to_value.to_cartesian().x] = destination_field + return Board(board_state) def pretty_print(self): print() @@ -376,8 +410,3 @@ def pretty_print(self): for field in row]) print(row_str) print() - - def __eq__(self, other): - if isinstance(other, self.__class__): - return self.board == other.board - return False From 8b9abcf7ff524d0dd21c87252b1d7385671ca087 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 May 2023 20:55:50 +0200 Subject: [PATCH 02/15] feat(plugin): add dataclasses to coordinate module Add dataclasses to the coordinate module to simplify the implementation of Vector, CartesianCoordinate, and HexCoordinate classes. The Vector class now uses the @dataclass decorator and its attributes are defined in the class header. The __init__, __repr__, and __eq__ methods were removed. The Coordinate, CartesianCoordinate, and HexCoordinate classes are similarly modified to use the @dataclass decorator and define their attributes in the class header. The __init__, __repr__, and __eq__ methods were also removed. This commit improves code readability and reduces code redundancy. --- socha/api/plugin/penguins/coordinate.py | 53 +++++-------------------- 1 file changed, 9 insertions(+), 44 deletions(-) diff --git a/socha/api/plugin/penguins/coordinate.py b/socha/api/plugin/penguins/coordinate.py index 1137db6..a0b5e58 100644 --- a/socha/api/plugin/penguins/coordinate.py +++ b/socha/api/plugin/penguins/coordinate.py @@ -1,21 +1,15 @@ import math +from dataclasses import dataclass from typing import List, Optional +@dataclass(frozen=True, order=True, unsafe_hash=True) class Vector: """ Represents a vector in the hexagonal grid. It can calculate various vector operations. """ - - def __init__(self, d_x: int = 0, d_y: int = 0): - """ - Constructor for the Vector class. - - :param d_x: The x-coordinate of the vector. - :param d_y: The y-coordinate of the vector. - """ - self.d_x = d_x - self.d_y = d_y + d_x: int = 0 + d_y: int = 0 def magnitude(self) -> float: """ @@ -120,30 +114,11 @@ def is_one_hex_move(self): """ return abs(self.d_x) == abs(self.d_y) or (self.d_x % 2 == 0 and self.d_y == 0) - def __repr__(self) -> str: - """ - Returns the string representation of the vector. - - :return: The string representation of the vector. - """ - return f"Vector({self.d_x}, {self.d_y})" - - def __eq__(self, other): - """ - Overrides the default equality operator to check if two Vector objects are equal. - - :param other: The other Vector object to compare to. - :return: True if the two Vector objects are equal, False otherwise. - """ - if isinstance(other, Vector): - return self.d_x == other.d_x and self.d_y == other.d_y - return False - +@dataclass(frozen=True, order=True, unsafe_hash=True) class Coordinate: - def __init__(self, x: int, y: int): - self.x = x - self.y = y + x: int + y: int def to_vector(self) -> Vector: """ @@ -165,6 +140,7 @@ def add_vector(self, vector: Vector): ... def subtract_vector(self, vector: Vector): ... +@dataclass(frozen=True, order=True, unsafe_hash=True) class CartesianCoordinate(Coordinate): """ Represents a coordinate in a normal cartesian coordinate system, that has been taught in school. @@ -229,13 +205,8 @@ def from_index(index: int, width: int, height: int) -> Optional['CartesianCoordi y = index // width return CartesianCoordinate(x, y) - def __repr__(self) -> str: - return f"CartesianCoordinate({self.x}, {self.y})" - - def __eq__(self, other: object) -> bool: - return isinstance(other, CartesianCoordinate) and self.x == other.x and self.y == other.y - +@dataclass(frozen=True, order=True, unsafe_hash=True) class HexCoordinate(Coordinate): """ Represents a coordinate in a hexagonal coordinate system, that differs from the normal cartesian one. @@ -277,9 +248,3 @@ def get_neighbors(self) -> List['HexCoordinate']: :return: The list of neighbors. """ return [self.add_vector(vector) for vector in self.to_vector().directions] - - def __repr__(self) -> str: - return f"HexCoordinate({self.x}, {self.y})" - - def __eq__(self, other: object) -> bool: - return isinstance(other, HexCoordinate) and self.x == other.x and self.y == other.y From 85defaa15e4728545589ca8e54ebbf7f43fdb7c7 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 May 2023 21:01:20 +0200 Subject: [PATCH 03/15] feat(GameClient): Add feature for headless running and administrator activities --- socha/api/networking/game_client.py | 151 +++++++++++++++++++++------- 1 file changed, 116 insertions(+), 35 deletions(-) diff --git a/socha/api/networking/game_client.py b/socha/api/networking/game_client.py index 4d92e6d..38f3fe7 100644 --- a/socha/api/networking/game_client.py +++ b/socha/api/networking/game_client.py @@ -1,9 +1,10 @@ """ -This module handels the communication with the api and the students logic. +This module handles the communication with the api and the students' logic. """ import gc import logging import sys +import threading import time from typing import List, Union @@ -12,7 +13,7 @@ from socha.api.plugin.penguins.game_state import GameState from socha.api.plugin.penguins.utils import handle_move, if_last_game_state, if_not_last_game_state from socha.api.protocol.protocol import State, Error, Join, Joined, JoinPrepared, JoinRoom, Room, Result, MoveRequest, \ - Left, Errorpacket + Left, Errorpacket, Authenticate, Prepared, Slot, Prepare, Observe, Cancel, Observed, Step, Pause from socha.api.protocol.protocol_packet import ProtocolPacket @@ -24,21 +25,21 @@ def calculate_move(self) -> Move: Calculates a move that the logic wants the server to perform in the game room. """ - def on_update(self, state: GameState): + def on_update(self, state: GameState) -> None: """ - If the server _send a update on the current state of the game this method is called. + If the server _send an update on the current state of the game this method is called. :param state: The current state that server sent. """ - def on_game_over(self, roomMessage: Result): + def on_game_over(self, roomMessage: Result) -> None: """ If the game has ended the server will _send a result message. - This method will called if this happens. + This method will call if this happens. :param roomMessage: The Result the server has sent. """ - def on_error(self, logMessage: str): + def on_error(self, logMessage: str) -> None: """ If error occurs, for instance when the logic sent a move that is not rule conform, @@ -48,52 +49,75 @@ def on_error(self, logMessage: str): :param logMessage: The message, that server sent. """ - def on_room_message(self, data): + def on_room_message(self, data) -> None: """ - If the server sends a message that cannot be handelt by anny other method, + If the server sends a message that cannot be handheld by anny other method, this will be called. :param data: The data the Server sent. """ - def on_game_prepared(self, message): + def on_game_prepared(self, message) -> None: """ If the game has been prepared by the server this method will be called. :param message: The message that server sends with the response. """ - def on_game_joined(self, room_id): + def on_game_joined(self, room_id) -> None: """ If the client has successfully joined a game room this method will be called. :param room_id: The room id the client has joined. """ - def on_game_observed(self, message): - """ - If the client successfully joined as observer this method will be called. - - :param message: The message that server sends with the response. - """ - - def on_game_left(self): + def on_game_left(self) -> None: """ If the server left the room, this method will be called. If the client is running on survive mode it'll be running until shut downed manually. """ - def while_disconnected(self, player_client: 'GameClient'): + def while_disconnected(self, player_client: 'GameClient') -> None: """ The client loop will keep calling this method while there is no active connection to a game server. This can be used to do tasks after a game is finished and the server left. Please be aware, that the client has to be shut down manually if it is in survive mode. - The return statement is used to tell the client whether to exit or not. :type player_client: The player client in which the logic is integrated. :return: True if the client should shut down. False if the client should continue to run. """ + def on_create_game(self, game_client: 'GameClient') -> None: + """ + This method will be called if the client is in admin mode and the client has authenticated with the server. + The client can now create a game. + + :param game_client: The client that is in admin mode. + """ + + def on_prepared(self, game_client: 'GameClient', room_id: str, reservations: List[str]) -> None: + """ + This method will be called if the client is in admin mode and the client has created a game. + + :param game_client: The client that is in admin mode. + :param room_id: The room id of the game. + :param reservations: The reservations of the game. + """ + + def on_observed(self, game_client: 'GameClient', room_id: str): + """ + This method will be called if the client is in admin mode and the client is observing a game. + + :param game_client: The client that is in admin mode. + :param room_id: The room id of the game. + """ + + def while_waiting(self) -> None: + """ + This method will be called while the client is waiting for the server to send a new message. + This method is running threaded, which will be terminated without warning if the client receives a new message. + """ + class GameClient(XMLProtocolInterface): """ @@ -101,25 +125,55 @@ class GameClient(XMLProtocolInterface): """ def __init__(self, host: str, port: int, handler: IClientHandler, reservation: str, - room_id: str, auto_reconnect: bool, survive: bool): + room_id: str, password: str, auto_reconnect: bool, survive: bool, headless: bool): super().__init__(host, port) self._game_handler = handler self.reservation = reservation self.room_id = room_id + self.password = password self.auto_reconnect = auto_reconnect self.survive = survive + self.headless = headless def join_game(self): - self._send(Join()) + logging.info("Joining game") + self.send(Join()) def join_game_room(self, room_id: str): - self._send(JoinRoom(room_id=room_id)) + logging.info(f"Joining game room '{room_id}'") + self.send(JoinRoom(room_id=room_id)) def join_game_with_reservation(self, reservation: str): - self._send(JoinPrepared(reservation_code=reservation)) + logging.info(f"Joining game with reservation '{reservation}'") + self.send(JoinPrepared(reservation_code=reservation)) + + def authenticate(self, password: str): + logging.info(f"Authenticating with password '{password}'") + self.send(Authenticate(password=password)) + + def create_game(self, player_1: Slot, player_2: Slot, game_type: str, pause: bool): + logging.info(f"Creating game with {player_1}, {player_2} and game type '{game_type}'") + self.send(Prepare(game_type=game_type, pause=pause, slot=[player_1, player_2])) + + def observe(self, room_id: str): + logging.info(f"Observing game room '{room_id}'") + self.send(Observe(room_id=room_id)) + + def cancel(self, room_id: str): + logging.info(f"Cancelling game room '{room_id}'") + self.send(Cancel(room_id=room_id)) + + def step(self, room_id: str): + logging.info(f"Stepping game room '{room_id}'") + self.send(Step(room_id=room_id)) + + def pause(self, room_id: str, pause: bool): + logging.info(f"Set pause of game room '{room_id}' to '{pause}'") + self.send(Pause(room_id=room_id, pause=pause)) def send_message_to_room(self, room_id: str, message): - self._send(Room(room_id=room_id, data=message)) + logging.info(f"Sending message to room '{room_id}'") + self.send(Room(room_id=room_id, data=message)) def _on_object(self, message): """ @@ -135,21 +189,39 @@ def _on_object(self, message): logging.error(f"An error occurred while handling the request: {message}") self._game_handler.on_error(str(message)) self.stop() - else: + elif isinstance(message, Joined): + logging.info(f"Game joined received with room id '{message.room_id}'") + self._game_handler.on_game_joined(room_id=message.room_id) + elif isinstance(message, Left): + logging.info(f"Game left received with room id '{message.room_id}'") + self._game_handler.on_game_left() + elif isinstance(message, Prepared): + logging.info(f"Game prepared received with reservation '{message.reservation}'") + self._game_handler.on_prepared(game_client=self, room_id=message.room_id, reservations=message.reservation) + elif isinstance(message, Observed): + logging.info(f"Game observing received with room id '{message.room_id}'") + self._game_handler.on_observed(game_client=self, room_id=message.room_id) + elif isinstance(message, Room) and self.headless is False: room_id = message.room_id - if isinstance(message, Joined): - self._game_handler.on_game_joined(room_id=room_id) - elif isinstance(message, Left): - self._game_handler.on_game_left() - elif isinstance(message.data.class_binding, MoveRequest): + + if isinstance(message.data.class_binding, MoveRequest): + logging.info(f"Move request received for room id '{room_id}'") self._on_move_request(room_id) elif isinstance(message.data.class_binding, State): + logging.info(f"State received for room id '{room_id}'") self._on_state(message) elif isinstance(message.data.class_binding, Result): + logging.info(f"Result received for room id '{room_id}'") self._game_handler.history[-1].append(message.data.class_binding) self._game_handler.on_game_over(message.data.class_binding) - elif isinstance(message, Room): + else: + logging.info(f"Room message received for room id '{room_id}'") self._game_handler.on_room_message(message.data.class_binding) + else: + print(type(message)) + room_id = message.room_id + logging.info(f"Room message received for room id '{room_id}'") + self._game_handler.on_room_message(message) def _on_move_request(self, room_id): start_time = time.time() @@ -184,12 +256,16 @@ def start(self): def join(self) -> None: """ Tries to join a game with a connected server. It uses the reservation, or room id to join. - If their are not present it joins just without, as fallback. + If they are not present it joins just without, as fallback. """ if self.reservation: self.join_game_with_reservation(self.reservation) elif self.room_id: self.join_game_room(self.room_id) + elif self.password: + self.authenticate(self.password) + self.first_time = False + self._game_handler.on_create_game(game_client=self) else: self.join_game() @@ -198,7 +274,7 @@ def join(self) -> None: def _handle_left(self): self.first_time = True - self.disconnect() + self.network_interface.close() if self.survive: logging.info("The server left. Client is in survive mode and keeps running.\n" "Please shutdown the client manually.") @@ -227,6 +303,7 @@ def _client_loop(self): The client loop is the main loop, where the client waits for messages from the server and handles them accordingly. """ + while_waiting = None while self.running: if self.network_interface.connected: response = self._receive() @@ -234,11 +311,15 @@ def _client_loop(self): continue elif isinstance(response, ProtocolPacket): logging.debug(f"Received new object: {response}") + if while_waiting: + while_waiting.join(timeout=0.0) if isinstance(response, Left): self._game_handler.on_game_left() self._handle_left() else: self._on_object(response) + while_waiting = threading.Thread(target=self._game_handler.while_waiting) + while_waiting.start() gc.collect() elif self.running: logging.error(f"Received a object of unknown class: {response}") From 9f1b0bf0010a5e544e37e7ffb33465d8fb1fd6f4 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 May 2023 21:03:06 +0200 Subject: [PATCH 04/15] feat: Add dataclass for GameState --- socha/api/plugin/penguins/game_state.py | 54 +++++++++---------------- 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/socha/api/plugin/penguins/game_state.py b/socha/api/plugin/penguins/game_state.py index 1f9edf9..5461c81 100644 --- a/socha/api/plugin/penguins/game_state.py +++ b/socha/api/plugin/penguins/game_state.py @@ -1,12 +1,14 @@ import _pickle as pickle import logging +from dataclasses import dataclass from typing import List, Optional from socha.api.plugin.penguins.board import Board from socha.api.plugin.penguins.coordinate import CartesianCoordinate -from socha.api.plugin.penguins.team import TeamEnum, Team, Move +from socha.api.plugin.penguins.team import TeamEnum, Team, Move, Penguin +@dataclass(frozen=True, order=True, unsafe_hash=True) class GameState: """ A `GameState` contains all information, that describes the game state at a given time, that is, between two game @@ -30,23 +32,15 @@ class GameState: describing the then current state. """ - def __init__(self, board: Board, turn: int, first_team: Team, second_team: Team, last_move: Optional[Move]): - """ - Creates a new `GameState` with the given parameters. + board: Board + turn: int + first_team: Team + second_team: Team + last_move: Optional[Move] - Args: - board: The board of the game. - turn: The turn number of the game. - first_team: The team_enum that has the first turn. - second_team: The team_enum that has the second turn. - last_move: The last move made. - """ - self.board = board - self.turn = turn - self.first_team = first_team - self.second_team = second_team - self.last_move = last_move - self.possible_moves = self._get_possible_moves(self.current_team) + @property + def possible_moves(self): + return self._get_possible_moves(self.current_team) @property def round(self): @@ -76,7 +70,7 @@ def _get_possible_moves(self, current_team: Optional[Team]) -> List[Move]: if not current_team: return [] - if len(self.board.get_teams_penguins(current_team.name)) < 4: + if self.turn < 8: moves = [(x, y) for x in range(self.board.width()) for y in range(self.board.height())] moves = filter(lambda pos: not self.board.get_field( CartesianCoordinate(*pos).to_hex()).is_occupied() and self.board.get_field( @@ -118,9 +112,9 @@ def perform_move(self, move: Move) -> 'GameState': new_first_team: Team = pickle.loads(pickle.dumps(self.first_team, protocol=-1)) new_second_team: Team = pickle.loads(pickle.dumps(self.second_team, protocol=-1)) if self.current_team.name == TeamEnum.ONE: - self._update_team(new_first_team, move, new_board) + new_first_team = self._update_team(new_first_team, move, new_board) else: - self._update_team(new_second_team, move, new_board) + new_second_team = self._update_team(new_second_team, move, new_board) new_first_team.opponent = new_second_team new_second_team.opponent = new_first_team new_turn = self.turn + 1 @@ -131,7 +125,7 @@ def perform_move(self, move: Move) -> 'GameState': logging.error(f"Performed invalid move while simulating: {move}") raise ValueError(f"Invalid move attempted: {move}") - def _update_team(self, team: Team, move: Move, new_board: Board) -> None: + def _update_team(self, team: Team, move: Move, new_board: Board) -> Team: """ Helper function to update the given team when a move is performed. @@ -140,15 +134,11 @@ def _update_team(self, team: Team, move: Move, new_board: Board) -> None: move: The move that was performed. new_board: The updated board. """ - team.moves.append(move) - adding_fish = self.board.get_field(move.to_value).get_fish() - new_penguin = new_board.get_field(move.to_value).penguin - teams_penguin = next(filter(lambda x: x.coordinate == move.from_value, team.penguins), None) - if teams_penguin: - teams_penguin.coordinate = new_penguin.coordinate - else: - team.penguins.append(new_penguin) - team.fish += adding_fish + new_moves: List[Move] = team.moves + [move] + new_fish: int = team.fish + self.board.get_field(move.to_value).get_fish() + new_penguins: List[Penguin] = list(filter(lambda x: x.coordinate != move.from_value, team.penguins)) + [ + new_board.get_field(move.to_value).penguin] + return Team(name=team.name, fish=new_fish, penguins=new_penguins, moves=new_moves, opponent=team.opponent) def is_valid_move(self, move: Move) -> bool: """ @@ -168,7 +158,3 @@ def opponent(self, team: Optional[Team] = None) -> Team: """ team = team or self.current_team return team.opponent - - def __repr__(self): - return f"GameState(turn={self.turn}, round={self.round}, first_team={self.first_team}, " \ - f"second_team={self.second_team}, last_move={self.last_move}, current_team={self.current_team})" From d897ce4305a99b772293c63a882630432a0a4bb6 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 May 2023 21:04:15 +0200 Subject: [PATCH 05/15] refactor: typos and add "prepared" to regex --- socha/api/networking/network_socket.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/socha/api/networking/network_socket.py b/socha/api/networking/network_socket.py index dd79834..4e6504f 100644 --- a/socha/api/networking/network_socket.py +++ b/socha/api/networking/network_socket.py @@ -18,7 +18,7 @@ def __init__(self, host="localhost", port=13050, timeout=0.1): host (str): The hostname or IP address of the server to connect to. Defaults to "localhost". port (int): The port number to connect to on the server. Defaults to 13050. timeout (float): The timeout for socket operations, in seconds. Defaults to 0.1. - connected (bool): Whether or not the socket is currently connected to the server. + connected (bool): Whether the socket is currently connected to the server. socket (socket.socket): The underlying socket object. buffer (bytes): A buffer for storing received data. """ @@ -60,7 +60,8 @@ def receive(self) -> Union[bytes, None]: If a timeout occurs or a connection reset error is encountered, the socket is closed and None is returned. """ - regex = re.compile(br"<((room[\s\S]+?)|errorpacket[\s\S]+?|.*?/>)") + regex = re.compile( + br"<((room[\s\S]+?)|errorpacket[\s\S]+?|prepared[\s\S]+?|.*?/>)") while True: try: chunk = self.socket.recv(16129) From 28f91a1630fd3b36c4bb2be2dca3367b88b1c665 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 May 2023 21:04:56 +0200 Subject: [PATCH 06/15] feat: add several dataclasses for the xml protocol to be able to administer --- socha/api/protocol/protocol.py | 64 +++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/socha/api/protocol/protocol.py b/socha/api/protocol/protocol.py index d15a7a3..6ce2406 100644 --- a/socha/api/protocol/protocol.py +++ b/socha/api/protocol/protocol.py @@ -51,7 +51,7 @@ class Meta: @dataclass class Left(ProtocolPacket): """ - If the game is over the server will _send this message to the clients and closes the connection afterwards. + If the game is over the server will _send this message to the clients and closes the connection afterward. """ class Meta: @@ -123,7 +123,7 @@ class Meta: @dataclass -class JoinedGameRoom(ResponsePacket): +class JoinedGameRoom(ObservableRoomMessage): """ Sent to all administrative clients after a player joined a GameRoom via a JoinRoomRequest. """ @@ -190,7 +190,7 @@ class Meta: @dataclass -class Slot(ProtocolPacket): +class Slot(RoomOrchestrationMessage): """ Slots for a game which contains the player's name and its attributes. """ @@ -221,7 +221,7 @@ class Meta: @dataclass -class Step(AdminLobbyRequest): +class Step(RoomOrchestrationMessage): """ When the client is authenticated as administrator, it can _send this step request to the server to advance the game for one move. @@ -241,7 +241,7 @@ class Meta: @dataclass -class Prepare(AdminLobbyRequest): +class Prepare(RoomOrchestrationMessage): """ When the client is authenticated as administrator, it can _send this request to prepare the room for the game. @@ -522,7 +522,7 @@ class Meta: class Board: """ The protocol representation of a board. - It contains a list of list of fields, which size is 7x7. + It contains a list of fields, which size is 7x7. """ class Meta: @@ -675,7 +675,7 @@ class Meta: class OriginalMessage: """ The original message that was sent by the client. - Is sent by the server if a error occurs. + Is sent by the server if an error occurs. """ class Meta: @@ -830,12 +830,46 @@ class Result(ObservableRoomMessage): @dataclass class Error: """ - This sends the server when the client sent a erroneous message. + This sends the server when the client sent an erroneous message. """ message: str originalMessage: OriginalMessage +@dataclass +class Prepared(RoomOrchestrationMessage): + class Meta: + name = "prepared" + + room_id: Optional[str] = field( + default=None, + metadata={ + "name": "roomId", + "type": "Attribute", + } + ) + reservation: List[str] = field( + default_factory=list, + metadata={ + "type": "Element", + } + ) + + +@dataclass +class Observed(RoomOrchestrationMessage): + class Meta: + name = "observed" + + room_id: Optional[str] = field( + default=None, + metadata={ + "name": "roomId", + "type": "Attribute", + } + ) + + @dataclass class Protocol: """ @@ -909,3 +943,17 @@ class Meta: } ) + + prepared: Optional[Prepared] = field( + default=None, + metadata={ + "type": "Element", + } + ) + + observed: Optional[Observed] = field( + default=None, + metadata={ + "type": "Element", + } + ) From 816f273cd8eefdc86e38bf481ea080eb0a3dc2aa Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 May 2023 21:05:15 +0200 Subject: [PATCH 07/15] feat: add super class --- socha/api/protocol/room_message.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/socha/api/protocol/room_message.py b/socha/api/protocol/room_message.py index b089764..ff149ad 100644 --- a/socha/api/protocol/room_message.py +++ b/socha/api/protocol/room_message.py @@ -1,4 +1,7 @@ -class RoomMessage: +from socha.api.protocol.protocol_packet import ProtocolPacket + + +class RoomMessage(ProtocolPacket): """ For all communication within a GameRoom. """ From 38636b7283fa394a8de788c52244c5ac0a680f50 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 May 2023 21:05:59 +0200 Subject: [PATCH 08/15] feat: add configurations to Starter for headless and administrator mode --- socha/starter.py | 53 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/socha/starter.py b/socha/starter.py index ba1080f..7b22e2e 100644 --- a/socha/starter.py +++ b/socha/starter.py @@ -3,11 +3,11 @@ """ import argparse import datetime +import json import logging +import urllib.request import pkg_resources -import urllib.request -import json from socha.api.networking.game_client import GameClient, IClientHandler from socha.utils.package_builder import SochaPackageBuilder @@ -21,20 +21,25 @@ class Starter: """ def __init__(self, logic: IClientHandler, host: str = "localhost", port: int = 13050, reservation: str = None, - room_id: str = None, survive: bool = False, auto_reconnect: bool = False, - log: bool = False, verbose: bool = False, build: str = None): + room_id: str = None, password: str = None, survive: bool = False, auto_reconnect: bool = False, + headless: bool = False, log: bool = False, verbose: bool = False, build: str = None): """ All these arguments can be overwritten, when parsed via start arguments, or you initialize this class with the desired values. - :param logic: Your logic the client will call, if moves are requested. - :param host: The host that the client should connect to. - :param port: The port of the host. - :param reservation: Reservation code for a prepared game. - :param room_id: Room Id the client will try to connect. - :param survive: If True the client will keep running, even if the connection to the server is terminated. - :param log: If True the client will write a log file to the current directory. - :param verbose: Verbose option for logging. + Args: + logic: Your logic the client will call, if moves are requested. + host: The host that the client should connect to. + port: The port of the host. + reservation: Reservation code for a prepared game. + room_id: Room ID the client will try to connect. + password: Password for the server for authentication as admin. + survive: If True the client keep running, even if the connection to the server is terminated. + auto_reconnect: If True the client will try to reconnect to the server, if the connection is lost. + headless: If True the client will not use the penguin plugin. + log: If True the client write a log file to the current directory. + verbose: Verbose option for logging. + build: If set, the client will build a zip package with the given name. """ args = self._handle_start_args() @@ -54,11 +59,18 @@ def __init__(self, logic: IClientHandler, host: str = "localhost", port: int = 1 self.port: int = args.port or port self.reservation: str = args.reservation or reservation self.room_id: str = args.room or room_id + if self.room_id and self.reservation: + logging.warning("The room ID is not taken into account because a reservation is available.") + self.password: str = args.password or password + if self.password and (self.reservation or self.room_id): + logging.warning("The password is not taken into account because a reservation or Room ID is available.") self.survive: bool = args.survive or survive self.auto_reconnect: bool = args.auto_reconnect or auto_reconnect + self.headless: bool = args.headless or headless self.client = GameClient(host=self.host, port=self.port, handler=logic, reservation=self.reservation, - room_id=room_id, auto_reconnect=self.auto_reconnect, survive=self.survive) + room_id=room_id, password=self.password, auto_reconnect=self.auto_reconnect, + survive=self.survive, headless=self.headless) self.client.join() @@ -78,11 +90,11 @@ def _setup_debugger(self, verbose: bool): else: logging.basicConfig(level=level, format="%(asctime)s: %(levelname)s - %(message)s") logging.info("Starting...") - logging.critical("\nDiese Version von SoCha hat einige Änderungen.\n" - "Deshalb wird Code welcher mit 1.0.0 und niedriger geschrieben wurde ein paar Fehler haben.\n" - "Hilfe, um seinen Code anzupassen kann man unter: \n" - "https://github.com/FalconsSky/socha-python-client/blob/master/changes.md\n" - "finden, oder mir eine E-Mail oder Nachricht auf Discord schreiben.") + logging.info( + "We would greatly appreciate it if you could share any issues " + "or feature requests you may have regarding socha by either creating " + "an issue on our GitHub repository or contributing to the project." + "\n(https://github.com/maxblan/socha-python-client)") @staticmethod def check_socha_version(): @@ -96,6 +108,8 @@ def check_socha_version(): logging.warning( f"A newer version ({latest_version}) of {package_name} is available. You have version " f"{installed_version}.") + else: + logging.info(f"You're running the latest version of {package_name} ({latest_version})") except pkg_resources.DistributionNotFound: logging.error(f"{package_name} is not installed.") except urllib.error.URLError as e: @@ -112,6 +126,8 @@ def _handle_start_args(): parser.add_argument('-p', '--port', help='The port of the host. The default is 13050.', type=int) parser.add_argument('-r', '--reservation', help='Reservation code for a prepared game.', type=str) parser.add_argument('-R', '--room', help='Room Id the client will try to connect.', type=str) + parser.add_argument('-P', '--password', help='Password which will be used to authenticate with the server.', + type=str) parser.add_argument('-s', '--survive', action='store_true', help='If present the client will keep running, even if the connection to the server is ' 'terminated.') @@ -120,5 +136,6 @@ def _handle_start_args(): parser.add_argument('-v', '--verbose', action='store_true', help='Verbose option for logging.') parser.add_argument('--auto-reconnect', action='store_true', help='Automatically reconnect to the server if the connection is lost.') + parser.add_argument('--headless', action='store_true', help='Starts the client without the penguin plugin.') parser.add_argument('-b', '--build', help='Builds the this script into a package with all its dependencies.') return parser.parse_args() From 10771d8b5bbc344f493a4c861c9a1e38fa2d6fa7 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 May 2023 21:06:18 +0200 Subject: [PATCH 09/15] feat: add dataclass --- socha/api/plugin/penguins/team.py | 63 ++++++++----------------------- 1 file changed, 16 insertions(+), 47 deletions(-) diff --git a/socha/api/plugin/penguins/team.py b/socha/api/plugin/penguins/team.py index 55df38b..2ee2300 100644 --- a/socha/api/plugin/penguins/team.py +++ b/socha/api/plugin/penguins/team.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from enum import Enum from typing import List, Optional @@ -9,21 +10,15 @@ class TeamEnum(Enum): TWO = "TWO" +@dataclass(frozen=True, order=True, unsafe_hash=True) class Move: """ Represents a move in the game. """ - def __init__(self, team_enum: TeamEnum, to_value: HexCoordinate, from_value: Optional[HexCoordinate]): - """ - Args: - team_enum: The team_enum that performs the move. - to_value: The destination of the move. - from_value: The origin of the move. - """ - self.team_enum = team_enum - self.from_value = from_value - self.to_value = to_value + team_enum: TeamEnum + from_value: Optional[HexCoordinate] + to_value: HexCoordinate def get_delta(self) -> float: """ @@ -45,27 +40,15 @@ def reversed(self): return self if not self.from_value else Move(team_enum=self.team_enum, to_value=self.from_value, from_value=self.to_value) - def __repr__(self): - return f"Move(team={self.team_enum.value}, from={self.from_value}, to={self.to_value})" - - def __eq__(self, __o: object) -> bool: - return isinstance(__o, Move) and self.to_value == __o.to_value and \ - (self.from_value is None or self.from_value == __o.from_value) - +@dataclass(frozen=True, order=True, unsafe_hash=True) class Penguin: """ The Penguin class represents a penguin object with a coordinate and a team_enum. """ - def __init__(self, coordinate: HexCoordinate, team_enum: TeamEnum): - """ - Args: - coordinate (HexCoordinate): The coordinate of the penguin on the game board. - team_enum (TeamEnum): The team_enum that the penguin belongs to. - """ - self.coordinate = coordinate - self.team_enum = team_enum + coordinate: HexCoordinate + team_enum: TeamEnum def get_distance(self, destination: HexCoordinate) -> float: """ @@ -91,28 +74,19 @@ def get_direction(self, destination: HexCoordinate) -> Vector: """ return destination.subtract_vector(self.coordinate.to_vector()).to_vector() - def __eq__(self, other): - if not isinstance(other, Penguin): - return False - return self.coordinate == other.coordinate and self.team_enum == other.team_enum - - def __repr__(self): - return f'Penguin({self.coordinate}, {self.team_enum.value})' - +@dataclass(order=True, unsafe_hash=True) class Team: """ The Team class is useful for storing and manipulating information about teams in the game. It allows you to easily create objects for each team_enum, keep track of their attributes, and compare them to their opponents. """ - def __init__(self, name: TeamEnum, fish: int, penguins: List[Penguin], moves: List[Move], - opponent: Optional['Team'] = None): - self.name = name - self.fish = fish - self.penguins = penguins - self.moves = moves - self.opponent = opponent + name: TeamEnum + fish: int + penguins: List[Penguin] + moves: List[Move] + opponent: Optional['Team'] = None def team(self) -> TeamEnum: """ @@ -136,10 +110,5 @@ def color(self) -> str: return TeamEnum.TWO.value def __repr__(self) -> str: - return f"Team(name={self.name}, fish={self.fish}, penguins={len(self.penguins)}, moves={len(self.moves)})" - - def __eq__(self, other): - if isinstance(other, self.__class__): - return self.name == other.name and self.fish == other.fish and self.penguins == other.penguins and \ - self.moves == other.moves - return False + return f'Team(name={self.name.value}, fish={self.fish}, penguins={len(self.penguins)}, ' \ + f'moves={len(self.moves)}, opponent={None if not self.opponent else self.opponent.name})' From ef92949f96eea9a0c3f04aef47585bd9b88e6901 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 May 2023 21:06:50 +0200 Subject: [PATCH 10/15] fix: adjust tests for new dataclasses --- tests/test_penguins.py | 89 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 8 deletions(-) diff --git a/tests/test_penguins.py b/tests/test_penguins.py index 2948073..61f4fb7 100644 --- a/tests/test_penguins.py +++ b/tests/test_penguins.py @@ -138,8 +138,8 @@ def test_get_neighbors(self): class TestMove(unittest.TestCase): def setUp(self): self.move1 = Move(team_enum=TeamEnum.ONE, from_value=HexCoordinate(3, 4), to_value=HexCoordinate(5, 12)) - self.move2 = Move(team_enum=TeamEnum.TWO, from_value=HexCoordinate(5, 12), to_value=HexCoordinate(3, 4)) - self.move3 = Move(team_enum=TeamEnum.ONE, from_value=None, to_value=HexCoordinate(5, 12)) + self.move2 = Move(team_enum=TeamEnum.ONE, from_value=HexCoordinate(5, 12), to_value=HexCoordinate(3, 4)) + self.move3 = Move(team_enum=TeamEnum.TWO, from_value=None, to_value=HexCoordinate(5, 12)) self.move4 = Move(team_enum=TeamEnum.TWO, from_value=None, to_value=HexCoordinate(3, 4)) def test_get_delta(self): @@ -152,7 +152,7 @@ def test_reversed(self): self.assertEqual(self.move1.reversed(), self.move2) self.assertEqual(self.move2.reversed(), self.move1) self.assertEqual(self.move3.reversed(), - Move(team_enum=TeamEnum.ONE, from_value=None, to_value=HexCoordinate(5, 12))) + Move(team_enum=TeamEnum.TWO, from_value=None, to_value=HexCoordinate(5, 12))) self.assertEqual(self.move4.reversed(), Move(team_enum=TeamEnum.TWO, from_value=None, to_value=HexCoordinate(3, 4))) @@ -171,9 +171,12 @@ def test_equal(self): self.assertNotEqual(self.penguin1, self.penguin4) def test_repr(self): - self.assertEqual(repr(self.penguin1), "Penguin(HexCoordinate(3, 4), ONE)") - self.assertEqual(repr(self.penguin2), "Penguin(HexCoordinate(3, 4), TWO)") - self.assertEqual(repr(self.penguin3), "Penguin(HexCoordinate(5, 12), ONE)") + self.assertEqual(repr(self.penguin1), + "Penguin(coordinate=HexCoordinate(x=3, y=4), team_enum=)") + self.assertEqual(repr(self.penguin2), + "Penguin(coordinate=HexCoordinate(x=3, y=4), team_enum=)") + self.assertEqual(repr(self.penguin3), + "Penguin(coordinate=HexCoordinate(x=5, y=12), team_enum=)") class TestField(unittest.TestCase): @@ -332,7 +335,7 @@ def test_compare_to(self): board2.board[0][0] = Field(self.coord1, None, 5) self.assertNotEqual(len(board1.compare_to(board2)), 0) - board1.board[0][0].penguin = Penguin(HexCoordinate(0, 0), TeamEnum.ONE) + board1.board[0][0] = Field(HexCoordinate(0, 0), Penguin(HexCoordinate(0, 0), TeamEnum.ONE), 0) self.assertNotEqual(len(board1.compare_to(board2)), 0) def test_contains(self): @@ -396,6 +399,76 @@ def test_get_most_fish(self): self.assertEqual(len(self.board.get_most_fish()), 2) self.assertEqual(self.board.board[2][0].fish, self.board.get_most_fish()[0].fish) + def test_get_neighbor_fields(self): + # Get neighbor fields for a field + neighbor_fields = list(self.board.get_neighbor_fields(self.field2)) + + # Check that the neighbor fields are as expected + expected_neighbor_fields = [self.field1, self.field3, self.field4] + self.assertCountEqual(neighbor_fields, expected_neighbor_fields) + + # Get neighbor fields for a field at the border of the board + neighbor_fields = list(self.board.get_neighbor_fields(self.field1)) + + # Check that the neighbor fields are as expected + expected_neighbor_fields = [self.field2, self.field3] + self.assertCountEqual(neighbor_fields, expected_neighbor_fields) + + def test_get_neighbor_fields_coordinate(self): + # Get neighbor fields for a coordinate + neighbor_fields = list(self.board.get_neighbor_fields_coordinate(self.coord2)) + + # Check that the neighbor fields are as expected + expected_neighbor_fields = [self.field1, self.field3, self.field4] + self.assertCountEqual(neighbor_fields, expected_neighbor_fields) + + # Get neighbor fields for a coordinate at the border of the board + neighbor_fields = list(self.board.get_neighbor_fields_coordinate(self.coord1)) + + # Check that the neighbor fields are as expected + expected_neighbor_fields = [self.field2, self.field3] + self.assertCountEqual(neighbor_fields, expected_neighbor_fields) + + def test_get_valid_neighbor_fields(self): + # Get valid neighbor fields for a field with no neighbors + neighbor_fields = list(self.board.get_valid_neighbor_fields(self.field5)) + + # Check that there are no valid neighbor fields + self.assertCountEqual(neighbor_fields, [self.field3]) + + # Get valid neighbor fields for a field with only invalid neighbors + neighbor_fields = list(self.board.get_valid_neighbor_fields(self.field4)) + + # Check that there are no valid neighbor fields + self.assertCountEqual(neighbor_fields, [self.field3]) + + # Get valid neighbor fields for a field with some valid neighbors + neighbor_fields = list(self.board.get_valid_neighbor_fields(self.field2)) + + # Check that the valid neighbor fields are as expected + expected_neighbor_fields = [self.field3, self.field4] + self.assertCountEqual(neighbor_fields, expected_neighbor_fields) + + def test_get_valid_neighbor_fields_coordinate(self): + # Get valid neighbor fields for a coordinate with no neighbors + neighbor_fields = list(self.board.get_valid_neighbor_fields_coordinate(self.coord5)) + + # Check that there are no valid neighbor fields + self.assertCountEqual(neighbor_fields, [self.field3]) + + # Get valid neighbor fields for a coordinate with only invalid neighbors + neighbor_fields = list(self.board.get_valid_neighbor_fields_coordinate(self.coord4)) + + # Check that there are no valid neighbor fields + self.assertCountEqual(neighbor_fields, [self.field3]) + + # Get valid neighbor fields for a coordinate with some valid neighbors + neighbor_fields = list(self.board.get_valid_neighbor_fields_coordinate(self.coord2)) + + # Check that the valid neighbor fields are as expected + expected_neighbor_fields = [self.field3, self.field4] + self.assertCountEqual(neighbor_fields, expected_neighbor_fields) + class TestGameState(unittest.TestCase): def setUp(self): @@ -449,7 +522,7 @@ def test_current_team(self): move = Move(team_enum=TeamEnum.ONE, from_value=None, to_value=CartesianCoordinate(0, 0).to_hex()) new_game_state = self.game_state.perform_move(move) - self.assertEqual(new_game_state.current_team, self.second_team) + self.assertEqual(new_game_state.current_team, new_game_state.second_team) move = Move(team_enum=TeamEnum.TWO, from_value=None, to_value=HexCoordinate(2, 2)) new_game_state = new_game_state.perform_move(move) From 276c3fca3d067a1b2a6deaa75b822bd93e592eee Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 May 2023 21:07:36 +0200 Subject: [PATCH 11/15] refactor: typos and pep 8 --- .../api/networking/xml_protocol_interface.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/socha/api/networking/xml_protocol_interface.py b/socha/api/networking/xml_protocol_interface.py index ed549fd..426f81b 100644 --- a/socha/api/networking/xml_protocol_interface.py +++ b/socha/api/networking/xml_protocol_interface.py @@ -1,5 +1,5 @@ """ -Here are all incoming byte streams and all outgoing protocol objects handelt. +Here are all incoming byte streams and all outgoing protocol objects handheld. """ import contextlib import logging @@ -13,11 +13,11 @@ from xsdata.formats.dataclass.serializers.config import SerializerConfig from socha.api.networking.network_socket import NetworkSocket -from socha.api.plugin.penguins.team import TeamEnum, Move +from socha.api.plugin.penguins.team import TeamEnum from socha.api.protocol.protocol import * -def customClassFactory(clazz, params: dict): +def custom_class_factory(clazz, params: dict): if clazz.__name__ == "Data": try: params.pop("class_binding") @@ -59,7 +59,7 @@ def __init__(self, host: str, port: int): self.first_time = True context = XmlContext() - deserialize_config = ParserConfig(class_factory=customClassFactory) + deserialize_config = ParserConfig(class_factory=custom_class_factory) self.deserializer = XmlParser(handler=XmlEventHandler, context=context, config=deserialize_config) serialize_config = SerializerConfig(pretty_print=True, xml_declaration=False) @@ -73,9 +73,9 @@ def connect(self): def disconnect(self): """ - Sends a closing xml to the server and closes the connection afterwards. + Sends a closing xml to the server and closes the connection afterward. """ - self._send(Close()) + self.send(Close()) self.network_interface.close() def _receive(self): @@ -102,7 +102,7 @@ def _receive(self): self.running = False raise - def _send(self, obj: ProtocolPacket) -> None: + def send(self, obj: ProtocolPacket) -> None: """ Sends an object to the server. @@ -121,6 +121,7 @@ def _send(self, obj: ProtocolPacket) -> None: raise else: logging.debug("Sent shipment to server: %s", shipment) + self.first_time = False @contextlib.contextmanager def _encode_context(self) -> Iterator[Callable[[Any], bytes]]: @@ -133,13 +134,13 @@ def encode(obj: Any) -> bytes: yield encode - def _deserialize_object(self, byteStream: bytes) -> ProtocolPacket: + def _deserialize_object(self, byte_stream: bytes) -> ProtocolPacket: """ Deserialize a xml byte stream to a ProtocolPacket. - :param byteStream: The byte stream to deserialize. + :param byte_stream: The byte stream to deserialize. :return: The deserialized ProtocolPacket child. """ - return self.deserializer.from_bytes(byteStream) + return self.deserializer.from_bytes(byte_stream) def _serialize_object(self, object_class: object) -> bytes: """ From 9586aaf4d17a9208e0eb883756f7adf299e21b15 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 May 2023 21:07:48 +0200 Subject: [PATCH 12/15] refactor: bump to 1.0.8 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 65e626b..d6b1fc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "socha" -version = "1.0.7" +version = "1.0.8" authors = [ { name = "FalconsSky", email = "stu222782@mail.uni-kiel.de" }, ] From 01224b6f53bcc8db5b663df66d584d56e32d045b Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 May 2023 21:36:58 +0200 Subject: [PATCH 13/15] fix(package builder): not copying main logic --- socha/utils/package_builder.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/socha/utils/package_builder.py b/socha/utils/package_builder.py index 9d8c932..4bbe603 100644 --- a/socha/utils/package_builder.py +++ b/socha/utils/package_builder.py @@ -120,6 +120,10 @@ def _copy_modules(self): source_file_path = os.path.join(root, file) target_file_path = os.path.join(self.build_dir, self.package_name, os.path.relpath(source_file_path, source_folder)) + if file in sys.argv[0]: + os.makedirs(os.path.dirname(target_file_path), exist_ok=True) + shutil.copy2(source_file_path, target_file_path) + logging.info(f'Copying {source_file_path} to {target_file_path}') if source_file_path in main_modules: # Only copy files that were imported in the main script os.makedirs(os.path.dirname(target_file_path), exist_ok=True) shutil.copy2(source_file_path, target_file_path) From de22a2635fcc3cd7fef40bf44b6472b8029d8a04 Mon Sep 17 00:00:00 2001 From: Falcon <35808469+FalconsSky@users.noreply.github.com> Date: Mon, 8 May 2023 16:47:09 +0200 Subject: [PATCH 14/15] fix(package builder): a bug that caused infinite copying --- socha/utils/package_builder.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/socha/utils/package_builder.py b/socha/utils/package_builder.py index 4bbe603..fb1ae7c 100644 --- a/socha/utils/package_builder.py +++ b/socha/utils/package_builder.py @@ -113,8 +113,10 @@ def _copy_modules(self): """ logging.info(f'Copying python files to {self.package_name}') source_folder = os.getcwd() - main_modules = self._get_modules() # Get the set of module names + main_modules = self._get_modules() for root, dirs, files in os.walk(source_folder): + if "socha_builds" in dirs: + dirs.remove("socha_builds") for file in files: if file.endswith('.py'): source_file_path = os.path.join(root, file) @@ -124,7 +126,7 @@ def _copy_modules(self): os.makedirs(os.path.dirname(target_file_path), exist_ok=True) shutil.copy2(source_file_path, target_file_path) logging.info(f'Copying {source_file_path} to {target_file_path}') - if source_file_path in main_modules: # Only copy files that were imported in the main script + if source_file_path in main_modules: os.makedirs(os.path.dirname(target_file_path), exist_ok=True) shutil.copy2(source_file_path, target_file_path) logging.info(f'Copying {source_file_path} to {target_file_path}') From 4737422874e2f79caf0b4522ead9e6b1b201bbcd Mon Sep 17 00:00:00 2001 From: Falcon <35808469+FalconsSky@users.noreply.github.com> Date: Mon, 8 May 2023 17:12:31 +0200 Subject: [PATCH 15/15] feat(socha): log levels and verbose option --- socha/api/networking/game_client.py | 23 +++++++++++------------ socha/starter.py | 17 ++++++++++++----- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/socha/api/networking/game_client.py b/socha/api/networking/game_client.py index 38f3fe7..375e5f6 100644 --- a/socha/api/networking/game_client.py +++ b/socha/api/networking/game_client.py @@ -172,7 +172,7 @@ def pause(self, room_id: str, pause: bool): self.send(Pause(room_id=room_id, pause=pause)) def send_message_to_room(self, room_id: str, message): - logging.info(f"Sending message to room '{room_id}'") + logging.log(15, f"Sending message to room '{room_id}'") self.send(Room(room_id=room_id, data=message)) def _on_object(self, message): @@ -190,37 +190,36 @@ def _on_object(self, message): self._game_handler.on_error(str(message)) self.stop() elif isinstance(message, Joined): - logging.info(f"Game joined received with room id '{message.room_id}'") + logging.log(15, f"Game joined received with room id '{message.room_id}'") self._game_handler.on_game_joined(room_id=message.room_id) elif isinstance(message, Left): - logging.info(f"Game left received with room id '{message.room_id}'") + logging.log(15, f"Game left received with room id '{message.room_id}'") self._game_handler.on_game_left() elif isinstance(message, Prepared): - logging.info(f"Game prepared received with reservation '{message.reservation}'") + logging.log(15, f"Game prepared received with reservation '{message.reservation}'") self._game_handler.on_prepared(game_client=self, room_id=message.room_id, reservations=message.reservation) elif isinstance(message, Observed): - logging.info(f"Game observing received with room id '{message.room_id}'") + logging.log(15, f"Game observing received with room id '{message.room_id}'") self._game_handler.on_observed(game_client=self, room_id=message.room_id) - elif isinstance(message, Room) and self.headless is False: + elif isinstance(message, Room) and not self.headless: room_id = message.room_id if isinstance(message.data.class_binding, MoveRequest): - logging.info(f"Move request received for room id '{room_id}'") + logging.log(15, f"Move request received for room id '{room_id}'") self._on_move_request(room_id) elif isinstance(message.data.class_binding, State): - logging.info(f"State received for room id '{room_id}'") + logging.log(15, f"State received for room id '{room_id}'") self._on_state(message) elif isinstance(message.data.class_binding, Result): - logging.info(f"Result received for room id '{room_id}'") + logging.log(15, f"Result received for room id '{room_id}'") self._game_handler.history[-1].append(message.data.class_binding) self._game_handler.on_game_over(message.data.class_binding) else: - logging.info(f"Room message received for room id '{room_id}'") + logging.log(15, f"Room message received for room id '{room_id}'") self._game_handler.on_room_message(message.data.class_binding) else: - print(type(message)) room_id = message.room_id - logging.info(f"Room message received for room id '{room_id}'") + logging.log(15, f"Room message received for room id '{room_id}'") self._game_handler.on_room_message(message) def _on_move_request(self, room_id): diff --git a/socha/starter.py b/socha/starter.py index 7b22e2e..4ae833c 100644 --- a/socha/starter.py +++ b/socha/starter.py @@ -22,7 +22,8 @@ class Starter: def __init__(self, logic: IClientHandler, host: str = "localhost", port: int = 13050, reservation: str = None, room_id: str = None, password: str = None, survive: bool = False, auto_reconnect: bool = False, - headless: bool = False, log: bool = False, verbose: bool = False, build: str = None): + headless: bool = False, log: bool = False, verbose: bool = False, build: str = None, + log_level: int = logging.INFO): """ All these arguments can be overwritten, when parsed via start arguments, or you initialize this class with the desired values. @@ -41,11 +42,15 @@ def __init__(self, logic: IClientHandler, host: str = "localhost", port: int = 1 verbose: Verbose option for logging. build: If set, the client will build a zip package with the given name. """ + VERBOSE = 15 + logging.addLevelName(VERBOSE, "VERBOSE") + args = self._handle_start_args() self.write_log: bool = args.log or log self.verbose = args.verbose or verbose - self._setup_debugger(self.verbose) + self.log_level = args.log_level or log_level + self._setup_debugger(self.verbose, self.log_level) self.check_socha_version() @@ -76,11 +81,11 @@ def __init__(self, logic: IClientHandler, host: str = "localhost", port: int = 1 self.client.start() - def _setup_debugger(self, verbose: bool): + def _setup_debugger(self, verbose: bool, log_level: int): if verbose: level: int = logging.DEBUG else: - level: int = logging.INFO + level: int = log_level if self.write_log: now = datetime.datetime.now().strftime("%Y%m%d%H%M%S") @@ -133,7 +138,9 @@ def _handle_start_args(): 'terminated.') parser.add_argument('-l', '--log', action='store_true', help='If present the client will write a log file to the current directory.') - parser.add_argument('-v', '--verbose', action='store_true', help='Verbose option for logging.') + parser.add_argument('-v', '--verbose', action='store_true', help='Verbose option for logging. ' + 'This cancels out the log-level argument.') + parser.add_argument('-L', '--log_level', help='Sets the log level.', type=int) parser.add_argument('--auto-reconnect', action='store_true', help='Automatically reconnect to the server if the connection is lost.') parser.add_argument('--headless', action='store_true', help='Starts the client without the penguin plugin.')