Zum Inhalt
wannacode

Das ganze Spiel und die Balance

Kurz gesagt: Die Balance misst man mit einer Simulation: Tausende Kämpfe mit Startwert, eine Siegquote für jedes Paar. Die Zahlen werden einzeln mit einem Durchlauf beim selben Startwert eingestellt, und ein Test mit aufgezeichnetem Durchgang des ganzen Spiels fängt jede Änderung im Verhalten ab.

Problem

Ist das Spiel gut?

Das Spiel ist fertig. Die Karte deckt sich auf, Kämpfe laufen nach Tempo, Wunden bluten, Feinde jagen, ein Spielstand lädt. Was bleibt, ist eine Frage, auf die der Code nicht antwortet: Ist es gut? Kann die Hexe den Wächter der Tiefe besiegen, oder spaziert der Barbar durch ihn hindurch? Wenn du spielst, kämpfst du zehnmal und hast eine Meinung. Aber zehn Kämpfe sind Rauschen: In dieser Lektion ergibt ein und dieselbe Frage („Barbar gegen den Wächter“) bei einem Startwert 30 % und bei einem anderen 55 %, wenn es nur 20 Kämpfe sind.

Wir brauchen einen Weg, Tausende Kämpfe in Sekunden zu spielen, jedes Mal auf dieselbe Art und ohne etwas zu zeichnen. Das ist eine Simulation: Kämpfe laufen ohne Bildschirm, KI gegen KI, und das Ergebnis ist eine Tabelle von Prozentzahlen. Damit findet man die Zahlen, die das Spiel noch nicht hat: wie viel Leben der Wächter braucht, um schwer, aber schlagbar zu sein.

Die Lektion macht es zweimal. Zuerst an einem kleinen Spielzeug, damit man jeden Schritt sieht und es in vier Sprachen laufen lassen kann: der Kampf, die Tabelle, das Rauschen, das Einstellen und der aufgezeichnete Test. Dann am echten Spiel, das schon --simulate hat. Am Ende stehen das ganze Spiel zum Spielen und Herunterladen und Ideen zum Weitermachen.

Die Idee

Ein Kampf ohne Bildschirm

Die Regeln eines Kampfes im Spiel wissen nichts vom Bildschirm: Der Bildschirm ruft sie nur auf und zeichnet das Ergebnis (die Lektion über den Bildschirmstapel). Also können dieselben Regeln auch ohne Bildschirm laufen. Das Spielzeug unten ist das kleinste solche Ding: drei Helden gegen vier Feinde, jeder mit vier Zahlen (Leben, Treffsicherheit, kleinster und größter Schaden). Es hat kein Tempo, keine kritischen Treffer und keine Effekte: Die stehen im Kampfsystem, und hier braucht man nur die Form.

Zuerst der Generator. Das Spiel benutzt mulberry32 aus der ersten Lektion; dieser hier ist noch kleiner (nach Park und Miller), damit er hineinpasst, tut aber dasselbe: derselbe Startwert, dieselben Zahlen.

class Rng
{
long state;
public Rng(long seed)
{
state = seed;
}
public int Next(int n)
{
state = state * 48271 % 2147483647;
return (int)(state % n);
}
}

state * 48271 ist größer als ein int, darum ist der Zustand ein long. Der Rest % n ergibt eine Zahl von 0 bis n − 1.

Der Schlag und der Kampf:

// Ein Schlag: 0 bei einem Fehlschlag, sonst der Schaden. Der Wurf geht von 0 bis 99.
static int Strike(Rng rng, Fighter attacker)
{
if (rng.Next(100) < attacker.Hit)
{
return attacker.Min + rng.Next(attacker.Max - attacker.Min + 1);
}
return 0;
}
// Der Kampf: Der Held schlägt zuerst. Er endet, wenn jemand kein Leben mehr hat.
static Result Fight(Rng rng, Fighter hero, Fighter enemy)
{
int heroLife = hero.Hp;
int enemyLife = enemy.Hp;
int turns = 0;
while (true)
{
turns++;
enemyLife -= Strike(rng, hero);
if (enemyLife <= 0) return new Result(true, turns);
heroLife -= Strike(rng, enemy);
if (heroLife <= 0) return new Result(false, turns);
}
}

Ein Schlag würfelt eine Zahl von 0 bis 99. Ist sie kleiner als die Treffsicherheit (hit), ist es ein Treffer, und der Schaden ist eine Zufallszahl zwischen dem kleinsten und dem größten. Sonst ist es ein Fehlschlag. Der Kampf gibt zuerst dem Helden einen Zug, dann dem Feind, bis jemand kein Leben mehr hat. Er gibt Sieg oder Niederlage zurück und wie viele Züge der Held gemacht hat.

// Ein Schlag: 0 bei einem Fehlschlag, sonst der Schaden. Der Wurf geht von 0 bis 99.
static int Strike(Rng rng, Fighter attacker)
{
    if (rng.Next(100)  attacker.Hit)
    {
        return attacker.Min + rng.Next(attacker.Max - attacker.Min + 1);
    }
    return 0;
}

Die Idee

Die Balance-Tabelle

Ein Kampf sagt nichts. Darum lässt man Kämpfe viele Male mit einem gemeinsamen Generator laufen: Die Siege und Züge des Helden werden addiert.

static int Percent(int wins, int fights)
{
return wins * 100 / fights;
}
static string Cell(int wins, int fights, int turns)
{
int tenths = turns * 10 / fights;
return Percent(wins, fights) + "% " + tenths / 10 + "." + tenths % 10;
}
// Viele Kämpfe mit einem gemeinsamen Generator: Siege und Züge des Helden.
static Tally Duels(Rng rng, Fighter hero, Fighter enemy, int fights)
{
int wins = 0;
int turns = 0;
for (int i = 0; i < fights; i++)
{
Result result = Fight(rng, hero, enemy);
if (result.Win) wins++;
turns += result.Turns;
}
return new Tally(wins, turns);
}

