Artikel

Giveaways sind schwerer, als sie aussehen

Eine Verlosung ist ein Zeitgeber, eine Teilnehmerliste und ein Zufallsgriff. Klingt nach einer Stunde Arbeit — und ist der Befehl, der mich am meisten über Zustand, Neustarts und Fairness gelehrt hat.

BlackZackBlackzack

934 Wörter · 5 Min. Lesezeit

  • yurna
  • discord
  • architektur

„Kannst du eine Verlosung einbauen?" war eine der häufigsten Anfragen überhaupt. Und beim ersten Hinsehen ist es wirklich simpel: Nachricht schicken, Reaktion sammeln, nach einer Stunde einen zufälligen Teilnehmer ziehen, Gewinner ausrufen.

Meine erste Fassung war entsprechend kurz:

const nachricht = await kanal.send({ embeds: [einbettung] });
await nachricht.react("🎉");
 
setTimeout(async () => {
  const reaktion = nachricht.reactions.cache.get("🎉");
  const nutzer = await reaktion.users.fetch();
  const teilnehmer = [...nutzer.values()].filter((u) => !u.bot);
  const gewinner = teilnehmer[Math.floor(Math.random() * teilnehmer.length)];
  await kanal.send(`Gewonnen hat ${gewinner}!`);
}, dauerMs);

Sie hat funktioniert. Und sie hatte fünf Fehler, die ich alle erst im Betrieb gefunden habe.

Fehler 1: Der Zeitgeber überlebt keinen Neustart

setTimeout existiert im Speicher des Prozesses. Wenn der Bot neu startet — Deployment, Absturz, Serverneustart — sind alle laufenden Verlosungen weg. Die Nachricht steht noch im Kanal, Leute reagieren weiter, und es passiert nie wieder etwas.

Das ist die grundlegendste Lektion aus dieser Sache und sie gilt weit über Verlosungen hinaus: Alles, was länger dauern soll als ein Prozessleben, muss in die Datenbank. Nicht der Zeitgeber, sondern der Endzeitpunkt.

CREATE TABLE verlosungen (
  id           INTEGER PRIMARY KEY,
  gilde        VARCHAR(20) NOT NULL,
  kanal        VARCHAR(20) NOT NULL,
  nachricht    VARCHAR(20) NOT NULL,
  preis        TEXT        NOT NULL,
  gewinnerzahl INT         NOT NULL DEFAULT 1,
  endet_am     DATETIME    NOT NULL,
  beendet      BOOLEAN     NOT NULL DEFAULT 0
);

Und ein Wecker, der regelmäßig nachsieht, ob etwas fällig ist:

setInterval(async () => {
  const faellig = await db.verlosungen.findMany({
    where: { beendet: false, endet_am: { lte: new Date() } },
  });
  for (const v of faellig) await beenden(v);
}, 15_000);

Damit ist ein Neustart egal. Nach dem Hochfahren wird beim nächsten Durchlauf alles nachgeholt, was in der Zwischenzeit fällig geworden ist.

Fehler 2: Reaktionen sind keine verlässliche Teilnehmerliste

Discord liefert Reaktionen in Seiten von hundert Einträgen. Meine erste Fassung hat genau die erste Seite geholt. Bei einer Verlosung mit dreihundert Teilnehmern konnten also zwei Drittel nie gewinnen — und niemand hätte es je gemerkt, weil das Ergebnis trotzdem zufällig aussah.

Das ist die unangenehmste Sorte Fehler: einer, der nichts kaputtmacht, sondern still das Ergebnis verfälscht. Seitdem habe ich einen Reflex, wenn eine Schnittstelle Listen zurückgibt — ich suche zuerst nach der Seitengröße, bevor ich irgendetwas mit dem Ergebnis mache.

Fehler 3: Der Zufall war nicht fair

Math.random() ist für eine Verlosung ausreichend, solange niemand ein Interesse daran hat, sie vorherzusagen. Bei einer Verlosung mit echtem Wert ist das anders. Ich bin auf einen kryptografisch sicheren Generator umgestiegen, und das hat einen Nebeneffekt, der mir wichtiger war als die Technik: Man kann die Frage „ist das manipulierbar?" beantworten, statt sie wegzuwischen.

const { randomInt } = require("node:crypto");
const gewinner = teilnehmer[randomInt(teilnehmer.length)];

Dazu kam eine Regel, die ich anfangs vergessen hatte: Bei mehreren Gewinnern darf niemand doppelt gezogen werden. Klingt selbstverständlich, war es in meiner ersten Fassung nicht.

Fehler 4: Keine Nachvollziehbarkeit

