w20 auf Monitoring

Dungeons & Dragons: Ein Abenteuer voller Monster, Kämpfe und Anwendungsmetriken

Erin Schnabel

© Shutterstock / BrunoGarridoMacias

Was hat das wohl beliebteste Pen-und-Paper-Rollenspiel mit Anwendungsmetriken und Frameworks wie Quarkus oder Micrometer gemein? Dieser epische Artikel nimmt euch mit auf ein Abenteuer voller Monsterkämpfe, Graphen und Metriken. Und jetzt alle einen w20-Wurf auf „Lesen“ machen!

Im Sommer des Jahres 2019 habe ich meine Kinder beim Spielen beobachtet. Egal, ob sie drinnen oder draußen spielten, mit oder ohne Spielsachen – immer haben sie irgendwelche Spiele erfunden. Das tun sie bis heute. Damals haben sie Pokémon-Kämpfe nachgestellt, mit Soundeffekten, speziellen Bewegungen und verrückten Stimmen. Ich habe mich eine Weile intensiv mit der Frage beschäftigt, wie man diesen kreativen Geist ins Teenageralter retten kann. Die Antwort, die ich fand, hieß Dungeons & Dragons.

Ich selbst habe D&D zwei Mal gespielt: einmal im Alter von 11 Jahren mit dem Nachbarsjungen, was sich allerdings als ziemlich ziellose Übung im Erstellen von Charakteren für das Spiel herauskristallisierte, und einmal als Erwachsene. Beim zweiten Mal starb ich aus Peinlichkeit ungefähr 1 000 Tode. Aber für meinen Sohn und seine Freunde war ich bereit, das zu ertragen und so bot ich ihnen an, den Dungeon Master (DM) für sie zu geben. Natürlich machte ich einen typischen Anfängerfehler und hatte ihm kein Limit gesetzt, wie viele seiner Freunde mitspielen könnten. Unser Abenteuer startete mit einer Gruppe aus sechs 10-jährigen Jungs (ab und zu spielte auch ein 6-jähriges Mädchen mit), und ich habe die Arbeit und das Chaos um einiges unterschätzt.

Der ein oder andere wird sich nun fragen „In Ordnung, aber was genau hat das nun mit Metriken zu tun?“ Die Antwort ist: Timing.

Domänenspezifische Anwendungsmetriken sind wichtig. Um zu wissen, was genau die eigene Anwendung tut, reicht es nicht, den HTTP-Datendurchsatz zu messen (was allerdings der Fixpunkt jeder Beschreibung von Anwendungsmetriken zu sein scheint). Ich kann etwa einen fantastischen Datendurchsatz messen, wenn ein Bug in meinem Code etwa dafür sorgt, dass ganze Codepfade übersprungen werden. Ein erfolgreicher Response-Code kann keine Gewissheit darüber geben, ob die Anwendung subtil nicht doch irgendetwas Falsches macht. Dafür ist es einfach zu leicht, Unit-Tests zu schreiben, die keine Bugs aufspüren. Aus diesem Grund habe ich das getan, was Developer Advocates machen: Ich habe einen Abstract für einen Talk eingereicht, der die Wahrheit meiner Behauptung bestätigen sollte, habe aber den ganzen Sommer nicht daran gearbeitet (obwohl es auf der To-do-Liste stand).

Anfang September beschlich mich dann leichte Panik: Ich musste mir einen Haufen Informationen in Bezug auf D&D einverleiben, damit ich meinen Sohn und seine Freunde erfolgreich durch das Spiel führen konnte, und ich musste eine komplett neue Anwendung sowie einen ganzen Vortrag von null an vorbereiten. Aus eigenem Interesse verband ich das Ganze zu einer Aufgabe. Was nun folgt, ist die Geschichte von Monsterkämpfen, einer Anwendung, die klassische D&D-Monster gegeneinander antreten lässt (unter Verwendung der Kampfregeln der 5. Edition von Dungeons & Dragons) und Anwendungsmetriken, um das Geschehen zu analysieren.

Erster Versuch

Für die SpringOne 2019 erstellte ich eine Spring-Boot-Anwendung mit einigen Anwendungsendpunkten, die für die Ausführung der Kämpfe verantwortlich waren. Micrometer und ein entsprechender Spring-Boot-Aktuator wurden meinerseits in Verbindung mit einem Prometheus-Endpunkt für die Definition und das Aufspüren von Anwendungsmetriken verwendet. Rollenspieltechnisch verwendete ich Game Master 5 (eine Anwendung von Lion’s Den), um meine Kampagne auf dem iPad zu managen. In diese Anwendung ist ein Monsterkompendium integriert, das sich im XML-Format exportieren lässt. Meine Anwendung hat so die Spielwerte von 1 063 Monstern aus diesem Dokument erhalten. Basierend auf diesen Bemühungen erstellte ich schließlich die Game Engine für die Kämpfe, bei denen immer 2 bis 5 Monster gegeneinander kämpfen sollten, bis nur noch eines lebte. Ich maß die Anzahl an Runden und die jeweilige Dauer der Begegnungen, dazu ein paar Statistiken über die Effektivität von Attacken und die tödlichsten bzw. schwächsten Monster. Ich habe den Talk schließlich überstanden, war aber letztendlich nicht zufrieden mit der Anwendung.

Zweiter Versuch

Die Zeit verstrich, und es war Zeit, die Anwendung für die DevNexus 2020 zu überarbeiten, da ich – wie jeder gute Advocate es tut – meinen Talk natürlich erneut eingereicht hatte. Ich hatte mir vorgenommen, ein paar unterschiedliche Metrik-Libraries auszuprobieren und deren Möglichkeiten zu vergleichen. Das reichte mir schließlich auch als Ausrede, einer meiner Lieblingsaufgaben zu frönen: Dem Refactoring meines Codes! Ich traf die Entscheidung, die Definitionen der Monster von weniger aufgeblähten Wiki-Seiten zu importieren und das Ergebnis dann als JSON-Datei zu speichern. Die Game Engine habe ich in eine Core-Bibliothek geschoben und sie so überarbeitet, dass sie mit dieser etwas saubereren Datenquelle arbeiten kann. Auch wenn ich nur noch 215 Monster hatte, war ich mit dieser Lösung deutlich zufriedener.

D&D: eine kurze Übersicht

