diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..6b81d1f --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,26 @@ +name: Deploy Package + +on: + release: + types: [published] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml new file mode 100644 index 0000000..77de04d --- /dev/null +++ b/.github/workflows/push.yaml @@ -0,0 +1,29 @@ +name: Push Action + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: '3.10' + - name: Dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 ./src/socha/ --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 ./src/socha/ --count --exit-zero --extend-ignore=F403,F405 --max-line-length=120 --statistics + - name: Unittest + run: | + python -m unittest \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e6ca62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,126 @@ +# Logs +log* + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# poetry +poetry.lock + +# pdm +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +.idea/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..418dc9a --- /dev/null +++ b/LICENSE @@ -0,0 +1,165 @@ +GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ccbb2ad --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +##

Software-Challenge Logo

+ +# Python Client for the Software-Challenge Germany 2023 + +> Please note that this is a very early version, which may still contain some bugs. +> However, the client is able to play a game from start to end. +> +> If you have any questions about this package, you can write a issue +> or for faster answer write a message on our [Discord](https://discord.gg/ARZamDptG5) server. +> +> If you find bugs, +> or have suggestions for improvements, +> please post an issue, +> or contribute to the project yourself. +> +> Thanks a lot! + +This repository contains the Python package for the +[Software-Challenge Germany](https://www.software-challenge.de), a programming competition for students. The students +have to develop an artificial intelligence that plays and competes against other opponents in an annually changing game. + +> This year it is the game +> **[Hey, danke für den Fisch!](https://docs.software-challenge.de/spiele/penguins)**. + +## Installation + +The installation is quite simple with pip. + +```commandline +pip install socha +``` + +If you want to install the package manually, then you have to download the release of your choice, unpack the package +and then run `setup.py` with Python. + +```commandline +python --user setup.py install +``` + +This should satisfy the dependencies and you can start right away. + +## Getting Started + +If you want to start with the Software-Challenge Python client, you have to import some dependencies first. + +- Your logic must inherit from the `IClientHandler` in order for it to communicate correctly with the API. +- Furthermore, you should import the plugin of this year's game so that you can communicate with the `GameState` + and use other functionalities. +- To make your player start when the script is executed, you have to import the `Starter` and call it later. + +````python +from socha.api.networking.PlayerClient import IClientHandler +from socha.api.plugin.Penguins import * +from socha.Starter import Starter +```` + +If you now want to develop and implement your logic, then the structure of the class should look like this. + +````python +class Logic(IClientHandler): + gameState: GameState + + def calculate_move(self) -> Move: + possibleMoves = self.gameState.get_possible_moves() + return possibleMoves[0] + + def on_update(self, state: GameState): + self.gameState = state + + def on_error(self, logMessage: str): + ... +```` + +The above example is the simplest working Logic you can build. As you can see the Logic must inherit from +the `IClientHandler`, so that you can overwrite its methods and the api knows where to find your logic. + +If you're done with your version of an working player, than you have to finish your file with this function, where you +call the Starter with your desired arguments. + +````python +if __name__ == "__main__": + Starter("localhost", 13050, Logic()) +```` \ No newline at end of file diff --git a/logic.py b/logic.py new file mode 100644 index 0000000..8520e8e --- /dev/null +++ b/logic.py @@ -0,0 +1,23 @@ +import random + +from src.socha.starter import Starter +from src.socha.api.networking.player_client import IClientHandler +from src.socha.api.plugin.penguins import GameState, Move + + +class Logic(IClientHandler): + gameState: GameState + + def calculate_move(self) -> Move: + mostFish = self.gameState.get_most_fish_moves() + return mostFish[random.randint(0, len(mostFish) - 1)] + + def on_update(self, state: GameState): + self.gameState = state + + def on_error(self, logMessage: str): + ... + + +if __name__ == "__main__": + Starter("Localhost", 13050, Logic()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..be3d16d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = [ + "hatchling", + "xsdata ~= 22.8" +] +build-backend = "hatchling.build" + +[project] +name = "socha" +version = "0.9.0" +authors = [ + { name = "FalconsSky", 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." +readme = "README.md" +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", + "Operating System :: OS Independent", +] +[project.urls] +"Homepage" = "https://github.com/FalconsSky/Software-Challenge-Python-Client" +"Bug Tracker" = "https://github.com/FalconsSky/Software-Challenge-Python-Client/issues" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d745fba --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +xsdata~=22.8 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e731c15 --- /dev/null +++ b/setup.py @@ -0,0 +1,14 @@ +from setuptools import setup + +setup( + name='socha', + version='0.9.0', + packages=['src', 'src.socha', 'src.socha.api', 'src.socha.api.plugin', 'src.socha.api.protocol', + 'src.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. ' +) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/socha/__init__.py b/src/socha/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/socha/api/__init__.py b/src/socha/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/socha/api/networking/__init__.py b/src/socha/api/networking/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/socha/api/networking/_network_interface.py b/src/socha/api/networking/_network_interface.py new file mode 100644 index 0000000..28dd93d --- /dev/null +++ b/src/socha/api/networking/_network_interface.py @@ -0,0 +1,87 @@ +""" +Handels the tcp connection to the server. +""" +import logging +import re +import socket +from queue import Queue + + +class _NetworkInterface: + """ + This interface handels all package transfers. It'll send and _receive data from a given connection. + """ + + def __init__(self, host="localhost", port=13050, timeout=5): + """ + :param host: Host of the server. Default is localhost. + :param port: Port of the server. Default is 13050. + :param timeout: Timeout for receiving data from the server. Default are 10 seconds. + """ + self.host = host + self.port = port + self.connected: bool = False + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.settimeout(timeout) + + self.queue = Queue() + self.buffer: bytes = b"" + + def connect(self): + """ + Connects the socket to the server and will be ready to listen for and send data. + """ + self.socket.connect((self.host, self.port)) + self.connected = True + logging.info("Connected to server.") + + def close(self): + """ + Closes the connection to the server. + """ + self.socket.close() + self.connected = False + logging.info("Closed connection.") + + def send(self, data: bytes): + """ + Sends the data to the server. It puts the data in the sending queue and the _SocketHandler thread will get + and send it. + :param data: The data that is being sent as string. + """ + self.socket.sendall(data) + logging.debug("Sent data: %s", data.decode("utf-8")) + + def receive_socket_data(self) -> bytes | None: + """ + Receives the raw tcp socket packages. + :return: A package in bytes, None if there where no packages. + """ + try: + data = self.socket.recv(16129) + return data + except socket.timeout: + return None + except ConnectionResetError: + logging.error("The remote host closed the connection unexpectedly.") + self.close() + + def receive(self) -> bytes: + """ + Appends all incoming packages into one and tries to find any protocol related data. + :return: The protocol object. + """ + room_regex = re.compile(br"") + tag_regex = re.compile(br"<.*/>") + while True: + chunk = self.receive_socket_data() + if chunk: + self.buffer += chunk + if room_regex.search(self.buffer): + receive = room_regex.search(self.buffer).group() + self.buffer = self.buffer.replace(receive, b"") + return receive + if tag_regex.search(self.buffer): + receive = tag_regex.search(self.buffer).group() + self.buffer = self.buffer.replace(receive, b"") + return receive diff --git a/src/socha/api/networking/_xflux.py b/src/socha/api/networking/_xflux.py new file mode 100644 index 0000000..8542a7f --- /dev/null +++ b/src/socha/api/networking/_xflux.py @@ -0,0 +1,182 @@ +""" +Here are all incoming byte streams and all outgoing protocol objects handelt. +""" +import logging +import sys + +from xsdata.formats.dataclass.context import XmlContext +from xsdata.formats.dataclass.parsers import XmlParser +from xsdata.formats.dataclass.parsers.config import ParserConfig +from xsdata.formats.dataclass.parsers.handlers import XmlEventHandler +from xsdata.formats.dataclass.serializers import XmlSerializer +from xsdata.formats.dataclass.serializers.config import SerializerConfig + +from src.socha.api.networking._network_interface import _NetworkInterface +from src.socha.api.plugin.penguins import Move +from src.socha.api.protocol.protocol import * + + +def customClassFactory(clazz, params: dict): + if clazz.__name__ == "Data": + try: + params.pop("class_binding") + except KeyError: + ... + if params.get("class_value") == "welcomeMessage": + welcome_message = WelcomeMessage(Team(params.get("color"))) + return clazz(class_binding=welcome_message, **params) + elif params.get("class_value") == "memento": + state_object = params.get("state") + return clazz(class_binding=state_object, **params) + elif params.get("class_value") == "moveRequest": + move_request_object = MoveRequest() + return clazz(class_binding=move_request_object, **params) + elif params.get("class_value") == "result": + result_object = Result(definition=params.get("definition"), scores=params.get("scores"), + winner=params.get("winner")) + return clazz(class_binding=result_object, **params) + elif params.get("class_value") == "error": + raise TypeError("Error Class not found!") + + return clazz(**params) + + +class _XFlux: + """ + Serialize and deserialize objects to and from XML. + """ + + def __init__(self): + context = XmlContext() + deserialize_config = ParserConfig(class_factory=customClassFactory) + self.deserializer = XmlParser(handler=XmlEventHandler, context=context, config=deserialize_config) + + serialize_config = SerializerConfig(pretty_print=True, xml_declaration=False) + self.serializer = XmlSerializer(config=serialize_config) + + def deserialize_object(self, byteStream: bytes) -> ProtocolPacket: + """ + Deserialize a xml byte stream to a ProtocolPacket. + :param byteStream: The byte stream to deserialize. + :return: The deserialized ProtocolPacket child. + """ + parsed = self.deserializer.from_bytes(byteStream) + return parsed + + def serialize_object(self, object_class: object) -> bytes: + """ + Serialize a ProtocolPacket child to a xml byte stream. + :param object_class: The ProtocolPacket child to serialize. + :return: The serialized byte stream. + """ + if isinstance(object_class, Move): + from_value = From(x=object_class.from_value.x, y=object_class.from_value.y) + to_value = To(x=object_class.to_value.x, y=object_class.to_value.y) + data = Data(class_value="move", from_value=from_value, to=to_value) + return self.serializer.render(data).encode("utf-8") + + return self.serializer.render(object_class).encode("utf-8") + + +class _XFluxClient: + """ + Streams data from and to the server. + """ + + def __init__(self, host: str, port: int): + """ + :param host: Host of the server. + :param port: Port of the server. + """ + self.network_interface = _NetworkInterface(host, port) + self.connect_to_server() + self.x_flux = _XFlux() + self.running = False + self.first_time = True + + def start(self): + """ + Starts the client loop. + """ + self.running = True + self._client_loop() + + def _client_loop(self): + """ + The client loop. + This is the main loop, + where the client waites for messages from the server + and handles them accordingly. + """ + while self.running: + response = self._receive() + if isinstance(response, ProtocolPacket): + if isinstance(response, Left): + logging.info("The server left. Shutting down...") + self.handle_disconnect() + else: + self.on_object(response) + elif self.running: + logging.error(f"Received object of unknown class: {response}") + raise NotImplementedError("Received object of unknown class.") + logging.info("Done.") + sys.exit() + + def _receive(self): + """ + Gets a receiving byte stream from the server. + :return: The next object in the stream. + """ + try: + receiving = self.network_interface.receive() + cls = self.x_flux.deserialize_object(receiving) + return cls + except OSError: + logging.error("Shutting down abnormally...") + self.running = False + + def send(self, obj: ProtocolPacket): + """ + Sends an object to the server. + :param obj: The object to send. + """ + shipment = self.x_flux.serialize_object(obj) + if self.first_time: + shipment = "".encode("utf-8") + shipment + self.first_time = False + self.network_interface.send(shipment) + + def connect_to_server(self): + """ + Creates a TCP connection with the server. + """ + self.network_interface.connect() + + def close_connection(self): + """ + Sends a closing xml to the server and closes the connection afterwards. + """ + close_xml = self.x_flux.serialize_object(Close()) + self.network_interface.send(close_xml) + self.network_interface.close() + + def handle_disconnect(self): + """ + Closes the connection and stops the client loop. + """ + self.close_connection() + self.running = False + + def on_object(self, message): + """ + Handles an object received from the server. + :param message: The object to handle. + """ + + def stop(self): + """ + Disconnects from the server and stops the client loop. + """ + if self.network_interface.connected: + self.close_connection() + self.running = False diff --git a/src/socha/api/networking/player_client.py b/src/socha/api/networking/player_client.py new file mode 100644 index 0000000..9ec8704 --- /dev/null +++ b/src/socha/api/networking/player_client.py @@ -0,0 +1,121 @@ +""" +This module handels the communication with the api and the students logic. +""" +import logging +import time + +from src.socha.api.networking._xflux import _XFluxClient +from src.socha.api.plugin import penguins +from src.socha.api.plugin.penguins import Field, GameState, Move, Coordinate +from src.socha.api.protocol.protocol import * + + +def _convertBoard(protocolBoard: Board) -> penguins.Board: + """ + Converts a protocol Board to a usable gam board for using in the logic. + :rtype: object + """ + boardList: list[list[Field]] = [] + for y, row in enumerate(protocolBoard.list_value): + rowList: list[Field] = [] + for x, fieldsValue in enumerate(row.field_value): + fieldCoordinate = Coordinate(x, y, is_double=False).get_double_hex() + rowList.append(Field(coordinate=fieldCoordinate, field=fieldsValue)) + boardList.append(rowList) + return penguins.Board(boardList) + + +class IClientHandler: + 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): + """ + If the server send a 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_error(self, logMessage: str): + """ + If error occurs, + for instance when the logic sent a move that is not rule conform, + the server will send an error message and closes the connection. + If this happens, this method is called. + + :param logMessage: The message, that server sent. + """ + + def on_room_message(self, data): ... + + def on_game_prepared(self, message): ... + + def on_game_joined(self, message): ... + + def on_game_observed(self, message): ... + + +class _PlayerClient(_XFluxClient): + """ + The PlayerClient handles all incoming and outgoing objects accordingly to their types. + """ + + def __init__(self, host: str, port: int, handler: IClientHandler, keep_alive: bool): + super().__init__(host, port) + self.game_handler = handler + self.keep_alive = keep_alive + + def authenticate(self, password: str, consumer): + ... + + def observe_room(self, room_id: str, observer): + ... + + def join_game(self, game_type: str = None): + super().send(Join()) + + def join_game_room(self, room_id: str): + super().send(JoinRoom(room_id=room_id)) + + def join_game_with_reservation(self, reservation: str): + super().send(JoinPrepared(reservation_code=reservation)) + + def send_message_to_room(self, room_id: str, message): + super().send(Room(room_id=room_id, data=message)) + + def on_object(self, message): + if isinstance(message, Room): + room_id: str = message.room_id + data = message.data.class_binding + if isinstance(data, MoveRequest): + start_time = time.time() + response = self.game_handler.calculate_move() + logging.info(f"Sent {response} after {time.time() - start_time} seconds.") + if response: + from_value = None + to = To(x=response.to_value.x, y=response.to_value.y) + if response.from_value: + from_value = From(x=response.from_value.x, y=response.from_value.y) + response = Data(class_value="move", from_value=from_value, to=to) + self.send_message_to_room(room_id, response) + if isinstance(data, ObservableRoomMessage): + # TODO Set observer data + if isinstance(data, State): + game_state = GameState(turn=data.turn, start_team=Team(data.start_team), + board=_convertBoard(data.board), last_move=data.last_move, + fishes=penguins.Fishes(data.fishes.int_value[0], data.fishes.int_value[1])) + self.game_handler.on_update(game_state) + elif isinstance(data, Result): + self.game_handler.on_game_over(data) + elif isinstance(data, object): + # TODO Logger + self.game_handler.on_error(str(message)) + else: + self.game_handler.on_room_message(data) + elif isinstance(message, JoinedGameRoom): + self.game_handler.on_game_joined(message) + elif isinstance(message, object): + self.game_handler.on_error(str(message)) diff --git a/src/socha/api/plugin/__init__.py b/src/socha/api/plugin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/socha/api/plugin/penguins.py b/src/socha/api/plugin/penguins.py new file mode 100644 index 0000000..2245bbd --- /dev/null +++ b/src/socha/api/plugin/penguins.py @@ -0,0 +1,719 @@ +""" +This is the plugin for this year's game `Penguins`. +""" +import math + + +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.degrees(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 is a one hex move. + :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 to_coordinates(self) -> 'Coordinate': + """ + Converts the vector to coordinate object. + :return: The coordinate object. + """ + return Coordinate(self.d_x, self.d_y, is_double=True) + + def __str__(self) -> str: + """ + Returns the string representation of the vector. + :return: The string representation of the vector. + """ + return f"Vector({self.d_x}, {self.d_x})" + + +class Coordinate: + """ + Representation of a coordination system in the hexagonal grid. + """ + + def __init__(self, x: int, y: int, is_double: bool = True): + """ + Constructor for the Coordinates class. + :param x: The x-coordinate of the coordination system. + :param y: The y-coordinate of the coordination system. + :param is_double: Determines if the coordinate is in double hex format. Default is True. + """ + self.x = x + self.y = y + self.is_double = is_double + + def add_vector(self, vector: Vector) -> 'Coordinate': + """ + Adds a vector to the coordinate. + :param vector: The vector to add. + :return: The new coordinate. + """ + + return self.get_vector().addition(vector).to_coordinates() if self.is_double else \ + self.get_double_hex().get_vector().addition(vector).to_coordinates().get_array() + + def subtract_vector(self, vector: Vector) -> 'Coordinate': + """ + Subtracts a vector from the coordinate. + :param vector: The vector to subtract. + :return: The new coordinate. + """ + return self.get_vector().subtraction(vector).to_coordinates() + + def get_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 coordinates as Vector object. + """ + return self.get_vector().subtraction(other.get_vector()).magnitude() + + def get_vector(self) -> Vector: + """ + Gets the vector from the coordinate to the origin. + :return: The vector from the coordinate to the origin. + """ + return Vector(self.x, self.y) + + def get_hex_neighbors(self) -> list[Vector]: + """ + Gets the six neighbors of the coordinate. + :return: A list of the six neighbors of the coordinate. + """ + ... + + def __array_to_double_hex(self) -> 'Coordinate': + """ + Converts the coordinate to double hex coordinates. + :return: The double hex coordinates. + """ + return Coordinate(self.x * 2 + (1 if self.y % 2 == 1 else 0), self.y, True) + + def __double_hex_to_array(self) -> 'Coordinate': + """ + Converts the double hex coordinates to coordinate. + :return: The coordinate. + """ + return Coordinate(math.floor((self.x / 2 - (1 if self.y % 2 == 1 else 0)) + 0.5), self.y, False) + + def get_array(self) -> 'Coordinate': + """ + Checks if the coordinate is an array or double hex coordinate. + :return: Self if the coordinate is an array, __double_hex_to_array if the coordinate is a double hex coordinate. + """ + return self if not self.is_double else self.__double_hex_to_array() + + def get_double_hex(self) -> 'Coordinate': + """ + Checks if the coordinate is a double hex coordinate. + :return: Self if the coordinate is a double hex coordinate, __double_hex_to_array if the coordinate is an array. + """ + return self if self.is_double else self.__array_to_double_hex() + + def __str__(self) -> str: + return f"Coordinate[{self.x}, {self.y}]" + + +class Move: + """ + Represents a move in the game. + """ + + def __init__(self, to_value: Coordinate, from_value: Coordinate = None): + """ + :param to_value: The destination of the move. + :param from_value: The origin of the move. + """ + self.from_value = from_value + self.to_value = to_value + + def get_delta(self): + """ + Gets the distance between the origin and the destination. + :return: The delta of the move as a Vector object. + """ + return self.to_value.get_distance(self.from_value) + + def reversed(self): + """ + Reverses the move. + :return: The reversed move. + """ + return Move(from_value=self.to_value, to_value=self.from_value) + + def compare_to(self, other: 'Move'): + """ + Compares two moves. + :param other: The other move to compare to. + :return: True if the moves are equal, false otherwise. + """ + return self.from_value == other.from_value and self.to_value == other.to_value + + def __str__(self) -> str: + return "Move(from = {}, to = {})".format(self.from_value, self.to_value) + + +class Team: + """ + Represents a team in the game. + """ + + def __init__(self, color: str): + self.one = { + 'opponent': 'TWO', + 'name': 'ONE', + 'letter': 'R', + 'color': 'Rot' + } + self.two = { + 'opponent': 'ONE', + 'name': 'TWO', + 'letter': 'B', + 'color': 'Blau' + } + self.team_enum = None + if color == "ONE": + self.team_enum = self.one + elif color == "TWO": + self.team_enum = self.two + else: + raise Exception(f"Invalid : {color}") + + def team(self) -> 'Team': + """ + :return: The team object. + """ + return self + + def color(self) -> str: + """ + :return: The color of this team. + """ + return self.team_enum['name'] + + def opponent(self) -> 'Team': + """ + :return: The opponent of this team. + """ + return Team(self.team_enum['opponent']) + + def __eq__(self, __o: object) -> bool: + return isinstance(__o, Team) and self.team_enum['name'] == __o.team_enum['name'] + + def __str__(self) -> str: + return f"Team {self.team_enum['name']}." + + +class Field: + """ + Represents a field in the game. + """ + + def __init__(self, coordinate: Coordinate, field: int | str | Team): + self.coordinate = coordinate + self.field: int | str | Team + if isinstance(field, int): + self.field = field + elif field.isalpha(): + self.field = Team(field) + else: + raise TypeError(f"The field's input is wrong: {field}") + + def is_empty(self) -> bool: + """ + :return: True if the field is has no fishes, False otherwise. + """ + return self.field == 0 + + def is_occupied(self) -> bool: + """ + :return: True if the field is occupied by a penguin, False otherwise. + """ + return isinstance(self.field, Team) + + def get_fish(self) -> None | int: + """ + :return: The amount of fish on the field, None if the field is occupied. + """ + return None if self.is_occupied() else self.field + + def get_team(self) -> Team | None: + """ + :return: The team of the field if it is occupied by penguin, None otherwise. + """ + return self.field if isinstance(self.field, Team) else None + + def __eq__(self, __o: object) -> bool: + return isinstance(__o, Field) and self.field == __o.field + + def __str__(self): + return f"This Field is occupied by {self.field}" + ( + " fish(es)." if isinstance(self.field, int) else ".") + + +class Board: + """ + Class which represents a game board. Consisting of a two-dimensional array of fields. + """ + + def __init__(self, game_field: list[list[Field]]): + self.game_field = game_field + + def get_empty_fields(self) -> list[Field]: + """ + :return: A list of all empty fields. + """ + fields: list[Field] = [] + for row in self.game_field: + for field in row: + if field.is_empty(): + fields.append(field) + return fields + + def is_occupied(self, coordinates: Coordinate) -> 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: Coordinate) -> 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.get_array() + 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.game_field) + + def height(self) -> int: + """ + :return: The height of the board. + """ + return len(self.game_field[0]) + + 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.game_field[y][x] + + def get_field(self, position: Coordinate) -> 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. + """ + array_coordinates = position.get_array() + if self.is_valid(array_coordinates): + return self._get_field(array_coordinates.x, array_coordinates.y) + + raise IndexError(f"Index out of range: [x={array_coordinates.x}, y={array_coordinates.y}]") + + def get_field_or_none(self, position: Coordinate) -> 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. + """ + position = position.get_array() + if self.is_valid(position): + return self._get_field(position.x, position.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. + """ + x = index // self.width() + y = index % self.width() + return self.get_field(Coordinate(x, y, False)) + + def get_all_fields(self) -> list[Field]: + """ + Gets all hexFields of the board. + :return: All hexFields of the board. + """ + return [self.get_field_by_index(i) for i in range(self.width() * self.height())] + + def compare_to(self, other: 'Board') -> list[Field]: + """ + Compares two boards and returns a list of the hexFields that are different. + :param other: The other board to compare to. + :return: A list of hexFields that are different or a empty list if the boards are equal. + """ + fields = [] + for x in range(len(self.game_field)): + for y in range(len(self.game_field[0])): + if self.game_field[x][y] != other.game_field[x][y]: + fields.append(self.game_field[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, Flase otherwise. + """ + for row in self.game_field: + 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. + """ + for field in fields: + if not self.contains(field): + return False + return True + + def get_moves_in_direction(self, origin: Coordinate, direction: Vector) -> list[Move]: + """ + Gets all moves in the given direction from the given origin. + :param origin: The origin of the move. + :param direction: The direction of the move. + :return: A list with all moves that fullfill the criteria. + """ + moves = [] + for i in range(1, self.width()): + destination = origin.get_double_hex().add_vector(direction.scalar_product(i)) + if self._is_destination_valid(destination): + moves.append(Move(from_value=origin, to_value=destination)) + else: + break + return moves + + def _is_destination_valid(self, field: Coordinate) -> 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: Coordinate) -> list[Move]: + """ + Returns a list of all possible moves from the given position. That are all moves in all hexagonal directions. + :param position: The position to start from. + :return: A list of all possible moves from the given position. + :raise: IndexError if the position is not valid. + """ + if not self.is_valid(position): + raise IndexError(f"Index out of range: [x={position.x}, y={position.y}]") + moves = [] + for direction in Vector().directions: + moves.extend(self.get_moves_in_direction(position, direction)) + return moves + + def get_penguins(self) -> list[Field]: + """ + Searches the board for all penguins. + :return: A list of all hexFields that are occupied by a penguin. + """ + return [field for field in self.get_all_fields() if field.is_occupied()] + + def get_teams_penguins(self, team: Team) -> list[Coordinate]: + """ + Searches the board for all penguins of the given team. + :param team: The team to search for. + :return: A list of all coordinates that are occupied by a penguin of the given team. + """ + teams_penguins = [] + for x in range(self.width()): + for y in range(self.height()): + current_field = self.get_field(Coordinate(x, y, False)) + if current_field.is_occupied() and current_field.get_team().team() == team: + coordinates = Coordinate(x, y, False).get_double_hex() + teams_penguins.append(coordinates) + return teams_penguins + + def get_most_fish(self) -> list[Field]: + """ + Returns a list of all fields with the most fish. + :return: A list of Fields. + """ + fields = self.get_all_fields() + fields.sort(key=lambda field: field.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] + + @staticmethod + def get_field_intersection(first: list[Field], second: list[Field]) -> list[Field]: + """ + Returns a list of all fields that are in both list of Fields. + :param first: The first list of Fields to compare to. + :param second: The second list of Fields to compare to. + :return: A list of Fields. + """ + return [field for field in first if field in second] + + @staticmethod + def get_move_intersection(first: list[Move], second: list[Move]) -> list[Move]: + """ + Returns a list of all moves that are in both list of Fields. + :param first: The first list of moves to compare to. + :param second: The second list of moves to compare to. + :return: A list of moves. + """ + return [move for move in first if move in second] + + @staticmethod + def get_move_field_intersection(moves: list[Move], fields: list[Field]) -> list[Move]: + """ + Returns a list of all moves that to-coordinates are the coordinates of a field in the list of fields. + :param moves: The list of moves that coordinates to compare to. + :param fields: The list of fields that coordinates to compare to. + :return: A list of moves. + """ + intersection = [] + for move in moves: + for field in fields: + if move.to_value == field.coordinate: + intersection.append(move) + return intersection + + def __eq__(self, __o: 'Board'): + return self.game_field == __o.game_field + + +class Fishes: + """ + Represents the amount of fish each player has. + """ + + def __init__(self, fishes_one: int, fishes_two: int): + self.fishes_one = fishes_one + self.fishes_two = fishes_two + + def get_fish_by_team(self, team: Team): + """ + Looks up the amount of fish a team has. + :param team: A team object, that represents the team to get the fish amount of. + :return: The amount of fish of the given team. + """ + return self.fishes_one if team.team_enum == Team("ONE").team_enum else self.fishes_two + + +class GameState: + """ + A `GameState` contains all information, that describes the game state at a given time, that is, between two game + moves. + + This includes: + - a consecutive turn number (round & turn) and who's turn it is + - the board + - 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 and to execute moves + + The game server sends a new copy of the `GameState` to both participating players after each completed move, + describing the then current state. Information about the course of the game can only be obtained from the + `GameState` to a limited extent and must therefore be recorded by a game client itself if necessary. + + In addition to the actual information certain partial information can be queried. + """ + + def __init__(self, board: Board, turn: int, start_team: Team, fishes: Fishes, last_move: Move = None): + """ + Creates a new `GameState` with the given parameters. + :param board: The board of the game. + :param turn: The turn number of the game. + :param start_team: The team that has the first turn. + :param fishes: The number of fishes each team has. + :param last_move: The last move made. + """ + self.start_team = start_team + self.board = board + self.turn = turn + self.round = int((self.turn + 1) / 2) + self.current_team = self.current_team_from_turn() + self.other_team = self.current_team_from_turn().opponent() + self.last_move = last_move + self.fishes = fishes + self.current_pieces = self.board.get_teams_penguins(self.current_team) + + def get_possible_moves(self, current_team: Team = None) -> list[Move]: + """ + Gets all possible moves for the current team. + That includes all possible moves from all hexFields that are not occupied by a penguin from that team. + :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)) < 4: + for x in range(self.board.width() - 1): + for y in range(self.board.height() - 1): + field = self.board.get_field(Coordinate(x, y, False)) + if not field.is_occupied() and field.get_fish() == 1: + moves.append(Move(from_value=None, to_value=Coordinate(x, y, False).get_double_hex())) + else: + for piece in self.board.get_teams_penguins(current_team): + moves.extend(self.board.possible_moves_from(piece)) + return moves + + def get_most_fish_moves(self) -> list[Move]: + """ + Returns a list of all Moves that will get the most fish from possible moves. + :return: A list of Moves. + """ + moves = self.get_possible_moves() + moves.sort(key=lambda move: self.board.get_field(move.to_value).get_fish(), reverse=True) + for i, move in enumerate(moves): + first_fish = self.board.get_field(moves[0].to_value).get_fish() + current_fish = self.board.get_field(move.to_value).get_fish() + if first_fish and current_fish: + if current_fish < first_fish: + moves = moves[:i] + break + return moves + + def current_team_from_turn(self) -> Team: + """ + Calculates the current team from the turn number. + :return: The team that has the current turn. + """ + current_team_by_turn = self.start_team if self.turn % 2 == 0 else self.start_team.opponent() + if not self.get_possible_moves(current_team_by_turn): + return current_team_by_turn.opponent() + return current_team_by_turn diff --git a/src/socha/api/protocol/__init__.py b/src/socha/api/protocol/__init__.py new file mode 100644 index 0000000..2b19676 --- /dev/null +++ b/src/socha/api/protocol/__init__.py @@ -0,0 +1,13 @@ +""" +The protocol module contains the classes that define the protocol used by the Software-Challenge for communication +between the server and the client. It contains all necessary classes that represent the xml messages that sends the +server and the client. +""" +from src.socha.api.protocol.protocol_packet import * + +__all__ = [ + 'ProtocolPacket', + 'ResponsePacket', + 'LobbyRequest', + 'AdminLobbyRequest' +] diff --git a/src/socha/api/protocol/protocol.py b/src/socha/api/protocol/protocol.py new file mode 100644 index 0000000..670d201 --- /dev/null +++ b/src/socha/api/protocol/protocol.py @@ -0,0 +1,855 @@ +from dataclasses import dataclass, field +from typing import List, Optional, Union + +from src.socha.api.plugin.penguins import Team +from src.socha.api.protocol import AdminLobbyRequest, ResponsePacket, ProtocolPacket, LobbyRequest +from src.socha.api.protocol.room_message import RoomOrchestrationMessage, RoomMessage, \ + ObservableRoomMessage + + +@dataclass +class Left(ProtocolPacket): + """ + If the game is over the server will send this message to the clients and closes the connection afterwards. + """ + + class Meta: + name = "left" + + room_id: Optional[str] = field( + default=None, + metadata={ + "name": "roomId", + "type": "Attribute", + } + ) + + +@dataclass +class MoveRequest(RoomMessage): + """ + Request a client to send a Move. + """ + + +@dataclass +class Close(ProtocolPacket): + """ + Is sent by one party immediately before this party closes the communication connection and should make the + receiving party also close the connection. + + This should not be sent manually, the XFluxClient will automatically send it when stopped. + """ + + class Meta: + name = "close" + + +@dataclass +class Authenticate(AdminLobbyRequest): + """ + Authenticates a client as administrator to send AdminLobbyRequest`s. \n + *Is not answered if successful.* + """ + + class Meta: + name = "authenticate" + + password: Optional[str] = field( + default=None, + metadata={ + "type": "Attribute", + } + ) + + +@dataclass +class Cancel(AdminLobbyRequest): + """ + Deletes the GameRoom and cancels the Game within. + """ + + class Meta: + name = "cancel" + + room_id: Optional[str] = field( + default=None, + metadata={ + "name": "roomId", + "type": "Attribute", + } + ) + + +@dataclass +class JoinedGameRoom(ResponsePacket): + """ + Sent to all administrative clients after a player joined a GameRoom via a JoinRoomRequest. + """ + + class Meta: + name = "joinedGameRoom" + + room_id: Optional[str] = field( + default=None, + metadata={ + "name": "roomId", + "type": "Attribute", + } + ) + player_count: Optional[int] = field( + default=None, + metadata={ + "name": "playerCount", + "type": "Attribute", + } + ) + + +@dataclass +class Observe(AdminLobbyRequest): + """ + Sent to client as response to successfully joining a GameRoom as Observer. + """ + + class Meta: + name = "observe" + + room_id: Optional[str] = field( + default=None, + metadata={ + "name": "roomId", + "type": "Attribute", + } + ) + + +@dataclass +class Pause(AdminLobbyRequest): + """ + Indicates to observers that the game has been (un)paused. + """ + + class Meta: + name = "pause" + + room_id: Optional[str] = field( + default=None, + metadata={ + "name": "roomId", + "type": "Attribute", + } + ) + pause: Optional[bool] = field( + default=None, + metadata={ + "type": "Attribute", + } + ) + + +@dataclass +class Slot(ProtocolPacket): + """ + Slots for a game which contains the player's name and its attributes. + """ + + class Meta: + name = "slot" + + display_name: Optional[str] = field( + default=None, + metadata={ + "name": "displayName", + "type": "Attribute", + } + ) + can_timeout: Optional[bool] = field( + default=None, + metadata={ + "name": "canTimeout", + "type": "Attribute", + } + ) + reserved: Optional[bool] = field( + default=None, + metadata={ + "type": "Attribute", + } + ) + + +@dataclass +class Step(AdminLobbyRequest): + """ + When the client is authenticated as administrator, + it can send this step request to the server to advance the game for one move. + This is not possible if the game is not paused. + """ + + class Meta: + name = "step" + + room_id: Optional[str] = field( + default=None, + metadata={ + "name": "roomId", + "type": "Attribute", + } + ) + + +@dataclass +class Prepare(AdminLobbyRequest): + """ + When the client is authenticated as administrator, + it can send this request to prepare the room for the game. + """ + + class Meta: + name = "prepare" + + game_type: Optional[str] = field( + default=None, + metadata={ + "name": "gameType", + "type": "Attribute", + } + ) + pause: Optional[bool] = field( + default=None, + metadata={ + "type": "Attribute", + } + ) + slot: List[Slot] = field( + default_factory=list, + metadata={ + "type": "Element", + } + ) + + +@dataclass +class Join(LobbyRequest): + """ + Joins any room that is open. + If no room is open, + a new room is created by the server. + """ + + class Meta: + name = "join" + + +@dataclass +class JoinPrepared(LobbyRequest): + """ + Join a prepared room with a reservation code. + """ + + class Meta: + name = "joinPrepared" + + reservation_code: Optional[str] = field( + default=None, + metadata={ + "name": "reservationCode", + "type": "Attribute", + } + ) + + +@dataclass +class JoinRoom(LobbyRequest): + """ + To join a room with a `room_id`. + """ + + class Meta: + name = "joinRoom" + + room_id: Optional[str] = field( + default=None, + metadata={ + "name": "roomId", + "type": "Attribute", + } + ) + + +@dataclass +class Fishes: + """ + The amount of fishes a player has. + """ + + class Meta: + name = "fishes" + + int_value: List[int] = field( + default_factory=list, + metadata={ + "name": "int", + "type": "Element", + } + ) + + +@dataclass +class Fragment: + """ + This holds the fragments of a winning definition. + """ + + class Meta: + name = "fragment" + + name: Optional[str] = field( + default=None, + metadata={ + "type": "Attribute", + } + ) + aggregation: Optional[str] = field( + default=None, + metadata={ + "type": "Element", + } + ) + relevant_for_ranking: Optional[bool] = field( + default=None, + metadata={ + "name": "relevantForRanking", + "type": "Element", + } + ) + + +@dataclass +class From: + """ + The origin of a move. + """ + + class Meta: + name = "from" + + x: Optional[int] = field( + default=None, + metadata={ + "type": "Attribute", + } + ) + y: Optional[int] = field( + default=None, + metadata={ + "type": "Attribute", + } + ) + + +@dataclass +class Joined(ResponsePacket): + """ + Sent to all clients after a player joined a GameRoom via a Join Request. + """ + + class Meta: + name = "joined" + + room_id: Optional[str] = field( + default=None, + metadata={ + "name": "roomId", + "type": "Attribute", + } + ) + + +@dataclass +class ListType: + """ + Represents a list for the game board, that contains the fields. + """ + + class Meta: + name = "list" + + field_value: List[Union[str, int]] = field( + default_factory=list, + metadata={ + "name": "field", + "type": "Element", + } + ) + + +@dataclass +class Player: + """ + The player that has won the game. + """ + + class Meta: + name = "player" + + name: Optional[str] = field( + default=None, + metadata={ + "type": "Attribute", + } + ) + team: Optional[str] = field( + default=None, + metadata={ + "type": "Attribute", + } + ) + + +@dataclass +class Score: + """ + Score of the players when the game has ended. + """ + + class Meta: + name = "score" + + cause: Optional[str] = field( + default=None, + metadata={ + "type": "Attribute", + } + ) + reason: Optional[object] = field( + default=None, + metadata={ + "type": "Attribute", + } + ) + part: List[int] = field( + default_factory=list, + metadata={ + "type": "Element", + } + ) + + +@dataclass +class To: + """ + The target of a move. + """ + + class Meta: + name = "to" + + x: Optional[int] = field( + default=None, + metadata={ + "type": "Attribute", + } + ) + y: Optional[int] = field( + default=None, + metadata={ + "type": "Attribute", + } + ) + + +@dataclass +class Winner: + """ + The winner of a game. + """ + + class Meta: + name = "winner" + + team: Optional[str] = field( + default=None, + metadata={ + "type": "Attribute", + } + ) + + +@dataclass +class Board: + """ + The protocol representation of a board. + It contains a list of list of fields, which size is 7x7. + """ + + class Meta: + name = "board" + + list_value: List[ListType] = field( + default_factory=list, + metadata={ + "name": "list", + "type": "Element", + } + ) + + +@dataclass +class Definition: + """ + The definition of a result of a game. + If for instance one player made an error move, the game is over and the other player wins, + the definition will tell that the other player wins, becaues of the error. + """ + + class Meta: + name = "definition" + + fragment: List[Fragment] = field( + default_factory=list, + metadata={ + "type": "Element", + } + ) + + +@dataclass +class Entry: + """ + Is send when a game is won by one of the players. + This element contains the winning player and the score of the player. + """ + + class Meta: + name = "entry" + + player: Optional[Player] = field( + default=None, + metadata={ + "type": "Element", + } + ) + score: Optional[Score] = field( + default=None, + metadata={ + "type": "Element", + } + ) + + +@dataclass +class LastMove: + """ + Last move of a player. + """ + + class Meta: + name = "lastMove" + + from_value: Optional[From] = field( + default=None, + metadata={ + "name": "from", + "type": "Element", + } + ) + to: Optional[To] = field( + default=None, + metadata={ + "type": "Element", + } + ) + + +@dataclass +class Scores: + """ + Then endresult of a game when its over. + """ + + class Meta: + name = "scores" + + entry: List[Entry] = field( + default_factory=list, + metadata={ + "type": "Element", + } + ) + + +@dataclass +class State(ObservableRoomMessage): + """ + The state of the game, with the current board, score and last move. + """ + + class Meta: + name = "state" + + class_value: Optional[str] = field( + default=None, + metadata={ + "name": "class", + "type": "Attribute", + } + ) + turn: Optional[int] = field( + default=None, + metadata={ + "type": "Attribute", + } + ) + start_team: Optional[str] = field( + default=None, + metadata={ + "name": "startTeam", + "type": "Element", + } + ) + board: Optional[Board] = field( + default=None, + metadata={ + "type": "Element", + } + ) + last_move: Optional[LastMove] = field( + default=None, + metadata={ + "name": "lastMove", + "type": "Element", + } + ) + fishes: Optional[Fishes] = field( + default=None, + metadata={ + "type": "Element", + } + ) + + +@dataclass +class OriginalMessage: + """ + The original message that was sent by the client. + Is sent by the server if a error occurs. + """ + + class Meta: + name = "originalMessage" + + class_value: Optional[str] = field( + default=None, + metadata={ + "name": "class", + "type": "Attribute", + } + ) + from_value: Optional[From] = field( + default=None, + metadata={ + "name": "from", + "type": "Element", + } + ) + to: Optional[To] = field( + default=None, + metadata={ + "type": "Element", + } + ) + + +@dataclass +class Data: + """ + This element is sent by the server to the client to notify the client of a changing state of the game. + It can contain a move, gamestate, or winnig team with the reason. + """ + + class Meta: + name = "data" + + class_value: Optional[str] = field( + default=None, + metadata={ + "name": "class", + "type": "Attribute", + } + ) + class_binding: Optional[object] = field( + default=None + ) + definition: Optional[Definition] = field( + default=None, + metadata={ + "type": "Element", + } + ) + scores: Optional[Scores] = field( + default=None, + metadata={ + "type": "Element", + } + ) + winner: Optional[Winner] = field( + default=None, + metadata={ + "type": "Element", + } + ) + from_value: Optional[From] = field( + default=None, + metadata={ + "name": "from", + "type": "Element", + } + ) + to: Optional[To] = field( + default=None, + metadata={ + "type": "Element", + } + ) + state: Optional[State] = field( + default=None, + metadata={ + "type": "Element", + } + ) + color: Optional[str] = field( + default=None, + metadata={ + "type": "Attribute", + } + ) + original_message: Optional[OriginalMessage] = field( + default=None, + metadata={ + "name": "originalMessage", + "type": "Element", + } + ) + + +@dataclass +class Room(ProtocolPacket): + """ + The root element of every room packet. + It contains a data element when send that contains the actual data, + that are needed for the game to work. + """ + + class Meta: + name = "room" + + room_id: Optional[str] = field( + default=None, + metadata={ + "name": "roomId", + "type": "Attribute", + } + ) + data: Optional[Data] = field( + default=None, + metadata={ + "type": "Element", + } + ) + + +@dataclass +class WelcomeMessage(RoomOrchestrationMessage): + """ + Welcome message is sent to the client when the client joins the room. + In this message the server tells the client which team it is. + """ + team: Team + + +@dataclass +class Result: + """ + Result of a game. + This will the server send after a game is finished. + """ + definition: Definition + scores: Scores + winner: Winner + + +@dataclass +class Protocol: + """ + This is the root element of the protocol. + Even it's in here it will never be called, + because the children of this root elment have to be handeld separately. + """ + + class Meta: + name = "protocol" + + authenticate: Optional[Authenticate] = field( + default=None, + metadata={ + "type": "Element", + } + ) + joined_game_room: Optional[JoinedGameRoom] = field( + default=None, + metadata={ + "name": "joinedGameRoom", + "type": "Element", + } + ) + prepare: Optional[Prepare] = field( + default=None, + metadata={ + "type": "Element", + } + ) + observe: Optional[Observe] = field( + default=None, + metadata={ + "type": "Element", + } + ) + pause: Optional[Pause] = field( + default=None, + metadata={ + "type": "Element", + } + ) + step: Optional[Step] = field( + default=None, + metadata={ + "type": "Element", + } + ) + cancel: Optional[Cancel] = field( + default=None, + metadata={ + "type": "Element", + } + ) + join: Optional[Join] = field( + default=None, + metadata={ + "type": "Element", + } + ) + joined: Optional[Joined] = field( + default=None, + metadata={ + "type": "Element", + } + ) + room: List[Room] = field( + default_factory=list, + metadata={ + "type": "Element", + } + + ) diff --git a/src/socha/api/protocol/protocol_packet.py b/src/socha/api/protocol/protocol_packet.py new file mode 100644 index 0000000..bfff09a --- /dev/null +++ b/src/socha/api/protocol/protocol_packet.py @@ -0,0 +1,14 @@ +class ProtocolPacket: + ... + + +class LobbyRequest(ProtocolPacket): + ... + + +class AdminLobbyRequest(LobbyRequest): + ... + + +class ResponsePacket(ProtocolPacket): + ... diff --git a/src/socha/api/protocol/room_message.py b/src/socha/api/protocol/room_message.py new file mode 100644 index 0000000..b089764 --- /dev/null +++ b/src/socha/api/protocol/room_message.py @@ -0,0 +1,19 @@ +class RoomMessage: + """ + For all communication within a GameRoom. + """ + ... + + +class RoomOrchestrationMessage(RoomMessage): + """ + A RoomMessage that does not concern the progress of the game. + """ + ... + + +class ObservableRoomMessage(RoomMessage): + """ + A RoomMessage that can be received by observers. + """ + ... diff --git a/src/socha/starter.py b/src/socha/starter.py new file mode 100644 index 0000000..c6c8e84 --- /dev/null +++ b/src/socha/starter.py @@ -0,0 +1,37 @@ +""" +This is the main entry point for the SoCha application. +""" +import datetime +import logging + +from src.socha.api.networking.player_client import _PlayerClient, IClientHandler + + +class Starter: + """ + When this is called, the client will try to connect to the server and join a game. + When successful, the client will start the loop and call the on_update and calculate_move methods, + if the server sends updates. + """ + + def __init__(self, host: str, port: int, logic: IClientHandler, reservation: str = None, room_id: str = None, + keep_alive: bool = False): + now = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + logging.basicConfig(filename=f"log{now}", level=logging.INFO) + logging.getLogger().addHandler(logging.StreamHandler()) + logging.info("Starting...") + self.host = host + self.port = port + self.reservation = reservation + self.roomId = room_id + self.logic = logic + self.client = _PlayerClient(host=host, port=port, handler=self.logic, keep_alive=keep_alive) + + if reservation: + self.client.join_game_with_reservation(reservation) + elif room_id: + self.client.join_game_room(room_id) + else: + self.client.join_game() + + self.client.start() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_penguins.py b/tests/test_penguins.py new file mode 100644 index 0000000..5a2cc5c --- /dev/null +++ b/tests/test_penguins.py @@ -0,0 +1,89 @@ +import unittest + +from src.socha.api.plugin.penguins import * + + +class VectorTest(unittest.TestCase): + def testVectorInit(self): + v = Vector(5, -5) + self.assertEqual(v.d_x, 5) + self.assertEqual(v.d_y, -5) + + def testMagnitude(self): + v = Vector(5, -5) + self.assertEqual(v.magnitude(), 7.0710678118654755) + + def testDotProduct(self): + v1 = Vector(5, -5) + v2 = Vector(5, -5) + self.assertEqual(v1.dot_product(v2), 50) + + def testCrossProduct(self): + v1 = Vector(5, -5) + v2 = Vector(5, -5) + self.assertEqual(v1.cross_product(v2), 0) + + def testScalarProduct(self): + v1 = Vector(5, -5) + v2 = Vector(10, -10) + self.assertEqual(v1.scalar_product(2).d_x, v2.d_x) + self.assertEqual(v1.scalar_product(2).d_y, v2.d_y) + + def testArcTangent(self): + v1 = Vector(5, -5) + self.assertEqual(v1.get_arc_tangent(), -45.0) + + +class CoordinateTest(unittest.TestCase): + def testCoordinateInit(self): + c = Coordinate(5, -5) + self.assertEqual(c.x, 5) + self.assertEqual(c.y, -5) + + def testGetDistance(self): + c1 = Coordinate(5, 15) + c2 = Coordinate(15, 5) + self.assertEqual(c1.get_distance(c2), 14.142135623730951) + + def testGetArray(self): + c = Coordinate(15, 7) + self.assertEqual(c.get_array().x, Coordinate(7, 7, False).x) + self.assertEqual(c.get_array().y, Coordinate(7, 7, False).y) + + def testGetDoubleHex(self): + c = Coordinate(7, 7, False) + self.assertEqual(c.get_double_hex().x, Coordinate(15, 7, True).x) + self.assertEqual(c.get_double_hex().y, Coordinate(15, 7, True).y) + + +class MoveTest(unittest.TestCase): + def testMoveInit(self): + m = Move(from_value=Coordinate(0, 0), to_value=Coordinate(15, 7)) + self.assertEqual(m.from_value.x, 0) + self.assertEqual(m.from_value.y, 0) + self.assertEqual(m.to_value.x, 15) + self.assertEqual(m.to_value.y, 7) + + +class TeamTest(unittest.TestCase): + def testTeamInit(self): + t = Team(color="ONE") + self.assertEqual(t.color(), "ONE") + + +class FieldTest(unittest.TestCase): + def testFieldInit(self): + f = Field(coordinate=Coordinate(0, 0), field="ONE") + self.assertEqual(f.coordinate.x, 0) + self.assertEqual(f.coordinate.y, 0) + self.assertEqual(f.field.color(), "ONE") + + def testGetEmptyField(self): + l = [] + for i in range(7): + l.append([]) + for j in range(7): + l[i].append(Field(coordinate=Coordinate(j, i), field=0)) + b = Board(l) + e = b.get_empty_fields() + self.assertEqual(len(e), 49)