Nach der dritten Verlosung kam die erste Beschwerde: „Der hat doch gar nicht teilgenommen." Ich konnte nichts belegen. Die Teilnehmerliste existierte nur für den Moment der Ziehung.

Seitdem wird das Ergebnis mitgeschrieben: wie viele Teilnehmer, wer gezogen wurde, wann. Nicht als Beweismittel gegenüber Nutzern, sondern damit ich selbst nachsehen kann, ob etwas schiefgelaufen ist. Bei einer Neuziehung — die es geben muss, weil Gewinner sich manchmal nie melden — ist außerdem wichtig, dass frühere Gewinner ausgeschlossen bleiben.

Fehler 5: Die Ausnahmen fehlen

Sobald das Grundgerüst lief, kamen die Sonderwünsche, und jeder einzelne war berechtigt:

  • Nur Mitglieder mit einer bestimmten Rolle dürfen teilnehmen.
  • Mitglieder, die erst seit gestern auf dem Server sind, dürfen nicht.
  • Bestimmte Rollen bekommen mehr Lose.
  • Der Veranstalter selbst kann nicht gewinnen.

Jede dieser Regeln ist trivial. Zusammen ergeben sie ein Filtersystem, und plötzlich ist der Punkt erreicht, an dem man entscheiden muss, ob man das ordentlich baut oder mit Sonderfällen zupflastert.

Infobox

Ich habe mich für eine Bedingungsliste entschieden, die pro Verlosung gespeichert wird und die beim Ziehen der Reihe nach angewendet wird. Das war mehr Aufwand als drei if-Blöcke — und der einzige Grund, warum ein halbes Jahr später „Mehrfachlose für Unterstützer" eine Konfigurationszeile war statt einer Codeänderung.

Was passiert, wenn die Verlosung ins Leere läuft

Die Fälle, die ich beim Bauen nicht bedacht hatte und die alle innerhalb der ersten Monate eingetreten sind:

Niemand hat teilgenommen. Dann gibt es keinen Gewinner, und die Meldung darf nicht so aussehen, als wäre etwas kaputt.

Die Nachricht wurde gelöscht. Dann kann man die Teilnehmer nicht mehr ermitteln. Die Verlosung muss als abgebrochen enden statt endlos in der Warteschlange zu bleiben.

Der Kanal existiert nicht mehr. Wohin dann mit der Gewinnermeldung? Bei mir geht sie in dem Fall an den Veranstalter per Direktnachricht.

Der Bot wurde vom Server entfernt und später wieder hinzugefügt. Die alte Verlosung liegt noch in der Datenbank und wird beim nächsten Durchlauf fällig. Ohne Prüfung schreibt der Bot in einen Server, auf dem er zwischenzeitlich gar nicht war.

Der Gewinner hat den Server verlassen. Formal gewonnen, praktisch nicht erreichbar. Deshalb die Möglichkeit zur Neuziehung — mit Ausschluss der bisherigen Gewinner.

Der Aufbau, der daraus geworden ist

Jede dieser Situationen hat denselben Kern: Zwischen dem Start und dem Ende einer Verlosung liegt Zeit, und in dieser Zeit ändert sich die Welt.

Das ist der Unterschied zwischen einem Befehl und einem Vorgang. Ein Befehl läuft in Millisekunden ab, und dabei bleibt alles, wie es ist. Ein Vorgang läuft über Stunden, und jede Voraussetzung, die beim Start galt, muss beim Abschluss neu geprüft werden.

Ich habe deshalb angewöhnt, bei allem, was länger als einen Aufruf dauert, eine Prüfliste an den Abschluss zu hängen: Gibt es den Server noch? Den Kanal? Die Nachricht? Die beteiligten Personen? Jede Antwort mit „nein" braucht einen definierten Ausgang — und der ist selten „Fehler werfen".

Was ich daran allgemein gelernt habe

Verlosungen sind ein perfektes kleines Beispiel für den Unterschied zwischen einer Funktion und einem Feature.

Die Funktion — Zufall aus einer Liste ziehen — sind drei Zeilen. Das Feature ist alles drumherum: Was passiert bei einem Neustart? Was, wenn die Nachricht gelöscht wurde? Was, wenn niemand teilgenommen hat? Was, wenn der Kanal nicht mehr existiert? Was, wenn der Bot währenddessen vom Server geworfen wurde?

Bei mir sind das am Ende gut vierhundert Zeilen geworden, von denen die eigentliche Ziehung fünf ausmacht. Das Verhältnis überrascht mich bis heute nicht mehr — es ist bei fast jedem Feature ähnlich, das in der freien Wildbahn läuft.