python-forum-de / pydesw-muehle

https://www.python-forum.de/viewtopic.php?f=6&t=39928
GNU General Public License v3.0
1 stars 1 forks source link

Programmierung der Logik #2

Open seblin opened 6 years ago

seblin commented 6 years ago

Nach Abschluss von Issue #1 soll hier das Modell in Python-Code umgesetzt werden. Hierzu gehört auch das Abdecken mit Tests. Die Schnittstelle soll Wahrheitswerte, Listen und ähnliche Python-Objekte liefern. Ein Überführen in grafische Elemente mithilfe eines GUI-Frameworks erfolgt erst im nächsten Schritt.

seblin commented 6 years ago

Hier mal eine frühe Version auf Basis des Vorschlags von @sebastian2443:

from collections import namedtuple
from random import choice

SQUARE_NAMES = ('inner', 'mid', 'outer')

MIN_INDEX = 0
MAX_INDEX = 7
SQUARE_INDICES = range(MIN_INDEX, MAX_INDEX + 1)

COLORS = ('white', 'black')

class Field(namedtuple('Field', 'square_name, index, owner')):
    def __new__(cls, square_name, index, owner=None):
        if square_name not in SQUARE_NAMES:
            raise ValueError('Invalid square name')
        if not MIN_INDEX <= index <= MAX_INDEX:
            raise ValueError('Field index out of range')
        return tuple.__new__(cls, (square_name, index, owner))

class Board(object):
    def __init__(self):
        self.fields = {
            square: [Field(square, i) for i in SQUARE_INDICES]
            for square in SQUARE_NAMES
        }

    def get_all_fields(self, player=None):
        if not player:
            return self.fields.copy()
        return {
            square: [f for f in self.fields[square] if f.owner == player]
            for square in SQUARE_NAMES
        }

    def move_piece(self, player, old_field=None, new_field=None):
        # Get original field to avoid cheating
        new_field = self.fields[new_field.square_name][new_field.index]
        if new_field.owner:
            msg = 'New field already owned by {}'
            raise ValueError(msg.format(new_field.owner.name))
        new_field = new_field._replace(owner=player)
        self.fields[new_field.square_name][new_field.index] = new_field

class Player(object):
    def __init__(self, name, color):
        if color not in COLORS:
            raise ValueError('Invalid color')
        self.name = name
        self.color = color

    def make_move(self, board):
        # Use a random field for testing
        square_name = choice(SQUARE_NAMES)
        index = choice(SQUARE_INDICES)
        field = Field(square_name, index, self)
        board.move_piece(self, new_field=field)

    def __repr__(self):
        return '{}(name={!r}, color={!r})'.format(
            type(self).__name__, self.name, self.color
        )

board = Board()
walter = Player('Walter', 'white')
print(board.get_all_fields(walter))
walter.make_move(board)
print(board.get_all_fields(walter))
seblin commented 6 years ago

Wie gehen wir die Programmierung eigentlich an? TDD, d.h. erst einen Test schreiben und dann Code, der diesen Test besteht? Oder "klassisch" zuerst die jeweilige Methode erstellen und den Test mitliefern bzw zeitnah nachliefern?

sebastian2443 commented 6 years ago

Ich sehe zum ersten Mal eine Dict Comprehension. Wollen wir Code generell so kurz wie möglich halten?

Ich finde die frühe Version schon recht gut. Wir müssen mal schauen wie wir unsere Gottklasse 'Game' da einbringen. Wenn wir später 'Game' instanzieren, übergeben wir eine Referenz an die GUI, oder instanzieren wir das im init von der GUI?

Ich habe noch nie einen UNIT-Test geschrieben... Muss erst mal schauen wieviel für diesen Test geschrieben werden muss, bzw was ein Test überhaupt ausmacht.

Hier mal eine sehr frühe, aber funktionierende Version für unser is_mill() Problem: Unter dem Code kommt die Erklärung

class Point(object):
    def __init__(self, x, y, z):
        self.x = x
        self.y = y
        self.z = z

    def __repr__(self):
        return '\n|{}|\n|{}|\n|{}|'.format(
            self.x, self.y, self.z
        )