D&D ist ein Rollenspiel. Der Dungeon Master (DM) erstellt eine Welt und einen Kontext, in dem es Probleme zu lösen bzw. Herausforderungen zu meistern gibt. Er spielt zudem Kreaturen und Personen, mit denen die Spieler interagieren können. Die Spieler selbst erzählen dann ihren Teil der Geschichte und beschreiben, was ihre Charaktere tun. Die Schlüsselmechanik, die die beiden Erzählweisen verbindet, ist das Rollen der Würfel. Durch Würfeln werden Monster erstellt, es wird entschieden, ob die kluge Schurkin (eine Charakterklasse in D&D) eine epische Entdeckung macht oder sich den Zeh stößt und die Treppe hinunterfällt.

Der Kampf machte mir ein wenig Sorgen. Es würde ohnehin nicht so einfach werden, die Aufmerksamkeit von sechs Kindern konstant zu halten. Zwischendurch immer wieder die Regeln konsultieren und die Action stoppen zu müssen, würde nicht helfen. Die Regeln für den Kampf und dessen Ablauf sind relativ geradlinig und unkompliziert. Allerdings war es nicht besonders leicht, die verschiedenen Arten von Angriffen zu verstehen. Das Ganze wird jetzt sehr nerdig, aber ich hoffe, dass die Erklärungen auch für all jene ausreichen, die D&D noch nie gespielt haben, um meinen Abenteuern in der Welt der Metriken zu folgen. Wir starten mit den Charakteristiken und Attributen von Monstern und arbeiten uns dann zu den Angriffsregeln vor.

Jedes Monster im Monsterkompendium hat einen Typ, eine Größe, eine Rüstungsklasse (AC) und Trefferpunkte (HP). Sowohl Typ als auch die Größe sind feste Werte, man könnte hier von Aufzählungstypen (Enums) sprechen. Die Rüstungsklasse ist ein ganzzahliger Wert, der angibt, wie schwer es ist, eine Kreatur zu treffen (also erfolgreich anzugreifen). Kreaturen mit einer niedrigen Rüstungsklasse sind sehr viel einfacher zu treffen als jene, die eine hohe AC haben. Die Trefferpunkte repräsentieren die Lebensenergie eines Monsters. Ein paar Beispiele:

  • Ein Pseudodrache ist ein kleiner Drache mit einer Rüstungsklasse von 13. Er hat durchschnittlich 7 (2w4 + 2) Trefferpunkte. Damit ist gemeint, dass ein Dungeon Master entweder den Durchschnittswert von 7 wählen oder die HP mit zwei vierseitigen Würfeln ermitteln kann, zu deren Wurfergebnis dann zwei Punkte hinzugefügt werden.
  • Eine Deva ist ein mittelgroßes Himmelswesen mit einer AC von 17 und 136 (16w8 + 64) HP.
  • Eine Tarrasque, ein ikonisches Monster der fünften Edition von D&D, ist ein gigantisches Monster mit einer Rüstungsklasse von 25 und 676 (33w20 + 330) Trefferpunkten.

Jedes Monster hat sechs Attributswerte: Stärke, Geschicklichkeit, Konstitution, Intelligenz, Weisheit und Charisma. Es kann auch in gewissen Fertigkeiten (Skills) geübt sein. Übergreifend sind Fertigkeiten und Fähigkeiten (Proficiencies) die Basis in Form von Modifikatoren, die es einer Kreatur leichter (oder schwerer) machen, eine bestimmte Art von Attacke durchzuführen.

Die meiste Zeit haben Monster natürliche Waffen oder tragen Waffen, um bei Gegnern unterschiedliche Arten von Schaden anzurichten. Ein Wurf mit dem 20-seitigen Würfel wird für die meisten Abfragen (etwa für den Angriff) verwendet, dabei wird er von verschiedenen Modifikatoren beeinflusst. Das Ergebnis des Angriffswurfs wird dann mit der Rüstungsklasse des Gegners abgeglichen, wenn der Wurf gleich der AC oder höher ist, trifft das Monster mit seiner Attacke. Es gibt allerdings zwei wichtige Sonderfälle: Wird eine 1 gewürfelt, dann ist der Angriff in jedem Fall erfolglos (kritischer Fehlschlag aka. Critical Miss). Wird hingegen eine 20 gewürfelt, trifft die Attacke automatisch (ein kritischer Treffer aka. Critical Hit). Ein kritischer Treffer ist sogar so erfolgreich, dass der Angreifer den Schaden der Waffe doppelt auswürfeln darf.

Es gibt darüber hinaus auch noch andere Arten von Attacken, etwa magische Angriffe oder die Odemwaffe eines Monsters, etwa ein Drache, der Feuer speit. Diese Angriffe „passieren“ einfach, für sie muss kein Angriff gewürfelt werden. Stattdessen hat das Opfer einen Rettungswurf (Saving Throw): Es wirft einen w20 (mit entsprechenden Modifikatoren), um sich zu retten oder dem Angriff zumindest ein wenig auszuweichen. Das Ergebnis des Wurfs wird mit einem feststehenden Schwierigkeitsgrad (DC) abgeglichen. In manchen Fällen hängt der Schwierigkeitsgrad auch von den Fähigkeiten des Angreifers ab.

Der alte grüne Drache kann beispielsweise folgende Attacken durchführen. Er kann …

  • … seine Klauen zum Angriff nutzen: Der entsprechende Wurf, um einen Gegner innerhalb von 10 Fuß (drei Metern) zu treffen, ist 1w20 + 15, wobei der Modifikator auf den Fähigkeiten und Fertigkeiten des Drachens basiert. Wenn er trifft, bekommt das Opfer entweder einen durchschnittlichen Hiebschaden von 22 oder 4w6 + 8 Punkte, also 22 (4w6 + 8).
  • … seine Bissattacke nutzen: Der entsprechende Wurf, um einen Gegner innerhalb von 15 Fuß (4,5 Metern) zu treffen, ist 1w20 + 15. Wenn er trifft, bekommt das Opfer entweder 19 (2w10 + 8) Stichschaden und zusätzlich 10 (3w6) Giftschaden.
  • … seine Schwanzattacke einsetzen: Der entsprechende Wurf, um einen Gegner innerhalb von 20 Fuß (6 Metern) zu treffen, ist 1w20 + 15. Wenn er trifft, bekommt das Opfer 17 (2w8 + 8) Wuchtschaden.
  • … seinen Giftodem nutzen, eine spezielle Drachenfähigkeit. Der Erklärungstext folgt dabei dem gleichen Muster wie die meisten Angriffe, die einen Rettungswurf nach sich ziehen: „Der Drache speit giftiges Gas in einem Kegel mit 90 Fuß Reichweite (rund 30 Meter) aus. Jede Kreatur innerhalb dieses Bereichs muss einen DC-22-Konstitutions-Rettungswurf machen. Ein Scheitern bedeutet 77 (22w6) Giftschaden, ein erfolgreicher Rettungswurf halbiert den Schaden.