Die Quote ist Ganzzahldivision (Siege × 100 ÷ Kämpfe, abgerundet), und die mittleren Züge stehen in Zehnteln: turns * 10 / fights, dann X.Y. So gibt es keine Brüche, und die Ausgabe ist in allen vier Sprachen gleich. Die Tabelle ist simulate: Zeilen sind die Feinde, Spalten die Helden.

// Die Tabelle: Zeilen sind die Feinde, Spalten die Helden.
static void Simulate(Fighter[] heroes, Fighter[] enemies, int fights, long seed)
{
var rng = new Rng(seed);
string header = "Feind".PadRight(10);
foreach (Fighter hero in heroes) header += hero.Name.PadLeft(12);
Console.WriteLine(header);
var totals = new int[heroes.Length];
foreach (Fighter enemy in enemies)
{
string line = enemy.Name.PadRight(10);
for (int h = 0; h < heroes.Length; h++)
{
Tally tally = Duels(rng, heroes[h], enemy, fights);
totals[h] += Percent(tally.Wins, fights);
line += Cell(tally.Wins, fights, tally.Turns).PadLeft(12);
}
Console.WriteLine(line);
}
string average = "Mittel".PadRight(10);
foreach (int total in totals) average += (total / enemies.Length + "%").PadLeft(12);
Console.WriteLine(average);
}
Fighter[] heroes = [
new Fighter("Barbar", 40, 70, 4, 9),
new Fighter("Amazone", 40, 80, 3, 7),
new Fighter("Hexe", 24, 85, 3, 8),
];
Fighter[] enemies = [
new Fighter("Ratte", 9, 60, 1, 3),
new Fighter("Wolf", 18, 70, 2, 5),
new Fighter("Golem", 40, 60, 3, 6),
new Fighter("Wächter", 54, 70, 3, 7),
];
Simulate(heroes, enemies, 200, 7);
Ausgabe
Feind Barbar Amazone Hexe
Ratte 100% 2.7 100% 2.7 100% 2.5
Wolf 100% 4.6 100% 5.0 98% 4.4
Golem 98% 9.5 93% 10.6 64% 8.0
Wächter 54% 11.0 35% 11.3 7% 7.2
Mittel 88% 82% 67%

Ratte und Wolf werden fast immer besiegt, der Golem tut der Hexe schon weh. Der Wächter ist der Boss: Der Barbar besiegt ihn zu 54 %, die Amazone zu 35 %, die Hexe zu 7 %. Wenn der Wächter für jede Klasse schwer, aber schlagbar sein soll, liegt eine Heldin außerhalb des Bandes.

Sieh es dir an

Wie viele Kämpfe genügen und wie man eine Zahl einstellt

Die Szene zeigt zwei Fragen. Oben: Wie viele Kämpfe genügen, damit sich die Quote beruhigt? Unten: Wie bringt man die Hexe ins Band, indem man eine Zahl ändert?

// Ein Schlag: 0 bei einem Fehlschlag, sonst der Schaden. Der Wurf geht von 0 bis 99.
static int Strike(Rng rng, Fighter attacker)
{
if (rng.Next(100) < attacker.Hit)
{
return attacker.Min + rng.Next(attacker.Max - attacker.Min + 1);
}
return 0;
}
// Der Kampf: Der Held schlägt zuerst. Er endet, wenn jemand kein Leben mehr hat.
static Result Fight(Rng rng, Fighter hero, Fighter enemy)
{
int heroLife = hero.Hp;
int enemyLife = enemy.Hp;
int turns = 0;
while (true)
{
turns++;
enemyLife -= Strike(rng, hero);
if (enemyLife <= 0) return new Result(true, turns);
heroLife -= Strike(rng, enemy);
if (heroLife <= 0) return new Result(false, turns);
}
}
static int Percent(int wins, int fights)
{
return wins * 100 / fights;
}
static string Cell(int wins, int fights, int turns)
{
int tenths = turns * 10 / fights;
return Percent(wins, fights) + "% " + tenths / 10 + "." + tenths % 10;
}
// Viele Kämpfe mit einem gemeinsamen Generator: Siege und Züge des Helden.
static Tally Duels(Rng rng, Fighter hero, Fighter enemy, int fights)
{
int wins = 0;
int turns = 0;
for (int i = 0; i < fights; i++)
{
Result result = Fight(rng, hero, enemy);
if (result.Win) wins++;
turns += result.Turns;
}
return new Tally(wins, turns);
}
// Die Siegquote mit einem neuen Generator aus dem gegebenen Seed.
static int WinRate(Fighter hero, Fighter enemy, int fights, long seed)
{
Tally tally = Duels(new Rng(seed), hero, enemy, fights);
return Percent(tally.Wins, fights);
}
// Das Balance-Band: Der Wächter wird in 30 bis 60 von 100 Kämpfen besiegt.
static bool InBand(int rate)
{
return rate >= 30 && rate <= 60;
}
// Das Leben der Hexe von 24 bis 40: Jede Zeile hat denselben Seed, nur das Leben ist anders.
static void Sweep(Fighter warden, int fights, long seed)
{
for (int life = 24; life <= 40; life += 4)
{
var witch = new Fighter("Hexe", life, 85, 3, 8);
int rate = WinRate(witch, warden, fights, seed);
Console.WriteLine(witch.Name + ", Leben " + life + ": " + rate + "%" + (InBand(rate) ? " ✓" : ""));
}
}
Schritt für Schritt, in Worten
  1. Ein Kampf sagt nichts. Der Barbar gegen den Wächter gewinnt einmal und verliert einmal: Nach dem ersten Kampf liegt die Quote bei 0 %, nach dem zweiten bei 50 %. Jeder Punkt ist die Siegquote nach so vielen Kämpfen (die Skala ist logarithmisch).
  2. Die Schleife zählt die Siege zusammen: 60 % nach 5 Kämpfen, 37 % nach 8, 41 % nach 12. Die Punkte springen noch, weil jeder Kampf ein Würfelwurf ist.
  3. Die Quote ist Siege × 100 ÷ Kämpfe, mit Ganzzahldivision. Nach 50 Kämpfen sind es 40 %, nach 200 48 %: Die Ausschläge werden kleiner.
  4. Nach 1000 Kämpfen sind es 48 %, und die grüne Linie zeigt, wo es sich eingependelt hat. Dieselbe Frage mit fünf verschiedenen Seeds: Bei 20 Kämpfen liegen die Antworten zwischen 30 % und 55 %, bei 1000 zwischen 48 % und 51 %.
  5. Die Tabelle mit 200 Kämpfen gegen den Wächter: Barbar 54 %, Amazone 35 %, Hexe 7 %. Das Band geht von 30 bis 60: Zwei liegen darin, die Hexe weit darunter.
  6. Einstellen: Nur eine Zahl ändert sich, das Leben der Hexe, und jede Zeile hat denselben Seed. Bei 24 Leben sind es 5 % (die Tabelle gab 7 %: anderer Generator, anderes Glück).
  7. 28 Leben: 13 %, 32: 21 %. Ihr Balken wächst mit dem Leben, liegt aber noch unter 30.
  8. 36 Leben: 36 %, der Balken wird grün: Er liegt im Band. Die kleinste Änderung, die genügt.
  9. 40 Leben: 51 %: Auch das liegt im Band, aber nun besiegt die Hexe den Wächter häufiger als die Amazone (35 %). Der Durchlauf zeigt die ganze Kurve, die Wahl liegt bei dir.
  10. Die Wahl wird geprüft, 36 Leben, mit 1000 Kämpfen: 38 %. Sie liegt weiter im Band, und die Zahl ist stabiler als die mit 200 Kämpfen.

