Karten für Cards Against Humanity parsen

23.04.2020, 8 Minuten Lesezeit

Um das Sommerfest-Team auch während der Corona-Krise zusammenzuhalten, kam die Idee auf, im Web einige Spiele zu spielen. Eines der gewünschten Spiele war Cards Against Humanity (CaH). Von dem Spiel sind diverse Varianten im Internet zu finden, da das Ursprungsspiel unter CC-BY-NC-SA 2.0 Lizenz steht. Nach ein bisschen ausprobieren war mit Pixels Against Humanity auch eine gut funktionierende Variante gefunden. Das Deployen auf einem U7 war einfach und somit läuft nun eine eigene CaH-Instanz unter cah.wpaf.de.

Beim Ausprobieren mit Freunden hatten wir ein paar Probleme mit den englischen Karten, die den Spielspaß deutlich reduziert haben, da wir doch einiges nicht verstanden haben. Also kam die Idee auf, ob es nicht auch deutsche Karten gibt.

Erstmal wurde geguckt, wie die Karten denn überhaupt definiert werden. Diese Variante verwendet dafür recht einfach eine json-Datei, die beim Start des Servers eingelesen wird.

{
 'blackCards': [{"text": "I got 99 problems, but __ ain't one", "pick": 1}, ...],
 'whiteCards': ["Active Listening", ...],
 'Base': {
	'name': 'Base Set',
	'black': [0,...],
    'white': [0,...]
  }
}

Zuerst gibt es für jede Farbe eine Liste mit Karten. Dabei sind die schwarzen Karten jeweils ein Dict aus dem Text und der Anzahl an weißen Karten, die auf die schwarze Karte gespielt werden sollen. Bei den weißen Karten ist es einfacher, hier ist es einfach eine Liste. Anschließend wird ein Set aus diesen Karten generiert. Es kann mehrere Sets geben, die über den Key identifiziert werden (hier Base). Sets verfügen über einen Namen und referenzieren dann die weißen und schwarzen Karten über ihre Position in den entsprechenden Listen blackCards und whiteCards.

Also ging die Suche los und Nikolas hat recht fix eine deutsche Variante in LaTeX auf GitHub gefunden. Das Problem war nun: Wie bekommt man die Fragen aus den TeX-Sources raus? Da hilft ein Blick in die Sources (categories/basic.tex):

[...]
\titlecard{Basis}%
%
\whitecard{Basis}{Aktives Zuhören.}%
[...]
\blackcard{1}{Basis}{Ich habe 99 Probleme, \newline aber \underl ist keines davon.}%
% engl. "I got 99 problems, but __ ain't one"
[...]

Das sieht doch schon mal sehr gut aus, da es strukturiert ist. Die weißen Karten sind jeweils durch den Befehl \whitecard{Kategorie}{Text} gegeben und bei den schwarzen Karten analog, wobei dort zusätzlich noch die Anzahl an zu legenden Karten mit angegeben ist. Das passt schon ziemlich gut zu dem, was wir in unserer json brauchen und lässt sich auch recht einfach mit Python parsen. Fangen wir mal an und legen uns ein Objekt an, dass die json-Struktur von oben repräsentiert. Anschließend suchen wir alle *.tex-Dateien im Verzeichnis (categories/) und lesen alle Zeilen ein. Das komplette Script gibt es hier im Gist.

import json
import glob

cards = {'blackCards': [], 'whiteCards': [], 'Base': {'name': 'Base Set dt.', 'black': [], 'white': []}}

tex_files = glob.glob("*.tex")
for tex in tex_files:
  with open(tex, 'r') as f:
    lines = f.readlines()

Damit haben wir jetzt eine große Liste von Strings, welche die einzelne Zeilen der Datei enthält. Die müssen wir jetzt einzeln verarbeiten, daher schauen wir uns die individuellen Zeilen einmal an.

Wie auch oben im Beispiel zu sehen, beginnen einige Zeilen mit einem Prozentzeichen. So werden in LaTeX Kommentare eingeleitet. Diese Zeilen können wir ignorieren und springen mittels continue einfach in die nächste Zeile.

for line in lines:
	if line[0:1] == '%':
		continue