Ein alter grüner Drache ist ein beängstigendes Biest. Zudem kann er pro Kampfrunde zwei Klauenattacken und eine Bissattacke machen. Abenteurer sind einfach wahnsinnig – das muss mal gesagt werden. Ein Kampf folgt einer recht simplen Struktur:

  1. Es gibt möglicherweise eine Überraschungsrunde, wenn Angreifer versteckt sind oder sich versteckt bewegen.
  2. Die Teilnehmer am Kampf würfeln einen Initiativwert aus, der bestimmt, in welcher Reihenfolge Aktionen durchgeführt werden können.
  3. Dann startet eine Kampfrunde, bei der die Teilnehmer je nach Initiativwert ihre Angriffe durchführen.
  4. Schritt 3 wird so oft wiederholt, bis der Kampf endet.

In einem regulären Spiel kann der Kampf aus unterschiedlichsten Gründen beendet werden. Aber meine Methode geht hier wie bereits erwähnt nach dem Highland-Modell: Der Kampf ist beendet, wenn nur noch eine Kreatur stehenbleibt.

Die richtigen Fragen stellen

Bei der Entscheidung, was genau ich messen sollte, habe ich großartige Hilfe von meinem Ehemann erhalten, der eine sehr viel deutlichere Meinung zu Statistiken und Dashboards hat als ich. Er sagte: „Jeder kann bedeutungslose Daten schön auf einem Dashboard darstellen. Konzentriere dich lieber auf die Dinge, die dich wirklich weiterbringen.“ Mit anderen Worten: Miss keine Daten und kreiere keine Dashboards, deren Ergebnisse dich nicht weiterbringen.

Auf der einen Seite war ich natürlich neugierig. Ich wollte wissen, wie die einzelnen Teile des Systems zusammenarbeiten. Wie viele Runden dauert ein Kampf im Schnitt? Wie oft sind Attacken erfolgreich? Welche Art von Waffen macht den meisten Schaden? Welches ist das tödlichste, welches das harmloseste Monster?

Auf der anderen Seite konnten viele Faktoren die Resultate der Kämpfe verändern. Das Verhalten der Würfel, die Art und Anzahl der Kreaturen, die in den Kampf verwickelt waren, die Auswahl, wer als Nächstes angegriffen wird, und ob der durchschnittliche Schaden genutzt oder ausgewürfelt (variabel ermittelt) wird. Einer der Grundsätze des Monitorings von Anwendungen ist es, zu wissen, was mit der Anwendung los ist – und zwar lediglich durch Betrachten von offengelegten Messungen. Welche Faktoren beeinflussen die Dauer eines Kampfes? Würde es einen sichtbaren Unterschied machen, wenn ich die Art und Weise verändere, wie Ziele gewählt werden?

Kernmechaniken: Wie die Würfel fallen

Das Erste, das ich messen wollte, war das Verhalten der Würfel. Bedenkt man, wie essenziell das Würfeln für den Gameflow ist, fand ich es wichtig, die Verteilung der Würfe zu beobachten. Wären die Ergebnisse sehr ungleichmäßig, müsste ich (nach der Logik meines Mannes) den Quellcode für den Zufallswert ändern oder einen anderen Algorithmus für das Würfeln verwenden. Beim Refactoring der Game Engine habe ich eine Utility-Klasse erstellt, die sämtliche Würfelwürfe übernimmt. Ein einfacher Injection-Punkt erlaubt es, sämtliche Würfelergebnisse aufzuzeichnen. Mit Micrometer können beispielsweise die Ergebnisse via Zähler gemessen werden:

Dice.setMonitor((k, v) -> registry.counter("dice.rolls", "die", k, "face", label(v)).increment());

Die Methode label konvertiert den Integer-Wert eines Wurfs (9) in einen sogenannten Padded String (09), der dann als Wert für das Label face genutzt wird. Die gesammelten Prometheus-Daten für die Würfelwürfe dieses Zählers sehen dann wie folgt aus:

dice_rolls_total{die="d10",face="08",} 16750.0
dice_rolls_total{die="d10",face="09",} 16724.0
dice_rolls_total{die="d10",face="06",} 16804.0

In Abbildung 1 sieht man das erste Grafana-Dashboard, das mit diesen Daten gefüttert wurde.

Abb. 1: Graph für die Wurffrequenz, erster Versuch

Der Graph für den 12-seitigen Würfel sollte direkt ins Auge springen, denn er ist komplett anders als die anderen. Das liegt daran, dass es hier einen Bug gab. Obwohl ich Tests geschrieben habe, um sichergehen zu können, dass sämtliche Arten von Würfelwurfformularen geparst werden können, habe ich keine Tests geschrieben, um sicherzustellen, dass jeder Würfel auch korrekt verwendet wurde. In einem klassischen Facepalm-Moment entdeckte ich, dass in einem Switch Statement ein Case fehlte. Das stellte zugleich auch den ersten Sieg für aggregierte Metriken dar, denn den Bug hätte ich niemals gefunden, hätte ich nur Lognachrichten verwendet: Es wären einfach zu viele Daten gewesen, und mit den Tests hatte ich es nicht herausgefunden. Allerdings muss ich fairerweise zugeben, dass der Fehler im Pair Programming vermutlich nicht passiert wäre, aber ich habe nun mal allein gearbeitet. Betrachtet man es aus der Perspektive meines Talks, hätte ich mich nicht besser vorbereiten können.

Die aktualisierte Fassung des Dashboards ist in Abbildung 2 zu sehen. Man beachte hierbei vor allem die unterschiedlichen Skalierungen zwischen den y-Achsen in den Graphen. Der w20, der genutzt wird, um zu bestimmen, ob eine Attacke trifft oder nicht, wird öfter als jeder andere Wurf durchgeführt.