class Cube(object):
    SQUARE_NAME = {'inner': 0, 'mid': 1, 'outer': 2}
    INDEX = ([0,2],[1,2],[2,2],[2,1],[2,0],[1,0],[0,0],[0,1])

    def __init__(self, dimension):
        self.dimension = dimension

    def convert_to_cube_coordinates(self, field):
        point = Point( Cube.INDEX[field.index][0],
            Cube.SQUARE_NAME[field.square_name],
            Cube.INDEX[field.index][1]
        )
        return point

    def convert_to_game_coordinates(self, point):
        name = ''
        index = -1
        for axis in Cube.SQUARE_NAME:
            if Cube.SQUARE_NAME[axis] == point.y:
                name = axis
                break
        for nr, value in enumerate(Cube.INDEX):
            if value[0] == point.x and value[1] == point.z:
                index = nr
                break

        return Field(name, nr)

    def determine_mill_rows(self, field):
        rows = []
        point = self.convert_to_cube_coordinates(field)
        if self.is_corner(point):
            row_first_axis = self.generate_row('x', point)
            row_last_axis = self.generate_row('z', point)
            rows = [row_first_axis, row_last_axis]
        else:
            row_first_axis = self.generate_row('y', point)
            if 0 < point.x < self.dimension[0]-1:
                row_last_axis = self.generate_row('x', point)
            elif 0 < point.z < self.dimension[2]-1:
                row_last_axis = self.generate_row('z', point)
            rows = [row_first_axis, row_last_axis]

        possible_mills = []
        for row in rows:
            fields = []
            for point in row:
                fields.append(self.convert_to_game_coordinates(point))
            possible_mills.append(fields)
        return possible_mills

    def generate_row(self, axis, point):
        points = []
        if axis == 'y':
            for y in range(0, self.dimension[1]):
                points.append(Point(point.x, y, point.z))
        elif axis == 'x':
            for x in range(0, self.dimension[0]):
                points.append(Point(x, point.y, point.z))
        elif axis == 'z':
            for z in range(0, self.dimension[2]):
                points.append(Point(point.x, point.y, z))
        return points

    def is_corner(self, point):
        if not 0 < point.x < self.dimension[0]-1 and not 0 < point.z < self.dimension[2]-1:
            return True
        return False

DIMENSION = (3,3,3)
walter = Player('Walter', 'white')
cube = Cube(DIMENSION)
field = Field('outer', 5, walter)
muehlen = cube.determine_mill_rows(field)
for reihe in muehlen:
    print(reihe)

Erklärung: Der Spieler 'Walter' sucht sich ein Feld aus (Spielstein setzen/bewegen) und die Klasse board.move_piece() überprüft die Validität des Zuges. Ist alles Ok, dann wird geprüft ob eine Mühle entstanden ist. Hier setzt mein Code an. Ich habe eine neue Klasse Cube und Points erstellt. Ich stelle mir das Feld als 3-Dimensionales Objekt vor. So kann ich die Mühle-Reihen berechnen(determine_mill_rows). Ich wandele beispielsweise das Feld Fields('outer', 5, walter) in eine 3D Koordinate um (convert_to_cube_coordinates). (1|2|0) -> (x|y|z) Als nächstes prüfe ich diesen Punkt auf eine mögliche Ecke im Würfel. Für Ecken sind immer die X und Z Koordinaten wichtig. Sind diese MIN oder MAX Werte der Dimension(3,3,3) des Würfels, handelt es sich IMMER um eine Ecke. 0|0, 0|2, 2|2, 2|0

Bei einer Ecke brauchen wir nur die X und Z Koordinaten für die Mühlen, und bei keiner! Ecke werden einmal Y und je nach Punkt X oder Z Koordinaten genutzt für die Mühlen.

Wenn das geschehen ist, wandele ich jeden Punkt wieder in ein Field(owner=None) um. Für jede mögliche Mühle sammele ich die Felder in einer Liste. Und die Felder einer Mühle kann ich mit dem vom Board abgleichen -> 3 mal gleicher Owner BINGO!

# gesetzter / bewegter Spielstein auf:
Field('outer', 5, walter)
#erste moegliche Muehle
[Field(square_name='inner', index=5, owner=None), 
Field(square_name='mid', index=5, owner=None), 
Field(square_name='outer', index=5, owner=None)]
# zweite moegliche Muehle
[Field(square_name='outer', index=6, owner=None),
Field(square_name='outer', index=5, owner=None),
Field(square_name='outer', index=4, owner=None)]

Oder ist das zu kompliziert gedacht?

seblin commented 6 years ago

Ich finde das auf dem ersten Blick sehr kompliziert. Ich verstehe auch nicht, welchen Mehrwert mit den x,y,z-Koordinaten und dem ganzen Kram erreicht werden soll. Einen Vorschlag zu is_in_mill(), der deutlich kürzer ist, hatte ich schon in Issue #1 gezeigt. Vielleicht ist das untergegangen, daher hier nochmal:

SQUARE_NAMES = ('inner', 'mid', 'outer')

