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" }, ] diff --git a/socha/api/networking/game_client.py b/socha/api/networking/game_client.py index 4d92e6d..375e5f6 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.log(15, f"Sending message to room '{room_id}'") + self.send(Room(room_id=room_id, data=message)) def _on_object(self, message): """ @@ -135,21 +189,38 @@ 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.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.log(15, f"Game left received with room id '{message.room_id}'") + self._game_handler.on_game_left() + elif isinstance(message, Prepared): + 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.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 not self.headless: 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.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.log(15, f"State received for room id '{room_id}'") self._on_state(message) elif isinstance(message.data.class_binding, Result): + 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) - elif isinstance(message, Room): + else: + logging.log(15, f"Room message received for room id '{room_id}'") self._game_handler.on_room_message(message.data.class_binding) + else: + room_id = message.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): start_time = time.time() @@ -184,12 +255,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 +273,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 +302,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 +310,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}") 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) 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: """ 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 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 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})" 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})' 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", + } + ) 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. """ diff --git a/socha/starter.py b/socha/starter.py index ba1080f..4ae833c 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,26 +21,36 @@ 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, + 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. - :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. """ + 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() @@ -54,21 +64,28 @@ 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() 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") @@ -78,11 +95,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 +113,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,13 +131,18 @@ 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.') 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.') parser.add_argument('-b', '--build', help='Builds the this script into a package with all its dependencies.') return parser.parse_args() diff --git a/socha/utils/package_builder.py b/socha/utils/package_builder.py index 9d8c932..fb1ae7c 100644 --- a/socha/utils/package_builder.py +++ b/socha/utils/package_builder.py @@ -113,14 +113,20 @@ 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) target_file_path = os.path.join(self.build_dir, self.package_name, os.path.relpath(source_file_path, source_folder)) - if source_file_path in main_modules: # Only copy files that were imported in the main script + 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: 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}') 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)