“I done goofed!”, oder Fische, die lesen können, sind klar im Vorteil!

In meinem letztem Post hab ich mir das Spiel "Tempo, kleine Fische!" etwas genauer angesehen und es dabei für unfair bzw. unausgeglischen erklärt, da die Fischer mehr als die Hälfte aller Spiele gewinnen.

Anscheinend habe ich dem Spiel unrecht getan, denn ... ich hab eine Regel überlesen. *facepalm*

Denn analog zu der Regel, nachdem die Fischer auch dann ziehen wenn die Farbe eines gefangenen Fisches gewürfelt wird gibt es EIGENTLICH die Regel, daß wenn die Farbe eines geretteten Fisches gewürfelt wird man einen beliebigen Fisch ziehen darf!

Wie man sich denken kann hat das einen leichten Einfluß auf das Spiel. Mehr noch, es entkräftet auch meinen Vorwurf, es gäbe keine Entscheidungen in dem Spiel! Schließlich such man sich ja aus welchen Fisch man stattdessen zieht. (Es ist keine große Entscheidung, aber hey, Spielerinput! *jazzhands* )

Um das ganze analysieren zu können musste ich das natürlich erstmal implementieren. (Siehe den angepassten Quellcode im Appendix.) Unserem Fischspiel kann man bei der Initialisierung jetzt eine von drei Taktiken übergeben:

  • Taktik.falsch ist die Variante des ursprünglichen Artikels: Die regel wird ignoriert.
  • Taktik.zufall (Default) ist selbsterklärend: Wenn die Regel angewandt wird darf ein zufällig gewählter Fisch eins weiter ziehen.
  • Taktik.letzter bedeutet, daß immer der am weitesten zurückliegende Fisch gezogen wird.

Nachdem ein lieber Mensch auf Twitter nachgefragt hat wurde die Signatur von Tempo außerdem noch um die Argumente fische=6 und fischer=0 erweitert, die jeweils die Startpositionen angeben. Man könnte also ein Spiel testen bei dem die Fische weiter vorne oder die Fischer weiter hinten starten.

(Der Vollständigkeit halber sollte ich jetzt auch noch die Position des Meers configurierbar machen, aber dazu hatte ich dann keine Lust mehr. Vielleicht morgen. XD )

Also, gucken wir mal was passiert. Erstmal zur Kontrolle die Variante mit der falschen Regel:

games = [Tempo(taktik=Taktik.falsch).ergebnis() for x in range(10000)]

count = Counter(["Fischer" if x[0] > x[1] else "Fische" for x in games])

print("Die Fischer gewinnen etwa {}% der 10000 simulierten Spiele.".format(100 * count["Fischer"] / sum(count.values())))
Die Fischer gewinnen etwa 59.96% der 10000 simulierten Spiele.

Ok, soweit so gut. (Hey, zumindest hab ich zur Abwechslung anscheinend nichts kaputtgecoded! XD )

Mal schaun was passiert, wenn wir die Zufallstaktik wählen...

games = [Tempo(taktik=Taktik.zufall).ergebnis() for x in range(10000)]

count = Counter(["Fischer" if x[0] > x[1] else "Fische" for x in games])

print("Die Fischer gewinnen etwa {}% der 10000 simulierten Spiele.".format(100 * count["Fischer"] / sum(count.values())))
Die Fischer gewinnen etwa 50.77% der 10000 simulierten Spiele.

Oha! Mit der korrekten Regel und einfach nur zufällig einen Fisch auswählen ist das Spiel plötzlich fast ausgeglichen! Zwar immer noch einen leichten Vorteil für die Fischer, aber doch ein sehr krasser Unterschied.

Und was passiert, wenn man die (meinem Bauchgefühl nach bessere) Taktik von "Der meistgefährdete Fisch zieht eins vor" (Taktik.letzter) benutzt?

games = [Tempo(taktik=Taktik.letzter).ergebnis() for x in range(10000)]

count = Counter(["Fischer" if x[0] > x[1] else "Fische" for x in games])

print("Die Fischer gewinnen etwa {}% der 10000 simulierten Spiele.".format(100 * count["Fischer"] / sum(count.values())))
Die Fischer gewinnen etwa 51.51% der 10000 simulierten Spiele.