def is_in_mill(self, field):
    square_fields = self.fields[field.square_name]
    player = field.player
    if (field.index % 2) == 0:
        candidates = [
            [square_fields[(field.index + i) % 8] for i in (0, 1, 2)],
            [square_fields[(field.index - i) % 8] for i in (0, 1, 2)]
        ]
    else:
        candidates = [
            [self.fields[square][field.index] for square in SQUARE_NAMES],
            [square_fields[(field.index + i) % 8] for i in (-1, 0, 1)]
        ]
    for candidate in candidates:
        if all(field.player == player for field in candidate):
            return True
    return False

Sieht jemand Nachteile in diesem Vorgehen?

sebastian2443 commented 6 years ago

Oh, ich hatte deine Lösung gar nicht im Blick. Die ist schön kurz! Auf die Idee mit '% 8' wäre ich gar nicht gekommen. Ich habe also ne ganze Ecke zu kompliziert gedacht. Die Methode können wir so übernehmen. Eine Sache nur, wollen wir es jetzt 'field.owner' oder 'field.player' nennen?

Auf deiner Basis habe ich mal die Funktion get_allowed_fields geschrieben: Wenn ein Feld übergeben wird sucht es alle freien Nachbarfelder. Ohne Feld werden alle Felder ohne Besitzer zurückgegeben. So war es jaangedacht, oder?

def get_allowed_fields(self, field=None):    
    if field:
        square_fields = self.fields[field.square_name]
        square_index = SQUARE_NAMES.index(field.square_name)
        player = field.player

        candidates = [self.fields[SQUARE_NAMES[square_index-i]][field.index] for i in (-1,1) if 0 <= square_index-i <= 2] + \
        [square_fields[(field.index + i) % 8] for i in (-1, 1)]

        fields = { square: [] for square in SQUARE_NAMES }
        [fields[field.square_name].append(field) for field in candidates if not field.player]
        return { square: fields[square] for square in fields }

    return {
        square: [field for field in self.fields[square] if not field.player]
        for square in SQUARE_NAMES
    }

~~Ich weiß nicht, wie man aus der for-Schleife mit so vielen If Funktionen eine Comprehension schreibt. Glaube das wäre auch sonst zu undurchsichtig. Ist die Funktion 'Ok' für euch?~~ Habe es kürzer hinbekommen.

sebastian2443 commented 6 years ago

Für welchen Test entscheiden wir uns? Doctest oder Unittest? Ich habe etwas mit Unittest rumgespielt. Müsste das so für alle Funktionen ausgebaut werden?

import unittest
# root.py ; darunter liegt der derzeitige Code zum Muehle Spiel
import root

class TestBoard(unittest.TestCase):
    def is_in_mill(self):
        board = root.Board()
        walter = root.Player('Walter', 'white')

        board.fields['outer'][0] = root.Field(square_name='outer', index=0, player=walter)
        board.fields['outer'][1] = root.Field(square_name='outer', index=0, player=walter)
        board.fields['outer'][2] = root.Field(square_name='outer', index=0, player=walter)

        field = root.Field('outer', 0, walter)
        self.assertEqual(board.is_in_mill(field), True)

        field = root.Field('outer', 1, walter)
        self.assertEqual(board.is_in_mill(field), True)

        field = root.Field('outer', 2, walter)
        self.assertEqual(board.is_in_mill(field), True)

        field = root.Field('mid', 1, walter)
        self.assertEqual(board.is_in_mill(field), False)

if __name__ == '__main__':
    test = TestBoard()
    test.is_in_mill()

So, bei is_in_mill ist das ja wegen eines booleschen Wertes noch einfach. Aber wie läuft das jetzt bei den anderen Funktionen der Klasse Board? Je nach dem welche Felder von den Spielern besetzt sind, ist der Rückgabewerte von get_all_fields() ein anderer. Lösen wir das dann wie oben im Code? Einen festen Zustand hinterlegen und vergleichen dann das Ergebnis?

sls89 commented 6 years ago

Generell: wäre es nicht eigentlich eine Idee, den Code nicht nur im Issue zu teilen, sondern einfach die ersten Dateien erstellt und bei Änderungen / Vorschlägen einen Merge-Request stellt? Ich verliere etwas die Übersicht von welcher Version wir denn nun ausgehen können usw.

sebastian2443 commented 6 years ago

Ich habe mal unseren Code zusammengefasst und in main.py gesichert. Der muss jetzt glaube nur noch freigegeben werden?

Welche Versionen meinst du? Wir haben bisher nur eine, die am Anfang von seblin. In den nachfolgenden Kommentaren haben wir nur zwei weitere Methoden erstellt, die man dann in den Code kopieren muss. Lässt sich trotzdem sehr viel besser über ein Merge-Request händeln.