Der Code neben der Szene ist der Kampf, die Schleife über die Kämpfe und der Durchlauf aus den Programmen dieser Lektion. In jedem Schritt leuchten die Zeilen auf, die gerade arbeiten.

Die Idee

Rauschen: dieselbe Frage, ein anderer Startwert

Eine Prozentzahl aus einer Simulation ist eine Schätzung, nicht die Wahrheit. Jeder Startwert gibt eine andere Reihe von Würfen und damit eine andere Zahl. Wie weit liegen die Antworten auseinander? Hier ist ein und dieselbe Frage (Barbar gegen den Wächter) mit fünf Startwerten und zwei Größen:

// Die Siegquote mit einem neuen Generator aus dem gegebenen Seed.
static int WinRate(Fighter hero, Fighter enemy, int fights, long seed)
{
Tally tally = Duels(new Rng(seed), hero, enemy, fights);
return Percent(tally.Wins, fights);
}
Fighter barbarian = new Fighter("Barbar", 40, 70, 4, 9);
Fighter warden = new Fighter("Wächter", 54, 70, 3, 7);
Console.WriteLine(barbarian.Name + " gegen " + warden.Name + ":");
foreach (int fights in new[] { 20, 1000 })
{
string line = fights + " Kämpfe:";
for (long seed = 1; seed <= 5; seed++)
{
line += " " + WinRate(barbarian, warden, fights, seed) + "%";
}
Console.WriteLine(line);
}
Ausgabe
Barbar gegen Wächter:
20 Kämpfe: 50% 45% 55% 50% 30%
1000 Kämpfe: 51% 51% 48% 50% 48%

Bei 5 Startwerten und 20 Kämpfen liegen die Antworten zwischen 30 % und 55 %: eine Spanne von 25 Punkten für ein und dieselbe Frage. Bei 1000 Kämpfen liegen sie zwischen 48 % und 51 %, eine Spanne von 3. Die Regel: Das Rauschen schrumpft mit der Wurzel aus der Zahl der Kämpfe. Für den halben Fehler braucht man viermal so viele Kämpfe.

Zwei verschiedene Dinge darf man nicht vermischen. Wiederholbarkeit: Derselbe Startwert gibt genau dieselbe Zahl, darum sehen ein Test und ein Kollege dasselbe. Rauschen: Zwei verschiedene Startwerte geben verschiedene Zahlen, und die Frage ist, welcher man glaubt. Wiederholbarkeit ist eine Eigenschaft des Codes, Rauschen eine Eigenschaft der Frage.

Beim echten Spiel ist es nicht anders. Ein Lauf mit 200 Kämpfen (wie in der aufgezeichneten Ausgabe der Seitenprüfung) gibt für den Wächter 41 %, 58 % und 55 %, mit 1000 Kämpfen 44 %, 58 % und 54 %. Der Barbar wandert um 3 Punkte, ohne dass am Spiel etwas geändert wurde.

Die Idee

Einstellen: eine Zahl nach der anderen

Schreibe das Ziel auf, bevor du irgendeine Zahl anfasst: Der Wächter wird in 30 bis 60 von 100 Kämpfen besiegt. Dieses Band ist das Maß, keine Meinung des Augenblicks. Die Hexe liegt darunter. Was soll sich ändern? Es gibt viele Möglichkeiten (mehr Leben, ein stärkerer Zauber, ein schwächerer Wächter), aber die Regel ist eine: Ändere eine Zahl und vergleiche beim selben Startwert. Sonst weißt du nicht, welche Änderung gewirkt hat.

Der Durchlauf lässt die Hexe mit 24, 28, 32, 36 und 40 Leben antreten. Jede Zeile hat denselben Startwert, die Zeilen unterscheiden sich also nur im Leben:

