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 { constructor(seed) { this.state = seed; }
next(n) { this.state = (this.state * 48271) % 2147483647; return this.state % n; }}Das größte Produkt ist etwa 10¹⁴, unter 2⁵³, darum rechnet auch JavaScript genau, ohne etwas Zusätzliches. 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.function strike(rng, 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.function fight(rng, hero, enemy) { let heroLife = hero.hp; let enemyLife = enemy.hp; let turns = 0; while (true) { turns++; enemyLife -= strike(rng, hero); if (enemyLife <= 0) return { win: true, turns }; heroLife -= strike(rng, enemy); if (heroLife <= 0) return { win: 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.
function strike(rng, attacker) {
if (rng.next(100) attacker.hit) {
return attacker.min + rng.next(attacker.max - attacker.min + 1);
}
return 0;
}Der Wurf geht von 0 bis 99, und Treffsicherheit 70 heißt 70 von 100. Die gewinnenden Zahlen sind 0 bis 69: kleiner als 70. Bei <= wären es 71, und jeder Schlag wäre 1 % treffsicherer als geschrieben. Kein einzelner Kampf zeigt so einen Fehler, aber die Tabelle zeigt ihn.
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.
function percent(wins, fights) { return Math.floor((wins * 100) / fights);}
function cell(wins, fights, turns) { const tenths = Math.floor((turns * 10) / fights); return percent(wins, fights) + "% " + Math.floor(tenths / 10) + "." + (tenths % 10);}
// Viele Kämpfe mit einem gemeinsamen Generator: Siege und Züge des Helden.function duels(rng, hero, enemy, fights) { let wins = 0; let turns = 0; for (let i = 0; i < fights; i++) { const result = fight(rng, hero, enemy); if (result.win) wins++; turns += result.turns; } return { 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.function simulate(heroes, enemies, fights, seed) { const rng = new Rng(seed); let header = "Feind".padEnd(10); for (const hero of heroes) header += hero.name.padStart(12); console.log(header); const totals = heroes.map(() => 0); for (const enemy of enemies) { let line = enemy.name.padEnd(10); for (let h = 0; h < heroes.length; h++) { const { wins, turns } = duels(rng, heroes[h], enemy, fights); totals[h] += percent(wins, fights); line += cell(wins, fights, turns).padStart(12); } console.log(line); } let average = "Mittel".padEnd(10); for (const total of totals) average += (Math.floor(total / enemies.length) + "%").padStart(12); console.log(average);}const heroes = [ { name: "Barbar", hp: 40, hit: 70, min: 4, max: 9 }, { name: "Amazone", hp: 40, hit: 80, min: 3, max: 7 }, { name: "Hexe", hp: 24, hit: 85, min: 3, max: 8 },];const enemies = [ { name: "Ratte", hp: 9, hit: 60, min: 1, max: 3 }, { name: "Wolf", hp: 18, hit: 70, min: 2, max: 5 }, { name: "Golem", hp: 40, hit: 60, min: 3, max: 6 }, { name: "Wächter", hp: 54, hit: 70, min: 3, max: 7 },];
simulate(heroes, enemies, 200, 7);Feind Barbar Amazone HexeRatte 100% 2.7 100% 2.7 100% 2.5Wolf 100% 4.6 100% 5.0 98% 4.4Golem 98% 9.5 93% 10.6 64% 8.0Wächter 54% 11.0 35% 11.3 7% 7.2Mittel 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.function strike(rng, 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.function fight(rng, hero, enemy) { let heroLife = hero.hp; let enemyLife = enemy.hp; let turns = 0; while (true) { turns++; enemyLife -= strike(rng, hero); if (enemyLife <= 0) return { win: true, turns }; heroLife -= strike(rng, enemy); if (heroLife <= 0) return { win: false, turns }; }}
function percent(wins, fights) { return Math.floor((wins * 100) / fights);}
function cell(wins, fights, turns) { const tenths = Math.floor((turns * 10) / fights); return percent(wins, fights) + "% " + Math.floor(tenths / 10) + "." + (tenths % 10);}
// Viele Kämpfe mit einem gemeinsamen Generator: Siege und Züge des Helden.function duels(rng, hero, enemy, fights) { let wins = 0; let turns = 0; for (let i = 0; i < fights; i++) { const result = fight(rng, hero, enemy); if (result.win) wins++; turns += result.turns; } return { wins, turns };}
// Die Siegquote mit einem neuen Generator aus dem gegebenen Seed.function winRate(hero, enemy, fights, seed) { const { wins } = duels(new Rng(seed), hero, enemy, fights); return percent(wins, fights);}
// Das Balance-Band: Der Wächter wird in 30 bis 60 von 100 Kämpfen besiegt.function inBand(rate) { return rate >= 30 && rate <= 60;}
// Das Leben der Hexe von 24 bis 40: Jede Zeile hat denselben Seed, nur das Leben ist anders.function sweep(warden, fights, seed) { for (let life = 24; life <= 40; life += 4) { const witch = { name: "Hexe", hp: life, hit: 85, min: 3, max: 8 }; const rate = winRate(witch, warden, fights, seed); console.log(witch.name + ", Leben " + life + ": " + rate + "%" + (inBand(rate) ? " ✓" : "")); }}Schritt für Schritt, in Worten
- 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).
- 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.
- Die Quote ist Siege × 100 ÷ Kämpfe, mit Ganzzahldivision. Nach 50 Kämpfen sind es 40 %, nach 200 48 %: Die Ausschläge werden kleiner.
- 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 %.
- 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.
- 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).
- 28 Leben: 13 %, 32: 21 %. Ihr Balken wächst mit dem Leben, liegt aber noch unter 30.
- 36 Leben: 36 %, der Balken wird grün: Er liegt im Band. Die kleinste Änderung, die genügt.
- 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.
- 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.function winRate(hero, enemy, fights, seed) { const { wins } = duels(new Rng(seed), hero, enemy, fights); return percent(wins, fights);}const barbarian = { name: "Barbar", hp: 40, hit: 70, min: 4, max: 9 };const warden = { name: "Wächter", hp: 54, hit: 70, min: 3, max: 7 };console.log(barbarian.name + " gegen " + warden.name + ":");for (const fights of [20, 1000]) { let line = fights + " Kämpfe:"; for (let seed = 1; seed <= 5; seed++) { line += " " + winRate(barbarian, warden, fights, seed) + "%"; } console.log(line);}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.function inBand(rate) { return rate >= 30 && rate <= 60;}
// Das Leben der Hexe von 24 bis 40: Jede Zeile hat denselben Seed, nur das Leben ist anders.function sweep(warden, fights, seed) { for (let life = 24; life <= 40; life += 4) { const witch = { name: "Hexe", hp: life, hit: 85, min: 3, max: 8 }; const rate = winRate(witch, warden, fights, seed); console.log(witch.name + ", Leben " + life + ": " + rate + "%" + (inBand(rate) ? " ✓" : "")); }}const warden = { name: "Wächter", hp: 54, hit: 70, min: 3, max: 7 };sweep(warden, 200, 7);const tuned = { name: "Hexe", hp: 36, hit: 85, min: 3, max: 8 };const tunedRate = winRate(tuned, warden, 1000, 7);console.log(tuned.name + ", Leben 36, 1000 Kämpfe: " + tunedRate + "%" + (inBand(tunedRate) ? " ✓" : ""));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.const SIM_LEVELS = [1, 1, 3, 5, 7];
// Der simulierte Held lernt die niedrigste Stufe des Baums (bei Gleichstand den ersten Zweig).function simSkillChoice(hero) { let best = ''; for (const skillId of learnableSkills(hero)) { if (best === '' || SKILLS.get(skillId).tier < SKILLS.get(best).tier) best = skillId; } return best;}
function makeSimHero(game, heroClass, level) { const hero = createHero(game, cap(nm(heroClass)), heroClass.id); for (let next = 2; next <= level; next++) { raiseLevel(hero); const skill = simSkillChoice(hero); if (next % 2 === 0 && skill !== '') hero.skills.push(skill); else if (heroClass.id === 'barbarian') applyLevelChoice(hero, { kind: 'stat', stat: 'strength', amount: 1 }); else if (heroClass.id === 'amazon') applyLevelChoice(hero, { kind: 'stat', stat: 'agility', amount: 1 }); else applyLevelChoice(hero, { 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', Math.floor(level / 2)); return hero;}
function simulateFight(game, heroClass, kind) { game.hero = makeSimHero(game, heroClass, SIM_LEVELS[kind.depth]); const battle = new Battle(game, [new Enemy(game.newId(), kind)], false); battle.autoHero = true; battle.advance(); return { win: battle.outcome === 'win', turns: battle.heroTurns };}
function simulate(io, fights, seed) { const 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'); let header = pad(t('sim.enemy'), 26) + padLeft(t('sim.level'), 5); for (const heroClass of CLASSES.values()) header += padLeft(cap(nm(heroClass)), 14); io.write(header + '\n'); const totals = []; for (let i = 0; i < CLASSES.size; i++) totals.push(0); let rows = 0; for (const kind of ENEMY_KINDS.values()) { if (kind.depth === 0) continue; rows++; let line = pad(cap(nm(kind)), 26) + padLeft(String(SIM_LEVELS[kind.depth]), 5); let column = 0; for (const heroClass of CLASSES.values()) { let wins = 0; let turns = 0; for (let fight = 0; fight < fights; fight++) { const result = simulateFight(game, heroClass, kind); if (result.win) wins++; turns += result.turns; } const winShare = percent(wins, fights); totals[column] += winShare; column++; const tenths = Math.floor((turns * 10) / fights); line += padLeft(winShare + '% ' + Math.floor(tenths / 10) + '.' + (tenths % 10), 14); } io.write(line + '\n'); } let footer = pad(t('sim.average'), 31); for (const total of totals) footer += padLeft(Math.floor(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:
Feind Stufe Barbar Amazone HexeRiesenratte 1 100% 2.4 100% 1.3 100% 1.2Speergoblin 1 100% 2.4 100% 2.5 100% 2.0Goblin-Schamane 1 100% 2.4 100% 2.3 100% 2.6Blutwolf 1 99% 4.0 99% 3.5 92% 3.2Wolfsführer 3 78% 10.0 93% 7.2 77% 5.8Skelett 3 99% 11.3 100% 4.4 100% 3.0Kultist 3 99% 5.9 100% 3.5 100% 3.3Erzgolem 5 100% 7.6 97% 14.2 99% 7.6Wächter der Tiefe 7 44% 21.0 58% 19.4 54% 12.3Mittel 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|depthwarden|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|4Sein 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:
1Iva1(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:
> 1Ein neuer Held
Wie heißt dein Held?Bis 12 Zeichen: Buchstaben, Ziffern, Leerzeichen, Strich.> IvaEin 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.function describe(turn, who, target, damage, left) { if (damage === 0) return turn + ". " + who + " verfehlt"; return turn + ". " + who + " trifft " + damage + " → " + target + " " + left;}
function fight(rng, hero, enemy, log) { let heroLife = hero.hp; let enemyLife = enemy.hp; let turns = 0; while (true) { turns++; let damage = strike(rng, hero); enemyLife -= damage; log.push(describe(turns, hero.name, enemy.name, damage, Math.max(0, enemyLife))); if (enemyLife <= 0) return { win: true, turns }; damage = strike(rng, enemy); heroLife -= damage; log.push(describe(turns, enemy.name, hero.name, damage, Math.max(0, heroLife))); if (heroLife <= 0) return { win: false, turns }; }}
// Die erste Zeile, in der sich die beiden Protokolle unterscheiden (0, wenn sie gleich sind).function firstDifference(a, b) { for (let i = 0; i < Math.max(a.length, b.length); i++) { if (i >= a.length || i >= b.length || a[i] !== b[i]) return i + 1; } return 0;}
function lineAt(lines, at) { return at <= lines.length ? lines[at - 1] : "-";}
// Der Test: ein Kampf mit Seed 2027, verglichen mit der Aufzeichnung.function check(label, recorded, hero, wolf) { const log = []; fight(new Rng(2027), hero, wolf, log); const at = firstDifference(recorded, log); if (at === 0) { console.log(label + ": stimmt, " + log.length + " Zeilen"); } else { console.log(label + ": Zeile " + at); console.log(" erwartet: " + lineAt(recorded, at)); console.log(" erhalten: " + lineAt(log, at)); }}const recorded = [ "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",];const hero = { name: "Barbar", hp: 40, hit: 70, min: 4, max: 9 };check("Wolf, maximaler Schaden 5", recorded, hero, { name: "Wolf", hp: 18, hit: 70, min: 2, max: 5 });check("Wolf, maximaler Schaden 6", recorded, hero, { name: "Wolf", hp: 18, hit: 70, min: 2, max: 6 });Wolf, maximaler Schaden 5: stimmt, 11 ZeilenWolf, maximaler Schaden 6: Zeile 6 erwartet: 3. Wolf trifft 3 → Barbar 37 erhalten: 3. Wolf trifft 5 → Barbar 35Das 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:
| Lektion | Regionen | Zeilen |
|---|---|---|
| game-loop | main game | 353 |
| screen-stack | screen screen-stack | 92 |
| screen-results | target-screen overlays | 465 |
| console-render | buffer terminal hud | 168 |
| map-fov | level fov map-load map-screen | 500 |
| entities | entity | 234 |
| combat | combat-order attack battle-screen | 381 |
| wounds-effects | effects bleeding | 198 |
| actions | actions replay replay-screen | 234 |
| ai | pathfind ai-map ai-battle | 216 |
| items-loot | items-data inventory equip loot shop | 267 |
| quests-events | xp-levelup event-bus quests dialog dialogs-data skills-data | 296 |
| save-load | save load | 311 |
| arcanum-final | simulate node | 137 |
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:
node arcanum.jsMit --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:
- 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. - Eine neue Klasse. Eine Zeile in
CLASSES_DATA, Fähigkeiten inskills-dataund 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. - Eine neue Quest. Ein Dialog in
dialogs-dataund eine Anmeldung bei den Ereignissen (quests): Der kleine Auftrag „bring Erz“ ist das kürzeste Beispiel im Spiel. - 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. - 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.
- Eine andere Ansicht.
Terminalist eine Schnittstelle mit zwei Arten; eine dritte kann auf einecanvasoder in ein Fenster zeichnen, ohne dass die Bildschirme es merken. Eingabe und Ausgabe sind genau dafür getrennt (io). - 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 textan, statt ihn still zu überspringen. - Eine neue Portierung. Das Spiel gibt es in vier Sprachen mit einer Ausgabe. Eine fünfte (Rust, Go, Kotlin) wird gegen dieselbe
playthrough.outgeprüft: Solange sie nicht Byte für Byte gleich ist, ist die Portierung nicht fertig. - 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?
function percent(wins, fights) { return Math.floor((wins * 100) / fights);}
function cell(wins, fights, turns) { const tenths = Math.floor((turns * 10) / fights); return percent(wins, fights) + "% " + Math.floor(tenths / 10) + "." + (tenths % 10);}
console.log(cell(1, 3, 7));console.log(cell(2, 3, 10));console.log(cell(3, 3, 9));Sowohl die Quote als auch die mittleren Züge sind Ganzzahldivision, abgerundet. 1 Sieg von 3 sind 33 % (nicht 33,3 und nicht 34), 2 von 3 sind 66 %, 3 von 3 sind 100 %. Die Züge stehen in Zehnteln: 7 Züge für 3 Kämpfe sind 70 / 3 = 23 Zehntel, also 2.3; 10 Züge sind 3.3; 9 Züge sind 90 / 3 = 30, also 3.0. Die zweite Antwort rundet (67 %), die dritte gibt die mittleren Züge als ganze Zahl an.
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:
const heroes = [ { name: "Barbar", hp: 40, hit: 70, min: 4, max: 9 }, { name: "Amazone", hp: 40, hit: 80, min: 3, max: 7 }, { name: "Hexe", hp: 24, hit: 85, min: 3, max: 8 },];const golem = { name: "Golem", hp: 40, hit: 60, min: 3, max: 6 };for (const hero of heroes) { const { wins, turns } = duelsBug(7, hero, golem, 200); console.log(hero.name + ": " + cell(wins, 200, turns));}Barbar: 100% 10.0Amazone: 100% 11.0Hexe: 0% 8.0Gegen 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.
new Rng(seed) in der Schleife beginnt die Reihe in jedem Kampf von vorn. So sind alle 200 Kämpfe ein und derselbe Kampf, 200-mal wiederholt: Das Ergebnis ist 0 % oder 100 %, und die mittleren Züge sind ganze Zahlen. Der Generator muss einmal vor der Schleife erzeugt und an jeden Kampf weitergegeben werden (rng in duels). Der Startwert gibt Wiederholbarkeit, darf aber nicht für jeden Wurf neu übergeben werden.
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:
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
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Konstruktoren und this · Instanzmethoden · Arrays: push, splice und sort · Daten, switch und Destrukturierung
- Aufgaben zum Thema: Die Chroniken von Arkanum