Die ganze Strategie
Kurz gesagt: Das Spiel endet mit der ersten von fünf Prüfungen, die „Ja“ sagt: Eroberung, Niederlage, Forschung, Wirtschaft oder Frist (Punkte). Ein Spielstand ist Text „Schlüssel=Wert“ mit Version und Prüfsumme, das Laden prüft alles, bevor es das Spiel anfasst, und die Balance misst man mit Hunderten Partien ohne Bildschirm.
Problem
Wann endet das Spiel?
Fünf Lektionen lang haben wir Teile gebaut: eine Galaxie aus einem Startwert, einen Technologiebaum, Flotten und Kämpfe, Rivalen mit Charakter und eine Runde mit Phasen und Ereignissen. Aber das Spiel hat noch kein Ende. Gewinnt nie jemand, endet die Partie nie, und ist der Sieg schlecht gebaut, spielt man hundertfünfzig Runden und merkt, dass alles in den ersten zwanzig entschieden war.
Diese Lektion schließt das Modul mit den drei Dingen, auf denen jedes ganze Spiel steht: Sieg (wann und wie eine Partie endet), Speichern (wie man morgen weitermacht) und Balance (woher du weißt, dass das Spiel fair ist, ohne es tausendmal von Hand zu spielen). Danach ist das Spiel hier, ganz.
So endet der Durchlauf, mit dem die Seite das Spiel auf allen vier Sprachen prüft. Das Terranische Reich erforscht das Sternentor in Runde 27, und der Endbildschirm zeigt die Rangliste:
SPIELENDE
Terranisches Reich hat das Sternentor in Runde 59 erforscht.
Rangliste (Planeten, Technologien, Punkte):A Terranisches Reich 7 13 376C Lyra-Union 0 3 38 zerstörtD Vega-Akademie 7 12 319B Orion-Hegemonie 2 8 154
Kämpfe 27, verlorene Einheiten 159, Kolonien 12, Technologien36, Ereignisse 27.
(Enter: beenden)Auf diesem Bildschirm sieht man drei Dinge: die Art des Siegs (Forschung), die Rangliste (Planeten, Technologien und Punkte jedes Reichs; die Lyra-Union ist zerstört) und die Statistik der Partie. Sie kommen aus drei kleinen Funktionen, die du hier siehst.
Die Idee
Vier Siege, eine Kette von Prüfungen
Das Spiel hat vier Arten zu enden und noch eine, damit es nie ewig hängt:
- Eroberung: Nur ein Reich ist noch am Leben.
- Niederlage: Der Spieler ist vernichtet (die Partie endet, und das stärkste der übrigen wird als Sieger eingetragen).
- Forschung: Ein lebendes Reich hat das Sternentor erforscht, die Technologie am Ende des Baums.
- Wirtschaft: Ein lebendes Reich hat insgesamt 9800 produziert.
- Frist: Runde 150 ist da, und wer die meisten Punkte hat, gewinnt.
Die Punkte eines Reichs sind 10 je Planet, 6 je Technologie, die Flottenstärke geteilt durch 10 und die Produktion geteilt durch 40. Hier ist der echte Code aus dem Spiel:
// Drei Siege: Eroberung (nur ein Reich ist übrig), Forschung (das Sternentor ist erforscht) und// Wirtschaft (Produktion ECON_GOAL angesammelt). Gewinnt bis MAX_TURNS niemand, siegt der// mit den meisten Punkten. Wird der Spieler zerstört, endet das Spiel mit einer Niederlage.function empireScore(world, empire) { return ownedPlanets(world, empire.id).length * 10 + empire.techs.length * 6 + Math.floor(empirePower(world, empire.id) / 10) + Math.floor(empire.produced / 40);}
function bestScoring(world) { let best = null; let bestScore = -1; for (const e of world.empires) { if (!e.alive) continue; const score = empireScore(world, e); if (score > bestScore) { bestScore = score; best = e; } } return best;}
function checkVictory(world) { if (world.victory !== null) return; if (world.livingCount() === 1) { for (const e of world.empires) { if (e.alive) world.victory = { empire: e.id, kind: 'conquest', turn: world.turn }; } return; } for (const e of world.empires) { if (e.human && !e.alive) { world.victory = { empire: bestScoring(world).id, kind: 'lost', turn: world.turn }; return; } } for (const e of world.empires) { if (e.alive && knows(e, 'stargate')) { world.victory = { empire: e.id, kind: 'science', turn: world.turn }; return; } } for (const e of world.empires) { if (e.alive && e.produced >= ECON_GOAL) { world.victory = { empire: e.id, kind: 'economy', turn: world.turn }; return; } } if (world.turn >= MAX_TURNS) world.victory = { empire: bestScoring(world).id, kind: 'points', turn: world.turn };}checkVictory ist nicht eine Prüfung, sondern fünf in genauer Reihenfolge, und die erste, die „Ja“ sagt, entscheidet. Das ist ein früher Ausstieg: Sobald du die Antwort kennst, kehrst du zurück. Die Reihenfolge ist nicht zufällig. Ist nur ein Reich am Leben, hat es keinen Sinn, nach dem Tor zu fragen. Ist der Spieler vernichtet, ist die Partie verloren, egal wie viel jemand produziert hat. Und die Frist steht zuletzt, weil sie der schwächste Sieg ist.
Das kleine Programm macht dasselbe mit denselben Regeln, aber mit drei Reichen und ohne Galaxie. Ein Reich weiß, wie viele Punkte es hat:
class Empire { constructor(name, human, planets, techs, power, produced) { this.name = name; this.human = human; this.alive = true; this.stargate = false; this.planets = planets; this.techs = techs; this.power = power; this.produced = produced; }
score() { return this.planets * 10 + this.techs * 6 + Math.floor(this.power / 10) + Math.floor(this.produced / 40); }}class Victory { constructor(kind, winner) { this.kind = kind; this.winner = winner; }}Die Prüfungen sind dieselben fünf, in derselben Reihenfolge (ECON_GOAL ist 9800, MAX_TURNS ist 150):
function checkVictory(empires, turn) { const living = empires.filter((e) => e.alive); if (living.length === 1) return new Victory("Eroberung", living[0].name); for (const e of empires) { if (e.human && !e.alive) return new Victory("Niederlage", bestScoring(empires).name); } for (const e of empires) { if (e.alive && e.stargate) return new Victory("Forschung", e.name); } for (const e of empires) { if (e.alive && e.produced >= ECON_GOAL) return new Victory("Wirtschaft", e.name); } if (turn >= MAX_TURNS) return new Victory("Punkte", bestScoring(empires).name); return null;}Sieben Welten, sieben Antworten. Die letzten beiden zeigen das Wichtigste: Ein vernichteter Spieler ergibt Niederlage, und Runde 150 ohne anderen Sieg ergibt Punkte.
let game = fresh();show("Runde 40, nichts ist entschieden", game, 40);
game = fresh();game[2].stargate = true;show("Sternentor erforscht", game, 40);
game = fresh();game[1].produced = ECON_GOAL;show("9800 produziert", game, 40);
game = fresh();game[2].stargate = true;game[2].produced = ECON_GOAL;show("Tor und 9800 in derselben Runde", game, 40);
game = fresh();game[1].alive = false;game[2].alive = false;show("nur ein Reich ist übrig", game, 40);
game = fresh();game[0].alive = false;show("der Spieler ist vernichtet", game, 40);
show("Runde 150, kein anderer Sieg", fresh(), 150);Runde 40, nichts ist entschieden: -Sternentor erforscht: Forschung (Vega-Akademie)9800 produziert: Wirtschaft (Orion-Hegemonie)Tor und 9800 in derselben Runde: Forschung (Vega-Akademie)nur ein Reich ist übrig: Eroberung (Terranisches Reich)der Spieler ist vernichtet: Niederlage (Orion-Hegemonie)Runde 150, kein anderer Sieg: Punkte (Terranisches Reich)Jeder Fall beginnt mit demselben frischen Spiel und ändert ein oder zwei Dinge. Der vierte ist der knifflige: Die Vega-Akademie hat das Sternentor erforscht und in derselben Runde 9800 produziert. Es gewinnt die Forschung, weil sie in der Reihenfolge früher kommt.
Sieh es dir an
Das erste „Ja“ entscheidet
Vier dieser Fälle in Bewegung. Achte darauf, welche Prüfungen laufen und welche dunkel werden, weil sie nie erreicht werden:
function checkVictory(empires, turn) { const living = empires.filter((e) => e.alive); if (living.length === 1) return new Victory("Eroberung", living[0].name); for (const e of empires) { if (e.human && !e.alive) return new Victory("Niederlage", bestScoring(empires).name); } for (const e of empires) { if (e.alive && e.stargate) return new Victory("Forschung", e.name); } for (const e of empires) { if (e.alive && e.produced >= ECON_GOAL) return new Victory("Wirtschaft", e.name); } if (turn >= MAX_TURNS) return new Victory("Punkte", bestScoring(empires).name); return null;}Schritt für Schritt, in Worten
- Nach jeder Runde stellt die Welt fünf Fragen der Reihe nach: Ist nur ein Reich übrig, ist der Spieler vernichtet, gibt es ein Sternentor, hat jemand 9800 produziert, ist Runde 150. Das erste „Ja“ entscheidet.
- Runde 40. Alle drei Reiche leben, niemand hat das Sternentor, niemand hat 9800 erreicht.
- Fünfmal „Nein“. checkVictory gibt null zurück, und das Spiel geht zur nächsten Runde.
- Vega-Akademie: Das Sternentor ist erforscht und 9800 sind produziert, beides in derselben Runde. Zwei Siege auf einmal.
- Die dritte Prüfung (Forschung) sagt „Ja“ und kehrt sofort zurück. Die vierte, die Wirtschaft, wird nie erreicht: Die Reihenfolge der Prüfungen entscheidet, wer gewinnt.
- Orion-Hegemonie und Vega-Akademie sind vernichtet. Ein Reich ist übrig: Terranisches Reich.
- Die erste Prüfung sagt „Ja“: Eroberung. Die vier danach laufen gar nicht erst.
- Runde 150: die Frist. Niemand hat auf andere Weise gewonnen.
- Vier „Nein“ und dann die Frist: Wer die meisten Punkte hat, gewinnt. Terranisches Reich und Orion-Hegemonie sind gleichauf, und den Gleichstand gewinnt der Frühere.
Warum kehrt das erste „Ja“ sofort zurück? Weil danach nichts mehr zu entscheiden ist: Eine Partie hat einen Sieg. Es gibt aber auch einen praktischen Grund. Liefe die vierte Prüfung nach der dritten, müsste die Funktion zwischen zwei Siegen wählen, und das ist eine neue Regel. Mit dem frühen Ausstieg ist die Regel die Reihenfolge, und du liest sie direkt im Code: Tauschst du zwei Zeilen, hast du geändert, wer gewinnt, wenn sich Bedingungen überlappen.
Genauer, für Neugierige
Hier wird der Sieg einmal pro Runde geprüft, nachdem alles andere in der Runde fertig ist. So gewinnt ein Spieler, der 9800 erreicht hat, nicht mitten in der Runde, bevor die Rivalen ihren Zug gemacht haben. In der Region turn des Kanons ist checkVictory(world) die letzte Phase der Runde, nach Produktion, Forschung und Ereignis.
Das Spiel hält den Sieg in der Welt (world.victory), statt ihn zurückzugeben: Endbildschirm, Spielstand und Simulation lesen dieselbe Stelle. So funktioniert dieselbe Prüfung für einen Spieler und für eine Simulation ohne Bildschirm.
Die Idee
Punkte: Gleichstand und die Vernichteten
Die Frist ist der einfachste der vier Siege, aber sie hat zwei versteckte Regeln. Erstens zählen vernichtete Reiche nicht: Ein Reich mit vielen Punkten, das schon verschwunden ist, darf nicht gewinnen. Zweitens der Gleichstand: Zwei Reiche mit gleichen Punkten brauchen einen vorhersehbaren Sieger, sonst ist die Simulation nicht deterministisch.
function bestScoring(empires) { let best = null; let bestScore = -1; for (const e of empires) { if (!e.alive) continue; const score = e.score(); if (score > bestScore) { bestScore = score; best = e; } } return best;}bestScore beginnt bei −1, damit auch ein Reich mit null Punkten Führender werden kann. Der Vergleich ist strikt (>), deshalb bleibt bei Gleichstand das frühere. Das Programm vergleicht vier Reiche, eins vernichtet und zwei mit gleichen Punkten:
const rivals = [ new Empire("Terranisches Reich", false, 5, 9, 400, 3000), new Empire("Orion-Hegemonie", false, 3, 11, 450, 3120), new Empire("Vega-Akademie", false, 4, 8, 300, 2000),];const lyra = new Empire("Lyra-Union", false, 8, 14, 900, 6000);lyra.alive = false;rivals.push(lyra);
for (const e of rivals) { const note = e.alive ? "" : " (zerstört)"; console.log(e.name + ": " + e.score() + note);}console.log("Meiste Punkte: " + bestScoring(rivals).name);Terranisches Reich: 219Orion-Hegemonie: 219Vega-Akademie: 168Lyra-Union: 404 (zerstört)Meiste Punkte: Terranisches ReichDie Lyra-Union hat die meisten Punkte (404), ist aber vernichtet und scheidet aus. Terranisches Reich und Orion-Hegemonie stehen beide bei 219, und Sieger ist das erste. Die Divisionen sind ganzzahlig, wie überall im Spiel (Math.floor in JavaScript), und deshalb sind die Punkte auf allen vier Sprachen gleich.
Die Idee
Speichern: Text „Schlüssel=Wert“
Wer das Spiel in Runde 80 schließt, will morgen in Runde 80 weitermachen. Speichern heißt, die Welt wieder einzulesen, und dabei gibt es drei Fragen: was gespeichert wird, in welcher Form und woher man weiß, dass die Datei ganz ist.
Was. Nicht alles. Die Galaxie (Systeme, Wege, Planetenarten) entsteht aus dem Startwert: derselbe Startwert, dieselbe Galaxie (Lektion 1 des Moduls). Ein Spielstand hält also nur, was sich ändert: wem jeder Planet gehört, was gebaut ist, die Ressourcen, Technologien, Flotten, die Bauwarteschlange, Frieden und Krieg und den Zustand des Generators, damit die Würfel an derselben Stelle weitergehen.
In welcher Form. Text, Zeile für Zeile, Schlüssel=Wert. Man liest ihn auf einen Blick, vergleicht ihn mit einem anderen Spielstand, repariert ihn im Notfall von Hand, und das Format ist nicht an die Sprache des Spiels gebunden: Denselben Text kann eine Version in jeder Sprache schreiben und lesen. Die erste Zeile ist der Kopf (format=COSMOS), die zweite die Version des Formats, die letzte die Prüfsumme von allem darüber.
function saveWorld(world) { const lines = []; const put = (key, value) => lines.push(key + '=' + value); put('format', SAVE_FORMAT); put('version', SAVE_VERSION); put('summary', saveSummary(world)); put('seed', world.seed); put('rng', world.rng.state); put('turn', world.turn); put('systems', world.systems.length); put('planets', world.planets.length); put('empires', world.empires.length); for (const planet of world.planets) { put('planet.' + planet.id, [planet.owner, planet.mines, planet.lab, planet.yard, planet.fort, planet.siege].concat(planet.rich).join(',')); } for (const empire of world.empires) { const prefix = 'empire.' + empire.id; put(prefix, [empire.def.id, empire.personality.id, empire.alive ? 1 : 0, empire.progress, empire.produced, empire.flare].concat(empire.res).join(',')); put(prefix + '.researching', empire.researching === '' ? '-' : empire.researching); put(prefix + '.techs', joinOrDash(empire.techs)); put(prefix + '.war', empire.war.join(',')); put(prefix + '.since', empire.since.join(',')); put(prefix + '.grudge', empire.grudge.join(',')); const orders = []; for (const order of empire.orders) orders.push([order.from, order.to, order.dest].concat(order.ships).join(':')); put(prefix + '.orders', joinOrDash(orders)); } put('fleets', world.fleets.length); for (let i = 0; i < world.fleets.length; i++) { const fleet = world.fleets[i]; put('fleet.' + i, [fleet.owner, fleet.systemId].concat(fleet.ships).join(',')); } put('queue', world.queue.length); for (let i = 0; i < world.queue.length; i++) { const item = world.queue[i]; put('queue.' + i, [item.owner, item.planetId, item.kind, item.count, item.left].join(',')); } const offers = []; for (const offer of world.offers) offers.push(offer.from + ':' + offer.to + ':' + offer.turn); put('offers', joinOrDash(offers)); const s = world.stats; put('stats', [s.battles, s.lost, s.colonies, s.techs, s.events].join(',')); const body = lines.join('\n') + '\n'; return body + 'checksum=' + checksum(body) + '\n';}Das kleine Programm speichert dasselbe im Kleinen: einen Startwert, eine Runde und drei Reiche (Planeten, Produktion, Technologien), mit Version und Prüfsumme am Ende:
class Game { constructor(seed, turn) { this.seed = seed; this.turn = turn; this.empires = []; }}function save(game) { const lines = ["format=COSMOS", "version=" + VERSION]; lines.push("seed=" + game.seed); lines.push("turn=" + game.turn); lines.push("empires=" + game.empires.length); game.empires.forEach((e, i) => lines.push("empire." + i + "=" + e.join(","))); const body = lines.join("\n") + "\n"; return body + "checksum=" + checksum(body) + "\n";}process.stdout.write(save(sample()));format=COSMOSversion=1seed=2026turn=12empires=3empire.0=5,3120,9empire.1=4,2750,7empire.2=3,4180,10checksum=987468948Das ist der Spielstand, Zeile für Zeile. Die Version steht nicht für heute da, sondern für morgen: Ändert sich das Format (ein neuer Schlüssel, eine andere Reihenfolge), müssen alte Spielstände erkannt und abgelehnt oder umgewandelt werden. Ohne Version würde ein Spielstand einer alten Version falsch und stillschweigend gelesen.
Die Idee
Prüfsumme: eine Änderung und die Summe stimmt nicht
Die letzte Zeile eines Spielstands ist die Zahl, die aus allem darüber entsteht. Beim Laden rechnet das Spiel sie neu und vergleicht sie mit der gespeicherten: Ist die Datei abgeschnitten, kaputt oder von Hand angefasst, sind die Zahlen verschieden. Der Algorithmus ist klein:
function checksum(text) { let sum = 0; for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 1000000007; return sum;}Für jedes Zeichen wird die Summe mit 31 multipliziert, der Code des Zeichens addiert und der Rest modulo 1000000007 genommen. Wichtig ist die Multiplikation mit 31: Sie macht die Reihenfolge wichtig. Eine einfache Summe der Codes ergäbe für „ab“ und „ba“ dieselbe Zahl, und zwei vertauschte Ziffern im Spielstand sind genau der Fehler, den wir finden wollen.
const texts = ["ab", "ba", "turn=12", "turn=21"];for (const word of texts) { console.log(word + " -> " + checksum(word));}ab -> 3105ba -> 3135turn=12 -> 408655515turn=21 -> 408655545„ab“ ist 97 × 31 + 98 = 3105, und „ba“ ist 98 × 31 + 97 = 3135. Der Rest hält die Zahl unter einer Milliarde, damit sie in 32 Bit passt, und JavaScript rechnet bis 253 genau, und das Zwischenergebnis ist hier viel kleiner.
function checksum(text) {
let sum = 0;
for (let i = 0; i < text.length; i++) {
sum = (sum * 31 + text.charCodeAt(i)) 1000000007;
}
return sum;
}Der Rest (%) bringt die Summe nach jedem Zeichen wieder unter 1000000007. Ohne ihn würde die Zahl so lange wachsen, wie der Spielstand lang ist.
Genauer, für Neugierige
Eine Prüfsumme findet Fehler, keine Fälschung. Wer die Formel kennt, ändert einen Wert und berechnet die Summe neu (das Programm unten tut genau das mit reseal, um seine anderen Schutzmaßnahmen zu testen). Für ein Spiel auf deiner eigenen Platte reicht das: Das Ziel ist, keinen halben Spielstand zu laden. Für eine Wettbewerbs-Rangliste auf einem Server braucht es etwas anderes: eine Signatur mit geheimem Schlüssel (HMAC), die der Spieler nicht berechnen kann.
Der Algorithmus gehört zur Familie der „polynomiellen Summen“, derselben wie hashCode eines Strings in Java. Für echte Hashes (SHA-256) gibt es fertige Bibliotheken, aber das Spiel hält eine 32-Bit-Prüfung ohne etwas von außen: Du liest sie auf einen Blick, und sie gibt auf allen vier Sprachen dieselben Zahlen.
Die Idee
Ein Lader, der niemandem traut
Ein Spielstand kommt von der Platte, und die Platte kann alles zurückgeben: eine abgeschnittene Datei, einen Spielstand einer anderen Version des Spiels, eine Datei, die im Texteditor bearbeitet wurde. Liest das Spiel ihn auf Treu und Glauben, stürzt es bei einem schlechten Spielstand ab oder (schlimmer) bleibt in einem halben Zustand, den man erst in Runde 90 sieht. Deshalb prüft das Laden alles, bevor es das Spiel anfasst, und sagt genau, was nicht stimmt.
class SaveReader { constructor(text) { this.values = new Map(); const lines = text.split('\n'); while (lines.length > 0 && lines[lines.length - 1].length === 0) lines.pop(); if (lines.length === 0 || lines[0] !== 'format=' + SAVE_FORMAT) throw new SaveError(t('load.badHeader')); for (let i = 0; i < lines.length; i++) { const eq = lines[i].indexOf('='); if (eq <= 0) throw new SaveError(t('load.badLine', { line: i + 1 })); this.values.set(lines[i].substring(0, eq), lines[i].substring(eq + 1)); } const version = this.text('version'); if (version !== String(SAVE_VERSION)) throw new SaveError(t('load.badVersion', { found: version, expected: SAVE_VERSION })); const last = lines[lines.length - 1]; if (!last.startsWith('checksum=')) throw new SaveError(t('load.missing', { key: 'checksum' })); const body = lines.slice(0, lines.length - 1).join('\n') + '\n'; const actual = checksum(body); if (parseIntStrict(last.substring(9)) !== actual) { throw new SaveError(t('load.badChecksum', { expected: last.substring(9), actual: actual })); } }
text(key) { if (!this.values.has(key)) throw new SaveError(t('load.missing', { key: key })); return this.values.get(key); }
// Eine Zahl in den Grenzen [low, high]. int(key, low, high) { const value = parseIntStrict(this.text(key)); if (value === null || value < low || value > high) throw new SaveError(t('load.badValue', { key: key })); return value; }
// Eine Liste von genau count Zahlen, jede in ihren Grenzen. ints(key, count, low, high) { const parts = this.text(key).split(','); if (parts.length !== count) throw new SaveError(t('load.badValue', { key: key })); const result = []; for (const part of parts) { const value = parseIntStrict(part); if (value === null || value < low || value > high) throw new SaveError(t('load.badValue', { key: key })); result.push(value); } return result; }
list(key) { return splitList(this.text(key)); }}Die Reihenfolge der Prüfungen ist wichtig: der Kopf, jede Zeile als Schlüssel=Wert, die Version, die Prüfsumme und erst dann die Werte. Jeder Wert wird über int oder ints gelesen, die die Bereiche prüfen (eine Runde von 1 bis 150, eine Anzahl von Reichen von 1 bis 9). Die kleine Version ist dieselbe im Kleinen:
class SaveReader { constructor(text) { this.values = new Map(); const lines = text.split("\n"); while (lines.length > 0 && lines[lines.length - 1].length === 0) lines.pop(); if (lines.length === 0 || lines[0] !== "format=COSMOS") throw new SaveError("das ist kein Epic-Cosmos-Spielstand"); for (let i = 0; i < lines.length; i++) { const eq = lines[i].indexOf("="); if (eq <= 0) throw new SaveError("Zeile " + (i + 1) + " ist nicht „Schlüssel=Wert“"); this.values.set(lines[i].substring(0, eq), lines[i].substring(eq + 1)); } const version = this.text("version"); if (version !== String(VERSION)) throw new SaveError("Version " + version + ", aber das Spiel liest Version " + VERSION); const last = lines[lines.length - 1]; if (!last.startsWith("checksum=")) throw new SaveError("„" + "checksum" + "“ fehlt"); const body = lines.slice(0, lines.length - 1).join("\n") + "\n"; const actual = checksum(body); if (digits(last.substring(9)) !== actual) { throw new SaveError("Prüfsumme falsch (gespeichert " + last.substring(9) + ", berechnet " + actual + "), Datei verändert"); } }
text(key) { if (!this.values.has(key)) throw new SaveError("„" + key + "“ fehlt"); return this.values.get(key); }
integer(key, low, high) { const value = digits(this.text(key)); if (value < low || value > high) throw new SaveError("falscher Wert bei „" + key + "“"); return value; }
numbers(key, count, low, high) { const parts = this.text(key).split(","); if (parts.length !== count) throw new SaveError("falscher Wert bei „" + key + "“"); const result = []; for (const part of parts) { const value = digits(part); if (value < low || value > high) throw new SaveError("falscher Wert bei „" + key + "“"); result.push(value); } return result; }}function load(text) { const reader = new SaveReader(text); const game = new Game(reader.integer("seed", 0, 99999), reader.integer("turn", 1, MAX_TURNS)); const count = reader.integer("empires", 1, 9); for (let i = 0; i < count; i++) { game.empires.push(reader.numbers("empire." + i, 3, 0, 99999)); } return game;}load erzeugt aus dem Gelesenen ein neues Spiel und gibt es zurück, ohne das aktuelle anzufassen. Der Fehler ist eine Exception SaveError, die die Worte für den Spieler trägt. Wer aufruft, fängt sie und zeigt sie an, und das Spiel geht mit der alten Welt weiter:
function showLoad(label, text) { try { const game = load(text); let produced = 0; for (const e of game.empires) produced += e[1]; console.log(label + ": " + "Runde " + game.turn + ", Reiche " + game.empires.length + ", produziert " + produced); } catch (error) { if (!(error instanceof SaveError)) throw error; console.log(label + ": " + error.message); }}Sechs Spielstände, sechs Antworten. Zwei davon sind so verändert, dass die Summe stimmt (deshalb braucht es die Prüfung der Bereiche und Schlüssel), und einer ist von Hand angefasst, und die Summe findet es:
const good = save(sample());const body = good.substring(0, good.indexOf("checksum="));showLoad("unversehrt", good);showLoad("von Hand geändert", good.replace("3120", "99999"));showLoad("andere Version", good.replace("version=1", "version=2"));showLoad("Zahl außerhalb der Grenzen", reseal(body.replace("turn=12", "turn=999")));showLoad("Zeile fehlt", reseal(body.replace("seed=2026\n", "")));showLoad("kein Spielstand", "hello");unversehrt: Runde 12, Reiche 3, produziert 10050von Hand geändert: Prüfsumme falsch (gespeichert 987468948, berechnet 410613307), Datei verändertandere Version: Version 2, aber das Spiel liest Version 1Zahl außerhalb der Grenzen: falscher Wert bei „turn“Zeile fehlt: „seed“ fehltkein Spielstand: das ist kein Epic-Cosmos-SpielstandDas echte Spiel macht dasselbe. In Platz 3 des Durchlaufs liegt ein absichtlich kaputter Spielstand, wie von Hand erhöhte Ressourcen, und das Spiel lehnt ihn ab, ohne abzustürzen und ohne die aktuelle Partie anzufassen:
> slotsSpielstände (save N, load N)Platz 1: Runde 5, Terranisches Reich, 2 PlanetenPlatz 2: leerPlatz 3: Runde 99, ohne Wache> load 3Der Spielstand ist beschädigt: Prüfsumme falsch (gespeichert123456, berechnet 262926817), Datei verändert.Zum Schluss verbindet der Kanon beides: loadWorld fängt SaveError und gibt entweder eine neue Welt oder eine Meldung zurück. So sieht der Bildschirm nie ein kaputtes Spiel.
// Gibt { world, error } zurück: entweder die neue Welt oder eine Meldung, was nicht stimmt.function loadWorld(text) { try { return { world: readWorld(text), error: null }; } catch (error) { if (error instanceof SaveError) return { world: null, error: error.message }; throw error; }}Die Idee
Balance: woher du weißt, dass das Spiel fair ist
Das Spiel hat drei KI-Charaktere: Kriegsherr (schätzt den Angriff), Baumeister (Ausbreitung und Bau) und Gelehrter (Forschung und das Sternentor): Ein Charakter ist ein Satz von Gewichten für die Entscheidungen des Reichs. Sind sie fair? Gewinnt einer 80 % der Partien, sind die anderen Dekoration. Tausend Partien von Hand spielt niemand, aber ein Computer schafft es in Sekunden: Er spielt gegen sich selbst.
--simulate=200 spielt zweihundert Partien ohne Bildschirm, KI gegen KI. Alles, was dabei hilft: Das Spiel druckt nicht selbst (der Bildschirm ist getrennt), der Zufall hat einen Startwert (das Ergebnis ist wiederholbar) und die Charaktere wechseln die Plätze:
// Die Balance: Hunderte Partien ohne Schirm, künstliche Intelligenz gegen künstliche Intelligenz. Die Charaktere// wechseln die Plätze, so dass jeder gleich oft auf jeder Position spielt; die Tabelle sagt, wie viele// Partien jeder Charakter gewinnt und wie die Partien enden.const PERSONALITY_IDS = ['warlord', 'builder', 'scholar'];const VICTORY_KINDS = ['conquest', 'science', 'economy', 'points'];
function simulate(io, games, seed) { io.write(t('sim.title', { games: games, seed: seed }) + '\n'); io.write(t('sim.note1', { turns: MAX_TURNS, goal: ECON_GOAL }) + '\n\n'); const played = [0, 0, 0]; const wins = [0, 0, 0]; const winTurns = [0, 0, 0]; const kinds = [0, 0, 0, 0]; let totalTurns = 0; let totalSystems = 0; for (let i = 0; i < games; i++) { const order = []; for (let k = 0; k < 4; k++) order.push(PERSONALITY_IDS[(i + k) % 3]); const world = newWorld(seed + i, false, order, true); totalSystems += world.systems.length; while (world.victory === null) runTurn(world); for (const empire of world.empires) played[empire.personality.index]++; const winner = world.empires[world.victory.empire]; wins[winner.personality.index]++; winTurns[winner.personality.index] += world.victory.turn; kinds[VICTORY_KINDS.indexOf(world.victory.kind)]++; totalTurns += world.victory.turn; } io.write(pad(t('sim.personality'), 20) + padLeft(t('sim.played'), 10) + padLeft(t('sim.wins'), 10) + padLeft(t('sim.share'), 8) + padLeft(t('sim.avgTurn'), 12) + '\n'); for (const personality of PERSONALITIES.values()) { const i = personality.index; const avg = wins[i] === 0 ? 0 : Math.floor(winTurns[i] / wins[i]); io.write(pad(cap(nm(personality)), 20) + padLeft(String(played[i]), 10) + padLeft(String(wins[i]), 10) + padLeft(percent(wins[i], played[i]) + '%', 8) + padLeft(String(avg), 12) + '\n'); } const parts = []; for (let k = 0; k < VICTORY_KINDS.length; k++) parts.push(t('victory.' + VICTORY_KINDS[k]) + ' ' + kinds[k]); io.write('\n' + t('sim.kinds', { list: parts.join(', ') }) + '\n'); io.write(t('sim.averages', { turns: Math.floor(totalTurns / games), systems: Math.floor(totalSystems / games) }) + '\n');}Die Drehung (i + k) % 3 gibt jedem Charakter jeden Platz gleich oft. Sonst würde der erste Platz (bei Gleichstand gewinnt das frühere Reich) den Prozentsatz dessen erhöhen, der immer Erster ist.
Eine kleine Version derselben Idee. Drei Charaktere mit demselben mittleren Einkommen, 30 pro Runde, aber verschieden großer Streuung: Der Kriegsherr hat Glück und Pech, der Baumeister ist gleichmäßig. Wer zuerst 600 erreicht, gewinnt:
function playGame(rng, seats) { const produced = seats.map(() => 0); let turn = 0; while (true) { turn++; for (let k = 0; k < seats.length; k++) { const p = seats[k]; produced[k] += BASE[p] + rng.nextInt(SPREAD[p]); } for (let k = 0; k < seats.length; k++) { if (produced[k] >= GOAL) return [seats[k], turn]; } }}const games = 200;const rng = new Random(7);const played = [0, 0, 0];const wins = [0, 0, 0];const winTurns = [0, 0, 0];let totalTurns = 0;for (let i = 0; i < games; i++) { const seats = [0, 1, 2, 3].map((k) => (i + k) % 3); for (const p of seats) played[p]++; const [winner, turn] = playGame(rng, seats); wins[winner]++; winTurns[winner] += turn; totalTurns += turn;}
const names = ["Kriegsherr", "Baumeister", "Gelehrter"];console.log("Balance-Simulation: " + games + " Partien, Seed 7, Ziel " + GOAL + ".");console.log();console.log(pad("Charakter", 20) + padLeft("Partien", 10) + padLeft("Siege", 10) + padLeft("Anteil", 8) + padLeft("Ø Runde", 12));for (let i = 0; i < 3; i++) { const share = Math.floor((wins[i] * 100) / played[i]); const avg = wins[i] === 0 ? 0 : Math.floor(winTurns[i] / wins[i]); console.log(pad(names[i], 20) + padLeft(String(played[i]), 10) + padLeft(String(wins[i]), 10) + padLeft(share + "%", 8) + padLeft(String(avg), 12));}console.log();console.log("Durchschnittliche Siegrunde: " + Math.floor(totalTurns / games) + ".");Balance-Simulation: 200 Partien, Seed 7, Ziel 600.
Charakter Partien Siege Anteil Ø RundeKriegsherr 267 79 29% 19Baumeister 267 46 17% 20Gelehrter 266 75 28% 20
Durchschnittliche Siegrunde: 19.Das mittlere Einkommen ist gleich, und doch gewinnt der Baumeister nur 17 % gegen 29 % beim Kriegsherrn und 28 % beim Gelehrten. Warum? In einem Rennen zu einem Ziel gewinnt, wer irgendwann vorn liegt, nicht wer den besten Durchschnitt hat. Ein gleichmäßiger Spieler ist nie weit von der Mitte, aber selten Erster; ein streuender setzt sich manchmal ab. Der Durchschnitt genügt also nicht, und deshalb simuliert man: Die Zahl kommt aus dem Spiel selbst.
Und hier die echte Simulation von Epic Cosmos: zweihundert Partien, Startwert 7, aus simulate.bg.out. Die Datei gehört zur Prüfung, wie der Durchlauf:
| Charakter | Partien | Siege | Anteil | Ø Runde |
|---|---|---|---|---|
| Kriegsherr | 223 | 61 | 27% | 47 |
| Baumeister | 220 | 59 | 26% | 57 |
| Gelehrter | 213 | 80 | 37% | 56 |
Siege nach Art: Eroberung 50, Forschung 90, Wirtschaft 60, Punkte 0. Das durchschnittliche Ende einer Partie liegt in Runde 53, und eine Galaxie hat im Mittel 15 Systeme.
Was sagt die Tabelle? Der Gelehrte gewinnt am häufigsten (37%), Kriegsherr und Baumeister liegen nah beieinander. Punkte: 0 von 200. Keine einzige Partie erreicht Runde 150, weil jemand viel früher erobert, das Tor erforscht oder 9800 produziert. Die Frist ist ein Sicherheitsnetz, kein Sieg, den die KI wählt. Das ist ein gutes Zeichen: Punkte sind für eine Partie, in der niemand etwas tut.
Die Balance stellt man mit den Zahlen in den Daten ein (Preis einer Technologie, Ertrag einer Mine, Stärke eines Schiffs), und nach jeder Änderung läuft die Simulation neu. Steht der Gelehrte bei 37 %, probierst du ein teureres Sternentor und siehst, wie sich die Tabelle bewegt.
Genauer, für Neugierige
Die Simulation ist auch ein Test des Spiels. Zwei Läufe mit zweihundert Partien und demselben Startwert müssen dieselbe Datei geben, und das ist die Prüfung: simulate.bg.out wird auf allen vier Sprachen Byte für Byte verglichen. Hat ein Port einen Unterschied in einem Würfelwurf, geht die Partie anders, und die Tabelle weicht ab. So findet eine Tabelle aus Hunderten Partien Fehler, die der Durchlauf mit 192 Befehlen nicht erreicht.
Probier es aus
Spiel es
Das ist das ganze Spiel, die JavaScript-Version des Kanons, auf der Seite gestartet. Du bist das Terranische Reich; die Karte zeigt die Systeme und die Wege dazwischen. Es gibt drei Wege zu gewinnen: das Sternentor erforschen, 9800 produzieren oder die Rivalen sich gegenseitig vernichten lassen. Je früher, desto besser, aber die Frist ist Runde 150.
Epic Cosmos: das Imperium
Vier Reiche, eine Galaxie und hundertfünfzig Runden. Sieg heißt Forschung, Wirtschaft oder Eroberung.
Tippe die Befehle in die Zeile unten und drücke Enter (help zeigt alle; eine leere Zeile ist map). Spielstände bleiben in diesem Browser.
Befehle (tippe sie in die Zeile unter dem Bildschirm): help zeigt alle, map ist die Karte, sys 3 ist System 3, tech ist der Technologiebaum, r stargate erforscht das Sternentor, b mine 2 baut eine Mine auf Planet 2, ship i 2 1 baut zwei Abfangjäger, go 1 3 schickt die Flotte, emp zeigt die Reiche, und end und wait 3 beenden die Runde (oder bis zu drei Runden). save 1, load 1 und slots arbeiten mit Spielständen: Sie bleiben im Browser.
Probiere alles aus der Lektion aus: Speichere in Platz 1, spiele noch ein wenig, lade Platz 1 und sieh, dass du zurück bist. Schau dir emp an: wie viele Punkte jedes hat. Bekommst du beim Spielen Lust auf einen anderen Preis für eine Technologie? Das ist Balance.
Die Idee
Auf deinem Computer starten
Die ganze Datei liegt hier: [Cosmos.java](/games/cosmos/de/Cosmos.java), [Cosmos.cs](/games/cosmos/de/Cosmos.cs), [cosmos.py](/games/cosmos/de/cosmos.py), [cosmos.js](/games/cosmos/de/cosmos.js). Leg sie in einen leeren Ordner und starte sie:
node cosmos.jsMit --plain druckt es die Bilder als einfachen Text, mit --seed=42 ist die Galaxie immer dieselbe, mit --lang=de ist es auf Deutsch (es gibt en, de und es). Die Simulation, die du gesehen hast, ist --simulate=200 (mit --seed=7 gibt sie dieselbe Tabelle). Spielstände sind Dateien in einem Ordner saves neben dem Spiel, und --memory-saves hält sie im Speicher.
import); beschwert sich Node, nenne sie in cosmos.mjs um.Das Spiel ist viermal mit denselben Namen geschrieben und gibt auf jeder Sprache Byte für Byte dieselbe Ausgabe. Die Prüfung der Seite startet jede Version mit dem aufgezeichneten Durchlauf (192 Befehle) und mit der Simulation und vergleicht sie mit einer Datei. So kannst du das Spiel in der Sprache lesen, die du kennst, und sicher sein, dass es dasselbe ist.
Die Idee
Ideen zum Weitermachen
Das Spiel gehört dir: Du hast den ganzen Code. Hier sind Dinge in genau der richtigen Größe, und jedes nutzt etwas aus dieser Lektion.
- Ein diplomatischer Sieg. Eine neue Prüfung in
checkVictory: Frieden mit allen lebenden Reichen 20 Runden in Folge. Wo in der Reihenfolge gehört sie hin? Vor oder nach der Wirtschaft? Baue sie ein und lass die Simulation laufen, um zu sehen, wie viele Partien so enden. - Automatisches Speichern. Speichere bei jedem
endin Platz 0. Was passiert, wenn das Spiel mitten im Speichern abstürzt? (Tipp: in eine temporäre Datei schreiben und sie umbenennen.) - Eine neue Version des Spielstands. Füge einen Schlüssel hinzu (etwa
difficulty) und hebe die Version auf 2. Das Laden soll auch die alte Version 1 lesen und einen Standardwert einsetzen. So geht jedes Spiel von Version zu Version. - Eine Partie als Befehle. Startwert und Liste der Befehle sind die ganze Partie: Das ist genau der Durchlauf, mit dem du das Spiel prüfst. Baue
replay <Datei>. - Balance nach deinem Geschmack. Starte
--simulate=1000mit verschiedenen Zahlen für Technologiepreis und Ertrag und schreibe in einer Tabelle auf, was am fairsten ist. - Eine andere Galaxie. Karten aus einer Datei statt aus einem Startwert, oder eine größere Galaxie (die Wege im großen Maßstab, der Graph aus der ersten Lektion).
- Spieler gegen Spieler im Netz. Die Befehle sind schon Text, eine Zeile je Befehl: Züge können über einen Socket reisen. Das Spiel ändert sich nicht, nur woher die Zeile kommt.
- Eine neue Sprache. Portiere das Spiel nach Go, Rust oder Kotlin und lass denselben Durchlauf laufen: Die Ausgabe muss dieselbe Datei sein.
Probier es aus
Sage die Ausgabe voraus
Was druckt das Programm?
function checksum(text) { let sum = 0; for (let i = 0; i < text.length; i++) { sum = (sum * 31 + text.charCodeAt(i)) % 1000000007; } return sum;}
console.log(checksum("ab") + ", " + checksum("ba") + ", " + checksum("abc"));„ab“ ist 97 × 31 + 98 = 3105, „ba“ ist 98 × 31 + 97 = 3135, und „abc“ ist 3105 × 31 + 99 = 96354. Die zweite Möglichkeit wäre eine Prüfsumme, die die Reihenfolge nicht unterscheidet (diese multipliziert aber mit 31, deshalb sind „ab“ und „ba“ verschieden). Die dritte ist die Summe der Codes (195, 195, 294): So käme es ohne die Multiplikation mit 31.
Probier es aus
Finde den Fehler
Ein Kollege wollte eine Zeile in bestScoring „verbessern“. Die beiden Reiche haben gleiche Punkte, und das Programm druckt, dass das spätere gewinnt (Orion-Hegemonie). Die Regel ist, dass das frühere gewinnt. Welche Zeile ist schuld?
Wähle die Zeile mit dem Fehler.
>= statt >: Bei Gleichstand wird das neue zum Führenden, deshalb gewinnt das letzte der Gleichen, nicht das erste. Mit > ist der Vergleich strikt, und ein Gleichstand verdrängt den Führenden nicht. Der Fehler zeigt sich bei verschiedenen Punkten nicht, deshalb ist er schwer zu finden: Er tritt nur bei Gleichstand auf.
Übung
Eine Prüfsumme für jede Zeile
Löse die Aufgabe in deinem Editor.
Eine Prüfsumme für jede Zeile. Das Programm liest Zeilen bis zum Ende der Eingabe und druckt für jede nicht leere Zeile die Zeile, ein Leerzeichen und ihre Prüfsumme (nach der Formel aus der Lektion, Rest modulo 1000000007).
Mit der Eingabe turn=12 | turn=13 | seed=2026 | COSMOS (je eine pro Zeile) lautet die Ausgabe:
turn=12 408655515turn=13 408655516seed=2026 801283199COSMOS 993660451Prüfe mit „ab“ und „ba“: 3105 und 3135. Achte auf das Zwischenergebnis sum * 31: In Python und JavaScript rechnen es ohne Überlauf, aber die Formel bleibt dieselbe.
Weitere Aufgaben
- Ein neuer Sieg. Füge dem Programm
victoryeinen diplomatischen Sieg hinzu (Frieden mit allen lebenden Reichen 20 Runden in Folge). Wo gehört er in der Reihenfolge hin? - Version 2. Füge dem Spielstand den Schlüssel
difficultyhinzu und hebeVERSIONauf 2.loadsoll auch Version 1 mit Standardwert 1 annehmen. Was passiert mit der Prüfsumme eines alten Spielstands? - Gleiche Balance. Wähle im Programm
balancedie Zahlen des Baumeisters (Ertrag und Streuung) so, dass alle drei Charaktere zwischen 30 % und 36 % der Partien gewinnen, während das mittlere Einkommen 30 bleibt. - Einen zweiten Eintrag fangen. Was passiert, wenn zwei Zeilen im Spielstand denselben Schlüssel haben? Lies
SaveReader: Welcher Wert bleibt? Schreibe eine Prüfung, die das ablehnt.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Exceptions · Werfen und eigene Exceptions · Arrays: push, splice und sort · Map und Set
- Aufgaben zum Thema: Epic Cosmos: das Imperium