Huh. Ok, soviel zu meinem Bauchgefühl. Wieder einmal bestätigt sich, was sich auch schon im Bambirds Software Projekt gezeigt hat: Besser sein als eine Zufallsentscheidung ist manchmal garnicht so einfach.

(Wen's interessiert: Das umgekehrte zu Taktik.letzter zu machen, sprich immer den erstplatzieren Fisch zu wählen, ist besser als den letztplatzierten, aber nicht eindeutig besser als einfach einen zufälligen Fisch zu wählen. Auch hier gewinnen die Fischer etwa die Hälfte der Spiele. Hab ich aber jetz nicht extra als Taktik implementiert, primär weil ich das erst beim Schreiben des Posts ausprobiert hab und dann die oberen Absätze nimma umschreiben wollte.)

Was wir jetzt noch ausprobieren könnten wäre zu sehen, welchen Einfluss die Startpositionen haben.

for position in [4, 5, 6, 7, 8]:
    games = [Tempo(fische = position, taktik=Taktik.zufall).ergebnis() for x in range(10000)]

    count = Counter(["Fischer" if x[0] > x[1] else "Fische" for x in games])

    print("Starten die Fische auf Position {}, gewinnen die Fischer etwa {}% der 10000 simulierten Spiele.".format(position, 100 * count["Fischer"] / sum(count.values())))
Starten die Fische auf Position 4, gewinnen die Fischer etwa 88.09% der 10000 simulierten Spiele.
Starten die Fische auf Position 5, gewinnen die Fischer etwa 73.27% der 10000 simulierten Spiele.
Starten die Fische auf Position 6, gewinnen die Fischer etwa 51.53% der 10000 simulierten Spiele.
Starten die Fische auf Position 7, gewinnen die Fischer etwa 28.87% der 10000 simulierten Spiele.
Starten die Fische auf Position 8, gewinnen die Fischer etwa 11.01% der 10000 simulierten Spiele.

Ok, die Startposition hat einen sehr heftigen Einfluß. Was, wenn man Taktik.falsch spielt?

for position in [5, 6, 7]:
    games = [Tempo(fische = position, taktik=Taktik.falsch).ergebnis() for x in range(10000)]

    count = Counter(["Fischer" if x[0] > x[1] else "Fische" for x in games])

    print("Starten die Fische auf Position {}, gewinnen die Fischer etwa {}% der 10000 simulierten Spiele.".format(position, 100 * count["Fischer"] / sum(count.values())))
Starten die Fische auf Position 5, gewinnen die Fischer etwa 78.55% der 10000 simulierten Spiele.
Starten die Fische auf Position 6, gewinnen die Fischer etwa 59.85% der 10000 simulierten Spiele.
Starten die Fische auf Position 7, gewinnen die Fischer etwa 36.6% der 10000 simulierten Spiele.

Ok, ein kleiner Vorsprung ist sichtlich ein größerer Vorteil als die fehlende Regel ein Nachteil.

Ein letztes Experiment hab ich noch! Zwar hab ich die Position des Meers nicht explizit konfigurierbar gemacht, doch läßt sich das ja dadurch simulieren, daß wir sowohl Fische als auch Fischer verschieben, den Abstand zwischen ihnen aber gleich belassen.

Tempo(fischer=-6, fische=0) wäre also so, als ob das Meer sechs Felder weiter weg währe.

Ob das einen Unterschied macht? (Mein Bauchgefühl sagt ja, daß es den leichten Vorteil der Fischer stärker ausprägt, aber mal gucken.)

for abstand in [0, 1, 2, 3]:
    games = [Tempo(fischer= 0 - abstand, fische= 6 - abstand , taktik=Taktik.zufall).ergebnis() for x in range(10000)]

    count = Counter(["Fischer" if x[0] > x[1] else "Fische" for x in games])

    print("Ist das Meer {} Felder weiter weg, gewinnen die Fischer etwa {}% der Spiele.".format(abstand, 100 * count["Fischer"] / sum(count.values())))
Ist das Meer 0 Felder weiter weg, gewinnen die Fischer etwa 52.0% der Spiele.
Ist das Meer 1 Felder weiter weg, gewinnen die Fischer etwa 61.73% der Spiele.
Ist das Meer 2 Felder weiter weg, gewinnen die Fischer etwa 71.27% der Spiele.
Ist das Meer 3 Felder weiter weg, gewinnen die Fischer etwa 78.17% der Spiele.

Yay, mein Bauchgefühl hatte doch mal Recht! \o/

Fazit:

Mit dieser erweiterten, korrekteren Analyse muß ich meine Einschätzung von gestern revidieren. "Tempo, kleine Fische!" ist anscheinend doch besser balanciert als ich dachte. Es gibt einen leichten Vorteil für die Fischer, aber da es sich hier um ca. dreijährige Kinder handelt wird der nur bedingt ins Gewicht fallen.

Alles in allem: Nicht unelegantes Spiel, es hat Spaß gemacht es zu simulieren und Kleinmonster fand es heut in der Früh auch spannend genug für ein Spiel vor dem Frühstück. (Alle Fische wurden gefangen.)

Appendix: Korrigierter Quellcode

from collections import defaultdict, Counter
import random
from enum import Enum, auto

class Taktik(Enum):
    falsch = auto()
    zufall = auto()
    letzter = auto()

class Boot(object):
    def __init__(self, farben, position=0):
        self.farben = farben
        self.position = position

    def __repr__(self):
        return "Fischerboot ({}, {})".format(self.farben, self.position)

class Fisch:
    def __init__(self, farbe, position=6):
        self.farbe = farbe
        self.position = position

    def __repr__(self):
        return "Fisch ({}, {})".format(self.farbe, self.position)

class Tempo:
    farben = ["Rot", "Grün", "Blau", "Rosa", "Orange", "Gelb"]

    def __init__(self, fischer=0, fische=6, taktik=Taktik.zufall):
        """Erstelle ein neues 'Tempo, kleine Fische!' Spiel."""

        self.spiel = {}
        self.taktik = taktik

        # Boot als Shortcut zu Fischer
        self.boot = Boot(["Rot", "Grün"], fischer)


        for farbe in Tempo.farben:
            if farbe in ["Rot", "Grün"]:
                self.spiel[farbe] = self.boot
            else:
                self.spiel[farbe] = Fisch(farbe, fische)



    def roll(self):
        """Würfle mit dem Farbenwürfel."""
        return random.choice(Tempo.farben)

    def fertig(self):
        """Überprüfe ob das Spiel vorbei ist."""
        return self._fischergewonnen() or self._fischegewonnen() or self.boot.position == 11

    def _fischergewonnen(self):
        """Fischer gewinnen, wenn alle Fische gefangen wurden."""

        return len(self.boot.farben) == 6

    def _fischegewonnen(self):
        """Fische gewinnen, wenn alle Fische im Meer landen."""
        return all([self.spiel[x].position == 12 for x in Tempo.farben[2:]])


    def zug(self):

        # Würfle Farbenwürfel
        wurf = self.roll()

        # Ziehe entsprechende Figur
        self.spiel[wurf].position += 1


        # Falls die Farbe eines Fisches gewürfelt wurde, der bereits im Meer ist
        # darf ein beliebiger noch im Fluß schwimmender Fisch stattdessen gezogen werden.
        # Verhalten hängt von der gewählten Taktik ab. (Zufall als Default.)
        if self.spiel[wurf].position > 12 and self.taktik is not Taktik.falsch:

            # hol alle verbleibenden Fische
            fische = [x for x in self.spiel.values() if x is not self.boot and x.position < 12]

            if fische:
                if self.taktik is Taktik.zufall:
                    # Such einen zufälligen aus
                    fisch = random.choice(fische)
                else: # Taktik ist Taktik.letzter
                    # Such den am weitest zurück liegenden Fisch
                    fisch = sorted(fische, key=lambda x:x.position)[0]


                # zieh den eins weiter
                fisch.position += 1

        # Falls Boot gezogen wurde
        if self.spiel[wurf] == self.boot:

            # Überprüfe ob irgendwelche Fische gefangen wurden und update Spielstand
            fische = [x for x in self.spiel.values() if type(x) is not Boot]

            gefangen = [fisch.farbe for fisch in fische if fisch.position <= self.boot.position]

            for farbe in gefangen:
                self.boot.farben.append(farbe)
                self.spiel[farbe] = self.boot

    def ergebnis(self):
        """Simuliere ein ganzes Spiel."""
        while not self.fertig():
            # Mache einen Zug.
            self.zug()

        # Gib das Ergebnis des Spiels als (Gefangene Fische, Gerettete Fische) Tupel zurück.

        return len(self.boot.farben) - 2, 6 - len(self.boot.farben)