diff --git a/.gitignore b/.gitignore index 311c305..31cd58b 100644 --- a/.gitignore +++ b/.gitignore @@ -71,4 +71,7 @@ docs/_build/ .vscode/ # Pyenv -.python-version \ No newline at end of file +.python-version + +# custom venv names +venv*/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index f94922b..e7104e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "_socha" -version = "4.3.9" +version = "5.0.0" edition = "2021" [lib] @@ -12,6 +12,7 @@ pyo3 = { version = "0.21.2" } pyo3-log = "0.10.0" log = "0.4.20" itertools = "0.13.0" +rand = "0.10.2" [features] extension-module = ["pyo3/extension-module"] diff --git a/README.md b/README.md index 711c0ee..c7c9802 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

Software-Challenge Logo

-# Python-Client für die Software-Challenge Germany 2025 +# Python-Client für die Software-Challenge Germany 2027 [![Read the Docs](https://img.shields.io/readthedocs/socha-python-client?label=Docs)](https://socha-python-client.readthedocs.io/de/latest/) [![PyPI](https://img.shields.io/pypi/v/socha?label=PyPi)](https://pypi.org/project/socha/) @@ -12,11 +12,11 @@ Dieses Repository enthält das Python-Paket für die [Software-Challenge Germany](https://www.software-challenge.de), einem Programmierwettbewerb für Schülerinnen und Schüler. Dabei muss eine künstliche Intelligenz entwickelt werden, die in einem jährlich wechselnden Spiel gegen andere Gegner antritt. -> In diesem Jahr ist es das Spiel **[Piranhas](https://docs.software-challenge.de/spiele/26_piranhas/)**. +> In diesem Jahr ist es das Spiel **[Blokus](https://docs.software-challenge.de/spiele/27_blokus/regeln)**. ## Inhaltsverzeichnis -- [Python-Client für die Software-Challenge Germany 2025](#python-client-für-die-software-challenge-germany-2025) +- [Python-Client für die Software-Challenge Germany 2027](#python-client-für-die-software-challenge-germany-2027) - [Inhaltsverzeichnis](#inhaltsverzeichnis) - [Installation](#installation) - [Global](#global) @@ -118,6 +118,10 @@ if __name__ == "__main__": Starter(Logik()) ``` +**ACHTUNG** Die aktuelle ReadtheDocs Dokumentation ist nicht aktuell und leider Fehlerhaft. Die wichtigen Startschritte findetet ihr wie gehabt in dieser ReadMe. + +Eine Liste an Klassen und Methoden findet sich hier [`_socha.pyi`](https://github.com/software-challenge/player_python/blob/master/python/socha/_socha.pyi). + > Ein komplettes Beispiel ist in dieser [`logic.py`](https://github.com/maxblan/socha-python-client/blob/master/logic.py) zu finden. ### Startargumente diff --git a/docs/conf.py b/docs/conf.py index 95e19b0..affba55 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,6 @@ import os import sys + sys.path.insert(0, os.path.abspath('../')) # -- Project information ----------------------------------------------------- @@ -34,4 +35,4 @@ todo_include_todos = True -autoclass_content = 'both' +autoclass_content = 'both' \ No newline at end of file diff --git a/logic.py b/logic.py index aff38b0..307a69c 100644 --- a/logic.py +++ b/logic.py @@ -6,15 +6,20 @@ import time from typing import Optional, Tuple from socha import ( + Move, + GameState, + Color, + Piece, + PieceShape, + Rotation, Coordinate, Vector, + GameRuleLogic, + Board, Direction, - FieldType, TeamEnum, - Board, - Move, - GameState, - RulesEngine, + Field, + FieldContent, ) from socha.api.networking.game_client import IClientHandler from socha.starter import Starter diff --git a/pyproject.toml b/pyproject.toml index 8b5ff5d..3234d2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "socha" -version = "4.3.9" +version = "5.0.0" authors = [ { name = "yoente", email = "stu250140@mail.uni-kiel.de" }, { name = "maxblan", email = "stu222782@mail.uni-kiel.de" }, diff --git a/python/socha/_socha.pyi b/python/socha/_socha.pyi index b105b30..b819d50 100644 --- a/python/socha/_socha.pyi +++ b/python/socha/_socha.pyi @@ -1,5 +1,39 @@ from enum import Enum -from typing import List, Optional + +class Vector: + """ + Ein 2 dimensionaler Vektor. + + Attributes: + delta_x (int): Die Entfernung in x-Richtung. + delta_y (int): Die Entfernung in y-Richtung. + """ + + delta_x: int + delta_y: int + + def __init__(self, delta_x: int, delta_y: int) -> None: ... + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... + + def deepcopy(self) -> Vector: + """Kopiert das Objekt.""" + + def add_vector(self, other: Vector) -> Vector: + """Addiert einen anderen Vector (nicht mutierend).""" + + def add_vector_mut(self, other: Vector) -> None: + """Addiert einen anderen Vector (mutierend).""" + + def scale(self, scalar: int) -> Vector: + """Skaliert diesen Vektor (nicht mutierend).""" + + def scale_mut(self, scalar: int) -> None: + """Skaliert diesen Vektor (mutierend).""" + + def get_length(self) -> float | None: + """Berechnet die Länge dieses Vektors.""" + class Coordinate: """ @@ -14,825 +48,577 @@ class Coordinate: y: int def __init__(self, x: int, y: int) -> None: ... - def __str__(self) -> str: ... - def __repr__(self) -> str: ... - def __eq__(self, other: Coordinate) -> bool: - """ - Unterstützt den Vergleichsoperator ==, um die Werte mit denen eines - weiteren Objektes zuvergleichen. - - Args: - other: (Coordinate): Die andere Koordinate. - - Returns: - bool: Das Ergebnis des Vergleichs. - """ - ... - def __ne__(self, other: Coordinate) -> bool: - """ - Unterstützt den Vergleichsoperator !=, um die Werte mit denen eines - weiteren Objektes zuvergleichen. + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... - Args: - other: (Coordinate): Die andere Koordinate. - - Returns: - bool: Das Ergebnis des Vergleichs. - """ - ... - - def deepcopy(self) -> Coordinate: - """ - Kopiert das Objekt rekursiv. - """ - ... + def deepcopy(self) -> Coordinate: + """Kopiert das Objekt.""" def add_vector(self, vector: Vector) -> Coordinate: - """ - Addiert einen Vector auf die Werte dieser Koordinate (**nicht mutierend**). + """Addiert einen Vector auf diese Koordinate (nicht mutierend).""" - Args: - vector (Vector): Der Vektor. + def add_vector_mut(self, vector: Vector) -> None: + """Addiert einen Vector auf diese Koordinate (mutierend).""" - Returns: - Coordinate: Ein neues Koordinatenobjekt mit den berechneten Werten. - """ - ... + def get_difference(self, other: Coordinate) -> Vector: + """Berechnet die Differenz zwischen zwei Koordinaten als Vektor.""" - def add_vector_mut(self, vector: Vector) -> None: - """ - Addiert einen Vector auf die Werte dieser Koordinate (**mutierend**). + def neighbors(self) -> list[Coordinate]: + """Gibt die vier benachbarten Feldkoordinaten zurück.""" - Args: - vector (Vector): Der Vektor. - """ - ... + def diagonal_neighbors(self) -> list[Coordinate]: + """Gibt die vier angrenzenden Ecken der Feldkoordinaten zurück.""" - def get_difference(self, other: Coordinate) -> Vector: - """ - Berechnet die Differenz zwischen zwei Koordinaten Punkten als Vektor. + def as_vector(self) -> Vector: + """Coordinate als Vektor Objekt""" - Args: - other (Coordinate): Die andere Koordinate - Returns: - Vector: Der Vektor zwischen den Punkten - """ - ... +class Direction(Enum): + """Eine Darstellung für eine normierte Richtung.""" -class Vector: - """ - Ein 2 dimensionaler Vektor. + Up = 0 + UpRight = 1 + Right = 2 + DownRight = 3 + Down = 4 + DownLeft = 5 + Left = 6 + UpLeft = 7 - Attributes: - delta_x (int): Die Entfernung in x-Richtung. - delta_y (int): Die Entfernung in y-Richtung. - """ + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... + def deepcopy(self) -> Direction: ... - delta_x: int - delta_y: int + @staticmethod + def from_vector(vector: Vector) -> Direction | None: + """Wandelt einen Vektor in eine der 8 Richtungen um.""" - def __init__(self, delta_x: int, delta_y: int) -> None: ... - def __str__(self) -> str: ... - def __repr__(self) -> str: ... - def __eq__(self, other: Vector) -> bool: - """ - Unterstützt den Vergleichsoperator ==, um die Werte mit denen eines - weiteren Objektes zuvergleichen. + @staticmethod + def all_directions() -> list[Direction]: + """Gibt eine Liste aller 8 Richtungen zurück.""" - Args: - other: (Vector): Die andere Koordinate. + @staticmethod + def cardinals() -> list[Direction]: + """Gibt die vier nicht-diagonalen Richtungen zurück (Up, Right, Down, Left).""" - Returns: - bool: Das Ergebnis des Vergleichs. - """ - ... - def __ne__(self, other: Vector) -> bool: - """ - Unterstützt den Vergleichsoperator !=, um die Werte mit denen eines - weiteren Objektes zuvergleichen. + @staticmethod + def diagonals() -> list[Direction]: + """Gibt die vier diagonalen Richtungen zurück.""" - Args: - other: (Vector): Die andere Koordinate. + def to_vector(self) -> Vector: + """Wandelt die Richtung in den entsprechenden Vektor um.""" - Returns: - bool: Das Ergebnis des Vergleichs. - """ - ... - - def deepcopy(self) -> GameState: - """ - Kopiert das Objekt rekursiv. - """ - ... + def to_mirrored(self) -> Direction: + """Spiegelt die gegebene Richtung.""" - def add_vector(self, other: Vector) -> Vector: - """ - Addiert einen anderen Vector auf die Werte dieses Vektors (**nicht mutierend**). - Args: - other (Vector): Der andere Vektor. +class TeamEnum(Enum): + """Eine Darstellung für die beiden Teams.""" - Returns: - Vector: Ein neues Vektorobjekt mit den berechneten Werten. - """ - ... + One = 0 + Two = 1 - def add_vector_mut(self, other: Vector) -> None: - """ - Addiert einen anderen Vector auf die Werte dieses Vektors (**mutierend**). + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... - Args: - other (Vector): Der andere Vektor. - """ - ... + def opponent(self) -> TeamEnum: + """Gibt den Gegner dieses Teams zurück.""" - def scale(self, scalar: int) -> Vector: - """ - Skaliert diesen Vektor um ein gegebenes Skalar (**nicht mutierend**). - Args: - scalar (int): Das Skalar. +class Color(Enum): + """Die Farbe eines Spielsteins / Teams.""" - Returns: - Vector: Ein neues Vektorobjekt mit den berechneten Werten. - """ - ... + BLUE = 0 + YELLOW = 1 + RED = 2 + GREEN = 3 - def scale_mut(self, scalar: int) -> None: - """ - Skaliert diesen Vektor um ein gegebenes Skalar (**mutierend**). + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... - Args: - scalar (int): Das Skalar. - """ - ... + def next(self) -> Color: + """Gibt die nächste Farbe in der Zugreihenfolge zurück.""" - def get_length(self) -> float: - """ - Berechnet die Länge dieses Vektors. + def team(self) -> TeamEnum: + """Gibt das Team zurück, zu dem diese Farbe gehört.""" - Returns: - float: Die Länge des Vektors (in 32 bit Präzision). - """ + def to_field_content(self) -> FieldContent: + """Wandelt die Farbe in den entsprechenden Feldinhalt um.""" + + def name(self) -> str: ... -class Direction(Enum): - """ - Eine Darstellung für eine normierte Richtung.
- Kann in einen Vektor konvertiert werden. - """ - - Up: int = 0 - """ - Richtung nach oben, entspricht Vektor(0, 1). - """ - UpRight: int = 1 - """ - Richtung nach oben-rechts, entspricht Vektor(1, 1). - """ - Right: int = 2 - """ - Richtung nach rechts, entspricht Vektor(1, 0). - """ - DownRight: int = 3 - """ - Richtung nach unten-rechts, entspricht Vektor(1, -1). - """ - Down: int = 4 - """ - Richtung nach unten, entspricht Vektor(0, -1). - """ - DownLeft: int = 5 - """ - Richtung nach unten-links, entspricht Vektor(-1, -1). - """ - Left: int = 6 - """ - Richtung nach links, entspricht Vektor(-1, 0). - """ - UpLeft: int = 7 - """ - Richtung nach oben-links, entspricht Vektor(-1, 1). - """ - def __str__(self) -> str: ... - def __repr__(self) -> str: ... - def __eq__(self, other: Direction) -> bool: - """ - Unterstützt den Vergleichsoperator ==, um die Werte mit denen eines - weiteren Objektes zuvergleichen. +class FieldContent(Enum): + """Der Inhalt eines Feldes: eine Farbe oder leer.""" - Args: - other: (Direction): Die andere Koordinate. + BLUE = 0 + YELLOW = 1 + RED = 2 + GREEN = 3 + EMPTY = 4 - Returns: - bool: Das Ergebnis des Vergleichs. - """ - ... - def __ne__(self, other: Direction) -> bool: - """ - Unterstützt den Vergleichsoperator !=, um die Werte mit denen eines - weiteren Objektes zuvergleichen. + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... - Args: - other: (Direction): Die andere Koordinate. + def to_team_color(self) -> Color | None: + """Wandelt den Feldinhalt in die entsprechende Farbe um, oder None, wenn leer.""" - Returns: - bool: Das Ergebnis des Vergleichs. - """ - ... + def is_empty(self) -> bool: + """Gibt zurück, ob das Feld leer ist.""" - @staticmethod - def from_vector(vector: Vector) -> Optional[Direction]: - """ - Wandelt einen Vektor in eine der 8 Richtungen um, insofern der Vektor exakt der Richtung entspricht. - Args: - vector (Vector): Der Vektor, der konvertiert werden soll. +class Field: + """Ein einzelnes Feld auf dem Spielbrett.""" - Returns: - Optional[Direction]: Die Richtung oder None, wenn der Vektor nicht direkt übersetzt werden kann. - """ - ... - - @staticmethod - def all_directions() -> List[Direction]: - """ - Gibt eine Liste aller 8 Richtungen aus.
- Der erste Wert ist oben und alle weiteren folgen im Uhrzeigersinn. - - Returns: - List[Direction]: Die Liste der Richtungen. - """ - ... + coordinate: Coordinate + content: FieldContent - def to_vector(self) -> Vector: - """ - Wandelt die Richtung in den entsprechenden Vektor um. + def __init__(self, coordinate: Coordinate, color: Color) -> None: ... + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... - Returns: - Vector: Der Richtungsvektor. - """ - ... + def deepcopy(self) -> Field: + """Kopiert das Objekt.""" - def to_mirrored(self) -> Direction: - """ - Spiegelt die gegebene Richtung.
- Beispiel: Up -> Down. + def is_empty(self) -> bool: + """Gibt zurück, ob das Feld leer ist.""" - Returns: - Direction: Die neue Richtung. - """ - ... -class FieldType(Enum): - """ - Stellt alle verfügbaren Feldtypen dar. +class Board: """ + Das Spielbrett. - OneS: int = 0 - """ - Der kleine Fisch von Spieler 1. - """ - OneM: int = 1 - """ - Der mittlere Fisch von Spieler 1. - """ - OneL: int = 2 - """ - Der große Fisch von Spieler 1. - """ - TwoS: int = 3 - """ - Der kleine Fisch von Spieler 2. - """ - TwoM: int = 4 - """ - Der mittlere Fisch von Spieler 2. - """ - TwoL: int = 5 - """ - Der große Fisch von Spieler 2. - """ - Squid: int = 6 - """ - Der Kraken. - """ - Empty: int = 7 - """ - Alle anderen unbesetzten Felder. + Attributes: + map (list[list[Field]]): Die 2-dimensionale Liste der Felder. """ - def __str__(self) -> str: ... - def __repr__(self) -> str: ... - def __eq__(self, other: FieldType) -> bool: - """ - Unterstützt den Vergleichsoperator ==, um die Werte mit denen eines - weiteren Objektes zuvergleichen. + map: list[list[Field]] - Args: - other: (FieldType): Die andere Koordinate. + def __init__(self, map: list[list[Field]] | None) -> None: ... + def __eq__(self, other: object) -> bool: ... - Returns: - bool: Das Ergebnis des Vergleichs. + def get(self, position: Coordinate) -> Field: """ - ... - def __ne__(self, other: FieldType) -> bool: - """ - Unterstützt den Vergleichsoperator !=, um die Werte mit denen eines - weiteren Objektes zuvergleichen. - - Args: - other: (FieldType): Die andere Koordinate. - - Returns: - bool: Das Ergebnis des Vergleichs. - """ - ... + Gibt das Feld an der gegebenen Koordinate zurück. - def get_value(self) -> int: - """ - Gibt die entsprechende Wertigkeit eines Feldes zurück.
- Für Fische je nachdem 1-3, für die anderen Felder 0. - - Returns: - int: Der Wert des Feldes. + Raises: + IndexError: Wenn die Koordinate außerhalb des Spielfelds liegt. """ - ... - def get_team(self) -> Optional[TeamEnum]: - """ - Gibt für ein Fischfeld aus, zu welchem Team dieser Fisch gehört. + def set_content(self, position: Coordinate, content: FieldContent) -> None: + """Setzt den Inhalt eines Feldes.""" - Returns: - Optional[TeamEnum]: Das Team, zudem der Fisch gehört, oder None, wenn das Feld kein Fisch ist. - """ - ... + def get_content(self, position: Coordinate) -> FieldContent | None: + """Gibt den Inhalt eines Feldes zurück, oder None, wenn außerhalb des Feldes.""" - @staticmethod - def all_field_types() -> List[FieldType]: - """ - Gibt eine Liste aller Feldtypen aus.
- - Returns: - List[Direction]: Die Liste der Richtungen. - """ - ... + def is_empty(self) -> bool: + """Prüft, ob alle Felder leer sind.""" -class TeamEnum(Enum): - """ - Eine Darstellung für die beiden Teams - """ + def is_obstructed(self, position: Coordinate) -> bool: + """Prüft, ob auf dieser Position bereits eine Spielerfarbe liegt.""" - One = 0 - """ - Team 1 - """ - Two = 1 - """ - Team 2 - """ + def get_team(self, position: Coordinate) -> Color | None: + """Gibt das Team zurück, das auf dem Feld liegt, oder None.""" - def __str__(self) -> str: ... - def __repr__(self) -> str: ... - def __eq__(self, other: TeamEnum) -> bool: - """ - Unterstützt den Vergleichsoperator ==, um die Werte mit denen eines - weiteren Objektes zuvergleichen. + def pretty_string(self) -> str: + """Gibt eine lesbare String-Darstellung des Spielfelds zurück.""" - Args: - other: (TeamEnum): Die andere Koordinate. + def compare(self, other: Board) -> list[Field]: + """Vergleicht dieses Board mit einem anderen und gibt die unterschiedlichen Felder zurück.""" - Returns: - bool: Das Ergebnis des Vergleichs. - """ - ... - def __ne__(self, other: TeamEnum) -> bool: - """ - Unterstützt den Vergleichsoperator !=, um die Werte mit denen eines - weiteren Objektes zuvergleichen. + @staticmethod + def random_fields() -> list[list[Field]]: + """Erstellt ein leeres Spielfeld.""" - Args: - other: (TeamEnum): Die andere Koordinate. + @staticmethod + def contains(position: Coordinate) -> bool: + """Prüft, ob die Koordinate innerhalb der Grenzen des Spielfelds liegt.""" - Returns: - bool: Das Ergebnis des Vergleichs. - """ - ... - def get_fish_types(self) -> List[FieldType]: - """ - Gibt eine Liste aller Fischtypen des Teams aus. +class Rotation(Enum): + """Beschreibt, wie weit eine PieceShape gedreht werden soll.""" - Returns: - List[FieldType]: Die Liste der Feldtypen. - """ - ... + NONE = 0 + RIGHT = 1 + MIRROR = 2 + LEFT = 3 - def opponent(self) -> TeamEnum: - """ - Gibt den Gegner dieses Teams an. + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... - Return: - TeamEnum: Das Gegnerteam. - """ - ... + def value(self) -> int: + """Gibt den numerischen Wert (Anzahl Vierteldrehungen) zurück.""" -class Board: - """ - Ein Spielbrett, das die Felder des Spiels enthält. + def rotate(self, other: Rotation) -> Rotation: + """Summiert beide Rotationen auf.""" - Das Feld unten-links hat Koordinate (0, 0) und das Feld oben-rechts ist an Position (9, 9).
- Gleichzeitig bedeutet das, dass map[0] auch die unterste Zeile des Spielfeldes ist. + @staticmethod + def all() -> list[Rotation]: + """Gibt alle vier Rotationen zurück.""" + + def name(self) -> str: + ... + +class PieceShape(Enum): + """Eine Enumeration aller 21 verschiedenen Formen.""" + + Mono = 0 + Domino = 1 + TrioL = 2 + TrioI = 3 + TetroO = 4 + TetroT = 5 + TetroI = 6 + TetroL = 7 + TetroZ = 8 + PentoL = 9 + PentoT = 10 + PentoV = 11 + PentoS = 12 + PentoZ = 13 + PentoI = 14 + PentoP = 15 + PentoW = 16 + PentoU = 17 + PentoR = 18 + PentoX = 19 + PentoY = 20 + + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... - Attributes: - map (List[List[Field]]): Die 2 dimensionale Liste der Felder, die das Spielbrett darstellen.
- """ + @staticmethod + def all() -> list[PieceShape]: + """Gibt alle 21 Formen in Reihenfolge zurück.""" - map: List[List[FieldType]] + @staticmethod + def from_index(index: int) -> PieceShape | None: + """Gibt die Form anhand ihres Index zurück, oder None.""" - def __init__(self, map: List[List[FieldType]]) -> None: ... - def __str__(self) -> str: ... - def __repr__(self) -> str: ... - - def __eq__(self, other: TeamEnum) -> bool: - """ - Unterstützt den Vergleichsoperator ==, um die Werte mit denen eines - weiteren Objektes zuvergleichen. + def coordinates(self) -> set[Coordinate]: + """Die normalisierten Koordinaten der Grundform.""" - Args: - other: (TeamEnum): Die andere Koordinate. + def dimension(self) -> Vector: + """Das kleinstmögliche Rechteck, das die Form umfasst.""" - Returns: - bool: Das Ergebnis des Vergleichs. - """ - ... - def __ne__(self, other: TeamEnum) -> bool: - """ - Unterstützt den Vergleichsoperator !=, um die Werte mit denen eines - weiteren Objektes zuvergleichen. + def as_vectors(self) -> set[Vector]: + """Die Form als Menge von Vektoren relativ zu (0,0).""" - Args: - other: (TeamEnum): Die andere Koordinate. + def size(self) -> int: + """Die Anzahl der Felder, die diese Form belegt.""" - Returns: - bool: Das Ergebnis des Vergleichs. - """ - ... - - def deepcopy(self) -> GameState: + def variants(self) -> list[tuple[set[Coordinate], Rotation, bool]]: """ - Kopiert das Objekt rekursiv. - """ - ... + Alle eindeutigen Varianten der Form (Rotation + Spiegelung), ohne Duplikate. - def get_field(self, position: Coordinate) -> Optional[FieldType]: + Returns: + Eine Liste von (Koordinatenmenge, Rotation, ist_gespiegelt)-Tupeln. """ - Gibt das Feld an der gegebenen Koordinate zurück. - Args: - position (Coordinate): Die Position des Feldes, das abgerufen werden soll. + def transform(self, rotation: Rotation, should_flip: bool) -> set[Coordinate]: + """Transformiert die Form entsprechend Rotation und Spiegelung.""" - Returns: - Field: Das Feld an der gegebenen Koordinate, oder None, wenn außerhalb des gültigen Bereichs. - """ + def name(self) -> str: ... - def get_fields_by_type(self, field: FieldType) -> List[Coordinate]: - """ - Gibt eine Liste aller Koordinaten zurück, auf dem der angegebene Feld-Typ zu finden ist. +class Piece: + """Ein Spielstein mit Farbe, Position und Transformation.""" - Args: - field (FieldType): Der Feld-Typ, nachdem gesucht werden soll. + color: Color + kind: PieceShape + rotation: Rotation + is_flipped: bool + position: Coordinate - Returns: - List[Coordinate]: Die Liste der Koordinaten. - """ - ... + def __init__( + self, + color: Color, + kind: PieceShape, + rotation: Rotation, + is_flipped: bool, + position: Coordinate, + ) -> None: ... + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... + def __hash__(self) -> int: ... - def get_fields_in_direction(self, position: Coordinate, direction: Direction) -> List[FieldType]: - """ - Gibt eine Liste aller Feld-Typen zurück, die in einer Richtung liegen.
- Dabei wird als Ausgangspunkt eine Koordinate genommen und dazu die Richtung, in die aufgelistet werden soll. + def shape(self) -> set[Coordinate]: + """Die normalisierte Form des Steins (gedreht/gespiegelt, nicht verschoben).""" - **Achtung**: Das Feld der Ausgangskoordinate wird *nicht* beachtet und ausgegeben.
- Wenn die Startkoordinate nicht im Spielfeld liegt, wird eine leere Liste zurückgegeben. + def coordinates(self) -> set[Coordinate]: + """Die tatsächlichen Koordinaten, die der Stein auf dem Feld einnimmt.""" - Args: - position (Coordinate): Die Ausgangskoordinate. - direction (Direction): Die Richtung. + def transform(self, rotation: Rotation, is_flipped: bool) -> Piece: + """Dreht/spiegelt den Stein, Position bleibt gleich.""" - Returns: - List[FieldType]: Die Liste der Felder. - """ - def get_fields_on_line(self, position: Coordinate, direction: Direction) -> List[FieldType]: - """ - Gibt eine Liste aller Feld-Typen zurück, die auf einer Gerade liegen.
- Die Gerade wird aufgespannt durch eine Koordinate und einen Richtungsvektor. +class Move: + """Repräsentiert einen Zug im Spiel: entweder ein SetMove oder ein SkipMove.""" - Das Ergebnis wird in Richtung des "Vektorpfeils" abgelesen.
- Wenn die Startkoordinate nicht im Spielfeld liegt, wird eine leere Liste zurückgegeben. + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... - Args: - position (Coordinate): Die Startkoordinate für die Gerade. - direction (Direction): Der aufspannende Richtungsvektor. + @staticmethod + def set_move(piece: Piece) -> Move: + """Erstellt einen Zug, der den gegebenen Stein platziert.""" - Returns: - List[FieldType]: Die Liste der Felder. - """ - ... + @staticmethod + def skip_move(color: Color) -> Move: + """Erstellt einen Zug, der die aktuelle Runde für die gegebene Farbe aussetzt.""" - def get_fish_on_line(self, position: Coordinate, direction: Direction) -> List[FieldType]: - """ - Funktioniert ähnlich wie *Board.get_fields_on_line()*, - gibt aber nur die Feldtypen, die Fische sind, auf einer Geraden als Liste aus. + def get_color(self) -> Color: + """Die Farbe, die diesen Zug getätigt hat.""" - Args: - position (Coordinate): Die Startkoordinate für die Gerade. - direction (Direction): Der aufspannende Richtungsvektor. + def as_piece(self) -> Piece | None: + """Gibt das verwendete Piece aus, wenn vorhanden""" - Returns: - List[FieldType]: Die Liste der Fisch-Felder. - """ - ... -class Move: +class GameState: """ - Repräsentiert einen Zug im Spiel. + Repräsentiert den aktuellen Spielstand. Attribute: - start (Coordinate): Die Koordinate, von wo aus ein Fisch bewegt werden soll. - direction (Direction): Die Richtung, in die der Fisch schwimmt. + turn (int): Die Anzahl der bereits getätigten Züge. + last_move (Move | None): Der zuletzt gespielte Zug. + board (Board): Das aktuelle Spielfeld. + start_piece (PieceShape): Der Spielstein, der im ersten Zug gesetzt werden muss. + last_move_mono (dict[Color, bool]): Ob das Monomino zuletzt für jede Farbe gelegt wurde. """ - start: Coordinate - direction: Direction + turn: int + last_move: Move | None + board: Board + start_piece: PieceShape + last_move_mono: dict[Color, bool] - def __init__(self, start: Coordinate, direction: Direction) -> None: ... - def __str__(self) -> str: ... - def __repr__(self) -> str: ... - - def __eq__(self, other: Move) -> bool: - """ - Unterstützt den Vergleichsoperator ==, um die Werte mit denen eines - weiteren Objektes zuvergleichen. + def __init__( + self, + turn: int = 0, + last_move: Move | None = None, + board: Board | None = None, + start_piece: PieceShape = PieceShape.Mono, + last_move_mono: dict[Color, bool] | None = None, + ) -> None: ... + def __eq__(self, other: object) -> bool: ... - Args: - other: (Move): Die andere Koordinate. + def round(self) -> int: + """Die aktuelle Rundenzahl.""" - Returns: - bool: Das Ergebnis des Vergleichs. - """ - ... - def __ne__(self, other: Move) -> bool: - """ - Unterstützt den Vergleichsoperator !=, um die Werte mit denen eines - weiteren Objektes zuvergleichen. + def undeployed_piece_shapes(self, color: Color) -> list[PieceShape]: + """Gibt die noch nicht gesetzten Formen der gegebenen Farbe zurück.""" - Args: - other: (Move): Die andere Koordinate. + def remove_undeployed_piece(self, color: Color, shape: PieceShape) -> bool: + """Entfernt eine Form aus der Liste der noch nicht gesetzten Steine.""" - Returns: - bool: Das Ergebnis des Vergleichs. - """ - ... - - def deepcopy(self) -> GameState: - """ - Kopiert das Objekt rekursiv. - """ - ... + def current_color(self) -> Color: + """Die Farbe, die aktuell am Zug ist.""" -class GameState: - """ - Repräsentiert einen Spielstand. + def has_valid_colors(self) -> bool: + """Gibt zurück, ob noch Farben im Spiel sind.""" - Attribute: - board (Board): Das Spielbrett. - turn (int): Die aktuelle Runde. - last_move (Optional[Move]): Der zuletzt ausgeführte Zug. - """ + def is_valid_color(self, color: Color) -> bool: + """Prüft, ob die gegebene Farbe noch im Spiel ist.""" - board: Board - turn: int - last_move: Optional[Move] + def remove_active_color(self) -> bool: + """Entfernt die aktuell aktive Farbe aus dem Spiel und rückt vor.""" - def __init__(self, board: Board, turn: int, last_move: Optional[Move]) -> None: ... - def __str__(self) -> str: ... - def __repr__(self) -> str: ... - def __eq__(self, other: GameState) -> bool: - """ - Unterstützt den Vergleichsoperator ==, um die Werte mit denen eines - weiteren Objektes zuvergleichen. + def advance(self, turns: int = 1) -> bool: + """Geht zum Zug der nächsten gültigen Farbe über.""" - Args: - other: (GameState): Die andere Koordinate. + def is_over(self) -> bool: + """Gibt zurück, ob das Spiel vorbei ist.""" - Returns: - bool: Das Ergebnis des Vergleichs. + def possible_moves(self) -> list[Move]: """ - ... - def __ne__(self, other: GameState) -> bool: + Berechnet alle sinnvollen Züge der aktuellen Farbe. + Enthält einen SkipMove nur, wenn kein anderer Zug möglich ist. """ - Unterstützt den Vergleichsoperator !=, um die Werte mit denen eines - weiteren Objektes zuvergleichen. - Args: - other: (GameState): Die andere Koordinate. + def get_points_for_color(self, color: Color) -> int: + """Berechnet die Punkteanzahl für die gegebene Farbe.""" - Returns: - bool: Das Ergebnis des Vergleichs. - """ - ... + def get_points_for_team(self, team: TeamEnum) -> int: + """Berechnet die Punkteanzahl für das gegebene Team (Summe der Farben des Teams).""" - def deepcopy(self) -> GameState: + def win_condition(self) -> TeamEnum | None: """ - Kopiert das Objekt rekursiv. + Gibt das Gewinnerteam zurück, oder None bei einem Unentschieden. """ - ... - def set_board_field(self, position: Coordinate, field: FieldType) -> None: - """ - Ändert ein Feld auf dem Spielfeld an einer Koordinate. - Args: - position (Coordinate): Die Position des Feldes, das geändert werden soll. - field (FieldType): Das Feld, was dort platziert werden soll. - """ - ... +class GameRuleLogic: + """Eine Sammlung an statischen Methoden, die die Spielregeln logisch umsetzen.""" - def possible_moves_for(self, start: Coordinate) -> List[Move]: - """ - Berechnet alle Züge, die aus der aktuellen Spielposition für den Fisch an der Koordinate möglich sind. - - Args: - start (Coordinate): Die Position des gewählten Fisch. + @staticmethod + def get_points_from_undeployed(undeployed: list[PieceShape], mono_last: bool = False) -> int: + """Berechnet den Punktestand anhand der gegebenen, nicht gelegten Formen.""" - Returns: - List[Move]: Die Liste der Züge. + @staticmethod + def perform_move(game_state: GameState, move: Move) -> None: """ - ... + Führt den Zug im GameState aus (mutierend). - def possible_moves(self) -> List[Move]: + Raises: + Eine der Blokus-Move-Mistake-Exceptions, wenn der Zug nicht valide ist. """ - Berechnet alle Züge, die aus der aktuellen Spielposition für den aktuellen Spieler möglich sind. - Returns: - List[Move]: Die Liste der Züge. + @staticmethod + def validate_move_color(game_state: GameState, move: Move) -> None: """ - ... + Prüft, ob die Farbe des Zuges der aktiven Farbe entspricht. - def perform_move(self, move: Move) -> GameState: + Raises: + WrongColor: Wenn die Farbe nicht am Zug ist. """ - Führt den gegebenen Zug auf dem Spielstand aus, insofern dieser ausführbar ist (**nicht mutierend**). - Dabei wird *kein* Zug an den Spielserver übermittelt. - - Args: - move_ (Move): Der zuverwendene Zug. - Returns: - Gamestate: Der neue Spielstand. + @staticmethod + def validate_set_move(game_state: GameState, piece: Piece) -> None: + """ + Prüft, ob der gegebene Stein gesetzt werden könnte. Raises: - PiranhasError: Wenn der Zug nicht valide ist. + Eine der Blokus-Move-Mistake-Exceptions, wenn der Zug nicht valide ist. """ - ... - def perform_move_mut(self, move: Move) -> None: - """ - Führt den gegebenen Zug auf dem Spielstand aus, insofern dieser ausführbar ist (**mutierend**). - Dabei wird *kein* Zug an den Spielserver übermittelt. + @staticmethod + def perform_set_move(game_state: GameState, piece: Piece) -> None: + """Platziert den gegebenen Stein auf dem Spielfeld (mutierend, intern genutzt).""" - Args: - move_ (Move): Der zuverwendene Zug. + @staticmethod + def validate_shape(game_state: GameState, shape: PieceShape, color: Color) -> None: + """ + Prüft, ob die Form im ersten Zug/den nachfolgenden Zügen erlaubt ist. Raises: - PiranhasError: Wenn der Zug nicht valide ist. + WrongShape: Im ersten Zug, falls die falsche Form gewählt wurde. + DuplicateShape: In folgenden Zügen, falls die Form bereits gesetzt wurde. """ - ... -class RulesEngine: - """ - Stellt Methoden, die zur Überprüfung der Spielregeln dienen. - """ + @staticmethod + def is_valid_set_move(game_state: GameState, piece: Piece) -> bool: + """Gibt zurück, ob der SetMove zulässig ist, ohne eine Exception zu werfen.""" @staticmethod - def move_distance(board: Board, move_: Move) -> int: + def validate_set_move_on_board(board: Board, piece: Piece) -> None: """ - Gibt die Länge / Anzahl der Felder von einem Zug auf dem Spielfeld zurück. + Prüft, ob der Stein auf dem Board platziert werden kann (Grenzen, Überlappung, Farbregeln). - Args: - board (Board): Das Spielfeld, auf dem die Länge berechnet werden soll. - move_ (Move): Der zuverwendene Zug. - - Returns: - int: Die Länge. + Raises: + OutOfBounds: Wenn der Stein nicht vollständig auf das Spielfeld passt. + Obstructed: Wenn der Stein eine andere Farbe überlagern würde. + TouchesSameColor: Wenn der Stein ein Feld gleicher Farbe berührt. """ - ... @staticmethod - def target_position(board: Board, move_: Move) -> Coordinate: + def validate_skip_move(game_state: GameState) -> None: """ - Gibt die Koordinate zurück, auf der ein Fisch landen würde, wenn man den Zug ausführt. + Prüft, ob die aktuelle Farbe den Zug überspringen kann. - Es wird nicht berücksichtigt, ob diese Koordinate im Spielfeld ist. + Raises: + SkipFirstTurn: Wenn im ersten Zug übersprungen werden soll. + """ - Args: - board (Board): Das Spielfeld, auf dem der Zug berechnet werden soll. - move_ (Move): Der zuverwendene Zug. + @staticmethod + def perform_skip_move(game_state: GameState) -> None: + """Führt einen Skip-Zug aus (validiert, mutiert aber sonst nichts).""" - Returns: - Coordinate: Die Koordinate. - """ - ... + @staticmethod + def borders_on_color(board: Board, field: Field) -> bool: + """Prüft, ob das Feld an ein Feld gleicher Farbe angrenzt (Kante).""" @staticmethod - def is_in_bounds(coordinate: Coordinate) -> bool: - """ - Gibt einen Wahrheitswert zurück, ob eine Position in dem (Standard-) Spielfeld (10x10) liegt. + def corners_on_color(board: Board, field: Field) -> bool: + """Prüft, ob das Feld an die Ecke eines Feldes gleicher Farbe angrenzt.""" - Args: - coordinate (Coordinate): Die Position + @staticmethod + def is_on_border(position: Coordinate) -> bool: + """Prüft, ob die Position am Rand des Spielfelds liegt.""" - Returns: - bool: Ob die Koordinate im Feld ist. - """ - ... + @staticmethod + def is_first_move(game_state: GameState) -> bool: + """Gibt zurück, ob sich der GameState noch in der ersten Runde befindet.""" @staticmethod - def can_execute_move(board: Board, move_: Move) -> None: - """ - Prüft, ob ein Zug auf dem Board nach den Regeln durchgeführt werden könnte.
- Dabei ist nicht relevant, welcher Spieler gerade tatsächlich dran wäre. + def get_random_start_pentomino() -> PieceShape: + """Gibt ein zufälliges Pentomino zurück (Startstein).""" - Gibt keinen Wert zurück, sondern wirft eine Fehlermeldung, falls der Zug nicht valide ist. + @staticmethod + def remove_invalid_colors(game_state: GameState) -> None: + """Entfernt rekursiv alle Farben, die keine Steine mehr platzieren können (mutierend).""" - Args: - board (Board): Das Spielfeld. - move_ (Move): Der Zug, der geprüft werden soll. + @staticmethod + def get_all_possible_moves(game_state: GameState) -> list[Piece]: + """Gibt eine Liste aller möglichen SetMoves zurück (inkl. möglicher Startzüge).""" - Raises: - PiranhasError: Wenn der Zug nicht valide ist. + @staticmethod + def get_filtered_possible_moves(game_state: GameState) -> list[Piece]: """ - ... - - @staticmethod - def get_team_on_turn(turn: int) -> TeamEnum: + Gibt eine gefilterte Liste möglicher SetMoves zurück: + Startzüge, dann 5 Runden nur Pentominos, danach alle. """ - Berechnet anhand der Zugzahl, welcher Spieler dran sein müsste.
- Es wird nicht beachtet, ob die Zahl kleiner 0 oder größer 59 ist. - - Args: - turn (int): Die Zugzahl. - Returns: - TeamEnum: Das Team, was dran ist. - """ - ... + @staticmethod + def get_possible_start_moves(game_state: GameState, filter: bool = False) -> list[Piece]: + """Gibt alle möglichen SetMoves für den ersten Zug zurück.""" @staticmethod - def swarm_from(board: Board, position: Coordinate) -> List[Coordinate]: - """ - Berechnet auf einem Spielbrett von einer Startposition aus, welche Fische - von dort aus in einem Schwarm zusammenhängen. - Dabei werden nur Fische beachtet, die im selben Team sind, wie der auf dem Startfeld. - Gibt eine Liste an Koordinaten zurück, die leer ist, wenn die Startposition - außerhalb des Spielbrettes ist, oder kein Fisch als Start angegeben ist. + def get_possible_moves(game_state: GameState) -> list[Piece]: + """Gibt alle möglichen SetMoves (ohne Startzug) zurück.""" - Args: - board (Board): Das Spielbrett. - position (Coordinate): Die Startkoordinate + @staticmethod + def get_pentomino_moves(game_state: GameState) -> list[Piece]: + """Gibt nur die möglichen SetMoves mit Pentominos zurück.""" - Returns: - List[Coordinate]: Die Liste an zusammenhängenden Fischen. + @staticmethod + def get_possible_moves_for_shape( + game_state: GameState, shape: PieceShape, valid_fields: set[Coordinate] + ) -> list[Piece]: + """Gibt alle möglichen SetMoves für eine bestimmte Form zurück.""" - """ - ... + @staticmethod + def get_valid_fields(board: Board, color: Color) -> set[Coordinate]: + """Gibt alle Koordinaten zurück, auf die die gegebene Farbe einen Stein platzieren könnte.""" @staticmethod - def swarms_of_team(board: Board, team: TeamEnum) -> List[List[Coordinate]]: - """ - Berechnet auf einem Spielbrett alle Schwärme, die ein Team gerade gebildet hat. - Gibt eine 2-Dimensionale Liste zurück, wobei jede Sub-Liste ein einzelner Schwarm ist. + def get_colored_fields(board: Board, color: Color) -> set[Coordinate]: + """Gibt alle Koordinaten mit der gegebenen Farbe auf dem Board zurück.""" - Args: - board (Board): Das Spielbrett. - team (TeamEnum): Das gewählte Team. - Returns: - List[List[Coordinate]]: Die Liste an Schwärmen. +class Constants: + """Hält globale Konstanten.""" - """ - ... + BOARD_LENGTH: int + ROUND_LIMIT: int + TOTAL_PIECE_SHAPES: int + COLORS: int + VALIDATE_MOVE: bool -class PluginConstants: - """ - Hält globale Konstanten. - """ - BOARD_WIDTH: int - BOARD_HEIGHT: int +class WrongColor(Exception): + """Die Farbe des Zuges ist nicht an der Reihe.""" - ROUND_LIMIT: int +class NotOnBorder(Exception): + """Der erste Zug muss an den Rand gesetzt werden.""" + +class NoSharedCorner(Exception): + """Alle Teile müssen ein vorheriges Teil gleicher Farbe über mindestens eine Ecke berühren.""" + +class WrongShape(Exception): + """Der erste Zug muss den festgelegten Spielstein setzen.""" + +class SkipFirstTurn(Exception): + """Der erste Zug muss einen Stein setzen.""" + +class DuplicateShape(Exception): + """Der gewählte Stein wurde bereits gesetzt.""" + +class OutOfBounds(Exception): + """Der Spielstein passt nicht vollständig auf das Spielfeld.""" + +class Obstructed(Exception): + """Der Spielstein würde eine andere Farbe überlagern.""" + +class TouchesSameColor(Exception): + """Der Spielstein berührt ein Feld gleicher Farbe.""" diff --git a/python/socha/api/networking/game_client.py b/python/socha/api/networking/game_client.py index b1d0ea1..f052465 100644 --- a/python/socha/api/networking/game_client.py +++ b/python/socha/api/networking/game_client.py @@ -7,15 +7,16 @@ import sys import threading import time -from typing import List, Union +from typing import ClassVar from socha._socha import GameState, Move - +from socha.api.networking.utils import handle_move, message_to_state from socha.api.networking.xml_protocol_interface import XMLProtocolInterface from socha.api.protocol.protocol import ( Authenticate, Cancel, Error, + Errorpacket, Join, Joined, JoinPrepared, @@ -33,14 +34,16 @@ State, Step, ) - -from socha.api.networking.utils import handle_move, message_to_state -from socha.api.protocol.protocol import Errorpacket from socha.api.protocol.protocol_packet import ProtocolPacket +logger = logging.getLogger(__name__) + +# custom "VERBOSE" level, between DEBUG and INFO, used for chatty protocol logs +VERBOSE = 15 + class IClientHandler: - history: List[List[Union[GameState, Error, Result]]] = [] + history: ClassVar[list[list[GameState | Error | Result]]] = [] def calculate_move(self) -> Move: """ @@ -118,7 +121,7 @@ def on_create_game(self, game_client: 'GameClient') -> None: """ def on_prepared( - self, game_client: 'GameClient', room_id: str, reservations: List[str] + self, game_client: 'GameClient', room_id: str, reservations: list[str] ) -> None: """ This method will be called if the client is in admin mode and the client has created a game. @@ -170,46 +173,46 @@ def __init__( self.headless = headless def join_game(self): - logging.info('Joining game') + logger.info('Joining game') self.send(Join()) def join_game_room(self, room_id: str): - logging.info(f"Joining game room '{room_id}'") + logger.info(f"Joining game room '{room_id}'") self.send(JoinRoom(room_id=room_id)) def join_game_with_reservation(self, reservation: str): - logging.info(f"Joining game with reservation '{reservation}'") + logger.info(f"Joining game with reservation '{reservation}'") self.send(JoinPrepared(reservation_code=reservation)) def authenticate(self, password: str): - logging.info(f"Authenticating with password '{password}'") + logger.info(f"Authenticating with password '{password}'") self.send(Authenticate(password=password)) def create_game(self, player_1: Slot, player_2: Slot, game_type: str, pause: bool): - logging.info( + logger.info( f"Creating game with {player_1}, {player_2} and game type '{game_type}'" ) self.send(Prepare(game_type=game_type, pause=pause, slot=[player_1, player_2])) def observe(self, room_id: str): - logging.info(f"Observing game room '{room_id}'") + logger.info(f"Observing game room '{room_id}'") self.send(Observe(room_id=room_id)) def cancel(self, room_id: str): - logging.info(f"Cancelling game room '{room_id}'") + logger.info(f"Cancelling game room '{room_id}'") self.send(Cancel(room_id=room_id)) def step(self, room_id: str): - logging.info(f"Stepping game room '{room_id}'") + logger.info(f"Stepping game room '{room_id}'") self.send(Step(room_id=room_id)) def pause(self, room_id: str, pause: bool): - logging.info(f"Set pause of game room '{room_id}' to '{pause}'") + logger.info(f"Set pause of game room '{room_id}' to '{pause}'") self.send(Pause(room_id=room_id, pause=pause)) def send_message_to_room(self, room_id: str, message): - logging.log(15, f"Sending message to room '{room_id}'") - logging.debug(f"Message is '{message}'") + logger.log(VERBOSE, f"Sending message to room '{room_id}'") + logger.debug(f"Message is '{message}'") self.send(Room(room_id=room_id, data=message)) def _on_object(self, message): @@ -224,18 +227,18 @@ def _on_object(self, message): """ if isinstance(message, Errorpacket): - logging.error(f'An error occurred while handling the request: {message}') + logger.error(f'An error occurred while handling the request: {message}') self._game_handler.on_error(str(message)) self.stop() elif isinstance(message, Joined): - logging.log(15, f"Game joined received with room id '{message.room_id}'") + logger.log(VERBOSE, f"Game joined received with room id '{message.room_id}'") self._game_handler.on_game_joined(room_id=message.room_id) elif isinstance(message, Left): - logging.log(15, f"Game left received with room id '{message.room_id}'") + logger.log(VERBOSE, f"Game left received with room id '{message.room_id}'") self._game_handler.on_game_left() elif isinstance(message, Prepared): - logging.log( - 15, f"Game prepared received with reservation '{message.reservation}'" + logger.log( + VERBOSE, f"Game prepared received with reservation '{message.reservation}'" ) self._game_handler.on_prepared( game_client=self, @@ -243,27 +246,27 @@ def _on_object(self, message): reservations=message.reservation, ) elif isinstance(message, Observed): - logging.log(15, f"Game observing received with room id '{message.room_id}'") + logger.log(VERBOSE, f"Game observing received with room id '{message.room_id}'") self._game_handler.on_observed(game_client=self, room_id=message.room_id) elif isinstance(message, Room) and not self.headless: room_id = message.room_id if isinstance(message.data.class_binding, MoveRequest): - logging.log(15, f"Move request received for room id '{room_id}'") + logger.log(VERBOSE, f"Move request received for room id '{room_id}'") self._on_move_request(room_id) elif isinstance(message.data.class_binding, State): - logging.log(15, f"State received for room id '{room_id}'") + logger.log(VERBOSE, f"State received for room id '{room_id}'") self._on_state(message) elif isinstance(message.data.class_binding, Result): - logging.info(f"Result received for room id '{room_id}'") - logging.info(f"Result was '{message.data.class_binding}'") + logger.info(f"Result received for room id '{room_id}'") + logger.info(f"Result was '{message.data.class_binding}'") self._game_handler.history[-1].append(message.data.class_binding) self._game_handler.on_game_over(message.data.class_binding) else: - logging.log(15, f"Room message received for room id '{room_id}'") + logger.log(VERBOSE, f"Room message received for room id '{room_id}'") self._game_handler.on_room_message(message.data.class_binding) else: room_id = message.room_id - logging.log(15, f"Room message received for room id '{room_id}'") + logger.log(VERBOSE, f"Room message received for room id '{room_id}'") self._game_handler.on_room_message(message) def _on_move_request(self, room_id): @@ -271,12 +274,12 @@ def _on_move_request(self, room_id): move_response = self._game_handler.calculate_move() if move_response: response = handle_move(move_response) - logging.info( + logger.info( f'Sent {move_response} after {round(time.time() - start_time, ndigits=3)} seconds.' ) self.send_message_to_room(room_id, response) else: - logging.error(f'{move_response} is not a valid move.') + logger.error(f'{move_response} is not a valid move.') def _on_state(self, message): _state = message_to_state(message) @@ -313,30 +316,30 @@ def _handle_left(self): self.first_time = True self.network_interface.close() if self.survive: - logging.info( + logger.info( 'The server left. Client is in survive mode and keeps running.\n' 'Please shutdown the client manually.' ) self._game_handler.while_disconnected(player_client=self) if self.auto_reconnect: - logging.info('The server left. Client tries to reconnect to the server.') + logger.info('The server left. Client tries to reconnect to the server.') for _ in range(3): - logging.info('Try to establish a connection with the server...') + logger.info('Try to establish a connection with the server...') try: self.connect() if self.network_interface.connected: - logging.info('Reconnected to server.') + logger.info('Reconnected to server.') break - except Exception as e: - logging.exception(e) - logging.info( + except Exception: + logger.exception('Failed to reconnect to the server.') + logger.info( "The client couldn't reconnect due to a previous error." ) self.stop() time.sleep(1) self.join() return - logging.info('The server left.') + logger.info('The server left.') self.stop() def _client_loop(self): @@ -351,7 +354,7 @@ def _client_loop(self): if not response: continue elif isinstance(response, ProtocolPacket): - logging.debug(f'Received new object: {response}') + logger.debug(f'Received new object: {response}') if while_waiting: while_waiting.join(timeout=0.0) if isinstance(response, Left): @@ -365,19 +368,19 @@ def _client_loop(self): while_waiting.start() gc.collect() elif self.running: - logging.error(f'Received a object of unknown class: {response}') + logger.error(f'Received a object of unknown class: {response}') raise NotImplementedError('Received object of unknown class.') else: self._game_handler.while_disconnected(player_client=self) - logging.info('Done.') + logger.info('Done.') sys.exit(0) def stop(self): """ Disconnects from the server and stops the client loop. """ - logging.info('Shutting down...') + logger.info('Shutting down...') if self.network_interface.connected: self.disconnect() - self.running = False + self.running = False \ No newline at end of file diff --git a/python/socha/api/networking/network_socket.py b/python/socha/api/networking/network_socket.py index 526bcd6..48f1afc 100644 --- a/python/socha/api/networking/network_socket.py +++ b/python/socha/api/networking/network_socket.py @@ -1,7 +1,8 @@ import logging import re import socket -from typing import Union + +logger = logging.getLogger(__name__) class NetworkSocket: @@ -52,7 +53,7 @@ def send(self, data: bytes): """ self.socket.sendall(data) - def receive(self) -> Union[bytes, None]: + def receive(self) -> bytes | None: """ Attempts to receive data from the server. The received data is processed using a regular expression to extract complete messages. If a complete message is found, it is returned as bytes and removed from the buffer. @@ -66,17 +67,17 @@ def receive(self) -> Union[bytes, None]: while True: try: chunk = self.socket.recv(16129) - except socket.timeout: + except TimeoutError: chunk = b"" except ConnectionResetError: self.close() return None if chunk: - logging.debug(f"Received message: {chunk}") + logger.debug(f"Received message: {chunk}") self.buffer += chunk if regex.search(self.buffer): receive = regex.search(self.buffer).group() self.buffer = self.buffer.replace(receive, b"") return receive else: - return None + return None \ No newline at end of file diff --git a/python/socha/api/networking/utils.py b/python/socha/api/networking/utils.py index 46f87dd..681cce6 100644 --- a/python/socha/api/networking/utils.py +++ b/python/socha/api/networking/utils.py @@ -1,121 +1,194 @@ -import re -from typing import List from socha import _socha from socha.api.protocol.protocol import ( - Coordinate, Board, + Data, + LastMove, + LastMoveMono, + Piece, + Position, Room, State, - Data, ) +# SCREAMING_SNAKE_CASE (Server) <-> CamelCase-Variantenname (Rust/_socha) +_SHAPE_NAME_MAP = { + "MONO": "Mono", + "DOMINO": "Domino", + "TRIO_L": "TrioL", + "TRIO_I": "TrioI", + "TETRO_O": "TetroO", + "TETRO_T": "TetroT", + "TETRO_I": "TetroI", + "TETRO_L": "TetroL", + "TETRO_Z": "TetroZ", + "PENTO_L": "PentoL", + "PENTO_T": "PentoT", + "PENTO_V": "PentoV", + "PENTO_S": "PentoS", + "PENTO_Z": "PentoZ", + "PENTO_I": "PentoI", + "PENTO_P": "PentoP", + "PENTO_W": "PentoW", + "PENTO_U": "PentoU", + "PENTO_R": "PentoR", + "PENTO_X": "PentoX", + "PENTO_Y": "PentoY", +} +_SHAPE_NAME_MAP_REVERSE = {v: k for k, v in _SHAPE_NAME_MAP.items()} + + +def map_shape(name: str) -> _socha.PieceShape: + """ + Wandelt einen Server-Formnamen (z.B. 'PENTO_Y') in die entsprechende + _socha.PieceShape-Variante (z.B. PieceShape.PentoY) um. + """ + return getattr(_socha.PieceShape, _SHAPE_NAME_MAP[name]) + + +def map_shape_to_string(shape: _socha.PieceShape) -> str: + """ + Wandelt eine _socha.PieceShape-Variante zurück in den Server-Formnamen. + """ + return _SHAPE_NAME_MAP_REVERSE[shape.name()] + + +def map_color(name: str) -> _socha.Color: + """ + Color-Varianten matchen 1:1 zwischen Server und _socha (beide UPPERCASE). + """ + return getattr(_socha.Color, name) + + +def map_rotation(name: str) -> _socha.Rotation: + """ + Rotation-Varianten matchen 1:1 zwischen Server und _socha (beide UPPERCASE). + """ + return getattr(_socha.Rotation, name) + + +def map_piece(piece: Piece) -> _socha.Piece: + """ + Konvertiert ein protokoll Piece-Objekt in ein _socha.Piece-Objekt. + """ + return _socha.Piece( + color=map_color(piece.color), + kind=map_shape(piece.kind), + rotation=map_rotation(piece.rotation), + is_flipped=piece.is_flipped, + position=_socha.Coordinate(piece.position.x, piece.position.y), + ) + + +def map_piece_to_protocol(piece: _socha.Piece) -> Piece: + """ + Konvertiert ein _socha.Piece-Objekt zurück in ein protokoll Piece-Objekt, + zum Versenden an den Server. + """ + return Piece( + color=piece.color.name(), + kind=map_shape_to_string(piece.kind), + rotation=piece.rotation.name(), + is_flipped=piece.is_flipped, + position=Position(x=piece.position.x, y=piece.position.y), + ) + + +def map_last_move(protocol_last_move: LastMove | None) -> _socha.Move | None: + """ + Konvertiert das lastMove-Element eines State in ein _socha.Move-Objekt. + Kann entweder ein SetMove (piece gesetzt) oder ein SkipMove (color gesetzt) sein. + """ + if protocol_last_move is None: + return None + if protocol_last_move.piece is not None: + return _socha.Move.set_move(map_piece(protocol_last_move.piece)) + if protocol_last_move.color is not None: + return _socha.Move.skip_move(map_color(protocol_last_move.color)) + return None + + +def map_last_move_mono(last_move_mono: LastMoveMono | None) -> dict: + """ + Konvertiert das lastMoveMono-Element (XStream-Standard-Map-Serialisierung) + in ein Python-Dict[Color, bool]. + + UNBESTÄTIGT: In allen bisherigen Aufzeichnungen war dieses Element leer, + daher basiert die Struktur auf einer fundierten Annahme über XStreams + Standardverhalten für unkonvertierte HashMaps. + """ + if last_move_mono is None: + return {} + result = {} + for entry in last_move_mono.entry: + if entry.color is not None: + result[map_color(entry.color)] = bool(entry.value) + return result + def map_board(protocol_board: Board) -> _socha.Board: """ - Converts a protocol Board to a usable game board for using in the logic. - :param protocol_board: A Board object in protocol format - :type protocol_board: Board - :return: A Board object in the format used by the game logic - :rtype: _socha.Board - """ - - board_map: List[List[_socha.FieldType]] = [] - - for row in protocol_board.rows: - board_map.append([]) - for field in row.field_value: - if field == 'EMPTY': - board_map[-1].append(_socha.FieldType.Empty) - elif field == 'ONE_S': - board_map[-1].append(_socha.FieldType.OneS) - elif field == 'ONE_M': - board_map[-1].append(_socha.FieldType.OneM) - elif field == 'ONE_L': - board_map[-1].append(_socha.FieldType.OneL) - elif field == 'TWO_S': - board_map[-1].append(_socha.FieldType.TwoS) - elif field == 'TWO_M': - board_map[-1].append(_socha.FieldType.TwoM) - elif field == 'TWO_L': - board_map[-1].append(_socha.FieldType.TwoL) - elif field == 'SQUID': - board_map[-1].append(_socha.FieldType.Squid) - else: - raise ValueError(f'Unknown field type: {field}') - + Baut ein volles Board auf. Der Server sendet nur belegte Felder, + daher wird zunächst ein leeres Board erzeugt und dann überschrieben. + """ + board_map = _socha.Board.random_fields() # bereits vollständig EMPTY + + for f in protocol_board.field_value: + color = map_color(f.content) + coord = _socha.Coordinate(f.x, f.y) + board_map[f.y][f.x] = _socha.Field(coord, color) + return _socha.Board(map=board_map) -def map_string_to_direction(direction: str) -> _socha.Direction: - direction = re.sub(r'[^A-Za-z0-9_]', '', direction) - - if direction == 'UP': - return _socha.Direction.Up - elif direction == 'UP_RIGHT': - return _socha.Direction.UpRight - elif direction == 'RIGHT': - return _socha.Direction.Right - elif direction == 'DOWN_RIGHT': - return _socha.Direction.DownRight - elif direction == 'DOWN': - return _socha.Direction.Down - elif direction == 'DOWN_LEFT': - return _socha.Direction.DownLeft - elif direction == 'LEFT': - return _socha.Direction.Left - elif direction == 'UP_LEFT': - return _socha.Direction.UpLeft - else: - raise ValueError(f'Unknown direction: {direction}') - -def map_direction_to_string(direction: _socha.Direction): - - if direction == _socha.Direction.Up: - return 'UP' - elif direction == _socha.Direction.UpRight: - return 'UP_RIGHT' - elif direction == _socha.Direction.Right: - return 'RIGHT' - elif direction == _socha.Direction.DownRight: - return 'DOWN_RIGHT' - elif direction == _socha.Direction.Down: - return 'DOWN' - elif direction == _socha.Direction.DownLeft: - return 'DOWN_LEFT' - elif direction == _socha.Direction.Left: - return 'LEFT' - elif direction == _socha.Direction.UpLeft: - return 'UP_LEFT' - else: - raise ValueError(f'Unknown direction: {direction}') -def handle_move(move_response: _socha.Move) -> Data: +def handle_move(move: _socha.Move) -> Data: + """ + Konvertiert einen _socha.Move (SetMove oder SkipMove) in das ausgehende + Data-Paket, das an den Server gesendet wird. - return Data( - class_value='move', - from_=Coordinate(move_response.start.x, move_response.start.y), # invert y coordinate for server compatibility - direction=map_direction_to_string(move_response.direction), + UNBESTÄTIGT für SkipMove: Es wurde in keiner Aufzeichnung tatsächlich + ein SkipMove beobachtet. Die Struktur (color als Kindelement) basiert + auf der Kotlin-Deklaration von SkipMove.color (kein @XStreamAsAttribute). + """ + piece = move.as_piece() + if piece is not None: + return Data( + class_value="sc.plugin2027.SetMove", + piece=map_piece_to_protocol(piece), ) + else: + return Data( + class_value="sc.plugin2027.SkipMove", + skip_color=move.get_color().name(), + ) + def message_to_state(message: Room) -> _socha.GameState: """ - Constructs a GameState from the provided message, ensuring to reflect the - current state based on the ships' positions, teams, and other attributes. - - Args: - message: The input message containing the current game state. - second_last_move: the last_move object from the last game state before this game state - - Returns: - GameState: The constructed game state from the message. + Konstruiert einen vollständigen GameState aus der vom Server empfangenen + Nachricht, inklusive aller noch nicht gesetzten Formen pro Farbe und + der aktuell gültigen Farben. """ - state: State = message.data.class_binding - # extract last move of current gameState - state_last_move = state.last_move.class_binding if state.last_move and state.last_move.class_binding else None - return _socha.GameState( - board=map_board(state.board), turn=state.turn, - last_move=state_last_move, - ) + last_move=map_last_move(state.last_move), + board=map_board(state.board), + start_piece=map_shape(state.start_piece), + last_move_mono=map_last_move_mono(state.last_move_mono), + blue_shapes=[map_shape(s) for s in state.blue_shapes.shape] + if state.blue_shapes + else [], + yellow_shapes=[map_shape(s) for s in state.yellow_shapes.shape] + if state.yellow_shapes + else [], + red_shapes=[map_shape(s) for s in state.red_shapes.shape] + if state.red_shapes + else [], + green_shapes=[map_shape(s) for s in state.green_shapes.shape] + if state.green_shapes + else [], + valid_colors=[map_color(c) for c in state.valid_colors.color] + if state.valid_colors + else [], + ) \ No newline at end of file diff --git a/python/socha/api/networking/xml_protocol_interface.py b/python/socha/api/networking/xml_protocol_interface.py index c8507fa..dbdf6a1 100644 --- a/python/socha/api/networking/xml_protocol_interface.py +++ b/python/socha/api/networking/xml_protocol_interface.py @@ -4,30 +4,32 @@ import contextlib import logging -from typing import Any, Callable, Iterator +from collections.abc import Callable, Iterator +from typing import Any + +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 socha.api.networking.utils import map_string_to_direction from socha import _socha from socha.api.networking.network_socket import NetworkSocket from socha.api.protocol.protocol import ( Close, + Data, Error, MoveRequest, Result, WelcomeMessage, ) from socha.api.protocol.protocol_packet import ProtocolPacket -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 socha.api.protocol.protocol import Data, LastMove +logger = logging.getLogger(__name__) -def map_object(data: Data , params: dict): +def map_object(data: Data, params: dict): try: params.pop('class_binding') @@ -58,36 +60,19 @@ def map_object(data: Data , params: dict): ) return data(class_binding=error_object, **params) else: - logging.warn('Unknown class value: %s', params.get('class_value')) + logger.warning('Unknown class value: %s', params.get('class_value')) return data(**params) -def map_last_move(last_move: LastMove, params: dict): - try: - params.pop('class_binding') - except KeyError: - ... - - move_object = _socha.Move( - start=_socha.Coordinate(x=params.get('from_').x, y=params.get('from_').y), - direction=map_string_to_direction(params.get('direction')), - ) - return last_move(class_binding=move_object, **params) - def custom_class_factory(clazz, params: dict): - # print("TEST01: ", clazz, params) - if clazz.__name__ == 'Data': return map_object(clazz, params) - if clazz.__name__ == 'LastMove': - test = map_last_move(clazz, params) - return test return clazz(**params) -PROTOCOL_PREFIX = ''.encode('utf-8') +PROTOCOL_PREFIX = b'' class XMLProtocolInterface: @@ -143,7 +128,7 @@ def _receive(self): # Return None if the server returns an empty response if not receiving: return None - + # weird replacing of unicode chars, that are not working in xml rn unicodes = [b"\xe5", b"\xf6", b"\xfc", b"\xdf"] replaces = [b"ae", b"oe", b"ue", b"ss"] @@ -153,13 +138,11 @@ def _receive(self): cls = self._deserialize_object(receiving) return cls except OSError: - logging.error('An OSError occurred while receiving data from the server.') + logger.error('An OSError occurred while receiving data from the server.') self.running = False raise - except Exception as e: - logging.error( - 'An error occurred while receiving data from the server: %s', e - ) + except Exception: + logger.exception('An error occurred while receiving data from the server.') self.running = False raise @@ -181,11 +164,11 @@ def send(self, obj: ProtocolPacket) -> None: try: self.network_interface.send(shipment) - except Exception as e: - logging.exception('Error sending shipment to server: %s', e) + except Exception: + logger.exception('Error sending shipment to server.') raise else: - logging.debug('Sent shipment to server: %s', shipment) + logger.debug('Sent shipment to server: %s', shipment) self.first_time = False @contextlib.contextmanager @@ -213,4 +196,4 @@ def _serialize_object(self, object_class: object) -> bytes: :param object_class: The ProtocolPacket child to serialize. :return: The serialized byte stream. """ - return self.serializer.render(object_class).encode('utf-8') + return self.serializer.render(object_class).encode('utf-8') \ No newline at end of file diff --git a/python/socha/api/protocol/protocol.py b/python/socha/api/protocol/protocol.py index f4d9d0c..0d57b7b 100644 --- a/python/socha/api/protocol/protocol.py +++ b/python/socha/api/protocol/protocol.py @@ -1,8 +1,6 @@ from dataclasses import dataclass, field -from typing import List, Optional from socha._socha import TeamEnum - from socha.api.protocol.protocol_packet import ( AdminLobbyRequest, LobbyRequest, @@ -17,103 +15,192 @@ @dataclass -class Row: +class Position: + """ + Eine Position auf dem Spielbrett, verschachtelt innerhalb eines Piece-Elements. + """ + class Meta: - name = 'row' + name = 'position' - field_value: List[str] = field( - default_factory=list, - metadata={ - 'name': 'field', - 'type': 'Element', - 'min_occurs': 1, - }, + x: int | None = field( + default=None, + metadata={'type': 'Attribute'}, + ) + y: int | None = field( + default=None, + metadata={'type': 'Attribute'}, ) @dataclass -class Board: +class Piece: + """ + Ein Spielstein mit Farbe, Form, Rotation, Spiegelung und Position. + """ + class Meta: - name = 'board' + name = 'piece' - rows: List[Row] = field( - default_factory=list, + color: str | None = field( + default=None, + metadata={'type': 'Attribute'}, + ) + kind: str | None = field( + default=None, + metadata={'type': 'Attribute'}, + ) + rotation: str | None = field( + default=None, + metadata={'type': 'Attribute'}, + ) + is_flipped: bool | None = field( + default=None, metadata={ - 'name': 'row', - 'type': 'Element', - 'min_occurs': 1, + 'name': 'isFlipped', + 'type': 'Attribute', }, ) + position: Position | None = field( + default=None, + metadata={'type': 'Element'}, + ) @dataclass -class Coordinate: - +class LastMove: class Meta: - name = 'from' + name = 'lastMove' - x: Optional[int] = field( + class_binding: object | None = field(default=None) + class_value: str | None = field( default=None, metadata={ + 'name': 'class', 'type': 'Attribute', + 'required': True, }, ) - y: Optional[int] = field( + piece: Piece | None = field( default=None, - metadata={ - 'type': 'Attribute', - }, + metadata={'type': 'Element'}, + ) + # Für SkipMove: SkipMove.color hat kein @XStreamAsAttribute in Kotlin, + # wird daher als Kindelement serialisiert (nicht als Attribut). + color: str | None = field( + default=None, + metadata={'type': 'Element'}, ) @dataclass -class LastMove: +class Field: class Meta: - name = 'lastMove' - - class_binding: Optional[object] = field(default=None) - from_: Optional[Coordinate] = field( + name = 'field' + + x: int | None = field( default=None, - metadata={ - 'name': 'from', - 'type': 'Element', - }, + metadata={'type': 'Attribute'}, ) - direction: Optional[str] = field( + y: int | None = field( default=None, + metadata={'type': 'Attribute'}, + ) + content: str | None = field( + default=None, + metadata={'type': 'Attribute'}, + ) + + +@dataclass +class Board: + """ + Das Spielbrett. Enthält nur belegte Felder; + leere Felder werden vom Server nicht mitgeschickt. + """ + + class Meta: + name = 'board' + + field_value: list[Field] = field( + default_factory=list, metadata={ + 'name': 'field', 'type': 'Element', }, ) @dataclass -class Player: +class ShapeList: + """ + Wiederverwendet für blueShapes / yellowShapes / redShapes / greenShapes. + Enthält die noch nicht gesetzten Formen einer Farbe. + """ + + shape: list[str] = field( + default_factory=list, + metadata={'type': 'Element'}, + ) + + +@dataclass +class ColorList: class Meta: - name = 'player' + name = 'validColors' + + color: list[str] = field( + default_factory=list, + metadata={'type': 'Element'}, + ) + - name: Optional[str] = field( +@dataclass +class LastMoveMonoEntry: + """ + Ein Eintrag der lastMoveMono-HashMap, wie von XStreams + Standard-Map-Serialisierung erzeugt (kein eigener Converter registriert). + + UNBESTÄTIGT: Struktur beruht auf XStream-Standardverhalten, + da lastMoveMono in allen Aufzeichnungen bisher leer war. + """ + + class Meta: + name = 'entry' + + color: str | None = field( default=None, metadata={ - 'type': 'Attribute', - 'required': True, + 'name': 'sc.plugin2027.Color', + 'type': 'Element', }, ) - team: Optional[str] = field( + value: bool | None = field( default=None, metadata={ - 'type': 'Attribute', - 'required': True, + 'name': 'boolean', + 'type': 'Element', }, ) +@dataclass +class LastMoveMono: + class Meta: + name = 'lastMoveMono' + + entry: list[LastMoveMonoEntry] = field( + default_factory=list, + metadata={'type': 'Element'}, + ) + + @dataclass class State(ObservableRoomMessage): class Meta: name = 'state' - class_value: Optional[str] = field( + class_value: str | None = field( default=None, metadata={ 'name': 'class', @@ -121,7 +208,7 @@ class Meta: 'required': True, }, ) - start_team: Optional[str] = field( + start_team: str | None = field( default=None, metadata={ 'name': 'startTeam', @@ -129,27 +216,105 @@ class Meta: 'required': True, }, ) - turn: Optional[int] = field( + turn: int | None = field( default=None, metadata={ 'type': 'Attribute', 'required': True, }, ) - board: Optional[Board] = field( + start_piece: str | None = field( default=None, metadata={ - 'type': 'Element', + 'name': 'startPiece', + 'type': 'Attribute', 'required': True, }, ) - last_move: Optional[LastMove] = field( + round: int | None = field( + default=None, + metadata={ + 'type': 'Attribute', + 'required': True, + }, + ) + last_move: LastMove | None = field( default=None, metadata={ 'name': 'lastMove', 'type': 'Element', }, ) + board: Board | None = field( + default=None, + metadata={ + 'type': 'Element', + 'required': True, + }, + ) + last_move_mono: LastMoveMono | None = field( + default=None, + metadata={ + 'name': 'lastMoveMono', + 'type': 'Element', + }, + ) + blue_shapes: ShapeList | None = field( + default=None, + metadata={ + 'name': 'blueShapes', + 'type': 'Element', + }, + ) + yellow_shapes: ShapeList | None = field( + default=None, + metadata={ + 'name': 'yellowShapes', + 'type': 'Element', + }, + ) + red_shapes: ShapeList | None = field( + default=None, + metadata={ + 'name': 'redShapes', + 'type': 'Element', + }, + ) + green_shapes: ShapeList | None = field( + default=None, + metadata={ + 'name': 'greenShapes', + 'type': 'Element', + }, + ) + valid_colors: ColorList | None = field( + default=None, + metadata={ + 'name': 'validColors', + 'type': 'Element', + }, + ) + + +@dataclass +class Player: + class Meta: + name = 'player' + + name: str | None = field( + default=None, + metadata={ + 'type': 'Attribute', + 'required': True, + }, + ) + team: str | None = field( + default=None, + metadata={ + 'type': 'Attribute', + 'required': True, + }, + ) @dataclass @@ -157,14 +322,14 @@ class OriginalRequest(ProtocolPacket): class Meta: name = 'originalRequest' - class_value: Optional[str] = field( + class_value: str | None = field( default=None, metadata={ 'name': 'class', 'type': 'Attribute', }, ) - reservation_code: Optional[str] = field( + reservation_code: str | None = field( default=None, metadata={ 'name': 'reservationCode', @@ -178,13 +343,13 @@ class Errorpacket(ProtocolPacket): class Meta: name = 'errorpacket' - message: Optional[str] = field( + message: str | None = field( default=None, metadata={ 'type': 'Attribute', }, ) - original_request: Optional[OriginalRequest] = field( + original_request: OriginalRequest | None = field( default=None, metadata={ 'name': 'originalRequest', @@ -202,7 +367,7 @@ class Left(ProtocolPacket): class Meta: name = 'left' - room_id: Optional[str] = field( + room_id: str | None = field( default=None, metadata={ 'name': 'roomId', @@ -241,7 +406,7 @@ class Authenticate(AdminLobbyRequest): class Meta: name = 'authenticate' - password: Optional[str] = field( + password: str | None = field( default=None, metadata={ 'type': 'Attribute', @@ -258,7 +423,7 @@ class Cancel(AdminLobbyRequest): class Meta: name = 'cancel' - room_id: Optional[str] = field( + room_id: str | None = field( default=None, metadata={ 'name': 'roomId', @@ -276,14 +441,14 @@ class JoinedGameRoom(ObservableRoomMessage): class Meta: name = 'joinedGameRoom' - room_id: Optional[str] = field( + room_id: str | None = field( default=None, metadata={ 'name': 'roomId', 'type': 'Attribute', }, ) - player_count: Optional[int] = field( + player_count: int | None = field( default=None, metadata={ 'name': 'playerCount', @@ -301,7 +466,7 @@ class Observe(AdminLobbyRequest): class Meta: name = 'observe' - room_id: Optional[str] = field( + room_id: str | None = field( default=None, metadata={ 'name': 'roomId', @@ -319,14 +484,14 @@ class Pause(AdminLobbyRequest): class Meta: name = 'pause' - room_id: Optional[str] = field( + room_id: str | None = field( default=None, metadata={ 'name': 'roomId', 'type': 'Attribute', }, ) - pause: Optional[bool] = field( + pause: bool | None = field( default=None, metadata={ 'type': 'Attribute', @@ -343,21 +508,21 @@ class Slot(RoomOrchestrationMessage): class Meta: name = 'slot' - display_name: Optional[str] = field( + display_name: str | None = field( default=None, metadata={ 'name': 'displayName', 'type': 'Attribute', }, ) - can_timeout: Optional[bool] = field( + can_timeout: bool | None = field( default=None, metadata={ 'name': 'canTimeout', 'type': 'Attribute', }, ) - reserved: Optional[bool] = field( + reserved: bool | None = field( default=None, metadata={ 'type': 'Attribute', @@ -376,7 +541,7 @@ class Step(RoomOrchestrationMessage): class Meta: name = 'step' - room_id: Optional[str] = field( + room_id: str | None = field( default=None, metadata={ 'name': 'roomId', @@ -395,20 +560,20 @@ class Prepare(RoomOrchestrationMessage): class Meta: name = 'prepare' - game_type: Optional[str] = field( + game_type: str | None = field( default=None, metadata={ 'name': 'gameType', 'type': 'Attribute', }, ) - pause: Optional[bool] = field( + pause: bool | None = field( default=None, metadata={ 'type': 'Attribute', }, ) - slot: List[Slot] = field( + slot: list[Slot] = field( default_factory=list, metadata={ 'type': 'Element', @@ -437,7 +602,7 @@ class JoinPrepared(LobbyRequest): class Meta: name = 'joinPrepared' - reservation_code: Optional[str] = field( + reservation_code: str | None = field( default=None, metadata={ 'name': 'reservationCode', @@ -455,7 +620,7 @@ class JoinRoom(LobbyRequest): class Meta: name = 'joinRoom' - room_id: Optional[str] = field( + room_id: str | None = field( default=None, metadata={ 'name': 'roomId', @@ -473,19 +638,19 @@ class Fragment: class Meta: name = 'fragment' - name: Optional[str] = field( + name: str | None = field( default=None, metadata={ 'type': 'Attribute', }, ) - aggregation: Optional[str] = field( + aggregation: str | None = field( default=None, metadata={ 'type': 'Element', }, ) - relevant_for_ranking: Optional[bool] = field( + relevant_for_ranking: bool | None = field( default=None, metadata={ 'name': 'relevantForRanking', @@ -503,7 +668,7 @@ class Joined(ResponsePacket): class Meta: name = 'joined' - room_id: Optional[str] = field( + room_id: str | None = field( default=None, metadata={ 'name': 'roomId', @@ -521,7 +686,7 @@ class Score: class Meta: name = 'score' - part: List[int] = field( + part: list[int] = field( default_factory=list, metadata={ 'type': 'Element', @@ -535,21 +700,21 @@ class Winner: class Meta: name = 'winner' - team: Optional[str] = field( + team: str | None = field( default=None, metadata={ 'type': 'Attribute', 'required': True, }, ) - regular: Optional[bool] = field( + regular: bool | None = field( default=None, metadata={ 'type': 'Attribute', 'required': True, }, ) - reason: Optional[str] = field( + reason: str | None = field( default=None, metadata={ 'type': 'Attribute', @@ -569,7 +734,7 @@ class Definition: class Meta: name = 'definition' - fragment: List[Fragment] = field( + fragment: list[Fragment] = field( default_factory=list, metadata={ 'type': 'Element', @@ -587,13 +752,13 @@ class Entry: class Meta: name = 'entry' - player: Optional[Player] = field( + player: Player | None = field( default=None, metadata={ 'type': 'Element', }, ) - score: Optional[Score] = field( + score: Score | None = field( default=None, metadata={ 'type': 'Element', @@ -610,7 +775,7 @@ class Scores: class Meta: name = 'scores' - entry: List[Entry] = field( + entry: list[Entry] = field( default_factory=list, metadata={ 'type': 'Element', @@ -645,12 +810,18 @@ class OriginalMessage: """ The original message that was sent by the client. Is sent by the server if an error occurs. + + UNBESTÄTIGT für Blokus: Struktur übernommen aus dem alten Piranhas-Format + mit from_/direction. Da wir keine echte error-Nachricht aufgezeichnet haben, + ist unklar, ob der Server hier stattdessen ein - oder -Element + verwendet, analog zu Data. Passe ggf. an, sobald eine echte error-Nachricht + für einen Blokus-Move vorliegt. """ class Meta: name = 'originalMessage' - class_value: Optional[str] = field( + class_value: str | None = field( default=None, metadata={ 'name': 'class', @@ -658,18 +829,13 @@ class Meta: 'required': True, }, ) - from_: Optional[Coordinate] = field( + piece: Piece | None = field( default=None, - metadata={ - 'name': 'from', - 'type': 'Element', - }, + metadata={'type': 'Element'}, ) - direction: Optional[str] = field( + color: str | None = field( default=None, - metadata={ - 'type': 'Element', - }, + metadata={'type': 'Element'}, ) @@ -688,7 +854,7 @@ class Data: class Meta: name = 'data' - class_value: Optional[str] = field( + class_value: str | None = field( default=None, metadata={ 'name': 'class', @@ -696,54 +862,57 @@ class Meta: 'required': True, }, ) - class_binding: Optional[object] = field(default=None) - definition: Optional[Definition] = field( + class_binding: object | None = field(default=None) + definition: Definition | None = field( default=None, metadata={ 'type': 'Element', }, ) - original_message: Optional[OriginalMessage] = field( + original_message: OriginalMessage | None = field( default=None, metadata={ 'name': 'originalMessage', 'type': 'Element', }, ) - scores: Optional[Scores] = field( + scores: Scores | None = field( default=None, metadata={ 'type': 'Element', }, ) - winner: Optional[Winner] = field( + winner: Winner | None = field( default=None, metadata={ 'type': 'Element', }, ) - state: Optional[State] = field( + state: State | None = field( default=None, metadata={ 'type': 'Element', }, ) - color: Optional[str] = field( + # Nur für welcomeMessage: color="ONE"/"TWO" (TeamEnum), als Attribut. + color: str | None = field( default=None, metadata={ 'type': 'Attribute', }, ) - from_: Optional[Coordinate] = field( + # Für ausgehenden SetMove. + piece: Piece | None = field( default=None, - metadata={ - 'name': 'from', - 'type': 'Element', - }, + metadata={'type': 'Element'}, ) - direction: Optional[str] = field( + # Für ausgehenden SkipMove: eigenes Feld, da 'color' oben schon als + # Attribut für welcomeMessage belegt ist. SkipMove.color ist ein + # Kindelement (kein @XStreamAsAttribute in Kotlin). + skip_color: str | None = field( default=None, metadata={ + 'name': 'color', 'type': 'Element', }, ) @@ -754,7 +923,7 @@ class Room(ProtocolPacket): class Meta: name = 'room' - room_id: Optional[str] = field( + room_id: str | None = field( default=None, metadata={ 'name': 'roomId', @@ -762,7 +931,7 @@ class Meta: 'required': True, }, ) - data: Optional[Data] = field( + data: Data | None = field( default=None, metadata={ 'type': 'Element', @@ -776,7 +945,7 @@ class Observed(RoomOrchestrationMessage): class Meta: name = 'observed' - room_id: Optional[str] = field( + room_id: str | None = field( default=None, metadata={ 'name': 'roomId', @@ -790,14 +959,14 @@ class Prepared(RoomOrchestrationMessage): class Meta: name = 'prepared' - room_id: Optional[str] = field( + room_id: str | None = field( default=None, metadata={ 'name': 'roomId', 'type': 'Attribute', }, ) - reservation: List[str] = field( + reservation: list[str] = field( default_factory=list, metadata={ 'type': 'Element', @@ -816,76 +985,76 @@ class Protocol: class Meta: name = 'protocol' - authenticate: Optional[Authenticate] = field( + authenticate: Authenticate | None = field( default=None, metadata={ 'type': 'Element', }, ) - joined_game_room: Optional[JoinedGameRoom] = field( + joined_game_room: JoinedGameRoom | None = field( default=None, metadata={ 'name': 'joinedGameRoom', 'type': 'Element', }, ) - prepare: Optional[Prepare] = field( + prepare: Prepare | None = field( default=None, metadata={ 'type': 'Element', }, ) - observe: Optional[Observe] = field( + observe: Observe | None = field( default=None, metadata={ 'type': 'Element', }, ) - pause: Optional[Pause] = field( + pause: Pause | None = field( default=None, metadata={ 'type': 'Element', }, ) - step: Optional[Step] = field( + step: Step | None = field( default=None, metadata={ 'type': 'Element', }, ) - cancel: Optional[Cancel] = field( + cancel: Cancel | None = field( default=None, metadata={ 'type': 'Element', }, ) - join: Optional[Join] = field( + join: Join | None = field( default=None, metadata={ 'type': 'Element', }, ) - joined: Optional[Joined] = field( + joined: Joined | None = field( default=None, metadata={ 'type': 'Element', }, ) - room: List[Room] = field( + room: list[Room] = field( default_factory=list, metadata={ 'type': 'Element', }, ) - prepared: Optional[Prepared] = field( + prepared: Prepared | None = field( default=None, metadata={ 'type': 'Element', }, ) - observed: Optional[Observed] = field( + observed: Observed | None = field( default=None, metadata={ 'type': 'Element', }, - ) + ) \ No newline at end of file diff --git a/python/socha/api/protocol/room_message.py b/python/socha/api/protocol/room_message.py index e24cd3a..254ea40 100644 --- a/python/socha/api/protocol/room_message.py +++ b/python/socha/api/protocol/room_message.py @@ -6,20 +6,14 @@ class RoomMessage(ProtocolPacket): 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. - """ - - ... + """ \ No newline at end of file diff --git a/python/socha/starter.py b/python/socha/starter.py index b722f26..c9df012 100644 --- a/python/socha/starter.py +++ b/python/socha/starter.py @@ -6,12 +6,16 @@ import datetime import json import logging +import sys import urllib.request import pkg_resources + from socha.api.networking.game_client import GameClient, IClientHandler from socha.utils.package_builder import SochaPackageBuilder +logger = logging.getLogger(__name__) + class Starter: """ @@ -25,17 +29,17 @@ def __init__( logic: IClientHandler, host: str = "localhost", port: int = 13050, - reservation: str = None, - room_id: str = None, - password: str = None, + reservation: str | None = None, + room_id: str | None = None, + password: str | None = None, survive: bool = False, auto_reconnect: bool = False, headless: bool = False, log: bool = False, verbose: bool = False, build: bool = False, - directory: str = None, - architecture: str = None, + directory: str | None = None, + architecture: str | None = None, log_level: int = logging.INFO, python_version: str = '3.10', ): @@ -69,26 +73,26 @@ def __init__( self.check_socha_version() - self.directory: str = args.directory or directory - self.architecture: str = args.architecture or architecture + self.directory: str | None = args.directory or directory + self.architecture: str | None = args.architecture or architecture self.build: str = args.build or build self.python_version: str = args.python_version or python_version if self.build: builder = SochaPackageBuilder(self.directory, self.architecture, self.python_version) builder.build_package() - exit(0) + sys.exit(0) self.host: str = args.host or host self.port: int = args.port or port - self.reservation: str = args.reservation or reservation - self.room_id: str = args.room or room_id + self.reservation: str | None = args.reservation or reservation + self.room_id: str | None = args.room or room_id if self.room_id and self.reservation: - logging.warning( + logger.warning( "The room ID is not taken into account because a reservation is available." ) - self.password: str = args.password or password + self.password: str | None = args.password or password if self.password and (self.reservation or self.room_id): - logging.warning( + logger.warning( "The password is not taken into account because a reservation or Room ID is available." ) self.survive: bool = args.survive or survive @@ -118,7 +122,7 @@ def _setup_debugger(self, verbose: bool, log_level: int): level: int = log_level if self.write_log: - now = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + now = datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y%m%d%H%M%S") logging.basicConfig( filename=f"log{now}", level=level, @@ -129,8 +133,8 @@ def _setup_debugger(self, verbose: bool, log_level: int): logging.basicConfig( level=level, format="%(asctime)s: %(levelname)s - %(message)s" ) - logging.info("Starting...") - logging.info( + logger.info("Starting...") + logger.info( "We would greatly appreciate it if you could share any issues " "or feature requests you may have regarding socha by either creating " "an issue on our GitHub repository or contributing to the project." @@ -149,18 +153,18 @@ def check_socha_version(): json_data = json.loads(response.read()) latest_version = json_data["info"]["version"] if installed_version != latest_version: - logging.warning( + logger.warning( f"A newer version ({latest_version}) of {package_name} is available. You have version " f"{installed_version}." ) else: - logging.info( + logger.info( f"You're running the latest version of {package_name} ({latest_version})" ) except pkg_resources.DistributionNotFound: - logging.error(f"{package_name} is not installed.") + logger.error(f"{package_name} is not installed.") except urllib.error.URLError as e: - logging.warning( + logger.warning( f"Could not check the latest version of {package_name} due to {type(e).__name__}: {e}" ) @@ -249,4 +253,4 @@ def _handle_start_args(): help="Specifies the build python version (e.g.: 3.10 - this is standard).", ) - return parser.parse_args() + return parser.parse_args() \ No newline at end of file diff --git a/python/socha/utils/package_builder.py b/python/socha/utils/package_builder.py index af2d0d1..e5109dd 100644 --- a/python/socha/utils/package_builder.py +++ b/python/socha/utils/package_builder.py @@ -11,6 +11,8 @@ import types import zipfile +logger = logging.getLogger(__name__) + class SochaPackageBuilder: @@ -29,16 +31,16 @@ def _download_dependencies(self): req_file = os.path.join(current_dir, "requirements.txt") try: - with open(req_file, "r") as f: + with open(req_file) as f: requirements = f.read().splitlines() - except Exception as e: - logging.error(f"Error reading requirements file: {str(e)}") - logging.info( + except OSError as e: + logger.error(f"Error reading requirements file: {e!s}") + logger.info( "Please create a 'requirements.txt' in the same folder as your logic." ) sys.exit(1) - logging.info(f"Downloading the following packages: {requirements}") + logger.info(f"Downloading the following packages: {requirements}") # Download all dependencies to the dependencies directory try: @@ -59,7 +61,7 @@ def _download_dependencies(self): + requirements ) except subprocess.CalledProcessError as e: - logging.error(f"Error downloading dependencies: {str(e)}") + logger.error(f"Error downloading dependencies: {e!s}") sys.exit(1) @staticmethod @@ -72,10 +74,10 @@ def _create_build_directory(): def _create_directory_structure(self): try: if not os.path.exists(f"{self.build_dir}/{self.package_name}"): - logging.info(f"Creating directory {self.package_name}") + logger.info(f"Creating directory {self.package_name}") os.mkdir(f"{self.build_dir}/{self.package_name}") - logging.info(f"Creating directory {self.dependencies_dir}") + logger.info(f"Creating directory {self.dependencies_dir}") if not os.path.exists( f"{self.build_dir}/{self.package_name}/{self.dependencies_dir}" ): @@ -83,20 +85,20 @@ def _create_directory_structure(self): f"{self.build_dir}/{self.package_name}/{self.dependencies_dir}" ) - logging.info(f"Creating directory {self.packages_dir}") + logger.info(f"Creating directory {self.packages_dir}") if not os.path.exists( f"{self.build_dir}/{self.package_name}/{self.packages_dir}" ): os.mkdir(f"{self.build_dir}/{self.package_name}/{self.packages_dir}") - logging.info(f"Creating directory {self.cache_dir}") + logger.info(f"Creating directory {self.cache_dir}") if not os.path.exists( f"{self.build_dir}/{self.package_name}/{self.cache_dir}" ): os.mkdir(f"{self.build_dir}/{self.package_name}/{self.cache_dir}") - except OSError as e: - logging.error(f"Error creating directory: {e}") + except OSError: + logger.exception("Error creating directory.") sys.exit(1) @staticmethod @@ -112,12 +114,12 @@ def _get_modules(): # and are in the same directory or a subdirectory main_modules = set(sys.modules) - set(globals()) if main_module: - main_modules |= set( + main_modules |= { obj.__name__ for obj in gc.get_objects() if isinstance(obj, types.ModuleType) and obj.__name__.startswith(main_module.__name__) - ) + } main_modules = { name for name in main_modules @@ -145,7 +147,7 @@ def _copy_modules(self): Recursively searches for the given python file in the current working directory and its subdirectories, and copies all python files with their directory structure to the target_folder. """ - logging.info(f"Copying python files to {self.package_name}") + logger.info(f"Copying python files to {self.package_name}") source_folder = os.getcwd() main_modules = self._get_modules() for root, dirs, files in os.walk(source_folder): @@ -162,18 +164,18 @@ def _copy_modules(self): if file in sys.argv[0]: os.makedirs(os.path.dirname(target_file_path), exist_ok=True) shutil.copy2(source_file_path, target_file_path) - logging.info( + logger.info( f"Copying {source_file_path} to {target_file_path}" ) if source_file_path in main_modules: os.makedirs(os.path.dirname(target_file_path), exist_ok=True) shutil.copy2(source_file_path, target_file_path) - logging.info( + logger.info( f"Copying {source_file_path} to {target_file_path}" ) def _create_shell_script(self): - logging.info(f"Creating shell script {self.package_name}/start.sh") + logger.info(f"Creating shell script {self.package_name}/start.sh") with open( f"{self.build_dir}/{self.package_name}/start.sh", "w", newline="\n" ) as f: @@ -200,10 +202,9 @@ def _create_shell_script(self): ) # Add all downloaded packages to the pip install command - for package in os.listdir( + f.writelines(f"./{self.package_name}/{self.dependencies_dir}/{package} " for package in os.listdir( f"{self.build_dir}/{self.package_name}/{self.dependencies_dir}" - ): - f.write(f"./{self.package_name}/{self.dependencies_dir}/{package} ") + )) f.write("\n\n") f.write( @@ -214,7 +215,7 @@ def _create_shell_script(self): ) def _zipdir(self): - logging.info(f"Zipping directory {self.package_name}") + logger.info(f"Zipping directory {self.package_name}") try: zipf = zipfile.ZipFile( f"{self.build_dir}/{self.package_name}.zip", "w", zipfile.ZIP_DEFLATED @@ -229,13 +230,13 @@ def _zipdir(self): arc_name = os.path.relpath(dir_path, self.build_dir) zipf.write(dir_path, arcname=arc_name) zipf.close() - logging.info(f"{self.package_name}.zip successfully created!") - except Exception as e: - logging.error(f"Error creating {self.package_name}.zip: {str(e)}") + logger.info(f"{self.package_name}.zip successfully created!") + except (OSError, zipfile.BadZipFile) as e: + logger.error(f"Error creating {self.package_name}.zip: {e!s}") sys.exit(1) def build_package(self): - logging.info("Building package...") + logger.info("Building package...") # Create the directory structure self._create_directory_structure() @@ -253,4 +254,4 @@ def build_package(self): self._zipdir() # Log a success message - logging.info(f"{self.package_name} package successfully built!") + logger.info(f"{self.package_name} package successfully built!") \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index c746fe7..5486d31 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,37 +1,45 @@ use pyo3::*; use types::PyModule; -pub mod plugin2026; - -use crate::plugin2026::utils::vector::Vector; -use crate::plugin2026::utils::direction::Direction; -use crate::plugin2026::utils::coordinate::Coordinate; -use crate::plugin2026::utils::constants::PluginConstants; -use crate::plugin2026::utils::team::TeamEnum; - -use crate::plugin2026::game_state::GameState; -use crate::plugin2026::board::Board; -use crate::plugin2026::field_type::FieldType; -use crate::plugin2026::r#move::Move; - -use crate::plugin2026::rules_engine::RulesEngine; +pub mod plugin2027; + +use crate::plugin2027::utils::constants::Constants; +use crate::plugin2027::utils::coordinate::Coordinate; +use crate::plugin2027::utils::direction::Direction; +use crate::plugin2027::utils::game_rule_logic::GameRuleLogic; +use crate::plugin2027::utils::team::TeamEnum; +use crate::plugin2027::utils::vector::Vector; + +use crate::plugin2027::board::Board; +use crate::plugin2027::color::Color; +use crate::plugin2027::field_content::FieldContent; +use crate::plugin2027::field::Field; +use crate::plugin2027::game_state::GameState; +use crate::plugin2027::r#move::Move; +use crate::plugin2027::piece_shape::PieceShape; +use crate::plugin2027::piece::Piece; +use crate::plugin2027::rotation::Rotation; #[pymodule] fn _socha(m: &Bound<'_, PyModule>) -> PyResult<()> { pyo3_log::init(); - - m.add_class::()?; - m.add_class::()?; + + m.add_class::()?; m.add_class::()?; - m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; + m.add_class::()?; - m.add_class::()?; m.add_class::()?; - m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; - - m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/src/plugin2026.rs b/src/plugin2026.rs deleted file mode 100644 index 9e5fc23..0000000 --- a/src/plugin2026.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod rules_engine; -pub mod game_state; -pub mod field_type; -pub mod board; -pub mod r#move; -pub mod utils; -pub mod test; -pub mod errors; \ No newline at end of file diff --git a/src/plugin2027.rs b/src/plugin2027.rs new file mode 100644 index 0000000..543f5f1 --- /dev/null +++ b/src/plugin2027.rs @@ -0,0 +1,11 @@ +pub mod game_state; +pub mod field; +pub mod field_content; +pub mod color; +pub mod piece; +pub mod piece_shape; +pub mod board; +pub mod r#move; +pub mod errors; +pub mod rotation; +pub mod utils; \ No newline at end of file diff --git a/src/plugin2027/board.rs b/src/plugin2027/board.rs new file mode 100644 index 0000000..e7a2ae8 --- /dev/null +++ b/src/plugin2027/board.rs @@ -0,0 +1,117 @@ +use pyo3::*; + +use crate::plugin2027::{ + color::Color, field::Field, field_content::FieldContent, + utils::{constants::Constants, coordinate::Coordinate}, +}; + +#[pyclass] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Board { + #[pyo3(get, set)] + pub map: Vec>, +} + +#[pymethods] +impl Board { + #[new] + pub fn new(map: Option>>) -> Self { + Self { + map: map.unwrap_or_else(Self::random_fields), + } + } + + /// Zugriff auf ein Feld über Koordinaten (entspricht Kotlins `this[position]`). + pub fn get(&self, position: Coordinate) -> PyResult { + self.map + .get(position.y as usize) + .and_then(|row| row.get(position.x as usize)) + .cloned() + .ok_or_else(|| pyo3::exceptions::PyIndexError::new_err("Coordinate out of bounds")) + } + + /// Setzt den Inhalt eines Feldes über Koordinaten. + pub fn set_content(&mut self, position: Coordinate, content: FieldContent) { + if let Some(row) = self.map.get_mut(position.y as usize) { + if let Some(field) = row.get_mut(position.x as usize) { + field.content = content; + } + } + } + + pub fn get_content(&self, position: Coordinate) -> Option { + self.map + .get(position.y as usize) + .and_then(|row| row.get(position.x as usize)) + .map(|field| field.content) + } + + pub fn is_empty(&self) -> bool { + self.map + .iter() + .all(|row| row.iter().all(|f| f.content == FieldContent::EMPTY)) + } + + pub fn is_obstructed(&self, position: Coordinate) -> bool { + self.get_content(position) + .is_some_and(|c| c != FieldContent::EMPTY) + } + + pub fn get_team(&self, position: Coordinate) -> Option { + self.get_content(position).and_then(|c| c.to_team_color()) + } + + pub fn pretty_string(&self) -> String { + self.map + .iter() + .map(|row| { + row.iter() + .map(|f| f.content.to_string()) + .collect::>() + .join(" ") + }) + .collect::>() + .join("\n") + } + + /// Vergleicht zwei Boards und gibt die Felder zurück, die sich unterscheiden (Werte von `other`). + pub fn compare(&self, other: &Board) -> Vec { + let mut different = Vec::new(); + for y in 0..self.map.len() { + for x in 0..self.map[y].len() { + let field = &self.map[y][x]; + let other_field = &other.map[y][x]; + if field != other_field { + different.push(other_field.clone()); + } + } + } + different + } + + #[staticmethod] + pub fn random_fields() -> Vec> { + (0..Constants::BOARD_LENGTH) + .map(|y| { + (0..Constants::BOARD_LENGTH) + .map(|x| Field { + coordinate: Coordinate::new(x as isize, y as isize), + content: FieldContent::EMPTY, + }) + .collect() + }) + .collect() + } + + #[staticmethod] + pub fn contains(position: Coordinate) -> bool { + let len = Constants::BOARD_LENGTH as isize; + position.x >= 0 && position.x < len && position.y >= 0 && position.y < len + } +} + +impl std::fmt::Display for Board { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Board {}", self.pretty_string()) + } +} \ No newline at end of file diff --git a/src/plugin2027/color.rs b/src/plugin2027/color.rs new file mode 100644 index 0000000..47d4257 --- /dev/null +++ b/src/plugin2027/color.rs @@ -0,0 +1,70 @@ + +use pyo3::*; + +use crate::plugin2027::{ + utils::team::TeamEnum, + field_content::FieldContent +}; + +#[pyclass] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Color { + BLUE, + YELLOW, + RED, + GREEN +} + +#[pymethods] +impl Color { + fn __str__(&self) -> String {self.to_string()} + fn __repr__(&self) -> String {format!("{:?}", self)} + fn __eq__(&self, other: &Color) -> bool {self == other} + fn __ne__(&self, other: &Color) -> bool {self != other} + fn __hash__(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.hash(&mut hasher); + hasher.finish() + } + + pub fn next(&self) -> Color { + match self { + Color::BLUE => Color::YELLOW, + Color::YELLOW => Color::RED, + Color::RED => Color::GREEN, + Color::GREEN => Color::BLUE + } + } + + pub fn team(&self) -> TeamEnum { + match self { + Color::BLUE | Color::RED => TeamEnum::One, + Color::YELLOW | Color::GREEN => TeamEnum::Two, + } + } + + pub fn to_field_content(&self) -> FieldContent { + match self { + Color::BLUE => FieldContent::BLUE, + Color::YELLOW => FieldContent::YELLOW, + Color::RED => FieldContent::RED, + Color::GREEN => FieldContent::GREEN + } + } + + pub fn name(&self) -> String { + format!("{:?}", self) + } +} + +impl std::fmt::Display for Color { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Color::BLUE => write!(f, "Color Blue"), + Color::YELLOW => write!(f, "Color Yellow"), + Color::RED => write!(f, "Color Red"), + Color::GREEN => write!(f, "Color green"), + } + } +} \ No newline at end of file diff --git a/src/plugin2027/errors.rs b/src/plugin2027/errors.rs new file mode 100644 index 0000000..4e906a5 --- /dev/null +++ b/src/plugin2027/errors.rs @@ -0,0 +1,69 @@ +use pyo3::prelude::*; +use pyo3::exceptions::PyException; +use pyo3::create_exception; + +create_exception!(_socha, WrongColor, PyException); +create_exception!(_socha, NotOnBorder, PyException); +create_exception!(_socha, NoSharedCorner, PyException); +create_exception!(_socha, WrongShape, PyException); +create_exception!(_socha, SkipFirstTurn, PyException); +create_exception!(_socha, DuplicateShape, PyException); +create_exception!(_socha, OutOfBounds, PyException); +create_exception!(_socha, Obstructed, PyException); +create_exception!(_socha, TouchesSameColor, PyException); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BlokusMoveMistake { + WrongColor, + NotOnBorder, + NoSharedCorner, + WrongShape, + SkipFirstTurn, + DuplicateShape, + OutOfBounds, + Obstructed, + TouchesSameColor, +} + +impl BlokusMoveMistake { + pub fn message(&self) -> &'static str { + match self { + BlokusMoveMistake::WrongColor => "Die Farbe des Zuges ist nicht an der Reihe", + BlokusMoveMistake::NotOnBorder => "Der erste Zug muss an den Rand gesetzt werden", + BlokusMoveMistake::NoSharedCorner => "Alle Teile müssen ein vorheriges Teil gleicher Farbe über mindestens eine Ecke berühren", + BlokusMoveMistake::WrongShape => "Der erste Zug muss den festgelegten Spielstein setzen", + BlokusMoveMistake::SkipFirstTurn => "Der erste Zug muss einen Stein setzen", + BlokusMoveMistake::DuplicateShape => "Der gewählte Stein wurde bereits gesetzt", + BlokusMoveMistake::OutOfBounds => "Der Spielstein passt nicht vollständig auf das Spielfeld", + BlokusMoveMistake::Obstructed => "Der Spielstein würde eine andere Farbe überlagern", + BlokusMoveMistake::TouchesSameColor => "Der Spielstein berührt ein Feld gleicher Farbe", + } + } + + pub fn to_py_err(&self) -> PyErr { + let msg = self.message(); + match self { + BlokusMoveMistake::WrongColor => WrongColor::new_err(msg), + BlokusMoveMistake::NotOnBorder => NotOnBorder::new_err(msg), + BlokusMoveMistake::NoSharedCorner => NoSharedCorner::new_err(msg), + BlokusMoveMistake::WrongShape => WrongShape::new_err(msg), + BlokusMoveMistake::SkipFirstTurn => SkipFirstTurn::new_err(msg), + BlokusMoveMistake::DuplicateShape => DuplicateShape::new_err(msg), + BlokusMoveMistake::OutOfBounds => OutOfBounds::new_err(msg), + BlokusMoveMistake::Obstructed => Obstructed::new_err(msg), + BlokusMoveMistake::TouchesSameColor => TouchesSameColor::new_err(msg), + } + } +} + +impl std::fmt::Display for BlokusMoveMistake { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message()) + } +} + +impl From for PyErr { + fn from(mistake: BlokusMoveMistake) -> PyErr { + mistake.to_py_err() + } +} \ No newline at end of file diff --git a/src/plugin2027/field.rs b/src/plugin2027/field.rs new file mode 100644 index 0000000..c591aa2 --- /dev/null +++ b/src/plugin2027/field.rs @@ -0,0 +1,42 @@ + +use pyo3::*; + +use crate::plugin2027::{ + color::Color, field_content::FieldContent, utils::coordinate::Coordinate +}; + +#[pyclass] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Field { + #[pyo3(get, set)] + pub coordinate: Coordinate, + #[pyo3(get, set)] + pub content: FieldContent +} + +#[pymethods] +impl Field { + #[new] + pub fn new(coordinate: Coordinate, color: Color) -> Self { + Self { + coordinate, + content: color.to_field_content(), + } + } + + fn __str__(&self) -> String {self.to_string()} + fn __repr__(&self) -> String {format!("{:?}", self)} + fn __eq__(&self, other: &Field) -> bool {self == other} + fn __ne__(&self, other: &Field) -> bool {self != other} + fn deepcopy(&self) -> Field {self.clone()} + + pub fn is_empty(&self) -> bool { + self.content == FieldContent::EMPTY + } +} + +impl std::fmt::Display for Field { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Field at {} filled with {}", self.coordinate, self.content) + } +} diff --git a/src/plugin2027/field_content.rs b/src/plugin2027/field_content.rs new file mode 100644 index 0000000..25e10db --- /dev/null +++ b/src/plugin2027/field_content.rs @@ -0,0 +1,50 @@ + +use pyo3::*; + +use crate::plugin2027::{ + color::Color, +}; + +#[pyclass] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FieldContent { + BLUE, + YELLOW, + RED, + GREEN, + EMPTY +} + +#[pymethods] +impl FieldContent { + fn __str__(&self) -> String {self.to_string()} + fn __repr__(&self) -> String {format!("{:?}", self)} + fn __eq__(&self, other: &FieldContent) -> bool {self == other} + fn __ne__(&self, other: &FieldContent) -> bool {self != other} + + pub fn to_team_color(&self) -> Option { + match self { + FieldContent::BLUE => Some(Color::BLUE), + FieldContent::YELLOW => Some(Color::YELLOW), + FieldContent::RED => Some(Color::RED), + FieldContent::GREEN => Some(Color::GREEN), + FieldContent::EMPTY => None + } + } + + pub fn is_empty(&self) -> bool { + *self == FieldContent::EMPTY + } +} + +impl std::fmt::Display for FieldContent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FieldContent::BLUE => write!(f, "B"), + FieldContent::YELLOW => write!(f, "Y"), + FieldContent::RED => write!(f, "R"), + FieldContent::GREEN => write!(f, "G"), + FieldContent::EMPTY => write!(f, "-"), + } + } +} \ No newline at end of file diff --git a/src/plugin2027/game_state.rs b/src/plugin2027/game_state.rs new file mode 100644 index 0000000..e9450b2 --- /dev/null +++ b/src/plugin2027/game_state.rs @@ -0,0 +1,178 @@ +use pyo3::prelude::*; +use std::collections::{HashMap, HashSet}; + +use crate::plugin2027::{ + board::Board, color::Color, r#move::Move, piece_shape::PieceShape, + utils::{constants::Constants, game_rule_logic::GameRuleLogic, team::TeamEnum}, +}; + +#[pyclass] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GameState { + #[pyo3(get, set)] + pub turn: usize, + #[pyo3(get, set)] + pub last_move: Option, + #[pyo3(get, set)] + pub board: Board, + #[pyo3(get, set)] + pub start_piece: PieceShape, + #[pyo3(get, set)] + pub last_move_mono: HashMap, + blue_shapes: HashSet, + yellow_shapes: HashSet, + red_shapes: HashSet, + green_shapes: HashSet, + valid_colors: Vec, + round: usize, +} + +#[pymethods] +impl GameState { + #[new] + #[pyo3(signature = (turn=0, last_move=None, board=None, start_piece=PieceShape::Mono, last_move_mono=None, blue_shapes=None, yellow_shapes=None, red_shapes=None, green_shapes=None, valid_colors=None))] + #[allow(clippy::too_many_arguments)] + pub fn new( + turn: usize, + last_move: Option, + board: Option, + start_piece: PieceShape, + last_move_mono: Option>, + blue_shapes: Option>, + yellow_shapes: Option>, + red_shapes: Option>, + green_shapes: Option>, + valid_colors: Option>, + ) -> Self { + Self { + turn, + last_move, + board: board.unwrap_or_else(|| Board::new(None)), + start_piece, + last_move_mono: last_move_mono.unwrap_or_default(), + blue_shapes: blue_shapes.map(|v| v.into_iter().collect()).unwrap_or_else(|| PieceShape::all().into_iter().collect()), + yellow_shapes: yellow_shapes.map(|v| v.into_iter().collect()).unwrap_or_else(|| PieceShape::all().into_iter().collect()), + red_shapes: red_shapes.map(|v| v.into_iter().collect()).unwrap_or_else(|| PieceShape::all().into_iter().collect()), + green_shapes: green_shapes.map(|v| v.into_iter().collect()).unwrap_or_else(|| PieceShape::all().into_iter().collect()), + valid_colors: valid_colors.unwrap_or_else(|| vec![Color::BLUE, Color::YELLOW, Color::RED, Color::GREEN]), + round: GameRuleLogic::round_from_turn(turn), + } + } + + fn round_from_turn(&self, turn: usize) -> usize { + 1 + turn / Constants::COLORS + } + + pub fn round(&self) -> usize { + self.round + } + + pub fn undeployed_piece_shapes(&self, color: Color) -> Vec { + self.shapes_for(color).iter().copied().collect() + } + + pub fn remove_undeployed_piece(&mut self, color: Color, shape: PieceShape) -> bool { + self.shapes_for_mut(color).remove(&shape) + } + + pub fn current_color(&self) -> Color { + let ordered = [Color::BLUE, Color::YELLOW, Color::RED, Color::GREEN]; + ordered[self.turn % Constants::COLORS] + } + + pub fn has_valid_colors(&self) -> bool { + !self.valid_colors.is_empty() + } + + pub fn is_valid_color(&self, color: Color) -> bool { + self.valid_colors.contains(&color) + } + + pub fn remove_active_color(&mut self) -> bool { + let color = self.current_color(); + self.valid_colors.retain(|c| *c != color); + self.advance(1) + } + + pub fn advance(&mut self, turns: usize) -> bool { + if !self.has_valid_colors() { + return false; + } + self.turn += turns; + while !self.is_valid_color(self.current_color()) { + self.turn += 1; + } + self.round = self.round_from_turn(self.turn); + true + } + + pub fn is_over(&self) -> bool { + !self.has_valid_colors() || self.round >= Constants::ROUND_LIMIT + } + + pub fn possible_moves(&self) -> Vec { + let pieces = GameRuleLogic::get_filtered_possible_moves(self); + if pieces.is_empty() { + vec![Move::skip_move(self.current_color())] + } else { + pieces.into_iter().map(Move::set_move).collect() + } + } + + pub fn get_points_for_color(&self, color: Color) -> usize { + let pieces = self.undeployed_piece_shapes(color); + let last_mono = *self.last_move_mono.get(&color).unwrap_or(&false); + GameRuleLogic::get_points_from_undeployed(pieces, last_mono) + } + + pub fn get_points_for_team(&self, team: TeamEnum) -> usize { + [Color::BLUE, Color::YELLOW, Color::RED, Color::GREEN] + .iter() + .filter(|c| c.team() == team) + .map(|c| self.get_points_for_color(*c)) + .sum() + } + + pub fn win_condition(&self) -> Option { + let one = self.get_points_for_team(TeamEnum::One); + let two = self.get_points_for_team(TeamEnum::Two); + if one > two { + Some(TeamEnum::One) + } else if two > one { + Some(TeamEnum::Two) + } else { + None // Unentschieden + } + } +} + +impl GameState { + fn shapes_for(&self, color: Color) -> &HashSet { + match color { + Color::BLUE => &self.blue_shapes, + Color::YELLOW => &self.yellow_shapes, + Color::RED => &self.red_shapes, + Color::GREEN => &self.green_shapes, + } + } + + fn shapes_for_mut(&mut self, color: Color) -> &mut HashSet { + match color { + Color::BLUE => &mut self.blue_shapes, + Color::YELLOW => &mut self.yellow_shapes, + Color::RED => &mut self.red_shapes, + Color::GREEN => &mut self.green_shapes, + } + } +} + +impl std::fmt::Display for GameState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "GameState(turn={}, currentColor={})", + self.turn, + self.current_color() + ) + } +} \ No newline at end of file diff --git a/src/plugin2027/move.rs b/src/plugin2027/move.rs new file mode 100644 index 0000000..12901c4 --- /dev/null +++ b/src/plugin2027/move.rs @@ -0,0 +1,51 @@ +use pyo3::prelude::*; + +use crate::plugin2027::{color::Color, piece::Piece}; + +#[pyclass] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Move { + SetMove { piece: Piece }, + SkipMove { color: Color }, +} + +#[pymethods] +impl Move { + #[staticmethod] + pub fn set_move(piece: Piece) -> Move { + Move::SetMove { piece } + } + + #[staticmethod] + pub fn skip_move(color: Color) -> Move { + Move::SkipMove { color } + } + + pub fn get_color(&self) -> Color { + match self { + Move::SetMove { piece } => piece.color, + Move::SkipMove { color } => *color, + } + } + + pub fn as_piece(&self) -> Option { + match self { + Move::SetMove { piece } => Some(piece.clone()), + Move::SkipMove { .. } => None, + } + } + + fn __str__(&self) -> String {self.to_string()} + fn __repr__(&self) -> String {format!("{:?}", self)} + fn __eq__(&self, other: &Move) -> bool {self == other} + fn __ne__(&self, other: &Move) -> bool {self != other} +} + +impl std::fmt::Display for Move { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Move::SetMove { piece } => write!(f, "Setze {}", piece), + Move::SkipMove { color } => write!(f, "{} setzt aus", color), + } + } +} \ No newline at end of file diff --git a/src/plugin2027/piece.rs b/src/plugin2027/piece.rs new file mode 100644 index 0000000..5bdc76a --- /dev/null +++ b/src/plugin2027/piece.rs @@ -0,0 +1,110 @@ +use std::collections::HashSet; +use pyo3::*; + +use crate::plugin2027::{ + color::Color, piece_shape::PieceShape, rotation::Rotation, + utils::{coordinate::Coordinate, coordinate_helper::CoordinateSetExt}, +}; + +#[pyclass] +#[derive(Debug, Clone)] +pub struct Piece { + #[pyo3(get, set)] + pub color: Color, + #[pyo3(get, set)] + pub kind: PieceShape, + #[pyo3(get, set)] + pub rotation: Rotation, + #[pyo3(get, set)] + pub is_flipped: bool, + #[pyo3(get, set)] + pub position: Coordinate, +} + +#[pymethods] +impl Piece { + #[new] + pub fn new( + color: Color, + kind: PieceShape, + rotation: Rotation, + is_flipped: bool, + position: Coordinate, + ) -> Self { + Self { color, kind, rotation, is_flipped, position } + } + + /// Die normalisierte Form des Steins (gedreht/gespiegelt, aber nicht verschoben). + pub fn shape(&self) -> HashSet { + self.kind + .coordinates() + .flip(self.is_flipped) + .rotate(self.rotation) + } + + /// Die tatsächlichen Koordinaten, die der Stein am Ende auf dem Feld einnimmt. + pub fn coordinates(&self) -> HashSet { + self.shape() + .iter() + .map(|c| self.position.add_vector(&c.as_vector())) + .collect() + } + + /// Dreht und spiegelt den Stein entsprechend den gegebenen Parametern, Position bleibt gleich. + pub fn transform(&self, rotation: Rotation, is_flipped: bool) -> Piece { + Piece::new(self.color, self.kind, rotation, is_flipped, self.position) + } + + fn __str__(&self) -> String { + self.to_string() + } + fn __repr__(&self) -> String { + format!("{:?}", self) + } + fn __eq__(&self, other: &Piece) -> bool { + self == other + } + fn __ne__(&self, other: &Piece) -> bool { + self != other + } + fn __hash__(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.hash(&mut hasher); + hasher.finish() + } +} + +/// Gleichheit basiert nur auf Farbe und den tatsächlichen Koordinaten (wie im Original). +impl PartialEq for Piece { + fn eq(&self, other: &Self) -> bool { + self.color == other.color && self.coordinates() == other.coordinates() + } +} +impl Eq for Piece {} + +impl std::hash::Hash for Piece { + fn hash(&self, state: &mut H) { + self.color.hash(state); + let mut coords: Vec = self.coordinates().into_iter().collect(); + // sortieren, damit die Reihenfolge das Hash-Ergebnis nicht beeinflusst + coords.sort_by_key(|c| (c.x, c.y)); + coords.hash(state); + } +} + +impl std::fmt::Display for Piece { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let rotation_part = if self.rotation != Rotation::NONE { + format!(", {}", self.rotation) + } else { + String::new() + }; + let flipped_part = if self.is_flipped { ", gespiegelt" } else { "" }; + write!( + f, + "{}({}{}{})[{},{}]", + self.kind, self.color, rotation_part, flipped_part, self.position.x, self.position.y + ) + } +} \ No newline at end of file diff --git a/src/plugin2027/piece_shape.rs b/src/plugin2027/piece_shape.rs new file mode 100644 index 0000000..15e0c9d --- /dev/null +++ b/src/plugin2027/piece_shape.rs @@ -0,0 +1,163 @@ +use pyo3::prelude::*; +use std::collections::{HashSet}; + +use crate::plugin2027::{ + utils::{coordinate::Coordinate, vector::Vector, coordinate_helper::CoordinateSetExt}, + rotation::Rotation, +}; + +#[pyclass] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PieceShape { + Mono, + Domino, + TrioL, + TrioI, + TetroO, + TetroT, + TetroI, + TetroL, + TetroZ, + PentoL, + PentoT, + PentoV, + PentoS, + PentoZ, + PentoI, + PentoP, + PentoW, + PentoU, + PentoR, + PentoX, + PentoY, +} + +#[pymethods] +impl PieceShape { + /// Alle 21 Formen in Reihenfolge (entspricht dem Enum-Index in Kotlin). + #[staticmethod] + pub fn all() -> Vec { + vec![ + PieceShape::Mono, PieceShape::Domino, PieceShape::TrioL, PieceShape::TrioI, + PieceShape::TetroO, PieceShape::TetroT, PieceShape::TetroI, PieceShape::TetroL, + PieceShape::TetroZ, PieceShape::PentoL, PieceShape::PentoT, PieceShape::PentoV, + PieceShape::PentoS, PieceShape::PentoZ, PieceShape::PentoI, PieceShape::PentoP, + PieceShape::PentoW, PieceShape::PentoU, PieceShape::PentoR, PieceShape::PentoX, + PieceShape::PentoY, + ] + } + + /// Gibt die Form anhand ihres Index zurück (analog zu Kotlins `shapes`-Map). + #[staticmethod] + pub fn from_index(index: usize) -> Option { + PieceShape::all().get(index).copied() + } + + /// Die normalisierten (an (0,0) ausgerichteten) Koordinaten der Grundform. + pub fn coordinates(&self) -> HashSet { + let coords: &[(isize, isize)] = match self { + PieceShape::Mono => &[(0, 0)], + PieceShape::Domino => &[(0, 0), (1, 0)], + PieceShape::TrioL => &[(0, 0), (0, 1), (1, 1)], + PieceShape::TrioI => &[(0, 0), (0, 1), (0, 2)], + PieceShape::TetroO => &[(0, 0), (1, 0), (0, 1), (1, 1)], + PieceShape::TetroT => &[(0, 0), (1, 0), (2, 0), (1, 1)], + PieceShape::TetroI => &[(0, 0), (0, 1), (0, 2), (0, 3)], + PieceShape::TetroL => &[(0, 0), (0, 1), (0, 2), (1, 2)], + PieceShape::TetroZ => &[(0, 0), (1, 0), (1, 1), (2, 1)], + PieceShape::PentoL => &[(0, 0), (0, 1), (0, 2), (0, 3), (1, 3)], + PieceShape::PentoT => &[(0, 0), (1, 0), (2, 0), (1, 1), (1, 2)], + PieceShape::PentoV => &[(0, 0), (0, 1), (0, 2), (1, 2), (2, 2)], + PieceShape::PentoS => &[(1, 0), (2, 0), (3, 0), (0, 1), (1, 1)], + PieceShape::PentoZ => &[(0, 0), (1, 0), (1, 1), (1, 2), (2, 2)], + PieceShape::PentoI => &[(0, 0), (0, 1), (0, 2), (0, 3), (0, 4)], + PieceShape::PentoP => &[(0, 0), (1, 0), (0, 1), (1, 1), (0, 2)], + PieceShape::PentoW => &[(0, 0), (0, 1), (1, 1), (1, 2), (2, 2)], + PieceShape::PentoU => &[(0, 0), (0, 1), (1, 1), (2, 1), (2, 0)], + PieceShape::PentoR => &[(0, 1), (1, 1), (1, 2), (2, 1), (2, 0)], + PieceShape::PentoX => &[(1, 0), (0, 1), (1, 1), (2, 1), (1, 2)], + PieceShape::PentoY => &[(0, 1), (1, 0), (1, 1), (1, 2), (1, 3)], + }; + coords + .iter() + .map(|&(x, y)| Coordinate::new(x, y)) + .collect::>() + .align() + } + + /// Das kleinstmögliche Rechteck, das die Form umfasst. + pub fn dimension(&self) -> Vector { + self.coordinates().area() + } + + /// Die Form als Menge von Vektoren relativ zu (0,0). + pub fn as_vectors(&self) -> HashSet { + self.coordinates() + .iter() + .map(|c| c.get_difference(&Coordinate::new(0, 0))) + .collect() + } + + /// Die Anzahl der Felder, die diese Form belegt. + pub fn size(&self) -> usize { + self.coordinates().len() + } + + /// Alle eindeutigen Varianten der Form (Rotation + Spiegelung), ohne Duplikate. + /// Gibt eine Liste von (Koordinatenmenge, Rotation, isFlipped) zurück. + pub fn variants(&self) -> Vec<(HashSet, Rotation, bool)> { + let base = self.coordinates(); + let mut seen: Vec> = Vec::new(); + let mut result = Vec::new(); + + for rotation in Rotation::all() { + for flip in [false, true] { + let shape = base.rotate(rotation).flip(flip); + if !seen.contains(&shape) { + seen.push(shape.clone()); + result.push((shape, rotation, flip)); + } + } + } + result + } + + /// Transformiert die Form entsprechend Rotation und Spiegelung. + /// Entspricht Kotlins `transform`/`get`-Operator. + pub fn transform(&self, rotation: Rotation, should_flip: bool) -> HashSet { + self.coordinates().rotate(rotation).flip(should_flip) + } + + pub fn name(&self) -> String { + format!("{:?}", self) + } +} + +impl std::fmt::Display for PieceShape { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = match self { + PieceShape::Mono => "Mono", + PieceShape::Domino => "Domino", + PieceShape::TrioL => "Trio-L", + PieceShape::TrioI => "Trio-I", + PieceShape::TetroO => "Tetro-O", + PieceShape::TetroT => "Tetro-T", + PieceShape::TetroI => "Tetro-I", + PieceShape::TetroL => "Tetro-L", + PieceShape::TetroZ => "Tetro-Z", + PieceShape::PentoL => "Pento-L", + PieceShape::PentoT => "Pento-T", + PieceShape::PentoV => "Pento-V", + PieceShape::PentoS => "Pento-S", + PieceShape::PentoZ => "Pento-Z", + PieceShape::PentoI => "Pento-I", + PieceShape::PentoP => "Pento-P", + PieceShape::PentoW => "Pento-W", + PieceShape::PentoU => "Pento-U", + PieceShape::PentoR => "Pento-R", + PieceShape::PentoX => "Pento-X", + PieceShape::PentoY => "Pento-Y", + }; + write!(f, "{}", name) + } +} \ No newline at end of file diff --git a/src/plugin2027/rotation.rs b/src/plugin2027/rotation.rs new file mode 100644 index 0000000..fdd10e3 --- /dev/null +++ b/src/plugin2027/rotation.rs @@ -0,0 +1,49 @@ + +use pyo3::*; + +#[pyclass] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Rotation { + NONE, + RIGHT, + MIRROR, // 180 rotation actually + LEFT +} + +#[pymethods] +impl Rotation { + fn __str__(&self) -> String {self.to_string()} + fn __repr__(&self) -> String {format!("{:?}", self)} + fn __eq__(&self, other: &Rotation) -> bool {self == other} + fn __ne__(&self, other: &Rotation) -> bool {self != other} + + pub fn value(&self) -> usize { + match self { + Rotation::NONE => 0, + Rotation::RIGHT => 1, + Rotation::MIRROR => 2, + Rotation::LEFT => 3 + } + } + + pub fn rotate(&self, other: &Rotation) -> Rotation { + let variants = [Rotation::NONE, Rotation::RIGHT, Rotation::MIRROR, Rotation::LEFT]; + let sum = self.value() + other.value(); + variants[sum % variants.len()] + } + + #[staticmethod] + pub fn all() -> Vec { + vec![Rotation::NONE, Rotation::RIGHT, Rotation::MIRROR, Rotation::LEFT] + } + + pub fn name(&self) -> String { + format!("{:?}", self) + } +} + +impl std::fmt::Display for Rotation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "rotated by {}°", {self.value() * 90}) + } +} \ No newline at end of file diff --git a/src/plugin2027/utils.rs b/src/plugin2027/utils.rs new file mode 100644 index 0000000..cc5ffed --- /dev/null +++ b/src/plugin2027/utils.rs @@ -0,0 +1,7 @@ +pub mod constants; +pub mod coordinate; +pub mod vector; +pub mod direction; +pub mod game_rule_logic; +pub mod team; +pub mod coordinate_helper; \ No newline at end of file diff --git a/src/plugin2027/utils/constants.rs b/src/plugin2027/utils/constants.rs new file mode 100644 index 0000000..8171dd9 --- /dev/null +++ b/src/plugin2027/utils/constants.rs @@ -0,0 +1,13 @@ +use pyo3::*; + +#[pyclass] +pub struct Constants; + +#[pymethods] +impl Constants { + pub const BOARD_LENGTH: usize = 20; + pub const ROUND_LIMIT: usize = 25; + pub const TOTAL_PIECE_SHAPES: usize = 21; + pub const COLORS: usize = 4; + pub const VALIDATE_MOVE: bool = true; +} diff --git a/src/plugin2027/utils/coordinate.rs b/src/plugin2027/utils/coordinate.rs new file mode 100644 index 0000000..122af3f --- /dev/null +++ b/src/plugin2027/utils/coordinate.rs @@ -0,0 +1,74 @@ +use pyo3::*; + +use crate::plugin2027::{ + utils::vector::Vector, utils::direction::Direction +}; + +#[pyclass] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Coordinate { + #[pyo3(get, set)] + pub x: isize, + #[pyo3(get, set)] + pub y: isize, +} + +#[pymethods] +impl Coordinate { + #[new] + pub fn new(x: isize, y: isize) -> Self { + Self { + x, y + } + } + + fn __str__(&self) -> String {self.to_string()} + fn __repr__(&self) -> String {format!("{:?}", self)} + fn __eq__(&self, other: &Coordinate) -> bool {self == other} + fn __ne__(&self, other: &Coordinate) -> bool {self != other} + fn deepcopy(&self) -> Coordinate {*self} + + pub fn add_vector(&self, vector: &Vector) -> Coordinate { + Coordinate { + x: self.x + vector.delta_x, + y: self.y + vector.delta_y + } + } + + pub fn add_vector_mut(&mut self, vector: &Vector) { + + self.x += vector.delta_x; + self.y += vector.delta_y; + } + + pub fn get_difference(&self, other: &Coordinate) -> Vector { + Vector { + delta_x: other.x - self.x, + delta_y: other.y - self.y + } + } + + pub fn neighbors(&self) -> Vec { + Direction::cardinals() + .iter() + .map(|d| self.add_vector(&d.to_vector())) + .collect() + } + + pub fn diagonal_neighbors(&self) -> Vec { + Direction::diagonals() + .iter() + .map(|d| self.add_vector(&d.to_vector())) + .collect() + } + + pub fn as_vector(&self) -> Vector { + Vector::new(self.x, self.y) + } +} + +impl std::fmt::Display for Coordinate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "({}, {})", self.x, self.y) + } +} \ No newline at end of file diff --git a/src/plugin2027/utils/coordinate_helper.rs b/src/plugin2027/utils/coordinate_helper.rs new file mode 100644 index 0000000..777f39e --- /dev/null +++ b/src/plugin2027/utils/coordinate_helper.rs @@ -0,0 +1,73 @@ +use std::collections::HashSet; + +use crate::plugin2027::{ + utils::{coordinate::Coordinate, vector::Vector, constants::Constants}, + rotation::Rotation, +}; + +pub trait CoordinateSetExt { + fn rotate(&self, rotation: Rotation) -> HashSet; + fn flip(&self, should_flip: bool) -> HashSet; + fn mirror(&self) -> HashSet; + fn turn_right(&self) -> HashSet; + fn turn_left(&self) -> HashSet; + fn align(&self) -> HashSet; + fn area(&self) -> Vector; +} + +impl CoordinateSetExt for HashSet { + fn rotate(&self, rotation: Rotation) -> HashSet { + match rotation { + Rotation::NONE => self.clone(), + Rotation::RIGHT => self.turn_right().align(), + Rotation::MIRROR => self.mirror().align(), + Rotation::LEFT => self.turn_left().align(), + } + } + + fn flip(&self, should_flip: bool) -> HashSet { + if !should_flip { + self.clone() + } else { + self.iter() + .map(|c| Coordinate::new(-c.x, c.y)) + .collect::>() + .align() + } + } + + fn mirror(&self) -> HashSet { + self.iter().map(|c| Coordinate::new(-c.x, -c.y)).collect() + } + + fn turn_right(&self) -> HashSet { + self.iter().map(|c| Coordinate::new(-c.y, c.x)).collect() + } + + fn turn_left(&self) -> HashSet { + self.iter().map(|c| Coordinate::new(c.y, -c.x)).collect() + } + + fn align(&self) -> HashSet { + let board_length = Constants::BOARD_LENGTH as isize; + let mut min_x = board_length; + let mut min_y = board_length; + for c in self.iter() { + min_x = min_x.min(c.x); + min_y = min_y.min(c.y); + } + self.iter() + .map(|c| Coordinate::new(c.x - min_x, c.y - min_y)) + .collect() + } + + fn area(&self) -> Vector { + let mut dx = 0; + let mut dy = 0; + for c in self.iter() { + dx = dx.max(c.x); + dy = dy.max(c.y); + } + Vector::new(dx, dy) + } +} \ No newline at end of file diff --git a/src/plugin2027/utils/direction.rs b/src/plugin2027/utils/direction.rs new file mode 100644 index 0000000..3f0f685 --- /dev/null +++ b/src/plugin2027/utils/direction.rs @@ -0,0 +1,107 @@ +use pyo3::*; + +use crate::plugin2027::{ + utils::vector::Vector +}; + +#[pyclass] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Direction { + Up, + UpRight, + Right, + DownRight, + Down, + DownLeft, + Left, + UpLeft +} + +#[pymethods] +impl Direction { + fn __str__(&self) -> String {self.to_string()} + fn __repr__(&self) -> String {format!("{:?}", self)} + fn __eq__(&self, other: &Direction) -> bool {self == other} + fn __ne__(&self, other: &Direction) -> bool {self != other} + fn deepcopy(&self) -> Direction {*self} + + #[staticmethod] + pub fn from_vector(vector: &Vector) -> Option { + match (vector.delta_x, vector.delta_y) { + (0, 1) => Some(Direction::Up), + (1, 1) => Some(Direction::UpRight), + (1, 0) => Some(Direction::Right), + (1, -1) => Some(Direction::DownRight), + (0, -1) => Some(Direction::Down), + (-1, -1) => Some(Direction::DownLeft), + (-1, 0) => Some(Direction::Left), + (-1, 1) => Some(Direction::UpLeft), + _ => None, + } + } + + #[staticmethod] + pub fn all_directions() -> Vec { + vec![ + Direction::Up, + Direction::UpRight, + Direction::Right, + Direction::DownRight, + Direction::Down, + Direction::DownLeft, + Direction::Left, + Direction::UpLeft + ] + } + + pub fn to_vector(&self) -> Vector { + match self { + Direction::Up => Vector { delta_x: 0, delta_y: 1 }, + Direction::UpRight => Vector { delta_x: 1, delta_y: 1 }, + Direction::Right => Vector { delta_x: 1, delta_y: 0 }, + Direction::DownRight => Vector { delta_x: 1, delta_y: -1 }, + Direction::Down => Vector { delta_x: 0, delta_y: -1 }, + Direction::DownLeft => Vector { delta_x: -1, delta_y: -1 }, + Direction::Left => Vector { delta_x: -1, delta_y: 0 }, + Direction::UpLeft => Vector { delta_x: -1, delta_y: 1 }, + } + } + + pub fn to_mirrored(&self) -> Direction { + match self { + Direction::Up => Direction::Down, + Direction::UpRight => Direction::DownLeft, + Direction::Right => Direction::Left, + Direction::DownRight => Direction::UpLeft, + Direction::Down => Direction::Up, + Direction::DownLeft => Direction::UpRight, + Direction::Left => Direction::Right, + Direction::UpLeft => Direction::DownRight, + } + } + + #[staticmethod] + pub fn cardinals() -> Vec { + vec![Direction::Up, Direction::Right, Direction::Down, Direction::Left] + } + + #[staticmethod] + pub fn diagonals() -> Vec { + vec![Direction::UpRight, Direction::DownRight, Direction::DownLeft, Direction::UpLeft] + } +} + +impl std::fmt::Display for Direction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Direction::Up => write!(f, "(Up ↑)"), + Direction::UpRight => write!(f, "(UpRight ↗)"), + Direction::Right => write!(f, "(Right →)"), + Direction::DownRight => write!(f, "(DownRight ↘)"), + Direction::Down => write!(f, "(Down ↓)"), + Direction::DownLeft => write!(f, "(DownLeft ↙)"), + Direction::Left => write!(f, "(Left ←)"), + Direction::UpLeft => write!(f, "(UpLeft ↖)") + } + } +} diff --git a/src/plugin2027/utils/game_rule_logic.rs b/src/plugin2027/utils/game_rule_logic.rs new file mode 100644 index 0000000..2f4678f --- /dev/null +++ b/src/plugin2027/utils/game_rule_logic.rs @@ -0,0 +1,366 @@ +use pyo3::prelude::*; +use std::collections::HashSet; + +use crate::plugin2027::{ + board::Board, color::Color, r#move::Move, piece::Piece, piece_shape::PieceShape, + field::Field, game_state::GameState, + utils::{constants::Constants, coordinate::Coordinate, coordinate_helper::CoordinateSetExt}, + errors::BlokusMoveMistake, +}; + +#[allow(clippy::identity_op)] +pub const SUM_MAX_SQUARES: usize = 1 * 1 + 1 * 2 + 2 * 3 + 5 * 4 + 12 * 5; // = 89 + +#[pyclass] +pub struct GameRuleLogic; + +#[pymethods] +impl GameRuleLogic { + + #[staticmethod] + pub fn round_from_turn(turn: usize) -> usize { + 1 + turn / Constants::COLORS + } + + #[staticmethod] + pub fn get_points_from_undeployed(undeployed: Vec, mono_last: bool) -> usize { + if undeployed.is_empty() { + SUM_MAX_SQUARES + 15 + if mono_last { 5 } else { 0 } + } else { + SUM_MAX_SQUARES + - undeployed + .iter() + .map(|shape| shape.coordinates().len()) + .sum::() + } + } + + #[staticmethod] + pub fn perform_move(game_state: &mut GameState, mv: &Move) -> PyResult<()> { + if Constants::VALIDATE_MOVE { + Self::validate_move_color(game_state, mv)?; + } + match mv { + Move::SkipMove { .. } => Self::perform_skip_move(game_state)?, + Move::SetMove { piece } => Self::perform_set_move(game_state, piece)?, + } + game_state.advance(1); + game_state.last_move = Some(mv.clone()); + Ok(()) + } + + #[staticmethod] + pub fn validate_move_color(game_state: &GameState, mv: &Move) -> PyResult<()> { + if mv.get_color() != game_state.current_color() { + return Err(BlokusMoveMistake::WrongColor.to_py_err()); + } + Ok(()) + } + + #[staticmethod] + pub fn validate_set_move(game_state: &GameState, piece: &Piece) -> PyResult<()> { + Self::validate_move_color(game_state, &Move::SetMove { piece: piece.clone() })?; + Self::validate_shape(game_state, piece.kind, piece.color)?; + Self::validate_set_move_on_board(&game_state.board, piece)?; + + if Self::is_first_move(game_state) { + if !piece.coordinates().iter().any(|c| Self::is_on_border(*c)) { + return Err(BlokusMoveMistake::NotOnBorder.to_py_err()); + } + } else { + let touches_corner = piece.coordinates().iter().any(|c| { + Self::corners_on_color(&game_state.board, &Field { coordinate: *c, content: piece.color.to_field_content() }) + }); + if !touches_corner { + return Err(BlokusMoveMistake::NoSharedCorner.to_py_err()); + } + } + Ok(()) + } + + #[staticmethod] + pub fn perform_set_move(game_state: &mut GameState, piece: &Piece) -> PyResult<()> { + if Constants::VALIDATE_MOVE { + Self::validate_set_move(game_state, piece)?; + } + Self::perform_set_move_on_board(&mut game_state.board, piece); + game_state.remove_undeployed_piece(piece.color, piece.kind); + + if game_state.undeployed_piece_shapes(piece.color).is_empty() { + game_state + .last_move_mono + .insert(piece.color, piece.kind == PieceShape::Mono); + } + Ok(()) + } + + #[staticmethod] + pub fn validate_shape(game_state: &GameState, shape: PieceShape, color: Color) -> PyResult<()> { + if Self::is_first_move(game_state) { + if shape != game_state.start_piece { + return Err(BlokusMoveMistake::WrongShape.to_py_err()); + } + } else if !game_state.undeployed_piece_shapes(color).contains(&shape) { + return Err(BlokusMoveMistake::DuplicateShape.to_py_err()); + } + Ok(()) + } + + #[staticmethod] + pub fn is_valid_set_move(game_state: &GameState, piece: &Piece) -> bool { + Self::validate_set_move(game_state, piece).is_ok() + } + + #[staticmethod] + pub fn validate_set_move_on_board(board: &Board, piece: &Piece) -> PyResult<()> { + for coord in &piece.coordinates() { + if !Board::contains(*coord) { + return Err(BlokusMoveMistake::OutOfBounds.to_py_err()); + } + if board.is_obstructed(*coord) { + return Err(BlokusMoveMistake::Obstructed.to_py_err()); + } + let field = Field { coordinate: *coord, content: piece.color.to_field_content() }; + if Self::borders_on_color(board, &field) { + return Err(BlokusMoveMistake::TouchesSameColor.to_py_err()); + } + } + Ok(()) + } + + #[staticmethod] + pub fn validate_skip_move(game_state: &GameState) -> PyResult<()> { + if Self::is_first_move(game_state) { + return Err(BlokusMoveMistake::SkipFirstTurn.to_py_err()); + } + Ok(()) + } + + #[staticmethod] + pub fn perform_skip_move(game_state: &GameState) -> PyResult<()> { + Self::validate_skip_move(game_state) + } + + #[staticmethod] + pub fn borders_on_color(board: &Board, field: &Field) -> bool { + field + .coordinate + .neighbors() + .iter() + .any(|n| match board.get_content(*n) { + Some(content) => content == field.content && !field.content.is_empty(), + None => false, + }) + } + + #[staticmethod] + pub fn corners_on_color(board: &Board, field: &Field) -> bool { + field + .coordinate + .diagonal_neighbors() + .iter() + .any(|n| match board.get_content(*n) { + Some(content) => content == field.content && !field.content.is_empty(), + None => false, + }) + } + + #[staticmethod] + pub fn is_on_border(position: Coordinate) -> bool { + let max = Constants::BOARD_LENGTH as isize - 1; + position.x == 0 || position.x == max || position.y == 0 || position.y == max + } + + #[staticmethod] + pub fn is_first_move(game_state: &GameState) -> bool { + game_state.undeployed_piece_shapes(game_state.current_color()).len() == Constants::TOTAL_PIECE_SHAPES + } + + #[staticmethod] + pub fn get_random_start_pentomino() -> PieceShape { + use rand::seq::IndexedRandom; + let pentominoes: Vec = PieceShape::all() + .into_iter() + .filter(|shape| shape.coordinates().len() == 5) + .collect(); + *pentominoes.choose(&mut rand::rng()).unwrap() + } + + #[staticmethod] + pub fn remove_invalid_colors(game_state: &mut GameState) { + if !game_state.has_valid_colors() { + return; + } + let no_valid_move = Self::get_all_possible_moves(game_state) + .iter() + .all(|piece| !Self::is_valid_set_move(game_state, piece)); + + if no_valid_move { + game_state.remove_active_color(); + Self::remove_invalid_colors(game_state); + } + } + + #[staticmethod] + pub fn get_all_possible_moves(game_state: &GameState) -> Vec { + if Self::is_first_move(game_state) { + Self::get_possible_start_moves(game_state, false) + } else { + Self::get_possible_moves(game_state) + } + } + + #[staticmethod] + pub fn get_filtered_possible_moves(game_state: &GameState) -> Vec { + if Self::is_first_move(game_state) { + Self::get_possible_start_moves(game_state, true) + } else if game_state.round() <= 5 { + Self::get_pentomino_moves(game_state) + } else { + Self::get_possible_moves(game_state) + } + } + + #[staticmethod] + pub fn get_possible_start_moves(game_state: &GameState, filter: bool) -> Vec { + let mut moves = Vec::new(); + let kind = game_state.start_piece; + let color = game_state.current_color(); + + for (shape, rotation, is_flipped) in kind.variants() { + let area = shape.area(); + let mut border_coords: Vec = Vec::new(); + + let top_left_half = !filter || color == Color::BLUE || color == Color::YELLOW; + let bottom_right_half = !filter || color == Color::RED || color == Color::GREEN; + + let board_len = Constants::BOARD_LENGTH as isize; + + if top_left_half { + for x in 0..(board_len - area.delta_x - 1) { + border_coords.push(Coordinate::new(x, 0)); + } + for y in 1..(board_len - area.delta_y) { + border_coords.push(Coordinate::new(0, y)); + } + } + if bottom_right_half { + for y in 0..(board_len - area.delta_y - 1) { + border_coords.push(Coordinate::new(board_len - area.delta_x - 1, y)); + } + for x in 1..(board_len - area.delta_x) { + border_coords.push(Coordinate::new(x, board_len - area.delta_y - 1)); + } + } + + for position in border_coords { + let piece = Piece::new(color, kind, rotation, is_flipped, position); + if Self::is_valid_set_move(game_state, &piece) { + moves.push(piece); + } + } + } + moves + } + + #[staticmethod] + pub fn get_possible_moves(game_state: &GameState) -> Vec { + let color = game_state.current_color(); + let valid_fields = Self::get_valid_fields(&game_state.board, color); + let mut moves = Vec::new(); + for shape in game_state.undeployed_piece_shapes(color) { + moves.extend(Self::get_possible_moves_for_shape(game_state, shape, valid_fields.clone())); + } + moves + } + + #[staticmethod] + pub fn get_pentomino_moves(game_state: &GameState) -> Vec { + let color = game_state.current_color(); + let valid_fields = Self::get_valid_fields(&game_state.board, color); + let mut moves = Vec::new(); + for shape in game_state.undeployed_piece_shapes(color) { + if shape.coordinates().len() == 5 { + moves.extend(Self::get_possible_moves_for_shape(game_state, shape, valid_fields.clone())); + } + } + moves + } + + #[staticmethod] + pub fn get_possible_moves_for_shape( + game_state: &GameState, + shape: PieceShape, + valid_fields: HashSet, + ) -> Vec { + let mut moves: HashSet = HashSet::new(); + + if Self::is_first_move(game_state) { + if shape == game_state.start_piece { + return Self::get_possible_start_moves(game_state, false); + } else { + return Vec::new(); + } + } + + let color = game_state.current_color(); + for field in &valid_fields { + for (_variant_shape, rotation, is_flipped) in shape.variants() { + let area = shape.transform(rotation, is_flipped).area(); + for x in (field.x - area.delta_x)..=field.x { + for y in (field.y - area.delta_y)..=field.y { + let position = Coordinate::new(x, y); + let piece = Piece::new(color, shape, rotation, is_flipped, position); + if Self::is_valid_set_move(game_state, &piece) { + moves.insert(piece); + } + } + } + } + } + moves.into_iter().collect() + } + + #[staticmethod] + pub fn get_valid_fields(board: &Board, color: Color) -> HashSet { + let colored_fields = Self::get_colored_fields(board, color); + colored_fields + .iter() + .flat_map(|c| c.diagonal_neighbors()) + .filter(|corner| { + Board::contains(*corner) + && board.get_content(*corner).is_some_and(|c| c.is_empty()) + && corner + .neighbors() + .iter() + .all(|n| { + !Board::contains(*n) + || board.get_content(*n) != Some(color.to_field_content()) + }) + }) + .collect() + } + + #[staticmethod] + pub fn get_colored_fields(board: &Board, color: Color) -> HashSet { + let board_len = Constants::BOARD_LENGTH as isize; + let mut colored = HashSet::new(); + for x in 0..board_len { + for y in 0..board_len { + let pos = Coordinate::new(x, y); + if board.get_content(pos) == Some(color.to_field_content()) { + colored.insert(pos); + } + } + } + colored + } +} + +impl GameRuleLogic { + fn perform_set_move_on_board(board: &mut Board, piece: &Piece) { + for coord in &piece.coordinates() { + board.set_content(*coord, piece.color.to_field_content()); + } + } +} \ No newline at end of file diff --git a/src/plugin2027/utils/team.rs b/src/plugin2027/utils/team.rs new file mode 100644 index 0000000..d091b38 --- /dev/null +++ b/src/plugin2027/utils/team.rs @@ -0,0 +1,32 @@ +use pyo3::*; + +#[pyclass] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TeamEnum { + One, + Two, +} + +#[pymethods] +impl TeamEnum { + fn __str__(&self) -> String {self.to_string()} + fn __repr__(&self) -> String {format!("{:?}", self)} + fn __eq__(&self, other: &TeamEnum) -> bool {self == other} + fn __ne__(&self, other: &TeamEnum) -> bool {self != other} + + pub fn opponent(&self) -> TeamEnum { + match self { + TeamEnum::One => TeamEnum::Two, + TeamEnum::Two => TeamEnum::One + } + } +} + +impl std::fmt::Display for TeamEnum { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::One => write!(f, "Team One"), + Self::Two => write!(f, "Team Two") + } + } +} \ No newline at end of file diff --git a/src/plugin2027/utils/vector.rs b/src/plugin2027/utils/vector.rs new file mode 100644 index 0000000..8b0b4af --- /dev/null +++ b/src/plugin2027/utils/vector.rs @@ -0,0 +1,66 @@ +use pyo3::*; + +#[pyclass] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Vector { + #[pyo3(get, set)] + pub delta_x: isize, + #[pyo3(get, set)] + pub delta_y: isize, +} + +#[pymethods] +impl Vector { + #[new] + pub fn new(delta_x: isize, delta_y: isize) -> Self { + Self { + delta_x, delta_y + } + } + + fn __str__(&self) -> String {self.to_string()} + fn __repr__(&self) -> String {format!("{:?}", self)} + fn __eq__(&self, other: &Vector) -> bool {self == other} + fn __ne__(&self, other: &Vector) -> bool {self != other} + fn deepcopy(&self) -> Vector {*self} + + pub fn add_vector(&self, other: &Vector) -> Vector { + Vector { + delta_x: self.delta_x + other.delta_x, + delta_y: self.delta_y + other.delta_y + } + } + + pub fn add_vector_mut(&mut self, other: &Vector) { + self.delta_x += other.delta_x; + self.delta_y += other.delta_y; + } + + pub fn scale(&self, scalar: isize) -> Vector { + Vector { + delta_x: self.delta_x * scalar, + delta_y: self.delta_y * scalar + } + } + + pub fn scale_mut(&mut self, scalar: isize) { + self.delta_x *= scalar; + self.delta_y *= scalar; + } + + pub fn get_length(&self) -> Option { + let squared_length = self.delta_x * self.delta_x + self.delta_y * self.delta_y; + + if squared_length < 0 { + None // Return None for negative numbers + } else { + Some((squared_length as f32).sqrt()) // Convert to f32 then compute sqrt + } + } +} + +impl std::fmt::Display for Vector { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "vec({}, {})", self.delta_x, self.delta_y) + } +} \ No newline at end of file