// Das Balance-Band: Der Wächter wird in 30 bis 60 von 100 Kämpfen besiegt.
static bool InBand(int rate)
{
return rate >= 30 && rate <= 60;
}
// Das Leben der Hexe von 24 bis 40: Jede Zeile hat denselben Seed, nur das Leben ist anders.
static void Sweep(Fighter warden, int fights, long seed)
{
for (int life = 24; life <= 40; life += 4)
{
var witch = new Fighter("Hexe", life, 85, 3, 8);
int rate = WinRate(witch, warden, fights, seed);
Console.WriteLine(witch.Name + ", Leben " + life + ": " + rate + "%" + (InBand(rate) ? " ✓" : ""));
}
}
Fighter warden = new Fighter("Wächter", 54, 70, 3, 7);
Sweep(warden, 200, 7);
var tuned = new Fighter("Hexe", 36, 85, 3, 8);
int tunedRate = WinRate(tuned, warden, 1000, 7);
Console.WriteLine(tuned.Name + ", Leben 36, 1000 Kämpfe: " + tunedRate + "%" + (InBand(tunedRate) ? " ✓" : ""));
Ausgabe
Hexe, Leben 24: 5%
Hexe, Leben 28: 13%
Hexe, Leben 32: 21%
Hexe, Leben 36: 36% ✓
Hexe, Leben 40: 51% ✓
Hexe, Leben 36, 1000 Kämpfe: 38% ✓

Die Kurve steigt mit dem Leben. Bei 36 Leben steht die Hexe bei 36 % und kommt ins Band; bei 40 sind es 51 %, das ist schon mehr als bei der Amazone (35 %). Man wählt die kleinste Änderung, die genügt: 36. Die letzte Zeile prüft die Wahl mit 1000 Kämpfen: 38 %, immer noch im Band. Die erste Zeile (24 Leben) gibt 5 %, die Tabelle aber 7 % für dieselbe Heldin: ein anderer Zahlenstrom, anderes Glück, nicht ein anderes Spiel.

Nach dem Einstellen zeichnest du den Durchgang des Spiels neu auf (die nächste Karte), um zu sehen, dass sich nur das geändert hat, was du ändern wolltest.

Die Idee

Dasselbe am echten Spiel

Das Spielzeug hat vier Zahlen pro Kämpfer. Das echte Spiel hat Hunderte, aber die Form ist dieselbe, und es hat sie schon eingebaut. Die Region simulate ist dasselbe, nur am echten Spiel:

// Balance: Hunderte Kämpfe ohne Bildschirm, KI gegen KI. Die Stufe des Helden
// passt zur Tiefe des Feindes; die Tabelle zeigt die Siegquote und die durchschnittliche Zahl der Züge.
class SimResult
{
public bool Win;
public int Turns;
}
static partial class Arcanum
{
public static readonly int[] SIM_LEVELS = { 1, 1, 3, 5, 7 };
// Der simulierte Held lernt die niedrigste Stufe des Baums (bei Gleichstand den ersten Zweig).
public static string SimSkillChoice(Hero hero)
{
string best = "";
foreach (string skillId in LearnableSkills(hero))
{
if (best == "" || SKILLS.Get(skillId).Tier < SKILLS.Get(best).Tier) best = skillId;
}
return best;
}
public static Hero MakeSimHero(Game game, HeroClass heroClass, int level)
{
Hero hero = CreateHero(game, Cap(Nm(heroClass)), heroClass.Id);
for (int next = 2; next <= level; next++)
{
RaiseLevel(hero);
string skill = SimSkillChoice(hero);
if (next % 2 == 0 && skill != "") hero.Skills.Add(skill);
else if (heroClass.Id == "barbarian") ApplyLevelChoice(hero, new LevelChoice { Kind = "stat", Stat = "strength", Amount = 1 });
else if (heroClass.Id == "amazon") ApplyLevelChoice(hero, new LevelChoice { Kind = "stat", Stat = "agility", Amount = 1 });
else ApplyLevelChoice(hero, new LevelChoice { Kind = "stat", Stat = "maxHp", Amount = 8 });
}
if (level >= 4)
{
hero.Equipment.Set("weapon", heroClass.BetterWeapon);
hero.Equipment.Set("armor", heroClass.BetterArmor);
}
hero.Hp = hero.MaxHp();
hero.Mana = hero.MaxMana();
hero.Inventory.Add("potion_heal", level / 2);
return hero;
}
public static SimResult SimulateFight(Game game, HeroClass heroClass, EnemyKind kind)
{
game.Hero = MakeSimHero(game, heroClass, SIM_LEVELS[kind.Depth]);
Battle battle = new Battle(game, new List<Enemy> { new Enemy(game.NewId(), kind) }, false);
battle.AutoHero = true;
battle.Advance();
return new SimResult { Win = battle.Outcome == "win", Turns = battle.HeroTurns };
}
public static void Simulate(IGameIo io, int fights, uint seed)
{
Game game = new Game(io, seed);
game.Simulation = true;
io.Write(T("sim.title", ("fights", fights), ("seed", seed)) + "\n");
io.Write(T("sim.levels") + "\n\n");
string header = Pad(T("sim.enemy"), 26) + PadLeft(T("sim.level"), 5);
foreach (HeroClass heroClass in CLASSES.Values()) header += PadLeft(Cap(Nm(heroClass)), 14);
io.Write(header + "\n");
List<int> totals = new List<int>();
for (int i = 0; i < CLASSES.Count; i++) totals.Add(0);
int rows = 0;
foreach (EnemyKind kind in ENEMY_KINDS.Values())
{
if (kind.Depth == 0) continue;
rows++;
string line = Pad(Cap(Nm(kind)), 26) + PadLeft(SIM_LEVELS[kind.Depth].ToString(), 5);
int column = 0;
foreach (HeroClass heroClass in CLASSES.Values())
{
int wins = 0;
int turns = 0;
for (int fight = 0; fight < fights; fight++)
{
SimResult result = SimulateFight(game, heroClass, kind);
if (result.Win) wins++;
turns += result.Turns;
}
int winShare = Percent(wins, fights);
totals[column] += winShare;
column++;
int tenths = (turns * 10) / fights;
line += PadLeft(winShare + "% " + (tenths / 10) + "." + (tenths % 10), 14);
}
io.Write(line + "\n");
}
string footer = Pad(T("sim.average"), 31);
foreach (int total in totals) footer += PadLeft((total / rows) + "%", 14);
io.Write(footer + "\n\n" + T("sim.note") + "\n");
}
}

