diff --git a/pyproject.toml b/pyproject.toml index 33da98f..e9bb508 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "socha" -version = "1.0.3" +version = "1.0.4" authors = [ { name = "FalconsSky", email = "stu222782@mail.uni-kiel.de" }, ] diff --git a/setup.py b/setup.py deleted file mode 100644 index 58965fa..0000000 --- a/setup.py +++ /dev/null @@ -1,18 +0,0 @@ -from setuptools import setup - -with open('requirements.txt') as f: - required = f.read().splitlines() - -setup( - name='socha', - version='1.0.3', - packages=['socha', 'socha.api', 'socha.api.plugin', 'socha.api.protocol', - 'socha.api.networking'], - url='https://github.com/FalconsSky/Software-Challenge-Python-Client', - license='GNU Lesser General Public License v3 (LGPLv3)', - author='FalconsSky', - author_email='stu222782@mail.uni-kiel.de', - description='This is the package for the Software-Challenge Germany 2023. This Season the game will be \'Hey, ' - 'danke für den Fisch\' a.k.a. \'Penguins\' in short. ', - install_requires=required -) diff --git a/socha/__init__.py b/socha/__init__.py index 83da4f6..d53e99c 100644 --- a/socha/__init__.py +++ b/socha/__init__.py @@ -1,5 +1,8 @@ from socha.api.networking.game_client import IClientHandler -from socha.api.plugin.penguins import * +from socha.api.plugin.penguins.game_state import GameState +from socha.api.plugin.penguins.coordinate import * +from socha.api.plugin.penguins.board import * +from socha.api.plugin.penguins.team import * from socha.api.protocol.protocol import Result from socha.starter import Starter diff --git a/socha/api/networking/game_client.py b/socha/api/networking/game_client.py index 475c075..27f33ab 100644 --- a/socha/api/networking/game_client.py +++ b/socha/api/networking/game_client.py @@ -1,20 +1,22 @@ """ This module handels the communication with the api and the students logic. """ +import gc import logging import sys import time from typing import List, Union from socha.api.networking.xml_protocol_interface import XMLProtocolInterface -from socha.api.plugin import penguins -from socha.api.plugin.penguins import Field, GameState, Move, CartesianCoordinate, TeamEnum, Penguin, HexCoordinate +from socha.api.plugin.penguins import game_state +from socha.api.plugin.penguins.board import Field, Move, CartesianCoordinate, TeamEnum, Penguin, HexCoordinate +from socha.api.plugin.penguins.game_state import GameState from socha.api.protocol.protocol import State, Board, Data, \ Error, From, Join, Joined, JoinPrepared, JoinRoom, To, Team, Room, Result, MoveRequest, Left, Errorpacket from socha.api.protocol.protocol_packet import ProtocolPacket -def _convert_board(protocol_board: Board) -> penguins.Board: +def _convert_board(protocol_board: Board) -> game_state.Board: """ Converts a protocol Board to a usable game board for using in the logic. :param protocol_board: A Board object in protocol format @@ -39,7 +41,7 @@ def _convert_board(protocol_board: Board) -> penguins.Board: else: raise ValueError(f"Invalid field value {fields_value} at coordinates {coordinate}") - return penguins.Board(board_list) + return game_state.Board(board_list) class IClientHandler: @@ -204,7 +206,7 @@ def _on_state(self, message): last_move = Move(team_enum=last_game_state.current_team.name, from_value=from_value, to_value=to_value) - game_state = last_game_state.perform_move(last_move) + _game_state = last_game_state.perform_move(last_move) else: first_team = Team(TeamEnum.ONE, fish=0 if not last_game_state else last_game_state.first_team.fish, @@ -214,16 +216,18 @@ def _on_state(self, message): 0 if not last_game_state else last_game_state.second_team.fish, penguins=[] if not last_game_state else last_game_state.second_team.penguins, moves=[] if not last_game_state else last_game_state.second_team.moves) + first_team.opponent = second_team + second_team.opponent = first_team - game_state = GameState( + _game_state = GameState( board=_convert_board(message.data.class_binding.board), turn=message.data.class_binding.turn, first_team=first_team, second_team=second_team, last_move=None, ) - self._game_handler.history[-1].append(game_state) - self._game_handler.on_update(game_state) + self._game_handler.history[-1].append(_game_state) + self._game_handler.on_update(_game_state) def start(self): """ @@ -278,7 +282,6 @@ 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 self.running: if self.network_interface.connected: response = self._receive() @@ -290,6 +293,7 @@ def _client_loop(self): self._handle_left() else: self._on_object(response) + gc.collect() elif self.running: logging.error(f"Received a object of unknown class: {response}") raise NotImplementedError("Received object of unknown class.") diff --git a/socha/api/networking/xml_protocol_interface.py b/socha/api/networking/xml_protocol_interface.py index e4b44d0..61324c9 100644 --- a/socha/api/networking/xml_protocol_interface.py +++ b/socha/api/networking/xml_protocol_interface.py @@ -13,7 +13,7 @@ from xsdata.formats.dataclass.serializers.config import SerializerConfig from socha.api.networking.network_socket import NetworkSocket -from socha.api.plugin.penguins import Move, TeamEnum +from socha.api.plugin.penguins.team import TeamEnum, Move from socha.api.protocol.protocol import * diff --git a/socha/api/plugin/penguins.py b/socha/api/plugin/penguins.py deleted file mode 100644 index 4a7db62..0000000 --- a/socha/api/plugin/penguins.py +++ /dev/null @@ -1,982 +0,0 @@ -""" -This is the plugin for this year's game `Penguins`. -""" -import copy -import logging -import math -import warnings -from enum import Enum -from typing import List, Union, Optional - - -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 - - def magnitude(self) -> float: - """ - Calculates the length of the vector. - - :return: The length of the vector. - """ - return (self.d_x ** 2 + self.d_y ** 2) ** 0.5 - - def dot_product(self, other: 'Vector'): - """ - Calculates the dot product of two vectors. - - :param other: The other vector to calculate the dot product with. - :return: The dot product of the two vectors. - """ - return self.d_x * other.d_x + self.d_y * other.d_y - - def cross_product(self, other: 'Vector'): - """ - Calculates the cross product of two vectors. - - :param other: The other vector to calculate the cross product with. - :return: The cross product of the two vectors. - """ - return self.d_x * other.d_y - self.d_y * other.d_x - - def scalar_product(self, scalar: int): - """ - Extends the vector by a scalar. - - :param scalar: The scalar to extend the vector by. - :return: The extended vector. - """ - return Vector(self.d_x * scalar, self.d_y * scalar) - - def addition(self, other: 'Vector'): - """ - Adds two vectors. - - :param other: The other vector to add. - :return: The sum of the two vectors as a new vector object. - """ - return Vector(self.d_x + other.d_x, self.d_y + other.d_y) - - def subtraction(self, other: 'Vector'): - """ - Subtracts two vectors. - - :param other: The other vector to subtract. - :return: The difference of the two vectors as a new vector object. - """ - return Vector(self.d_x - other.d_x, self.d_y - other.d_y) - - def get_arc_tangent(self) -> float: - """ - Calculates the arc tangent of the vector. - - :return: A radiant in float. - """ - return math.atan2(self.d_y, self.d_x) - - def are_identically(self, other: 'Vector'): - """ - Compares two vectors. - - :param other: The other vector to compare to. - :return: True if the vectors are equal, false otherwise. - """ - return self.d_x == other.d_x and self.d_y == other.d_y - - def are_equal(self, other: 'Vector'): - """ - Checks if two vectors have the same magnitude and direction. - - :param other: The other vector to compare to. - :return: True if the vectors are equal, false otherwise. - """ - return self.magnitude() == other.magnitude() and self.get_arc_tangent() == other.get_arc_tangent() - - @property - def directions(self) -> List['Vector']: - """ - Gets the six neighbors of the vector. - - :return: A list of the six neighbors of the vector. - """ - return [ - Vector(1, -1), # UP RIGHT - Vector(-2, 0), # LEFT - Vector(1, 1), # DOWN RIGHT - Vector(-1, 1), # DOWN LEFT - Vector(2, 0), # Right - Vector(-1, -1) # UP LEFT - ] - - def is_one_hex_move(self): - """ - Checks if the vector points to a hexagonal field that is a direct neighbor. - - :return: True if the vector is a one hex move, false otherwise. - """ - 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 - - -class Coordinate: - def __init__(self, x: int, y: int): - self.x = x - self.y = y - - def to_vector(self) -> Vector: - """ - Converts the coordinate to a vector. - """ - return Vector(d_x=self.x, d_y=self.y) - - def distance(self, other: 'Coordinate') -> float: - """ - Calculates the distance between two coordinates. - - :param other: The other coordinate to calculate the distance to. - :return: The distance between the two cartesian coordinates. - """ - return self.to_vector().subtraction(other.to_vector()).magnitude() - - def add_vector(self, vector: Vector): ... - - def subtract_vector(self, vector: Vector): ... - - -class CartesianCoordinate(Coordinate): - """ - Represents a coordinate in a normal cartesian coordinate system, that has been taught in school. - This class is used to translate and represent a hexagonal coordinate in a cartesian and with that a 2D-Array. - """ - - def add_vector(self, vector: Vector) -> 'CartesianCoordinate': - """ - Adds a vector to the cartesian coordinate. - - :param vector: The vector to add. - :return: The new cartesian coordinate. - """ - vector: Vector = self.to_vector().addition(vector) - return CartesianCoordinate(x=vector.d_x, y=vector.d_y) - - def subtract_vector(self, vector: Vector) -> 'CartesianCoordinate': - """ - Subtracts a vector from the cartesian coordinate. - - :param vector: The vector to subtract. - :return: The new cartesian coordinate. - """ - vector: Vector = self.to_vector().subtraction(vector) - return CartesianCoordinate(x=vector.d_x, y=vector.d_y) - - def to_hex(self) -> 'HexCoordinate': - """ - Converts the cartesian coordinate to a hex coordinate. - - :return: The hex coordinate. - """ - return HexCoordinate(x=self.x * 2 + (1 if self.y % 2 == 1 else 0), y=self.y) - - def to_index(self) -> Optional[int]: - """ - Converts the cartesian coordinate to an index. - - :return: The index or None if the coordinate is not valid. - """ - if 0 <= self.x <= 7 and 0 <= self.y <= 7: - return self.y * 8 + self.x - return None - - @staticmethod - def from_index(index: int, width: int, height: int) -> Optional['CartesianCoordinate']: - """ - Converts a given index to a CartesianCoordinate. - - Args: - index: The index to convert. - width: The width of the grid. - height: The height of the grid. - - Returns: - Optional[CartesianCoordinate]: The CartesianCoordinate that corresponds to the given index, or None if the - index is out of range. - """ - if index < 0 or index >= width * height: - raise IndexError(f"Index out of range. The index has to be 0 <= {index} < {width * height}") - x = index % width - 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 - - -class HexCoordinate(Coordinate): - """ - Represents a coordinate in a hexagonal coordinate system, that differs from the normal cartesian one. - This class is used to represent the hexagonal game board. - """ - - def to_cartesian(self) -> CartesianCoordinate: - """ - Converts the hex coordinate to a cartesian coordinate. - - :return: The cartesian coordinate. - """ - return CartesianCoordinate(x=math.floor((self.x / 2 - (1 if self.y % 2 == 1 else 0)) + 0.5), y=self.y) - - def add_vector(self, vector: Vector) -> 'HexCoordinate': - """ - Adds a vector to the hex coordinate. - - :param vector: The vector to add. - :return: The new hex coordinate. - """ - vector: Vector = self.to_vector().addition(vector) - return HexCoordinate(x=vector.d_x, y=vector.d_y) - - def subtract_vector(self, vector: Vector) -> 'HexCoordinate': - """ - Subtracts a vector from the hex coordinate. - - :param vector: The vector to subtract. - :return: The new hex coordinate. - """ - vector: Vector = self.to_vector().subtraction(vector) - return HexCoordinate(x=vector.d_x, y=vector.d_y) - - def get_neighbors(self) -> List['HexCoordinate']: - """ - Returns a list of all neighbors of the hex coordinate. - - :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 - - -class TeamEnum(Enum): - ONE = "ONE" - TWO = "TWO" - - -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 - - def get_delta(self) -> float: - """ - This method calculates and returns the difference in distance between the to_value and from_value properties - of the Move object. If the from_value is not initialized, the distance is calculated between the to_value and - itself. - - :return: The delta of the move as a float. - """ - return self.to_value.distance(self.to_value if not self.from_value else self.from_value) - - def reversed(self): - """ - This method returns a new Move object with the from_value and to_value properties reversed. - If the current Move object is not initialized with a from_value, the method returns the current object. - - :return: The reversed move or the current move. - """ - 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) - - -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 - - def get_distance(self, destination: HexCoordinate) -> float: - """ - Calculates the distance from the current position to the given destination. - - Args: - destination: The destination to calculate the distance to. - - Returns: - float: The distance from the current position to the given destination. - """ - return self.coordinate.distance(destination) - - def get_direction(self, destination: HexCoordinate) -> Vector: - """ - Gets the direction of the move from the current coordinate to the given destination. - - Args: - destination: The destination coordinate. - - Returns: - Vector: The direction of the move. - """ - 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})' - - -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 - - def is_empty(self) -> bool: - """ - :return: True if the field is has no fishes and no penguin, False otherwise. - """ - return True if not self.penguin and self.fish == 0 else False - - def is_occupied(self) -> bool: - """ - :return: True if the field is occupied by a penguin, False otherwise. - """ - return True if self.penguin else False - - def get_fish(self) -> int: - """ - :return: The amount of fish on the field, None if the field is occupied. - """ - return self.fish - - def get_team(self) -> Union[TeamEnum, None]: - """ - :return: The team_enum of the field if it is occupied by penguin, None otherwise. - """ - return None if not self.penguin else self.penguin.team_enum - - def get_value(self) -> Union[TeamEnum, int]: - """ - Returns the current value of the field. If the field has no penguin on it, it returns the number of fish on it, - otherwise it returns the TeamEnum of the penguin on it. - - Returns: - Union[TeamEnum, int]: The current value of the field. - """ - return self.fish if not self.penguin else self.penguin.team_enum - - def get_distance(self, destination: HexCoordinate) -> float: - """ - Calculates the distance from the current position to the given destination. - - Args: - destination: The destination to calculate the distance to. - - Returns: - float: The distance from the current position to the given destination. - """ - return self.coordinate.distance(destination) - - def get_direction(self, destination: HexCoordinate) -> Vector: - """ - Gets the direction of the move from the current coordinate to the given destination. - - Args: - destination: The destination coordinate. - - Returns: - Vector: The direction of the move. - """ - 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}))" - - -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]): - self.name = name - self.fish = fish - self.penguins = penguins - self.moves = moves - - def team(self) -> TeamEnum: - """ - :return: The team_enum object. - """ - return self.name - - def get_penguins(self) -> List[Penguin]: - return self.penguins - - def get_moves(self) -> List[Move]: - return self.moves - - def color(self) -> str: - """ - :return: The name of this team_enum. - """ - if self.name == TeamEnum.ONE: - return TeamEnum.ONE.value - else: - return TeamEnum.TWO.value - - @staticmethod - def opponent() -> None: - warnings.warn("Use the opponent method in GameState.") - return None - - 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 - - -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 - - def get_empty_fields(self) -> List[Field]: - """ - :return: A list of all empty fields. - """ - fields: List[Field] = [] - for row in self.board: - for field in row: - if field.is_empty(): - fields.append(field) - return fields - - def is_occupied(self, coordinates: HexCoordinate) -> bool: - """ - :param coordinates: The coordinates of the field. - :return: True if the field is occupied, false otherwise. - """ - return self.get_field(coordinates).is_occupied() - - def is_valid(self, coordinates: HexCoordinate) -> bool: - """ - Checks if the coordinates are in the boundaries of the board. - - :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() - - def width(self) -> int: - """ - :return: The width of the board. - """ - return len(self.board[0]) - - def height(self) -> int: - """ - :return: The height of the board. - """ - return len(self.board) - - def _get_field(self, x: int, y: int) -> Field: - """ - Gets the field at the given coordinates. - *Used only internally* - - :param x: The x-coordinate of the field. - :param y: The y-coordinate of the field. - :return: The field at the given coordinates. - """ - return self.board[y][x] - - def get_field(self, position: HexCoordinate) -> Field: - """ - Gets the field at the given position. - - :param position: The position of the field. - :return: The field at the given position. - :raise IndexError: If the position is not valid. - """ - cartesian = position.to_cartesian() - if self.is_valid(position): - return self._get_field(cartesian.x, cartesian.y) - - raise IndexError(f"Index out of range: [x={cartesian.x}, y={cartesian.y}]") - - def get_field_or_none(self, position: HexCoordinate) -> Union[Field, None]: - """ - Gets the field at the given position no matter if it is valid or not. - - :param position: The position of the field. - :return: The field at the given position, or None if the position is not valid. - """ - cartesian = position.to_cartesian() - if self.is_valid(position): - return self._get_field(cartesian.x, cartesian.y) - return None - - def get_field_by_index(self, index: int) -> Field: - """ - Gets the field at the given index. The index is the position of the field in the board. - The field of the board is calculated as follows: - - - `x = index / width` - - `y = index % width` - - The index is 0-based. The index is calculated from the top left corner of the board. - - :param index: The index of the field. - :return: The field at the given index. - """ - return self.get_field( - CartesianCoordinate.from_index(index=index, width=self.width(), height=self.height()).to_hex()) - - def get_all_fields(self) -> List[Field]: - """ - Gets all Fields of the board. - - :return: All Fields of the board. - """ - return [field for row in self.board for field in row] - - 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. - """ - if not isinstance(other, Board): - raise TypeError("Can only compare to another Board object") - - fields = [self.board[x][y] for x in range(len(self.board)) for y in range(len(self.board[0])) - if self.board[x][y] != other.board[x][y]] - return fields - - def contains(self, field: Field) -> bool: - """ - Checks if the board contains the given field. - - :param field: The field to check for. - :return: True if the board contains the field, False otherwise. - """ - for row in self.board: - if field in row: - return True - return False - - def contains_all(self, fields: List[Field]) -> bool: - """ - Checks if the board contains all the given fields. - - :param fields: The fields to check for. - :return: True if the board contains all the given fields, False otherwise. - """ - if not fields: - return False - - for field in fields: - if not self.contains(field): - return False - return True - - def get_moves_in_direction(self, origin: HexCoordinate, direction: Vector, team_enum: TeamEnum) -> List[Move]: - """ - Gets all moves in the given direction from the given origin. - - Args: - origin: The origin of the move. - direction: The direction of the move. - team_enum: Team to make moves for. - - Returns: - List[Move]: List of moves that can be made in the given direction from the given index, - for the given team_enum - """ - if not self.get_field(origin).penguin or self.get_field(origin).penguin.team_enum != team_enum: - return [] - - moves = [] - for i in range(1, self.width()): - destination = origin.add_vector(direction.scalar_product(i)) - if self._is_destination_valid(destination): - moves.append(Move(team_enum=team_enum, from_value=origin, to_value=destination)) - else: - break - return moves - - def _is_destination_valid(self, field: HexCoordinate) -> bool: - return self.is_valid(field) and not self.is_occupied(field) and not \ - self.get_field(field).is_empty() - - def possible_moves_from(self, position: HexCoordinate, team_enum: TeamEnum) -> List[Move]: - """ - Returns a list of all possible moves from the given position. That are all moves in all hexagonal directions. - - Args: - position: The position to start from. - team_enum: A list of all possible moves from the given position. - - Returns: - List[Move]: List of all possible moves that can be made from the given index, for the given team_enum - - Raises: - IndexError: If the Index is out of range. - """ - if not self.is_valid(position): - raise IndexError(f"Index out of range: [x={position.x}, y={position.y}]") - if not self.get_field(position).penguin or self.get_field(position).penguin.team_enum != team_enum: - return [] - return [move for direction in Vector().directions for move in - self.get_moves_in_direction(position, direction, team_enum)] - - def get_penguins(self) -> List[Penguin]: - """ - Searches the board for all penguins. - - :return: A list of all Fields that are occupied by a penguin. - """ - return [field.penguin for field in self.get_all_fields() if field.is_occupied()] - - def get_teams_penguins(self, team: TeamEnum) -> List[Penguin]: - """ - Searches the board for all penguins of the given team_enum. - - :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. - """ - penguins = [] - for row in self.board: - for field in row: - if field.penguin and field.penguin.team_enum == team: - penguins.append(field.penguin) - return penguins - - def get_most_fish(self) -> List[Field]: - """ - Returns a list of all fields with the most fish. - - :return: A list of Fields. - """ - - fields = list(filter(lambda field_x: not field_x.is_occupied(), self.get_all_fields())) - fields.sort(key=lambda field_x: field_x.get_fish(), reverse=True) - for i, field in enumerate(fields): - if field.get_fish() < fields[0].get_fish(): - fields = fields[:i] - return fields - - def get_board_intersection(self, other: 'Board') -> List[Field]: - """ - Returns a list of all fields that are in both boards. - - :param other: The other board to compare to. - :return: A list of Fields. - """ - return [field for field in self.get_all_fields() if field in other.get_all_fields()] - - def get_fields_intersection(self, other: List[Field]) -> List[Field]: - """ - Returns a list of all fields that are in both list of Fields. - - :param other: The other list of Fields to compare to. - :return: A list of Fields. - """ - 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 move(self, move: Move) -> 'Board': - """ - Moves the penguin from the origin to the destination. - **Please make sure that the move is correct, because this method will not check that.** - If there is no Penguin to move, than this method will return the current state unchanged. - - :param move: The move to execute. - :return: The new board with the moved penguin. - """ - board_state = copy.deepcopy(self.board) - 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: - logging.error(f"There is no penguin to move. Origin was: {self.get_field(move.from_value)}") - 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 - 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 - - def pretty_print(self): - print() - for i, row in enumerate(self.board): - if (i + 1) % 2 == 0: - print(" ", end="") - for field in row: - if field.is_empty(): - print("~", end=" ") - elif field.is_occupied(): - print(field.get_team().value[0], end=" ") - else: - print(field.get_fish(), end=" ") - print() - print() - - def __eq__(self, other): - if isinstance(other, self.__class__): - return self.board == other.board - return False - - -class GameState: - """ - A `GameState` contains all information, that describes the game state at a given time, that is, between two game - moves. - - This includes: - - the board - - a consecutive turn number (round & turn) and who's turn it is - - the team that has started the game - - the number of fishes each player has - - the last move made - - The `GameState` is thus the central object through which all essential information of the current game can be - accessed. - - Therefore, for easier handling, it offers further aids, such as: - - a method to calculate available moves - - a method to perform a move for simulating future game states - - The game server sends a new copy of the `GameState` to both participating players after each completed move, - 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. - - 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.round = int((self.turn + 1) / 2) - self.current_team = self.current_team_from_turn(self.turn) - self.current_pieces = self.current_team.get_penguins() - self.possible_moves = self._get_possible_moves(self.current_team) - - def _get_possible_moves(self, current_team: Optional[Team]) -> List[Move]: - """ - Gets all possible moves for the current team. - That includes all possible moves from all Fields that are not occupied by a penguin from that team. - - :param current_team: The team to get the possible moves for. - :return: A list of all possible moves from the current player's turn. - """ - current_team = current_team or self.current_team - moves = [] - if len(self.board.get_teams_penguins(current_team.name)) < 4: - for x in range(self.board.width()): - for y in range(self.board.height()): - field = self.board.get_field(CartesianCoordinate(x, y).to_hex()) - if not field.is_occupied() and field.get_fish() == 1: - moves.append( - Move(team_enum=current_team.name, from_value=None, - to_value=CartesianCoordinate(x, y).to_hex())) - else: - for piece in self.board.get_teams_penguins(current_team.name): - moves.extend(self.board.possible_moves_from(piece.coordinate, current_team.name)) - return moves - - def current_team_from_turn(self, turn: int) -> Team: - """ - Calculates the current team from the turn number and available moves. - - :return: The team that has the current turn. - """ - current_team = self.first_team if turn % 2 == 0 else self.second_team - possible_moves = self._get_possible_moves(current_team) - if not possible_moves: - current_team = self.second_team if turn % 2 == 0 else self.first_team - return current_team - - def perform_move(self, move: Move) -> 'GameState': - """ - Performs the given move on the current game state. - - Args: - move: The move that has to be performed. - - Returns: - GameState: The new state of the game after the move. - """ - if self.is_valid_move(move) and self.current_team.name == move.team_enum: - new_board = self.board.move(move) - new_first_team = copy.deepcopy(self.first_team) - new_second_team = copy.deepcopy(self.second_team) - if self.current_team.name == TeamEnum.ONE: - self._update_team(new_first_team, move, new_board) - else: - self._update_team(new_second_team, move, new_board) - new_turn = self.turn + 1 - new_last_move = move - return GameState(board=new_board, turn=new_turn, first_team=new_first_team, second_team=new_second_team, - last_move=new_last_move) - else: - 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: - """ - Helper function to update the given team when a move is performed. - - Args: - team: The team that will be updated. - 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 - - def is_valid_move(self, move: Move) -> bool: - """ - Checks if the given move is valid. - - :param move: The move to check. - :return: True if the move is valid, False otherwise. - """ - return move in self.possible_moves - - def opponent(self) -> Team: - """ - Returns the opponent team of the current team. - - Returns: - Team: The team which is the opponent of the current team. - """ - if self.current_team == self.first_team: - return self.second_team - else: - return self.first_team - - 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/__init__.py b/socha/api/plugin/penguins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/socha/api/plugin/penguins/board.py b/socha/api/plugin/penguins/board.py new file mode 100644 index 0000000..d0b3858 --- /dev/null +++ b/socha/api/plugin/penguins/board.py @@ -0,0 +1,407 @@ +import _pickle as pickle +import logging +import warnings +from typing import List, Union, Optional + +from socha.api.plugin.penguins.coordinate import HexCoordinate, Vector, CartesianCoordinate +from socha.api.plugin.penguins.team import Penguin, TeamEnum, Move + + +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 + + def is_empty(self) -> bool: + """ + :return: True if the field is has no fishes and no penguin, False otherwise. + """ + return True if not self.penguin and self.fish == 0 else False + + def is_occupied(self) -> bool: + """ + :return: True if the field is occupied by a penguin, False otherwise. + """ + return True if self.penguin else False + + def get_fish(self) -> int: + """ + :return: The amount of fish on the field, None if the field is occupied. + """ + return self.fish + + def get_team(self) -> Union[TeamEnum, None]: + """ + :return: The team_enum of the field if it is occupied by penguin, None otherwise. + """ + return None if not self.penguin else self.penguin.team_enum + + def get_value(self) -> Union[TeamEnum, int]: + """ + Returns the current value of the field. If the field has no penguin on it, it returns the number of fish on it, + otherwise it returns the TeamEnum of the penguin on it. + + Returns: + Union[TeamEnum, int]: The current value of the field. + """ + return self.fish if not self.penguin else self.penguin.team_enum + + def get_distance(self, destination: HexCoordinate) -> float: + """ + Calculates the distance from the current position to the given destination. + + Args: + destination: The destination to calculate the distance to. + + Returns: + float: The distance from the current position to the given destination. + """ + return self.coordinate.distance(destination) + + def get_direction(self, destination: HexCoordinate) -> Vector: + """ + Gets the direction of the move from the current coordinate to the given destination. + + Args: + destination: The destination coordinate. + + Returns: + Vector: The direction of the move. + """ + 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}))" + + +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 + + def get_empty_fields(self) -> List[Field]: + """ + :return: A list of all empty fields. + """ + fields: List[Field] = [] + for row in self.board: + for field in row: + if field.is_empty(): + fields.append(field) + return fields + + def is_occupied(self, coordinates: HexCoordinate) -> bool: + """ + :param coordinates: The coordinates of the field. + :return: True if the field is occupied, false otherwise. + """ + return self.get_field(coordinates).is_occupied() + + def is_valid(self, coordinates: HexCoordinate) -> bool: + """ + Checks if the coordinates are in the boundaries of the board. + + :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() + + def width(self) -> int: + """ + :return: The width of the board. + """ + return len(self.board[0]) + + def height(self) -> int: + """ + :return: The height of the board. + """ + return len(self.board) + + def _get_field(self, x: int, y: int) -> Field: + """ + Gets the field at the given coordinates. + *Used only internally* + + :param x: The x-coordinate of the field. + :param y: The y-coordinate of the field. + :return: The field at the given coordinates. + """ + return self.board[y][x] + + def get_field(self, position: HexCoordinate) -> Field: + """ + Gets the field at the given position. + + :param position: The position of the field. + :return: The field at the given position. + :raise IndexError: If the position is not valid. + """ + cartesian = position.to_cartesian() + if self.is_valid(position): + return self._get_field(cartesian.x, cartesian.y) + + raise IndexError(f"Index out of range: [x={cartesian.x}, y={cartesian.y}]") + + def get_field_or_none(self, position: HexCoordinate) -> Union[Field, None]: + """ + Gets the field at the given position no matter if it is valid or not. + + :param position: The position of the field. + :return: The field at the given position, or None if the position is not valid. + """ + cartesian = position.to_cartesian() + if self.is_valid(position): + return self._get_field(cartesian.x, cartesian.y) + return None + + def get_field_by_index(self, index: int) -> Field: + """ + Gets the field at the given index. The index is the position of the field in the board. + The field of the board is calculated as follows: + + - `x = index / width` + - `y = index % width` + - The index is 0-based. The index is calculated from the top left corner of the board. + + :param index: The index of the field. + :return: The field at the given index. + """ + return self.get_field( + CartesianCoordinate.from_index(index=index, width=self.width(), height=self.height()).to_hex()) + + def get_all_fields(self) -> List[Field]: + """ + Gets all Fields of the board. + + :return: All Fields of the board. + """ + return [field for row in self.board for field in row] + + 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. + """ + if not isinstance(other, Board): + raise TypeError("Can only compare to another Board object") + + fields = [self.board[x][y] for x in range(len(self.board)) for y in range(len(self.board[0])) + if self.board[x][y] != other.board[x][y]] + return fields + + def contains(self, field: Field) -> bool: + """ + Checks if the board contains the given field. + + :param field: The field to check for. + :return: True if the board contains the field, False otherwise. + """ + for row in self.board: + if field in row: + return True + return False + + def contains_all(self, fields: List[Field]) -> bool: + """ + Checks if the board contains all the given fields. + + :param fields: The fields to check for. + :return: True if the board contains all the given fields, False otherwise. + """ + if not fields: + return False + + for field in fields: + if not self.contains(field): + return False + return True + + def get_moves_in_direction(self, origin: HexCoordinate, direction: Vector, team_enum: Optional[TeamEnum] = None) \ + -> List[Move]: + """ + Gets all moves in the given direction from the given origin. + + Args: + origin: The origin of the move. + direction: The direction of the move. + team_enum: Team to make moves for. + + Returns: + List[Move]: List of moves that can be made in the given direction from the given index, + for the given team_enum + """ + if team_enum is None: + team_enum = self.get_field(origin).penguin.team_enum + if not self.get_field(origin).penguin or self.get_field(origin).penguin.team_enum != team_enum: + return [] + + moves = [] + for i in range(1, self.width()): + destination = origin.add_vector(direction.scalar_product(i)) + if self._is_destination_valid(destination): + moves.append(Move(team_enum=team_enum, from_value=origin, to_value=destination)) + else: + break + return moves + + def _is_destination_valid(self, field: HexCoordinate) -> bool: + return self.is_valid(field) and not self.is_occupied(field) and not \ + self.get_field(field).is_empty() + + 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. + + Args: + position: The position to start from. + team_enum: A list of all possible moves from the given position. + + Returns: + List[Move]: List of all possible moves that can be made from the given index, for the given team_enum + + Raises: + IndexError: If the Index is out of range. + """ + if not self.is_valid(position): + raise IndexError(f"Index out of range: [x={position.x}, y={position.y}]") + if not self.get_field(position).penguin or ( + team_enum and self.get_field(position).penguin.team_enum != team_enum): + return [] + return [move for direction in Vector().directions for move in + self.get_moves_in_direction(position, direction, team_enum)] + + def get_penguins(self) -> List[Penguin]: + """ + Searches the board for all penguins. + + :return: A list of all Fields that are occupied by a penguin. + """ + return [field.penguin for field in self.get_all_fields() if field.is_occupied()] + + def get_teams_penguins(self, team: TeamEnum) -> List[Penguin]: + """ + Searches the board for all penguins of the given team_enum. + + :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. + """ + penguins = [] + for row in self.board: + for field in row: + if field.penguin and field.penguin.team_enum == team: + penguins.append(field.penguin) + return penguins + + def get_most_fish(self) -> List[Field]: + """ + Returns a list of all fields with the most fish. + + :return: A list of Fields. + """ + + fields = list(filter(lambda field_x: not field_x.is_occupied(), self.get_all_fields())) + fields.sort(key=lambda field_x: field_x.get_fish(), reverse=True) + for i, field in enumerate(fields): + if field.get_fish() < fields[0].get_fish(): + fields = fields[:i] + return fields + + def get_board_intersection(self, other: 'Board') -> List[Field]: + """ + Returns a list of all fields that are in both boards. + + :param other: The other board to compare to. + :return: A list of Fields. + """ + return [field for field in self.get_all_fields() if field in other.get_all_fields()] + + def get_fields_intersection(self, other: List[Field]) -> List[Field]: + """ + Returns a list of all fields that are in both list of Fields. + + :param other: The other list of Fields to compare to. + :return: A list of Fields. + """ + 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 move(self, move: Move) -> 'Board': + """ + Moves the penguin from the origin to the destination. + **Please make sure that the move is correct, because this method will not check that.** + If there is no Penguin to move, than this method will return the current state unchanged. + + :param move: The move to execute. + :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: + logging.error(f"There is no penguin to move. Origin was: {self.get_field(move.from_value)}") + 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 + 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 + + def pretty_print(self): + print() + for i, row in enumerate(self.board): + if (i + 1) % 2 == 0: + print(" ", end="") + for field in row: + if field.is_empty(): + print("~", end=" ") + elif field.is_occupied(): + print(field.get_team().value[0], end=" ") + else: + print(field.get_fish(), end=" ") + print() + 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 new file mode 100644 index 0000000..1137db6 --- /dev/null +++ b/socha/api/plugin/penguins/coordinate.py @@ -0,0 +1,285 @@ +import math +from typing import List, Optional + + +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 + + def magnitude(self) -> float: + """ + Calculates the length of the vector. + + :return: The length of the vector. + """ + return (self.d_x ** 2 + self.d_y ** 2) ** 0.5 + + def dot_product(self, other: 'Vector'): + """ + Calculates the dot product of two vectors. + + :param other: The other vector to calculate the dot product with. + :return: The dot product of the two vectors. + """ + return self.d_x * other.d_x + self.d_y * other.d_y + + def cross_product(self, other: 'Vector'): + """ + Calculates the cross product of two vectors. + + :param other: The other vector to calculate the cross product with. + :return: The cross product of the two vectors. + """ + return self.d_x * other.d_y - self.d_y * other.d_x + + def scalar_product(self, scalar: int): + """ + Extends the vector by a scalar. + + :param scalar: The scalar to extend the vector by. + :return: The extended vector. + """ + return Vector(self.d_x * scalar, self.d_y * scalar) + + def addition(self, other: 'Vector'): + """ + Adds two vectors. + + :param other: The other vector to add. + :return: The sum of the two vectors as a new vector object. + """ + return Vector(self.d_x + other.d_x, self.d_y + other.d_y) + + def subtraction(self, other: 'Vector'): + """ + Subtracts two vectors. + + :param other: The other vector to subtract. + :return: The difference of the two vectors as a new vector object. + """ + return Vector(self.d_x - other.d_x, self.d_y - other.d_y) + + def get_arc_tangent(self) -> float: + """ + Calculates the arc tangent of the vector. + + :return: A radiant in float. + """ + return math.atan2(self.d_y, self.d_x) + + def are_identically(self, other: 'Vector'): + """ + Compares two vectors. + + :param other: The other vector to compare to. + :return: True if the vectors are equal, false otherwise. + """ + return self.d_x == other.d_x and self.d_y == other.d_y + + def are_equal(self, other: 'Vector'): + """ + Checks if two vectors have the same magnitude and direction. + + :param other: The other vector to compare to. + :return: True if the vectors are equal, false otherwise. + """ + return self.magnitude() == other.magnitude() and self.get_arc_tangent() == other.get_arc_tangent() + + @property + def directions(self) -> List['Vector']: + """ + Gets the six neighbors of the vector. + + :return: A list of the six neighbors of the vector. + """ + return [ + Vector(1, -1), # UP RIGHT + Vector(-2, 0), # LEFT + Vector(1, 1), # DOWN RIGHT + Vector(-1, 1), # DOWN LEFT + Vector(2, 0), # Right + Vector(-1, -1) # UP LEFT + ] + + def is_one_hex_move(self): + """ + Checks if the vector points to a hexagonal field that is a direct neighbor. + + :return: True if the vector is a one hex move, false otherwise. + """ + 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 + + +class Coordinate: + def __init__(self, x: int, y: int): + self.x = x + self.y = y + + def to_vector(self) -> Vector: + """ + Converts the coordinate to a vector. + """ + return Vector(d_x=self.x, d_y=self.y) + + def distance(self, other: 'Coordinate') -> float: + """ + Calculates the distance between two coordinates. + + :param other: The other coordinate to calculate the distance to. + :return: The distance between the two cartesian coordinates. + """ + return self.to_vector().subtraction(other.to_vector()).magnitude() + + def add_vector(self, vector: Vector): ... + + def subtract_vector(self, vector: Vector): ... + + +class CartesianCoordinate(Coordinate): + """ + Represents a coordinate in a normal cartesian coordinate system, that has been taught in school. + This class is used to translate and represent a hexagonal coordinate in a cartesian and with that a 2D-Array. + """ + + def add_vector(self, vector: Vector) -> 'CartesianCoordinate': + """ + Adds a vector to the cartesian coordinate. + + :param vector: The vector to add. + :return: The new cartesian coordinate. + """ + vector: Vector = self.to_vector().addition(vector) + return CartesianCoordinate(x=vector.d_x, y=vector.d_y) + + def subtract_vector(self, vector: Vector) -> 'CartesianCoordinate': + """ + Subtracts a vector from the cartesian coordinate. + + :param vector: The vector to subtract. + :return: The new cartesian coordinate. + """ + vector: Vector = self.to_vector().subtraction(vector) + return CartesianCoordinate(x=vector.d_x, y=vector.d_y) + + def to_hex(self) -> 'HexCoordinate': + """ + Converts the cartesian coordinate to a hex coordinate. + + :return: The hex coordinate. + """ + return HexCoordinate(x=self.x * 2 + (1 if self.y % 2 == 1 else 0), y=self.y) + + def to_index(self) -> Optional[int]: + """ + Converts the cartesian coordinate to an index. + + :return: The index or None if the coordinate is not valid. + """ + if 0 <= self.x <= 7 and 0 <= self.y <= 7: + return self.y * 8 + self.x + return None + + @staticmethod + def from_index(index: int, width: int, height: int) -> Optional['CartesianCoordinate']: + """ + Converts a given index to a CartesianCoordinate. + + Args: + index: The index to convert. + width: The width of the grid. + height: The height of the grid. + + Returns: + Optional[CartesianCoordinate]: The CartesianCoordinate that corresponds to the given index, or None if the + index is out of range. + """ + if index < 0 or index >= width * height: + raise IndexError(f"Index out of range. The index has to be 0 <= {index} < {width * height}") + x = index % width + 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 + + +class HexCoordinate(Coordinate): + """ + Represents a coordinate in a hexagonal coordinate system, that differs from the normal cartesian one. + This class is used to represent the hexagonal game board. + """ + + def to_cartesian(self) -> CartesianCoordinate: + """ + Converts the hex coordinate to a cartesian coordinate. + + :return: The cartesian coordinate. + """ + return CartesianCoordinate(x=math.floor((self.x / 2 - (1 if self.y % 2 == 1 else 0)) + 0.5), y=self.y) + + def add_vector(self, vector: Vector) -> 'HexCoordinate': + """ + Adds a vector to the hex coordinate. + + :param vector: The vector to add. + :return: The new hex coordinate. + """ + vector: Vector = self.to_vector().addition(vector) + return HexCoordinate(x=vector.d_x, y=vector.d_y) + + def subtract_vector(self, vector: Vector) -> 'HexCoordinate': + """ + Subtracts a vector from the hex coordinate. + + :param vector: The vector to subtract. + :return: The new hex coordinate. + """ + vector: Vector = self.to_vector().subtraction(vector) + return HexCoordinate(x=vector.d_x, y=vector.d_y) + + def get_neighbors(self) -> List['HexCoordinate']: + """ + Returns a list of all neighbors of the hex coordinate. + + :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 new file mode 100644 index 0000000..b94a2e9 --- /dev/null +++ b/socha/api/plugin/penguins/game_state.py @@ -0,0 +1,176 @@ +import _pickle as pickle +import logging +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 + + +class GameState: + """ + A `GameState` contains all information, that describes the game state at a given time, that is, between two game + moves. + + This includes: + - the board + - a consecutive turn number (round & turn) and who's turn it is + - the team that has started the game + - the number of fishes each player has + - the last move made + + The `GameState` is thus the central object through which all essential information of the current game can be + accessed. + + Therefore, for easier handling, it offers further aids, such as: + - a method to calculate available moves + - a method to perform a move for simulating future game states + + The game server sends a new copy of the `GameState` to both participating players after each completed move, + 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. + + 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 + + @property + def round(self): + return int((self.turn + 1) / 2) + + @property + def current_team(self): + return self.current_team_from_turn(self.turn) + + @property + def other_team(self): + return self.current_team.opponent + + @property + def current_pieces(self): + return self.current_team.get_penguins() + + @property + def possible_moves(self): + return self._get_possible_moves(self.current_team) + + def _get_possible_moves(self, current_team: Optional[Team]) -> List[Move]: + """ + Gets all possible moves for the current team. + That includes all possible moves from all Fields that are not occupied by a penguin from that team. + + :param current_team: The team to get the possible moves for. + :return: A list of all possible moves from the current player's turn. + """ + current_team = current_team or self.current_team + moves = [] + + if not current_team: + return moves + + if len(self.board.get_teams_penguins(current_team.name)) < 4: + for x in range(self.board.width()): + for y in range(self.board.height()): + field = self.board.get_field(CartesianCoordinate(x, y).to_hex()) + if not field.is_occupied() and field.get_fish() == 1: + moves.append( + Move(team_enum=current_team.name, from_value=None, + to_value=CartesianCoordinate(x, y).to_hex())) + else: + for piece in self.board.get_teams_penguins(current_team.name): + moves.extend(self.board.possible_moves_from(piece.coordinate, current_team.name)) + return moves + + def current_team_from_turn(self, turn: int) -> Team: + """ + Calculates the current team from the turn number and available moves. + + :return: The team that has the current turn. + """ + possible_moves_first = self._get_possible_moves(self.first_team) + possible_moves_second = self._get_possible_moves(self.second_team) + if turn % 2 == 0: + return self.first_team if possible_moves_first else self.second_team if possible_moves_second else None + else: + return self.second_team if possible_moves_second else self.first_team if possible_moves_first else None + + def perform_move(self, move: Move) -> 'GameState': + """ + Performs the given move on the current game state. + + Args: + move: The move that has to be performed. + + Returns: + GameState: The new state of the game after the move. + """ + if self.is_valid_move(move) and self.current_team.name == move.team_enum: + new_board = self.board.move(move) + new_first_team = pickle.loads(pickle.dumps(self.first_team, protocol=-1)) + new_second_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) + else: + self._update_team(new_second_team, move, new_board) + new_turn = self.turn + 1 + new_last_move = move + return GameState(board=new_board, turn=new_turn, first_team=new_first_team, second_team=new_second_team, + last_move=new_last_move) + else: + 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: + """ + Helper function to update the given team when a move is performed. + + Args: + team: The team that will be updated. + 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 + + def is_valid_move(self, move: Move) -> bool: + """ + Checks if the given move is valid. + + :param move: The move to check. + :return: True if the move is valid, False otherwise. + """ + return move in self.possible_moves + + def opponent(self, team: Optional[Team] = None) -> Team: + """ + Returns the opponent team of the current team. + + Returns: + Team: The team which is the opponent of the current 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 new file mode 100644 index 0000000..55df38b --- /dev/null +++ b/socha/api/plugin/penguins/team.py @@ -0,0 +1,145 @@ +from enum import Enum +from typing import List, Optional + +from socha.api.plugin.penguins.coordinate import HexCoordinate, Vector + + +class TeamEnum(Enum): + ONE = "ONE" + TWO = "TWO" + + +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 + + def get_delta(self) -> float: + """ + This method calculates and returns the difference in distance between the to_value and from_value properties + of the Move object. If the from_value is not initialized, the distance is calculated between the to_value and + itself. + + :return: The delta of the move as a float. + """ + return self.to_value.distance(self.to_value if not self.from_value else self.from_value) + + def reversed(self): + """ + This method returns a new Move object with the from_value and to_value properties reversed. + If the current Move object is not initialized with a from_value, the method returns the current object. + + :return: The reversed move or the current move. + """ + 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) + + +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 + + def get_distance(self, destination: HexCoordinate) -> float: + """ + Calculates the distance from the current position to the given destination. + + Args: + destination: The destination to calculate the distance to. + + Returns: + float: The distance from the current position to the given destination. + """ + return self.coordinate.distance(destination) + + def get_direction(self, destination: HexCoordinate) -> Vector: + """ + Gets the direction of the move from the current coordinate to the given destination. + + Args: + destination: The destination coordinate. + + Returns: + Vector: The direction of the move. + """ + 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})' + + +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 + + def team(self) -> TeamEnum: + """ + :return: The team_enum object. + """ + return self.name + + def get_penguins(self) -> List[Penguin]: + return self.penguins + + def get_moves(self) -> List[Move]: + return self.moves + + def color(self) -> str: + """ + :return: The name of this team_enum. + """ + if self.name == TeamEnum.ONE: + return TeamEnum.ONE.value + else: + 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 diff --git a/socha/api/protocol/protocol.py b/socha/api/protocol/protocol.py index f112e66..a7b958e 100644 --- a/socha/api/protocol/protocol.py +++ b/socha/api/protocol/protocol.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, field from typing import List, Optional, Union -from socha.api.plugin.penguins import Team +from socha.api.plugin.penguins.team import Team from socha.api.protocol.protocol_packet import AdminLobbyRequest, ResponsePacket, ProtocolPacket, LobbyRequest from socha.api.protocol.room_message import RoomOrchestrationMessage, RoomMessage, \ ObservableRoomMessage diff --git a/socha/starter.py b/socha/starter.py index d1199d3..ba1080f 100644 --- a/socha/starter.py +++ b/socha/starter.py @@ -5,6 +5,10 @@ import datetime import logging +import pkg_resources +import urllib.request +import json + from socha.api.networking.game_client import GameClient, IClientHandler from socha.utils.package_builder import SochaPackageBuilder @@ -38,6 +42,8 @@ def __init__(self, logic: IClientHandler, host: str = "localhost", port: int = 1 self.verbose = args.verbose or verbose self._setup_debugger(self.verbose) + self.check_socha_version() + self.build: str = args.build or build if self.build: builder = SochaPackageBuilder(self.build) @@ -78,6 +84,24 @@ def _setup_debugger(self, verbose: bool): "https://github.com/FalconsSky/socha-python-client/blob/master/changes.md\n" "finden, oder mir eine E-Mail oder Nachricht auf Discord schreiben.") + @staticmethod + def check_socha_version(): + package_name = 'socha' + try: + installed_version = pkg_resources.get_distribution(package_name).version + response = urllib.request.urlopen(f"https://pypi.org/pypi/{package_name}/json") + json_data = json.loads(response.read()) + latest_version = json_data['info']['version'] + if installed_version != latest_version: + logging.warning( + f"A newer version ({latest_version}) of {package_name} is available. You have version " + f"{installed_version}.") + except pkg_resources.DistributionNotFound: + logging.error(f"{package_name} is not installed.") + except urllib.error.URLError as e: + logging.warning( + f"Could not check the latest version of {package_name} due to {type(e).__name__}: {e}") + @staticmethod def _handle_start_args(): parser = argparse.ArgumentParser(description='All arguments are optional.', add_help=False, diff --git a/socha/utils/package_builder.py b/socha/utils/package_builder.py index 5429f9b..df447de 100644 --- a/socha/utils/package_builder.py +++ b/socha/utils/package_builder.py @@ -4,6 +4,7 @@ import shutil import subprocess import sys +import time import zipfile @@ -14,6 +15,7 @@ def __init__(self, package_name): self.dependencies_dir = 'dependencies' self.packages_dir = 'packages' self.cache_dir = '.pip_cache' + self.start_time = time.time_ns() def _download_dependencies(self): current_dir = os.getcwd() @@ -66,14 +68,21 @@ def _copy_scripts(self): """ logging.info(f'Copying python files to {self.package_name}') source_folder = os.getcwd() + start_files = set() # Set of file paths that exist at the beginning of the copying process + for root, dirs, files in os.walk(source_folder): + for file in files: + if file.endswith('.py'): + source_file_path = os.path.join(root, file) + start_files.add(source_file_path) # Add the file path to the set for root, dirs, files in os.walk(source_folder): for file in files: if file.endswith('.py'): source_file_path = os.path.join(root, file) target_file_path = os.path.join(self.package_name, os.path.relpath(source_file_path, source_folder)) - 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 start_files: # Only copy files that exist at the beginning + 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}') def _create_shell_script(self): logging.info(f'Creating shell script {self.package_name}/start.sh') diff --git a/tests/test_penguins.py b/tests/test_penguins.py index bc50bd6..dc2127e 100644 --- a/tests/test_penguins.py +++ b/tests/test_penguins.py @@ -1,6 +1,9 @@ +import copy import unittest -from socha.api.plugin.penguins import * +from socha.api.plugin.penguins.board import * +from socha.api.plugin.penguins.coordinate import * +from socha.api.plugin.penguins.game_state import * class TestVector(unittest.TestCase): @@ -224,9 +227,6 @@ def test_get_moves(self): def test_color(self): self.assertEqual(self.team_one.color(), TeamEnum.ONE.value) - def test_opponent(self): - self.assertWarns(Warning, self.team_one.opponent) - def test_eq(self): team = Team(name=TeamEnum.ONE, fish=10, penguins=[Penguin(coordinate=HexCoordinate(1, 1), team_enum=TeamEnum.ONE)], @@ -395,3 +395,91 @@ def test_get_teams_penguins(self): 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) + + +class TestGameState(unittest.TestCase): + def setUp(self): + self.coord1 = CartesianCoordinate(0, 0).to_hex() + self.coord2 = CartesianCoordinate(1, 0).to_hex() + self.coord3 = CartesianCoordinate(0, 1).to_hex() + self.coord4 = CartesianCoordinate(1, 1).to_hex() + self.coord5 = CartesianCoordinate(0, 2).to_hex() + self.coord6 = CartesianCoordinate(1, 2).to_hex() + self.coord7 = CartesianCoordinate(0, 3).to_hex() + self.coord8 = CartesianCoordinate(1, 3).to_hex() + self.coord9 = CartesianCoordinate(0, 4).to_hex() + self.coord10 = CartesianCoordinate(1, 4).to_hex() + self.penguin1 = Penguin(self.coord2, TeamEnum.ONE) + self.penguin2 = Penguin(self.coord7, TeamEnum.TWO) + self.field1 = Field(self.coord1, None, 1) + self.field2 = Field(self.coord2, self.penguin1, 1) + self.field3 = Field(self.coord3, None, 2) + self.field4 = Field(self.coord4, None, 3) + self.field5 = Field(self.coord5, None, 4) + self.field6 = Field(self.coord6, None, 1) + self.field7 = Field(self.coord7, self.penguin2, 1) + self.field8 = Field(self.coord8, None, 2) + self.field9 = Field(self.coord9, None, 3) + self.field10 = Field(self.coord10, None, 4) + self.game_field = [[self.field1, self.field2], + [self.field3, self.field4], + [self.field5, self.field6], + [self.field7, self.field8], + [self.field9, self.field10]] + self.board = Board(self.game_field) + self.first_team = Team(TeamEnum.ONE, 0, [], []) + self.second_team = Team(TeamEnum.TWO, 0, [], []) + self.first_team.opponent = self.second_team + self.second_team.opponent = self.first_team + self.game_state = GameState(self.board, 0, self.first_team, self.second_team, None) + + def test_round(self): + self.assertEqual(self.game_state.round, 0) + + 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.round, 1) + + move = Move(team_enum=TeamEnum.TWO, from_value=None, to_value=HexCoordinate(2, 2)) + new_game_state = new_game_state.perform_move(move) + self.assertEqual(new_game_state.round, 1) + + def test_current_team(self): + self.assertEqual(self.game_state.current_team, self.first_team) + + 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) + + move = Move(team_enum=TeamEnum.TWO, from_value=None, to_value=HexCoordinate(2, 2)) + new_game_state = new_game_state.perform_move(move) + self.assertIsNone(new_game_state.current_team) + + def test_other_team(self): + self.assertEqual(self.game_state.other_team, self.second_team) + + 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.other_team, self.first_team) + + move = Move(team_enum=TeamEnum.TWO, from_value=None, to_value=HexCoordinate(2, 2)) + new_game_state = new_game_state.perform_move(move) + self.assertIsNone(new_game_state.current_team) + + def test_possible_moves(self): + moves = self.game_state.possible_moves + self.assertEqual(len(moves), 2) + expected_moves = [ + Move(team_enum=TeamEnum.ONE, from_value=None, to_value=HexCoordinate(0, 0)), + Move(team_enum=TeamEnum.ONE, from_value=None, to_value=HexCoordinate(2, 2)), + ] + self.assertEqual(moves, expected_moves) + + 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) + moves = new_game_state.possible_moves + self.assertEqual(len(moves), 1) + expected_moves = [ + Move(team_enum=TeamEnum.TWO, from_value=None, to_value=HexCoordinate(2, 2)), + ] + self.assertEqual(moves, expected_moves)