Abb. 2: Graph für die Wurffrequenz, zweiter Versuch

Kämpfe

Der Monsterkampf hat zwei Applikationen: Eine Spring-Boot- und eine Quarkus-Applikation. Beide nutzen die Core Engine für das Erstellen und Durchführen der Begegnungen und Kampfrunden. Der entsprechende Code beider Anwendungen ist in Listing 1 zu sehen.

Encounter encounter = beastiary.buildEncounter()
  .setHowMany(howMany)
  .setTargetSelector(pickOne(howMany))
  .build();
 
List<RoundResult> results = new ArrayList<>();
while (!encounter.isFinal()) {
  RoundResult result = encounter.oneRound();
  metrics.endRound(result);
  results.add(result);
}
metrics.endEncounter(encounter, results.size());

Hier einige Erklärungen zu den Elementen im Snippet: Das bestiary enthält sämtliche Kreaturen, während howMany ein selbsterklärender Parameter ist. Das Objekt metrics erlaubt es einer Anwendung, die Ergebnisse in der gewünschten Form zu messen. Ich wollte eigentlich Micrometer, MP Metrics und OpenTelemetry verwenden, aber deren Einschränkungen machten es unmöglich, diesen Weg einzuschlagen. Die Liste der gerundeten Ergebnisse wird an den Client in einer HTTP-Nachricht zurückgegeben. Listing 2 zeigt den Logeintrag für eine Runde eines Kampfes.

: oneRound:
  Troll(LARGE GIANT){AC:15,HP:84(8d10+40),STR:18(+4),DEX:13(+1),CON:20(+5),INT:7(-2),WIS:9(-1),CHA:7(-2),CR:5,PP:12}(31/86.0)
  Pit Fiend(LARGE FIEND){AC:19,HP:300(24d10+168),STR:26(+8),DEX:14(+2),CON:24(+7),INT:22(+6),WIS:18(+4),CHA:24(+7),SAVE:[DEX(+8),CON(+13),WIS(+10)],CR:20,PP:14}(313/313.0)
 
: attack: miss: Troll(36) -> Pit Fiend(100)
: attack: miss: Troll(36) -> Pit Fiend(100)
: attack: hit> Troll(36) -> Pit Fiend(97) for 9 damage using Claws[7hit,11(2d6+4)|slashing]
: attack: hit> Pit Fiend(97) -> Troll(10) for 22 damage using Bite[14hit,22(4d6+8)|piercing]
: attack: MISS: Pit Fiend(97) -> Troll(10)
: attack: HIT> Pit Fiend(97) -> Troll(0) for 34 damage using Mace[14hit,15(2d6+8)|bludgeoning]
 
: oneRound: survivors
  Pit Fiend(LARGE FIEND){AC:19,HP:300(24d10+168),STR:26(+8),DEX:14(+2),CON:24(+7),INT:22(+6),WIS:18(+4),CHA:24(+7),SAVE:[DEX(+8),CON(+13),WIS(+10)],CR:20,PP:14}(304/313.0)

Das Ergebnis zeigt die Attributwerte für einen Troll und einen Höllenschlundteufel inklusive deren aktuelle Trefferpunkte zu Beginn der Kampfrunde. Der Troll hat lediglich 31 seiner eigentlich 86 Trefferpunkte übrig (36 % Gesundheit), während der Höllenschlundteufel bislang noch keinen Schaden bekommen hat (100 % Gesundheit). Der Troll hat es zwei Mal nicht vermocht, die Rüstungsklasse des Höllenschlundteufels (19) zu übertreffen, als er mit seinen Klauen angegriffen hat. Dieser wiederum hat den Troll einmal erfolgreich gebissen (15 Rüstungsklasse übertroffen) und dann eine 1 gewürfelt – kritischer Fehlschlag mit dem Streitkolben. Doch die dritte Attacke, die wieder mit der Nahkampfwaffe durchgeführt wurde, war ein kritischer Treffer (20 auf dem w20 gewürfelt). Infolgedessen wurde der Troll recht schnell besiegt und hatte keine Überlebenschance.

Treffer und Fehlschläge

Wie bereits erwähnt, war die Frage nach der Trefferwahrscheinlichkeit – also wie oft Angriffe erfolgreich sind – eine, die ich beantworten wollte. Was kommt öfter vor, Treffer oder Fehlschläge? Das Log-Snippet zeigt alle vier Arten von Resultaten nach einem Angriffswurf:

  • HIT> – Ein kritischer Treffer (20 gewürfelt)
  • MISS: – Ein kritischer Fehlschlag (1 gewürfelt)
  • hit> – Ein Treffer: Entweder war der Angriffswert (Wurf mit w20 plus Modifikatoren) höher oder gleich der Rüstungsklasse des Gegners, oder es war eine magische Attacke, die einen Rettungswurf nach sich zieht (und immer trifft).
  • miss: – Ein Fehlschlag: Der Angriffswert war geringer als die Rüstungsklasse des Opfers.

Um die Treffer und Fehlschläge zu messen, habe ich den folgenden Code verwendet, um drei Booleans (hit, critical und saved) in einen Stringwert für ein einzelnes hitOrMiss-Label zu konvertieren:

String hitOrMiss() {
  return (isCritical() ? "critical " : "")
         + (isSaved() ? "saved " : "")
         + (isHit() ? "hit" : "miss");
}

Ich erstellte eine Zusammenfassung der Verteilung, in der die Schwierigkeit des Angriffs gemessen wurde: Der Wert hierfür war entweder die Rüstungsklasse für einen Waffenangriff oder der Schwierigkeitsgrad für eine Attacke, die einen Rettungswurf nach sich zieht. Zu diesem Messwert fügte ich zwei Labels hinzu: Das Label hitOrMiss und ein weiteres, um die Art des Angriffs zu bestimmen: attack-ac für einen Angriff mit einer Waffe gegen die AC des Opfers oder attack-dc für eine magische Attacke, die einen Rettungswurf nach sich zieht.

registry.summary("attack.success", 
                         "attackType", event.getAttackType(),
                         "hitOrMiss", event.hitOrMiss())
  .record((double) event.getDifficultyClass());

Die Prometheus-Daten für diese Zusammenfassung der Verteilung sind in Listing 3 zu sehen.