Die Unterschiede zum Spielzeug dienen dem Realismus, nicht der Idee. Der Held hat eine Stufe nach der Tiefe des Feindes (SIM_LEVELS: 1, 3, 5, 7), lernt Fähigkeiten in einfacher Reihenfolge (simSkillChoice), trägt ab Stufe 4 bessere Ausrüstung und trinkt Tränke; der Kampf ist der echte Battle, gestartet mit autoHero, d. h. der Held auf Autopilot wählt nach der Nutzenbewertung aus der Lektion über die KI. Alles andere ist das Spiel: Nichts wurde für die Simulation abgeschrieben.

Gestartet wird mit einem Schalter: node arcanum.js --simulate=1000 --seed=7 --plain (in den anderen Sprachen genauso). Das ist die Ausgabe, die ich für 1000 Kämpfe pro Paar bekommen habe:

Ausgabe
Feind Stufe Barbar Amazone Hexe
Riesenratte 1 100% 2.4 100% 1.3 100% 1.2
Speergoblin 1 100% 2.4 100% 2.5 100% 2.0
Goblin-Schamane 1 100% 2.4 100% 2.3 100% 2.6
Blutwolf 1 99% 4.0 99% 3.5 92% 3.2
Wolfsführer 3 78% 10.0 93% 7.2 77% 5.8
Skelett 3 99% 11.3 100% 4.4 100% 3.0
Kultist 3 99% 5.9 100% 3.5 100% 3.3
Erzgolem 5 100% 7.6 97% 14.2 99% 7.6
Wächter der Tiefe 7 44% 21.0 58% 19.4 54% 12.3
Mittel 91% 94% 91%

Die Zeile des Wächters ist 44 %, 58 % und 54 %: im Band von 40 bis 60, das wir für das echte Spiel gesetzt haben. So ist es eingestellt: Der Boss ist für jede Klasse schwer, aber schlagbar.

Was passiert, wenn du anfängst, daran zu drehen? Feinde sind Daten, kein Code (die Lektion über Gegenstände und Daten aus Tabellen). Hier ist die Zeile des Wächters:

# id|en|de|es|glyph|color|life|strength|agility|defense|speed|min|max|kind|xp|gold|loot|resist|weak|immune|skill|mood|depth
warden|Warden of the Depths|Wächter der Tiefe|Guardián abisal|W|red|120|5|4|3|10|5|9|blunt|150|50|warden|-|-|bleed,poison|warden|guard|4

Sein Leben ist die Zahl 120. Änderst du sie auf 100 und lässt dieselbe Simulation laufen, wird der Wächter zu 60 %, 70 % und 72 % besiegt statt zu 44 %, 58 % und 54 %. Zwanzig Leben weniger machen den Boss für alle leicht. Die Seitenprüfung lässt auch diese Variante in JavaScript laufen, die Zahlen hier sind also gelaufen, nicht ausgedacht.

Genauer, für Neugierige

Die Simulation ist Teil des Spiels selbst, weil sie Zugriff auf das Innere braucht: Battle und Enemy. Sie ist kein externes Skript, das den Bildschirm liest. Darum lässt die Hauptprüfung der Seite (die nächste Karte) sie auch in vier Sprachen mit Startwert 7 und 200 Kämpfen laufen und vergleicht sie mit einer Datei.

Absichtlich einfach ist noch ein Detail: Der simulierte Held nimmt die niedrigste Stufe des Fähigkeitenbaums. Ein echter Spieler wählt klüger, die Tabelle sagt also, wie schwer der Wächter für einen einfachen Bot ist, nicht für den besten Spieler.

Die Idee

Ein Test mit aufgezeichnetem Durchgang

Woher weiß man, dass die Änderung einer Zahl nichts anderes kaputt gemacht hat? Das Spiel hat einen Test mit aufgezeichnetem Durchgang (golden test, snapshot test). Die Datei playthrough.txt hat 230 Befehle, einen pro Zeile, vom Erstellen des Helden bis zum Sieg über den Wächter. Die ersten vier:

playthrough.txt
1
Iva
1
(Enter)

Sie werden dem Spiel als Eingabe gegeben: --plain --seed=2026 --memory-saves (ohne Farben, mit festem Startwert, Spielstände im Speicher statt in Dateien). Das Spiel druckt jedes Bild, und die Ausgabe hat 4395 Zeilen. Hier sind die Bilder nach den ersten zwei Befehlen, mit > Befehl davor:

> 1
Ein neuer Held
Wie heißt dein Held?
Bis 12 Zeichen: Buchstaben, Ziffern, Leerzeichen, Strich.
> Iva
Ein neuer Held
Iva, wähle eine Klasse:
1) Barbar: Leben 48, Mana 4, Stärke 7, Geschick 4
Eine Axt, deren Wunden bluten. Viel Leben. Fähigkeit: Wut
(Stärke gegen Abwehr).
2) Amazone: Leben 34, Mana 10, Stärke 3, Geschick 8
Speer und Jägerauge: treffsicher, oft kritische Treffer.
Fähigkeit: Doppelschuss.
3) Hexe: Leben 26, Mana 20, Stärke 1, Geschick 5
Wenig Leben, viel Mana. Feuerball, Giftwolke und Heilen.

Die Ausgabe ist in einer Datei gespeichert, playthrough.de.out. Die Seitenprüfung lässt das Spiel in jeder der vier Sprachen mit jeder der vier Textsprachen laufen und vergleicht Byte für Byte mit der Datei: 20 Vergleiche zusammen mit der Simulation. Eine Änderung der Regeln, der Reihenfolge der Würfe oder eines gedruckten Textes ändert die Ausgabe, und die Prüfung schlägt fehl.