Jetzt wird es etwas trickreich. Ich zerteile die Zeile jeweils an der Zeichenkette }{. Ein kleines Beispiel für die schwarze Karte oben:

>>> '\blackcard{1}{Basis}{Ich habe 99 Probleme, \newline aber \underl ist keines davon.}%'.split('}{')
['\blackcard{1', 'Basis', 'Ich habe 99 Probleme, \newline aber \underl ist keines davon.}%']

Aus dem einzelnen String werden nun drei Strings. Man sieht nun schon, dass im ersten Teil die Anzahl Karten ist und im letzten Teil die Frage. Ein bisschen schön müssen wir die Teile aber noch machen, denn das \blackcard{1 wird nicht als 1 interpretiert.

Aber erstmal schauen wir, was für eine Karte wir den haben. Dazu schauen wir einfach, was eigentlich in den Zeichen 1-5 der Zeile steht. Hier kommt uns die Repräsentation von Strings in Python entgegen, da wir hier einfach einen List-Operator verwenden können: Der erste Paramter gibt den Start an und der zweite Parameter das Ende an, wobei dieser Werte exklusiv, also nicht mehr in der Liste ist.

Ähnlich machen wir das auch für den Pick, hierbei nutzen wir aus, dass negative Werte entsprechend vom Ende gerechnet werden. Wir gucken uns also nur das letzte Zeichen bis zum Ende an, da wir kein Ende explizit angegeben haben. Im anderen Fall des Texts (data) nutzen wir es andersherum und schneiden die letzten 3 Zeichen ab, was }%\n ist. Wo kommt jetzt das \n her? Das ist ein Zeilenumbruch, der noch in der Zeile ist, aber nicht explizit angezeigt wird.

Mittels replace() machen wir die Fragen noch schön und ersetzen einige LaTeX Besonderheiten, z.B. wird der LaTeX-Unterstrich \\underline ein einfacher Unterstrich _. Anschließend fügen wir die Karten unserer Liste an schwarzen bzw. weißen Karten hinzu. Bei den schwarzen müssen wir hier das dict erstellen, dass zusätzlich auch die Anzahl an zu spielenden Karten enthält.

split = line.split('}{')

if line[1:6] == 'black':
    pick = split[0][-1:]
    data = split[2][:-3].replace('\\newline', '<br />').replace('- ', '').replace('\\SymbReg', '').replace('\%','%').replace('\\underl', '_').replace('\'\'', '"').replace('``', '"')
    cards['blackCards'].append({"text": data, "pick": pick})

if line[1:6] == 'white':
	data = split[1][:-3].replace('\\newline', '<br />').replace('- ', '').replace('\\SymbReg', '').replace('\%','%').replace('\\underl', '_').replace('\'\'', '"').replace('``', '"')
    cards['whiteCards'].append(data)

Jetzt haben wir das meiste schon geschafft. Wir haben die TeX-Sources in unsere Struktur überführt, jetzt müssen wir noch ein Set anlegen. Dafür schreiben wir einfach alle die Nummern aller unserer Karten in die Liste. Indizes fangen in Python bei 0 an, daher haben wir im folgenden range(0, Anzahl an Karten). Auch hier ist der zweite Parameter exklusiv, d.h. es ist das Interval [0, Anzahl Karten). Die Syntax ist jeweils die Kurzform für eine Schleife, die jede Zahl zwischen 0 und Anzahl Karten - 1 in die Liste cards['Base']['black'] bzw. cards['Base']['white'] einfügt. Abschließend schreiben wir unser Set noch in eine cards.json, die wir nun auf den Server kopieren können und nach einem Neustart der CaH-Instanz nutzen können.


cards['Base']['black'] = [i for i in range(0, len(cards['blackCards']))]
cards['Base']['white'] = [i for i in range(0, len(cards['whiteCards']))]

with open('cards.json', 'w') as f:
    json.dump(cards, f)

Mehr Kartensets

Bei der Recherche nach den deutschen Karten war ich auf das Projekt JSON Against Humanity gestoßen, welches eine Vielzahl an unterschiedlichen Kartensets anbietet. Ein kurzer Blick in die Source zeigte, dass es hier jeweils eine black.md.txt und eine white.md.txt gibt, welche die Karten enthalten.

Das lässt sich auch recht leicht parsen, größtes Problem war die Erkennung, wie viele Karten der Spieler legen muss. Aber auch das lässt sich in Python elegant lösen:

max(1, line.count('_'))

Es wird einfach die Anzahl an Unterstrichen auf der schwarzen Karte gezählt. Da nicht alle Karten einen Unterstrich haben, wird das Ergebnis in die max-Funktion gegeben, die den höheren Wert zurückgibt. Somit ist sichergestellt, dass pick immer mindestens 1 ist.

Gleichzeitig sollten nun auch mehrere Karten-Sets möglich sein, daher brauchte die Logik für die Referenzierung der Karten in den Sets noch ein kleines Update. Zu Beginn des Einlesevorgangs wird die aktuelle Anzahl an weißen und schwarzen Karten abgefragt. Diese Zahl ist, nach dem Hinzufügen der Karten dieses Sets, der Startwert für die Referenzierung über die Indizes. Diese laufen dann wieder bis zur Anzahl der Karten - 1. Elegant, oder?

Eine kleine Veränderung zum Repo ist noch, dass die Dateien jetzt als Konvention jeweils [name].black und [name].white heißen müssen.

def get_pack(name, index):
    start_black = len(cards['blackCards'])
    start_white = len(cards['whiteCards'])

    with open(name + '.black', 'r') as f:
        for line in f.readlines():
            if len(line) > 0:
                cards['blackCards'].append({"text": line, "pick": max(1, line.count('_'))})

    with open(name + '.white', 'r') as f:
        for line in f.readlines():
            if len(line) > 0:
                cards['whiteCards'].append(line)

    cards[index] = {'Name': name}
    cards[index]['black'] = [i for i in range(start_black, len(cards['blackCards']))]
    cards[index]['white'] = [i for i in range(start_white, len(cards['whiteCards']))]

get_pack('prtg', 'Downtime')

Auswahl des Sets

Die Original-Variante von CaH hatte keine Auswahl des Sets im Frontend. Im Backend waren die Sets schon vorgesehen, aber die Auswahl war nicht möglich. Also habe ich mit etwas fluchen ins React-Frontend noch die Auswahl des Sets integriert. Dabei kam dann der Wunsch auf, auch weiterhin die Original-Variante spielen zu können. Also kleine Anpassung am Script: Wir laden zuerst mal das englische Original und ergänzen dann unsere Karten. Statt also das cards-Objekt zu initialsieren, lesen wir einfach die bestehende, originale cards.json.

with open('en.json', 'r') as en:
    cards = json.load(en)

Da wird jetzt schon einiges an Karten in den Liste haben, müssen wir unsere Referenzen für das Set entsprechend anpassen. Statt bei 0 zu starten, müssen wir hier, analog zu den zusätzlichen Sets, vor dem Hinzufügen der Karten zählen, wie viele Karten schon in der Liste sind und dann dort beginnen.

Eigene Kartensets

Nach dieser Vorarbeit lassen sich nun einfach und elegant weitere Kartensets anlegen. Es müssen einfach nur zwei Textdateien mit den vorgesehen Begriffen gefüllt werden und diese anschließend geparst werden. Das führte dann auch zu der Generierung eines Sommerfest-Sets. Schnell waren ein paar schwarze Karten und viele weiße Karten definiert und als Crowdsourcing in einem Cryptpad werden noch weitere Begriff gesucht und bald ergänzt.

Ein kleiner Auszug:

schwarz:
Das Wohnheim ist so leer ohne _.
_ auf der Bühne - ein Traum!
Ohne _ ist kein Sommerfest.

weiß:
Sommerfest-Keller
Kabeltrommel
RageCage
Band
Partyraum
ballern
Hemelinger
Bierdusche

Cards Against Muggles

Eine Besonderheit ist die Variante Cards Against Muggles. Hier gibt es nur eine PDF mit den Karten, aber keine Textdateien. Mittels des Unix-Tools pdftotext lässt sich die PDF aber einfach in eine Textdatei konvertieren und nach kleinen Korrekturen kann man dann zwei Textdateien (black/white) draus machen, die sich auch einfach parsen lassen. Da die Karten nicht free sind, müsst ihr etwas zaubern, um sie spielen zu können :-)

Eike Broda · dev@ebroda.de

Powered by Hugo & Kiss.