# HELP attack_success
# TYPE attack_success summary
attack_success_count{attackType="attack-ac",hitOrMiss="miss",} 65.0
attack_success_sum{attackType="attack-ac",hitOrMiss="miss",} 1124.0
attack_success_count{attackType="attack-ac",hitOrMiss="critical hit",} 13.0
attack_success_sum{attackType="attack-ac",hitOrMiss="critical hit",} 229.0
attack_success_count{attackType="attack-ac",hitOrMiss="critical miss",} 10.0
attack_success_sum{attackType="attack-ac",hitOrMiss="critical miss",} 179.0
attack_success_count{attackType="attack-dc",hitOrMiss="hit",} 6.0
attack_success_sum{attackType="attack-dc",hitOrMiss="hit",} 92.0
attack_success_count{attackType="attack-dc",hitOrMiss="saved hit",} 9.0
attack_success_sum{attackType="attack-dc",hitOrMiss="saved hit",} 134.0
attack_success_count{attackType="attack-ac",hitOrMiss="hit",} 133.0
attack_success_sum{attackType="attack-ac",hitOrMiss="hit",} 2050.0
# HELP attack_success_max
# TYPE attack_success_max gauge
attack_success_max{attackType="attack-ac",hitOrMiss="miss",} 22.0
attack_success_max{attackType="attack-ac",hitOrMiss="critical hit",} 22.0
attack_success_max{attackType="attack-ac",hitOrMiss="critical miss",} 20.0
attack_success_max{attackType="attack-dc",hitOrMiss="hit",} 22.0
attack_success_max{attackType="attack-dc",hitOrMiss="saved hit",} 19.0
attack_success_max{attackType="attack-ac",hitOrMiss="hit",} 20.0

Als ich den ersten Graphen zu diesen Daten erstellte, habe ich schon beim Blick auf die Legende ein Problem gefunden – in Abbildung 3 könnt ihr selbst auf die Suche gehen.

Abb. 3: Angriffserfolg, erster Versuch

Man muss schon ein kleiner D&D-Nerd sein, um diesen Fehler zu finden, aber so etwas wie einen „kritischen Rettungstreffer“ gibt es nicht. Ich musste also irgendwo noch einen zweiten Bug haben. Diesen zu finden, erwies sich als eines von diesen „6-Phasen-des-Debubbungs“-Abenteuern. Ich habe definitiv die Phase „Wie konnte dieses Programm je funktionieren“ dabei erreicht. Nach einigem Refactoring und ein paar Reparaturarbeiten habe ich einen weiteren Versuch der Visualisierung des Angriffserfolgs gestartet. Das Ergebnis zeigt Abbildung 4.

Abb. 4: Angriffserfolg, zweiter Versuch

Abb. 4: Angriffserfolg, zweiter Versuch

Leider war immer noch etwas nicht korrekt. Laut der Würfelmechanismen sollten Fehlschläge deutlich häufiger vorkommen als kritische Treffer oder kritische Fehlschläge. Es hat einige Zeit beansprucht, um herauszufinden, was schieflief, da der Fehler nicht im Anwendungscode verortet war. Schließlich fand ich heraus, dass ich einen Fehler in der Konvertierung von HTML zu JSON gemacht hatte. Reguläre Ausdrücke sind grandios, aber auch verdammt gefährlich: Ich hatte ein simples + beim Parsen der Rüstungsklasse vergessen. Dadurch war die höchste Rüstungsklasse 9! Woops. Ein weiterer Sieg für die aggregierten Metriken. Nachdem ich den Fehler behoben hatte, konnte ich das Dashboard aus Abbildung 5 erstellen.

Abb. 5: Angriffserfolg, dritter Versuch

Was genau zeigt uns dieses Board nun? Angriffe treffen eher öfter, als dass sie danebengehen. Kritische Treffer und Fehlschläge sind (wie erwartet) gleichermaßen unwahrscheinlich. Während Angriffe, die einen Rettungswurf nach sich ziehen (attack-dc), nicht regelmäßig stattfinden, zeigt sich deutlich: Der Rettungswurf gelingt häufiger, als dass er misslingt.

Betrachtet man den unteren Graphen, so entspricht der durchschnittliche Schwierigkeitsgrad oder die Rüstungsklasse für Treffer und Fehlschläge dem, was man erwarten würde: Attacken, die trafen, trafen gegen eine recht niedrige AC, Fehlschläge erfolgten gegen eine recht hohe Rüstungsklasse. Die Regelmäßigkeit der erfolgreichen Rettungswürfe erklärt sich durch einen generell recht niedrigen Schwierigkeitsgrad. Die Wahrscheinlichkeit eines kritischen Treffers oder kritischen Fehlschlags ist durchschnittlich. Das ergibt Sinn, immerhin ist es komplett von dem Wurf mit dem Würfel abhängig, ob man trifft oder daneben schlägt.

Schaden

Das nächste, worüber ich sprechen werde, ist der Schaden. Wie viel Schaden machen die Attacken im Schnitt? Sind meine Arten von Schaden höher als andere? Um diese Fragen zu beantworten, habe ich eine neue Zusammenfassung der Verteilung erstellt, die den Schaden jeder Attacke aufzeichnet. Getaggt werden diese Ergebnisse je nachdem, ob sie trafen oder nicht, ob es ein Waffenangriff gegen die AC oder ein magischer Angriff mit Rettungswurf war und welche Schadensart die Attacke hatte (Wucht, Stich, Hieb, Gift usw.):

registry.summary("round.attacks", 
                         "hitOrMiss", event.hitOrMiss(),
                         "attackType", event.getAttackType(),
                         "damageType", event.getType())
  .record((double) event.getDamageAmount());

Die daraus resultierenden Prometheus-Daten sehen in etwa wie das Beispiel in Listing 4 aus.

# HELP round_attacks
# TYPE round_attacks summary
round_attacks_count{attackType="attack-ac",damageType="bludgeoning",hitOrMiss="hit",} 49.0
round_attacks_sum{attackType="attack-ac",damageType="bludgeoning",hitOrMiss="hit",} 684.0
round_attacks_count{attackType="attack-ac",damageType="fire",hitOrMiss="miss",} 6.0
round_attacks_sum{attackType="attack-ac",damageType="fire",hitOrMiss="miss",} 0.0
round_attacks_count{attackType="attack-ac",damageType="slashing",hitOrMiss="critical hit",} 9.0
round_attacks_sum{attackType="attack-ac",damageType="slashing",hitOrMiss="critical hit",} 216.0
round_attacks_count{attackType="attack-ac",damageType="fire",hitOrMiss="hit",} 2.0
round_attacks_sum{attackType="attack-ac",damageType="fire",hitOrMiss="hit",} 41.0
...
 