Hier ein kleines Modell derselben Sache. Ein Kampf des Barbaren gegen den Wolf wird aufgezeichnet (Startwert 2027); dann wird eine Zahl geändert (der größte Schaden des Wolfs, von 5 auf 6) und das Ergebnis mit der Aufzeichnung verglichen:

// Eine Zeile des Kampfprotokolls.
static string Describe(int turn, string who, string target, int damage, int left)
{
if (damage == 0) return turn + ". " + who + " verfehlt";
return turn + ". " + who + " trifft " + damage + " → " + target + " " + left;
}
static Result Fight(Rng rng, Fighter hero, Fighter enemy, List<string> log)
{
int heroLife = hero.Hp;
int enemyLife = enemy.Hp;
int turns = 0;
while (true)
{
turns++;
int damage = Strike(rng, hero);
enemyLife -= damage;
log.Add(Describe(turns, hero.Name, enemy.Name, damage, Math.Max(0, enemyLife)));
if (enemyLife <= 0) return new Result(true, turns);
damage = Strike(rng, enemy);
heroLife -= damage;
log.Add(Describe(turns, enemy.Name, hero.Name, damage, Math.Max(0, heroLife)));
if (heroLife <= 0) return new Result(false, turns);
}
}
// Die erste Zeile, in der sich die beiden Protokolle unterscheiden (0, wenn sie gleich sind).
static int FirstDifference(List<string> a, List<string> b)
{
for (int i = 0; i < Math.Max(a.Count, b.Count); i++)
{
if (i >= a.Count || i >= b.Count || a[i] != b[i]) return i + 1;
}
return 0;
}
static string LineAt(List<string> lines, int at)
{
return at <= lines.Count ? lines[at - 1] : "-";
}
// Der Test: ein Kampf mit Seed 2027, verglichen mit der Aufzeichnung.
static void Check(string label, List<string> recorded, Fighter hero, Fighter wolf)
{
var log = new List<string>();
Fight(new Rng(2027), hero, wolf, log);
int at = FirstDifference(recorded, log);
if (at == 0)
{
Console.WriteLine(label + ": stimmt, " + log.Count + " Zeilen");
}
else
{
Console.WriteLine(label + ": Zeile " + at);
Console.WriteLine(" erwartet: " + LineAt(recorded, at));
Console.WriteLine(" erhalten: " + LineAt(log, at));
}
}
var recorded = new List<string>
{
"1. Barbar trifft 6 → Wolf 12",
"1. Wolf verfehlt",
"2. Barbar verfehlt",
"2. Wolf verfehlt",
"3. Barbar verfehlt",
"3. Wolf trifft 3 → Barbar 37",
"4. Barbar trifft 4 → Wolf 8",
"4. Wolf trifft 2 → Barbar 35",
"5. Barbar trifft 5 → Wolf 3",
"5. Wolf verfehlt",
"6. Barbar trifft 5 → Wolf 0",
};
Fighter hero = new Fighter("Barbar", 40, 70, 4, 9);
Check("Wolf, maximaler Schaden 5", recorded, hero, new Fighter("Wolf", 18, 70, 2, 5));
Check("Wolf, maximaler Schaden 6", recorded, hero, new Fighter("Wolf", 18, 70, 2, 6));
Ausgabe
Wolf, maximaler Schaden 5: stimmt, 11 Zeilen
Wolf, maximaler Schaden 6: Zeile 6
erwartet: 3. Wolf trifft 3 → Barbar 37
erhalten: 3. Wolf trifft 5 → Barbar 35

Das Programm sagt nicht „es gibt einen Unterschied“, sondern welche Zeile als erste abweicht: Zeile 6 von 11. Bis zu dieser Zeile ist das Verhalten gleich, der Unterschied liegt also dort, wo geändert wurde, und ist in Sekunden gelesen. Die ersten 5 Zeilen sind gleich, weil der Wolf dieselben Zahlen würfelt: Nur der Bereich, aus dem der Schaden genommen wird, hat sich geändert.

Wenn der Test fehlschlägt, gibt es zwei Fälle:

  • Die Änderung war gewollt (du hast eine Zahl eingestellt). Du liest den Unterschied, siehst, dass es nur das ist, was du erwartet hast, und zeichnest den Durchgang neu auf.
  • Die Änderung war unerwartet. Das ist ein Fehler, der sonst bei einem Spieler aufgetaucht wäre.

Zeichne nie neu auf, ohne den Unterschied zu lesen: Dann schützt der Test alles, sogar den Fehler. Und umgekehrt: Ein grüner Test heißt nicht, dass das Spiel richtig oder unterhaltsam ist, nur dass es dasselbe ist wie bei der Aufzeichnung.

Die Idee

Das ganze Spiel

Hier ist, was wir in den Lektionen gebaut haben und wo es im Spiel steht. Die Regionen sind aus der JavaScript-Version, die Zeilen auf oberster Ebene:

LektionRegionenZeilen
game-loopmain game353
screen-stackscreen screen-stack92
screen-resultstarget-screen overlays465
console-renderbuffer terminal hud168
map-fovlevel fov map-load map-screen500
entitiesentity234
combatcombat-order attack battle-screen381
wounds-effectseffects bleeding198
actionsactions replay replay-screen234
aipathfind ai-map ai-battle216
items-lootitems-data inventory equip loot shop267
quests-eventsxp-levelup event-bus quests dialog dialogs-data skills-data296
save-loadsave load311
arcanum-finalsimulate node137

Alles zusammen sind etwa 5500 Zeilen in jeder Sprache; der Rest sind Daten, Texte, ihr Laden und die Bildschirme von Titel, Stadt und Held. Die Tabelle ist keine vollständige Aufteilung, sondern ein Wegweiser: wo du nachsehen kannst, wenn du etwas ändern willst.

Spiel es bis zum Ende (es ist dasselbe Spiel wie in der ersten Lektion) und lade es herunter:

