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](https://software-challenge.de/site/themes/freebird/img/logo.png)
+
+# 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)