# HELP round_attacks_max
# TYPE round_attacks_max gauge
round_attacks_max{attackType="attack-ac",damageType="bludgeoning",hitOrMiss="hit",} 31.0
round_attacks_max{attackType="attack-ac",damageType="fire",hitOrMiss="miss",} 0.0
round_attacks_max{attackType="attack-ac",damageType="slashing",hitOrMiss="miss",} 0.0
round_attacks_max{attackType="attack-ac",damageType="fire",hitOrMiss="critical hit",} 30.0
round_attacks_max{attackType="attack-ac",damageType="slashing",hitOrMiss="critical miss",} 0.0
round_attacks_max{attackType="attack-ac",damageType="slashing",hitOrMiss="critical hit",} 32.0
round_attacks_max{attackType="attack-ac",damageType="fire",hitOrMiss="hit",} 22.0
round_attacks_max{attackType="attack-ac",damageType="slashing",hitOrMiss="hit",} 24.0
...

Erstellt man Daten wie diese, beginnt man die Mächtigkeit von „Labels“ in dimensionalen Zeitreihendaten zu schätzen. Obwohl der Typ damage ein festes Set von Daten ist, hat er sehr viel mehr mögliche Werte als hitOrMiss oder attackType, was in vielen einzigartigen Permutationen resultiert. Sobald dieser Datensatz mit zusätzlichen Labels kombiniert wurde, die von Prometheus für service/job-Instanzen hinzugefügt wurden, hat Grafana Probleme gemacht. Am Ende habe ich die Regeln für das Reporting von Prometheus verwendet, um eine neue Zeitreihe auszugeben, die die durchschnittliche Rate des Anstiegs der Schadenshöhe pro Sekunde über einen bestimmten Zeitraum (in meinem Fall 15 Minuten) enthält. Ganz nebenbei zeigt dies auch die Auswirkungen der Aggregation: Es ist unmöglich, von dieser Rate, die auf der Grundlage von Input aus mehreren Quellen berechnet wird, rückwärts zu einem einzelnen aufgezeichneten Ergebnis zu arbeiten.

Das Dashboard in Abbildung 6 enthält zwei Diagramme, die den „Zuwachs“ des Schadenswerts über 15 Minuten innerhalb der letzten 6 Stunden, basierend auf dem Angriffstyp, zeigen. Der Wert für den Anstieg berechnet sich aus einer Multiplikation des Ratenwerts mit dem Zeitintervall, was die Werte in eine für Menschen lesbare und verständliche Skala übersetzt (die Y-Achse). Wenn ich nun damit anfange, dann müsste ich auch Logeinträge oder verteiltes Tracing nutzen, weshalb diese auch essenzielle Elemente der ganzen Observability-Geschichte sind, aber ich schweife ab.

Abb. 6: Schaden je nach Angriffstyp

Die Graphen in diesem Dashboard zeigen, dass der durchschnittliche Angriffsschaden dem entspricht, was wir im Hinblick auf den Angriffstyp erwarten würden: Kritische Treffer machen im Schnitt den doppelten Schaden wie reguläre Waffenangriffe gegen die Rüstungsklasse des Opfers. Der durchschnittliche Schaden für magische Angriffe, die einen Rettungswurf nach sich ziehen, ist etwas variabler, da ein erfolgreicher Rettungswurf den Schaden halbiert. Die Zahlen sehen aber auch so aus, wie wir es erwartet hätten. Eine Sache, die ich vorher nicht bedacht habe, ist, dass diese Angriffe quasi automatische kritische Treffer sind. Aua! Gut, dass diese vergleichsweise selten sind und der Rettungswurf öfter klappt, als dass er danebengeht.

Abb. 7: Schaden je nach Schadenstyp

Welche Schadenstypen kommen nun am häufigsten vor? Welche sind besonders gefährlich? Mit unterschiedlichen Dimensionen der gleichen Daten lässt sich das in Abbildung 7 gezeigte Dashboard erstellen. Die dort enthaltenen Graphen zeigen, dass Gift- und Blitzangriffe eher rar gesät sind, allerdings dafür ungewöhnlich hohen Schaden verursachen. Der Schaden von Hieb-, Stich- und Wuchtwaffen (die gängigsten Schadensarten) rangieren im Mittel und zeigen praktisch keine ungewöhnlichen Ausschläge im Vergleich mit anderen Schadenstypen.

Abb. 8: Gift- und Blitzschaden je nach Angriffstyp

Die Schadenstypen Blitz und Gift stellt Abbildung 8 noch einmal gesondert dar. Die überwiegende Mehrheit an Blitzschaden resultiert aus Angriffen, die einen Rettungswurf (Geschicklichkeit) erlauben. Giftschaden ist andererseits gleichermaßen das Resultat der unterschiedlichen Angriffsarten (gegen die Rüstungsklasse oder als automatischer Treffer, der einen Rettungswurf nach sich zieht).

Abb. 9: Gift- und Blitzschaden je nach Angriffs- und Treffertyp

Abbildung 9 zeigt noch differenziertere Graphen für Gift- und Blitzattacken, insbesondere für kritische Treffer und Treffer, gegen die der Rettungswurf vom Opfer bestanden wurde. Der aufmerksame Beobachter wird erkennen, dass es dort Lücken gibt. Das liegt daran, dass wir es hier mit einer sehr engen Kombination von Faktoren zu tun haben, die so nicht immer gegeben ist. Aus diesen Graphen ziehe ich einige allgemeine Rückschlüsse. Ein kritischer Treffer mit Gift ist extrem schmerzhaft und es gibt nichts, was man dagegen tun kann. Schafft man den Rettungswurf allerdings, dann ist der durchschnittliche Schaden relativ gering, sodass auch Charaktere mit niedrigem Level eine Überlebenschance haben. So oder so ist ein hoher Konstitutionswert eine dringende Empfehlung meinerseits. Ach ja, wenn euer Charakter einen niedrigen Geschicklichkeitswert hat, sollte man Begegnungen mit Monstern meiden, die Blitze werfen können.