Die Chroniken von Arkanum

Die Mine unter Graufurt schweigt seit drei Tagen. Auf dem Weg ist Blut.

Klicke auf den Bildschirm, um mit den Tasten zu spielen (Pfeile oder WASD, I, M, Ziffern); Enter springt in die Befehlszeile. Spielstände bleiben in diesem Browser.

Die Dateien sind: [Arcanum.java](/games/arcanum/de/Arcanum.java), [Arcanum.cs](/games/arcanum/de/Arcanum.cs), [arcanum.py](/games/arcanum/de/arcanum.py), [arcanum.js](/games/arcanum/de/arcanum.js). Leg eine in einen leeren Ordner und starte sie:

Terminal
dotnet new console -n Arcanum
cd Arcanum
# Program.cs durch Arcanum.cs ersetzen
dotnet run

Mit --simulate=1000 druckt es die Balance-Tabelle, statt zu spielen, mit --seed=42 ist die Welt immer dieselbe, mit --lang=de ist das Spiel auf Deutsch (es gibt auch bg, en und es), mit --plain gibt es keine Farben. Spielstände liegen in einem Ordner saves neben dem Spiel.

Die Idee

Wie du weitermachen kannst

Das Spiel ist so gebaut, dass sich das meiste hinzufügen lässt, ohne die Schleife anzufassen. Einige Ideen, vom Kleinsten zum Größten, mit der Stelle im Code, an der du anfängst:

  1. Ein neuer Feind. Eine Zeile in der Feindtabelle (enemies-data), ein Zeichen und eine Farbe, ein Platz auf der Karte (maps-data). Dann sagt dir die Simulation, ob er zu leicht ist. Im Code wird nichts angefasst.
  2. Eine neue Klasse. Eine Zeile in CLASSES_DATA, Fähigkeiten in skills-data und Texte in vier Sprachen. Die Simulation zeigt die Spalte der neuen Klasse neben den anderen drei. Stelle sie mit dem Durchlauf aus dieser Lektion ein, bis sie im Band liegt.
  3. Eine neue Quest. Ein Dialog in dialogs-data und eine Anmeldung bei den Ereignissen (quests): Der kleine Auftrag „bring Erz“ ist das kürzeste Beispiel im Spiel.
  4. Ein neuer Effekt. Einfrieren oder Verlangsamen in effects: Wähle die Stapelregel (verlängert er sich, addiert er sich) so, wie sie für Bluten und Gift gewählt wurde.
  5. Ein klügerer Feind. Ersetze die Breitensuche durch A* oder ein Entfernungsfeld (siehe „Genauer“ in der Lektion über die KI) oder gib den Wölfen eine Rudeltaktik.
  6. Eine andere Ansicht. Terminal ist eine Schnittstelle mit zwei Arten; eine dritte kann auf eine canvas oder in ein Fenster zeichnen, ohne dass die Bildschirme es merken. Eingabe und Ausgabe sind genau dafür getrennt (io).
  7. Eine Sprache. Die Texte sind Tabellen nach Sprache: Du fügst eine fünfte Sprache mit einer weiteren Kopie der Übersetzungstabelle hinzu; fehlt ein Schlüssel, hält das Spiel mit missing text an, statt ihn still zu überspringen.
  8. Eine neue Portierung. Das Spiel gibt es in vier Sprachen mit einer Ausgabe. Eine fünfte (Rust, Go, Kotlin) wird gegen dieselbe playthrough.out geprüft: Solange sie nicht Byte für Byte gleich ist, ist die Portierung nicht fertig.
  9. Tagesmodus. Ein Startwert aus dem Datum, und alle spielen dieselbe Welt; die Wiederholung eines Kampfes (die Lektion über Befehle) zeichnet die Züge schon auf.

Was du auch tust: Lass zuerst den Test mit aufgezeichnetem Durchgang laufen, ändere dann. Schlägt er fehl, lies den ersten Unterschied. Das ist die ganze Lektion über Balance und über das Pflegen eines Spiels in einem.

Das ist das Ende des Moduls. Wir sind von der Frage „wie ist ein Spiel aufgebaut“ ausgegangen und bei einem ganzen Spiel in vier Sprachen angekommen, mit Blut, Kämpfen nach Tempo, Feinden, die denken, einem Spielstand, der abgelehnt wird, wenn er kaputt ist, und einem Werkzeug, das sagt, ob es gut ist. Der Rest liegt bei dir.

Probier es aus

Sage die Ausgabe voraus

Die Funktion cell schreibt eine Zelle der Tabelle: Siegquote und mittlere Züge. Was gibt das Programm aus?

static int Percent(int wins, int fights)
{
return wins * 100 / fights;
}
static string Cell(int wins, int fights, int turns)
{
int tenths = turns * 10 / fights;
return Percent(wins, fights) + "% " + tenths / 10 + "." + tenths % 10;
}
Console.WriteLine(Cell(1, 3, 7));
Console.WriteLine(Cell(2, 3, 10));
Console.WriteLine(Cell(3, 3, 9));
Antwort

Probier es aus

Finde den Fehler

Ein Kollege will die Simulation „sauberer“ machen und gibt jedem Kampf einen neuen Generator mit demselben Startwert. Die Tabelle sieht nach nichts mehr aus:

Fighter[] heroes = [
new Fighter("Barbar", 40, 70, 4, 9),
new Fighter("Amazone", 40, 80, 3, 7),
new Fighter("Hexe", 24, 85, 3, 8),
];
Fighter golem = new Fighter("Golem", 40, 60, 3, 6);
foreach (Fighter hero in heroes)
{
Tally tally = DuelsBug(7, hero, golem, 200);
Console.WriteLine(hero.Name + ": " + Cell(tally.Wins, 200, tally.Turns));
}
Ausgabe
Barbar: 100% 10.0
Amazone: 100% 11.0
Hexe: 0% 8.0