Manche Attacken machen keinen Schaden, sondern versetzen das Opfer in einen Zustand – z. B. paralysiert, verängstigt oder bewegungsunfähig. In Abbildung 7 sieht man, dass die Anzahl, wie oft so ein Zustand als Resultat einer Attacke vorkommt, durchaus gezählt wird. Die Effekte davon lassen sich allerdings bei einer reinen Darstellung von „Schaden“ nicht sinnvoll darstellen. Wenn die Kampf-Engine diese Zustände besser verarbeiten würde, könnte man diese vielleicht in eine eigene Kategorie auslagern, um deren Auswirkungen auf einen Kampf besser analysieren zu können. Zum Beispiel verhindern manche dieser Zustände die Nutzung von mehreren Angriffen pro Kampfrunde, was einen deutlichen Einfluss auf den Ausgang des Kampfes hat. So viele Dinge, mit denen man in Zukunft noch spielen kann!

Kämpfe und Kampfrunden

Abgesehen vom Verhalten der Würfel, gab es im vorherigen Abschnitt wenige Daten, die mein Ehemann als „aktional“ bezeichnen würde. Schauen wir doch daher einmal auf Daten, die ein wenig direkter von Entscheidungen der Implementierung beeinflusst werden: Etwa die Anzahl der Runden pro Kampf. Am Ende des Kampfes wird die Anzahl an Runden in einer neuen Zusammenfassung der Verteilung gespeichert:

registry.summary("encounter.rounds",
                         "numCombatants", label(e.getNumCombatants()),
                         "targetSelector", e.getSelector(),
                         "sizeDelta", label(e.getSizeDelta()))
  .record((double) totalRounds);

Für alle, die es noch nicht bemerkt haben: Ich benutze meist Zusammenfassungen von Verteilungen, da sie eine Anzahl und eine Summe bereitstellen, die ich für das Aggregieren von Daten über mehrere Quellen hinaus und das Berechnen des Durchschnitts nutzen kann. Ich bekomme darüber hinaus auch einen Maximalwert, aber das finde ich aus Sicht eines Gesamttrends eher uninteressant. Die Daten, die ich in dieser Zusammenfassung gespeichert habe, sind in Listing 5 zu sehen.

# HELP encounter_rounds
# TYPE encounter_rounds summary
encounter_rounds_count{numCombatants="05",sizeDelta="05",targetSelector="HighestHealth",} 18.0
encounter_rounds_sum{numCombatants="05",sizeDelta="05",targetSelector="HighestHealth",} 136.0
encounter_rounds_count{numCombatants="04",sizeDelta="00",targetSelector="LowestHealth",} 7.0
encounter_rounds_sum{numCombatants="04",sizeDelta="00",targetSelector="LowestHealth",} 58.0
encounter_rounds_count{numCombatants="05",sizeDelta="02",targetSelector="LowestHealth",} 86.0
encounter_rounds_sum{numCombatants="05",sizeDelta="02",targetSelector="LowestHealth",} 775.0
encounter_rounds_count{numCombatants="06",sizeDelta="03",targetSelector="SmallestFirst",} 91.0
encounter_rounds_sum{numCombatants="06",sizeDelta="03",targetSelector="SmallestFirst",} 935.0
encounter_rounds_count{numCombatants="03",sizeDelta="00",targetSelector="Random",} 22.0
encounter_rounds_sum{numCombatants="03",sizeDelta="00",targetSelector="Random",} 157.0
encounter_rounds_count{numCombatants="04",sizeDelta="02",targetSelector="Random",} 95.0
encounter_rounds_sum{numCombatants="04",sizeDelta="02",targetSelector="Random",} 654.0
encounter_rounds_count{numCombatants="05",sizeDelta="04",targetSelector="Random",} 35.0
encounter_rounds_sum{numCombatants="05",sizeDelta="04",targetSelector="Random",} 261.0
encounter_rounds_count{numCombatants="05",sizeDelta="01",targetSelector="SmallestFirst",} 42.0
encounter_rounds_sum{numCombatants="05",sizeDelta="01",targetSelector="SmallestFirst",} 398.0

Zur Erklärung der Labels:

  • numCombatants sollte selbsterklärend sein: Es ist die Anzahl der Kreaturen pro Kampf.
  • sizeDelta stellt die Größendifferenz zwischen der größten und der kleinsten Kreatur im Kampf dar. Der Maximalwert (5) ist dann gegeben, wenn ein gigantisches Monster (5) gegen ein winziges (0) kämpft. Das Delta wird als Wertestring mit zwei Zeichen angegeben.
  • targetSelector enthält die Repräsentation eines Parameters, nach dem eine Kreatur das Ziel für den Angriff wählt: HighestHealth, LowestHealth, BiggestFirst, SmallestFirst, Random und Faceoff (wenn es nur einen Gegner gibt).

Basierend auf den gesammelten Daten können wir nun sehen, wie gewisse Faktoren die Anzahl an Kampfrunden beeinflussen. Meine Annahme war, dass Kämpfe, an denen mehr Kreaturen beteiligt sind, auch länger dauern als solche, bei denen sich weniger Gegner gegenüberstehen. Ist doch klar, oder? Nun, schauen wir uns einmal das Dashboard in Abbildung 10 an.

Abb. 10: Runden pro Kampf je nach Teilnehmerzahl

Die Hypothese hat sich bewahrheitet, die Resultate sind sehr konsistent. Die Verteilung der Kampfbegegnungen auf die Anzahl der Teilnehmer war ziemlich gleichmäßig, und die durchschnittliche Anzahl der Runden pro Begegnung stieg mit der Anzahl der Teilnehmer an diesen an (getestet wurde anhand von Kämpfen, die zwei bis sechs Teilnehmer hatten).

Welche anderen Faktoren beeinflussen die Anzahl der Kampfrunden noch? Um das herauszufinden, konzentrierte ich mich auf Kämpfe mit zwei und auf solche mit vier Teilnehmern.

Bei zwei Teilnehmern stellt sich die Frage, wer wen angreift, natürlich nicht. Ich hätte jedenfalls erwartet, dass ein großer Unterschied zwischen der Größe (etwa eine gigantische gegen eine winzige Kreatur) der Teilnehmer zu einem recht schnellen Ende des Kampfes führen würde. Und ich hatte recht, jedenfalls unterstützen die gesammelten Daten meine These, wenn auch mit leichten Abweichungen, wie Abbildung 11 zeigt.