Gegen den Golem gewinnen die Helden 100 %, 100 % und 0 %, und die Züge sind ganze Zahlen. Welche Zeile ist schuld?

Wähle die Zeile mit dem Fehler.

Genauer

Genauer: Rauschen, Bots und Aufzeichnungen

Wie groß ist der Fehler. Für eine Quote p aus n Kämpfen ist der Standardfehler √(p(1 − p) / n). Bei p = 50 % sind es 11.2 Punkte bei 20 Kämpfen, 3.5 bei 200 und 1.6 bei 1000. Der doppelte Standardfehler (etwa 95 % Sicherheit) macht das Intervall bei 200 Kämpfen zu ±7 Punkten. Ein Unterschied von 3 Punkten zwischen zwei Varianten einer Einstellung bedeutet bei 200 Kämpfen also nichts, ein Unterschied von 20 dagegen viel. Eine Regel: Lass mehr Kämpfe laufen, wo die Entscheidung auf der Kippe steht.

Stelle nicht auf einen Startwert ein. Wenn du die Zahl so lange wählst, bis sie bei Startwert 7 gut aussieht, stellst du auf das Glück von Startwert 7 ein. Prüfe mit ein paar Startwerten (sagen wir drei) und nimm einen vernünftigen Mittelwert.

Ein Bot ist kein Spieler. Der simulierte Held trifft die einfachsten Entscheidungen. Die Tabelle sagt, wie die Balance für so einen Bot ist; ein echter Spieler ist klüger (wählt Fähigkeiten, flieht rechtzeitig) oder dümmer (trinkt keinen Trank). Eine Simulation fängt also die groben Fehler ab (eine Klasse ist nutzlos, der Boss unschlagbar), und die Feinabstimmung macht man mit Spielern.

Siegquote ist nicht Spaß. Neben den Siegen misst man die mittleren Züge (die Spalte oben), das übrige Leben, wie viele Tränke getrunken wurden. Ein Kampf, der zu 100 % gewonnen wird und bei dem dem Helden 1 Leben bleibt, ist etwas anderes als einer, der zu 100 % mit 40 gewonnen wird.

Was die Wiederholbarkeit bricht. Die Iterationsreihenfolge einer Hashtabelle, Brüche, das aktuelle Datum, die Spracheinstellungen des Systems, eine instabile Sortierung ohne zweite Regel. Das Spiel vermeidet sie (geordnete Sammlungen, ganze Zahlen, eine zweite Regel beim Sortieren), und die Prüfung in vier Sprachen fängt jede Rückkehr des Problems ab.

Welcher Commit hat das Verhalten geändert? Der Test ist automatisch und schnell, darum kann git bisect ihn laufen lassen und den Commit finden, nach dem die Aufzeichnung nicht mehr passt.

Wie viele Tests man braucht. Der aufgezeichnete Durchgang ist ein Integrationstest: Er fängt alles ab, sagt aber nur „hier ist der erste Unterschied“, nicht „hier ist der Fehler“. Neben ihm stehen die kleinen Tests einzelner Regeln (Schaden, Stapeln von Effekten). Wir haben beim Bau des Spiels auch 300 Spiele mit zufälligen Befehlen laufen lassen: kein Absturz und keine Zahl, die keine Zahl war. So entstehen Schichten.

Übung

Der schwerste Feind

Löse die Aufgabe in deinem Editor. Fang mit dem Programm für die Tabelle an.

Der schwerste Feind. Gib nach der Tabelle für jeden Helden den Feind aus, gegen den die Siegquote am niedrigsten ist, und diese Quote. Die Reihenfolge der Helden ist wie in der Tabelle. Bei gleichen Quoten gewinnt der erste Feind. Der Generator ist derselbe (einer für alle Paare, Startwert 7, 200 Kämpfe), damit die Zahlen mit der Tabelle übereinstimmen.

Die Ausgabe ist:

Ausgabe
Schwerster Feind für Barbar: Wächter (54%)
Schwerster Feind für Amazone: Wächter (35%)
Schwerster Feind für Hexe: Wächter (7%)

Weitere Aufgaben

  • Eine neue Klasse. Füge einen vierten Helden hinzu (etwa einen Jäger mit 32 Leben, Treffsicherheit 90 und Schaden von 2 bis 6) und sieh dir seine Spalte an. Stelle ihn mit einem Durchlauf ein, bis der Wächter im Band liegt.
  • Vertrauen. Zeige neben jeder Prozentzahl den Standardfehler (√(p(1 − p) / n)), damit man sieht, welche Unterschiede Rauschen sind.
  • Automatisches Einstellen. Schreibe eine Funktion, die das Leben der Hexe in Schritten von 1 erhöht, bis die Quote gegen den Wächter ins Band kommt, und es zurückgibt. Warum muss die Quote mit dem Leben wachsen, damit diese Suche funktioniert, und kannst du sie mit einer binären Suche beschleunigen?
  • Argumente. Lass --fights= und --seed= aus den Argumenten des Programms lesen, wie es das Spiel tut.

Test

Teste dich

1. Der Barbar besiegt den Wächter in 50 %, 30 % oder irgendwo dazwischen, wenn nur 20 Kämpfe mit verschiedenen Startwerten laufen. Bei 1000 Kämpfen liegen die Antworten zwischen 48 % und 51 %. Warum ist eine Simulation der bessere Beleg als ein paar von Hand gespielte Kämpfe?
2. Der Durchlauf über das Leben der Hexe beginnt jede Zeile mit einem neuen Generator aus demselben Startwert. Warum?
3. Nachdem du eine Zahl in den Daten eines Feindes geändert hast, schlägt der Test mit aufgezeichnetem Durchgang fehl. Was tust du?
4. Die Hexe mit 36 Leben gewinnt bei 200 Kämpfen 36 % und bei 1000 Kämpfen 38 %. Welcher Zahl vertraust du mehr?
5. Der Test mit aufgezeichnetem Durchgang ist grün. Was beweist das?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·