Abb. 11: Die Anzahl an Kampfrunden für Kämpfe mit zwei Kontrahenten je nach Größenunterschied

Zunächst einmal sieht man, dass es extreme Abweichungen in Sachen Frequenz gibt. Kein Wunder: Die meisten Kreaturen sind mittelgroß, wodurch Kämpfe mit extremen Unterschieden in Sachen Größe recht selten vorkommen. Die Lücken entstehen, da wir auch nach speziellen Konstellationen suchen, in denen nur zwei Kreaturen an einem Kampf beteiligt sind, was nur auf eine sehr geringe Menge an gesammelten Daten zutrifft. Zusammenfassend lässt sich sagen, dass Kämpfe zwischen zwei Kreaturen der gleichen Größe im Schnitt mehr Runden haben als solche, bei denen ein signifikanter Größenunterschied besteht. Stimmt das auch, wenn es mehrere Kampfteilnehmer gibt? Das schauen wir uns in Abbildung 12 an, in der es um Kämpfe mit vier Teilnehmern unterschiedlicher Größe geht.

Abb. 12: Die Anzahl an Kampfrunden für Kämpfe mit vier Kontrahenten je nach Größenunterschied

Das Ergebnis? Eindeutig „Ja“. Es ist manchmal wirklich schön, wenn die eigene Intuition bestätigt wird. Es gibt immer noch ein paar Abweichungen in der Frequenz, aber auch bei vier Teilnehmern dauern Kämpfe unter gleichgroßen Monstern länger.

Beim Schreiben meiner Kampf-Engine musste ich die Entscheidung treffen, wie eine Kreatur determiniert, wen sie angreifen soll. Ob diese Entscheidung einen Unterschied macht? Nun, wie bereits gesagt, habe ich fünf unterschiedliche Methoden geschaffen, nach denen die Kreatur ein Ziel festlegen kann. Sie kann den größten oder kleinsten Gegner angreifen, sich auf den mit den meisten oder wenigsten Wunden stürzen, oder einfach zufällig einen Gegner wählen. Der gewählte Algorithmus galt dann für den gesamten Kampf. Das Tag targetSelector in den gesammelten Daten gibt an, welcher Algorithmus verwendet wurde. Abbildung 13 zeigt die Daten von Kämpfen mit vier Teilnehmern und aufgeschlüsselt nach entsprechender Zielwahl.

Abb. 13: Anzahl an Kampfrunden für Kämpfe vierer Kontrahenten je nach Zielwahlalgorithmus

Das Dashboard zeigt mir einige Dinge, zunächst etwa, dass es eine sehr gleichmäßige Verteilung bezüglich der Auswahlalgorithmen gab. Das ist gut, da wir so weniger wahrscheinlich zu krummen Ergebnissen kommen, die einer seltenen Nutzung einzelner Algorithmen geschuldet sind (siehe y-Achse). Betrachtet man die kumulativen Runden auf dem Balkendiagramm in der Mitte und die Daten auf den Graphen unten, lässt sich sehr deutlich sehen, dass es keine besonders gute Strategie ist, sich gleich auf den größten Gegner zu stürzen. Man sieht weniger Abweichungen bei allen anderen Strategien, doch generell lässt sich sagen, dass die zufällige Gegnerwahl zu kürzeren Kämpfen führt.

Zusammenfassung

Beim Schreiben der Anwendung habe ich viel gelernt – sowohl als neue DM in Bezug auf die Spielmechaniken von Dungeons & Dragons, aber auch als Entwicklerin. Ich bin über das Ausschneiden und Einfügen von Beispielen hinausgegangen und habe meine Fähigkeit, gesammelte Daten zu nutzen und zu verstehen, verbessert. Anwendungsspezifische Metriken haben Bugs aufgezeigt, die meine Tests nicht gefunden haben, und erlaubten mir so, die Wirkung (oder den Mangel einer Wirkung) meiner Entscheidungen hinsichtlich der Implementierung zu evaluieren. In einem realen Szenario würde die Sammlung von Metriken eines Live-Service einfach weiterlaufen und so eine statistische Baseline ergeben, die man dann für Verhaltensänderungen nach einem Update der Anwendung heranziehen könnte.

Ich hatte erwähnt, dass eines der Dinge, die ich tun wollte, der Vergleich der Kapazitäten verschiedener Metrik-Libraries war. Ich habe recht schnell feststellen müssen, dass mir das nicht vergönnt sein würde. Die Java-Bibliothek für Metriken mit OpenTelemetry war zu Beginn des Jahres noch nicht vollständig fertiggestellt. Im September 2020 gedenke ich, auf der J4K eine aktualisierte Version meines Talks zu halten, ich habe also wieder etwas Zeit, das noch einmal auszuprobieren.

Auch mit MicroProfile Metrics gab es ein paar Probleme, da es nur „vorverdaute“ Histogrammwerte ausgibt. Hierdurch ist es leider unmöglich, Prometheus und Grafana für die Berechnung der Durchschnittswerte oder Raten von Daten zu nutzen, die über verschiedene Quellen gesammelt wurden. Daraus ergibt sich, dass die Quarkus-Anwendung auch Micrometer nutzt. Zunächst hat sie die Micrometer-Bibliothek direkt genutzt, aber später habe ich eine Micrometer-Erweiterung für Quarkus erstellt, um zu sehen, wie weit ich mit dem Bereitstellen einer erstklassigen Nutzererfahrung bei der Verwendung von Micrometer mit Quarkus komme.

Wer mit all diesen Dingen spielen und experimentieren will, der findet den gesamten Quellcode in meinem GitHub Repository – über Feedback und auch über Mitarbeit würde ich mich sehr freuen.

Geschrieben von
Erin Schnabel
Erin Schnabel
Erin Schnabel ist Java Champion, Entwicklerin, Advocate und Senior Technical Staff Member bei IBM. Sie lernt (und lehrt), indem sie fast lächerliche Dinge wie „Game On!“ programmiert, ein Text-Adventure für Microservices (https://gameontext.org). Auch ein Workshop zum Thema reaktiver Softwareentwicklung gehört zu ihrem Portfolio, der „den ganzen Quatsch“ verschleiert (https://github.com/IBM/reactive-code-workshop).
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: