Zum Inhalt
wannacode

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 376
C Lyra-Union 0 3 38 zerstört
D Vega-Akademie 7 12 319
B Orion-Hegemonie 2 8 154
Kämpfe 27, verlorene Einheiten 159, Kolonien 12, Technologien
36, 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:

  1. Eroberung: Nur ein Reich ist noch am Leben.
  2. Niederlage: Der Spieler ist vernichtet (die Partie endet, und das stärkste der übrigen wird als Sieger eingetragen).
  3. Forschung: Ein lebendes Reich hat das Sternentor erforscht, die Technologie am Ende des Baums.
  4. Wirtschaft: Ein lebendes Reich hat insgesamt 9800 produziert.
  5. 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.
static int empireScore(World world, Empire empire) {
return ownedPlanets(world, empire.id).size() * 10 + empire.techs.size() * 6 + empirePower(world, empire.id) / 10 + empire.produced / 40;
}
static Empire bestScoring(World world) {
Empire best = null;
int bestScore = -1;
for (Empire e : world.empires) {
if (!e.alive) continue;
int score = empireScore(world, e);
if (score > bestScore) {
bestScore = score;
best = e;
}
}
return best;
}
static void checkVictory(World world) {
if (world.victory != null) return;
if (world.livingCount() == 1) {
for (Empire e : world.empires) {
if (e.alive) world.victory = new Victory(e.id, "conquest", world.turn);
}
return;
}
for (Empire e : world.empires) {
if (e.human && !e.alive) {
world.victory = new Victory(bestScoring(world).id, "lost", world.turn);
return;
}
}
for (Empire e : world.empires) {
if (e.alive && knows(e, "stargate")) {
world.victory = new Victory(e.id, "science", world.turn);
return;
}
}
for (Empire e : world.empires) {
if (e.alive && e.produced >= ECON_GOAL) {
world.victory = new Victory(e.id, "economy", world.turn);
return;
}
}
if (world.turn >= MAX_TURNS) world.victory = new Victory(bestScoring(world).id, "points", 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 {
final String name;
final boolean human;
boolean alive = true;
boolean stargate = false;
int planets;
int techs;
int power;
int produced;
Empire(String name, boolean human, int planets, int techs, int power, int produced) {
this.name = name;
this.human = human;
this.planets = planets;
this.techs = techs;
this.power = power;
this.produced = produced;
}
int score() {
return planets * 10 + techs * 6 + power / 10 + produced / 40;
}
}
class Victory {
final String kind;
final String winner;
Victory(String kind, String 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):

static Victory checkVictory(List<Empire> empires, int turn) {
int living = 0;
Empire last = null;
for (Empire e : empires) {
if (e.alive) {
living++;
last = e;
}
}
if (living == 1) return new Victory("Eroberung", last.name);
for (Empire e : empires) {
if (e.human && !e.alive) return new Victory("Niederlage", bestScoring(empires).name);
}
for (Empire e : empires) {
if (e.alive && e.stargate) return new Victory("Forschung", e.name);
}
for (Empire e : 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.

List<Empire> game = fresh();
show("Runde 40, nichts ist entschieden", game, 40);
game = fresh();
game.get(2).stargate = true;
show("Sternentor erforscht", game, 40);
game = fresh();
game.get(1).produced = ECON_GOAL;
show("9800 produziert", game, 40);
game = fresh();
game.get(2).stargate = true;
game.get(2).produced = ECON_GOAL;
show("Tor und 9800 in derselben Runde", game, 40);
game = fresh();
game.get(1).alive = false;
game.get(2).alive = false;
show("nur ein Reich ist übrig", game, 40);
game = fresh();
game.get(0).alive = false;
show("der Spieler ist vernichtet", game, 40);
show("Runde 150, kein anderer Sieg", fresh(), 150);
Ausgabe
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:

static Victory checkVictory(List<Empire> empires, int turn) {
int living = 0;
Empire last = null;
for (Empire e : empires) {
if (e.alive) {
living++;
last = e;
}
}
if (living == 1) return new Victory("Eroberung", last.name);
for (Empire e : empires) {
if (e.human && !e.alive) return new Victory("Niederlage", bestScoring(empires).name);
}
for (Empire e : empires) {
if (e.alive && e.stargate) return new Victory("Forschung", e.name);
}
for (Empire e : 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
  1. 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.
  2. Runde 40. Alle drei Reiche leben, niemand hat das Sternentor, niemand hat 9800 erreicht.
  3. Fünfmal „Nein“. checkVictory gibt null zurück, und das Spiel geht zur nächsten Runde.
  4. Vega-Akademie: Das Sternentor ist erforscht und 9800 sind produziert, beides in derselben Runde. Zwei Siege auf einmal.
  5. 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.
  6. Orion-Hegemonie und Vega-Akademie sind vernichtet. Ein Reich ist übrig: Terranisches Reich.
  7. Die erste Prüfung sagt „Ja“: Eroberung. Die vier danach laufen gar nicht erst.
  8. Runde 150: die Frist. Niemand hat auf andere Weise gewonnen.
  9. 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.

static Empire bestScoring(List<Empire> empires) {
Empire best = null;
int bestScore = -1;
for (Empire e : empires) {
if (!e.alive) continue;
int 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:

List<Empire> rivals = new ArrayList<>();
rivals.add(new Empire("Terranisches Reich", false, 5, 9, 400, 3000));
rivals.add(new Empire("Orion-Hegemonie", false, 3, 11, 450, 3120));
rivals.add(new Empire("Vega-Akademie", false, 4, 8, 300, 2000));
Empire lyra = new Empire("Lyra-Union", false, 8, 14, 900, 6000);
lyra.alive = false;
rivals.add(lyra);
for (Empire e : rivals) {
String note = e.alive ? "" : " (zerstört)";
System.out.println(e.name + ": " + e.score() + note);
}
System.out.println("Meiste Punkte: " + bestScoring(rivals).name);
Ausgabe
Terranisches Reich: 219
Orion-Hegemonie: 219
Vega-Akademie: 168
Lyra-Union: 404 (zerstört)
Meiste Punkte: Terranisches Reich

Die 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 (/ auf ganzen Zahlen in Java und C#), 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.

static String saveWorld(World world) {
List<String> lines = new ArrayList<>();
put(lines, "format", SAVE_FORMAT);
put(lines, "version", SAVE_VERSION);
put(lines, "summary", saveSummary(world));
put(lines, "seed", world.seed);
put(lines, "rng", world.rng.state);
put(lines, "turn", world.turn);
put(lines, "systems", world.systems.size());
put(lines, "planets", world.planets.size());
put(lines, "empires", world.empires.size());
for (Planet planet : world.planets) {
put(lines, "planet." + planet.id, joinInts(concat(new int[] {planet.owner, planet.mines, planet.lab, planet.yard, planet.fort, planet.siege}, planet.rich), ","));
}
for (Empire empire : world.empires) {
String prefix = "empire." + empire.id;
put(lines, prefix, empire.def.id + "," + empire.personality.id + "," + (empire.alive ? 1 : 0) + "," + empire.progress + "," + empire.produced + "," + empire.flare + "," + joinInts(empire.res, ","));
put(lines, prefix + ".researching", empire.researching.equals("") ? "-" : empire.researching);
put(lines, prefix + ".techs", joinOrDash(empire.techs));
put(lines, prefix + ".war", joinInts(empire.war, ","));
put(lines, prefix + ".since", joinInts(empire.since, ","));
put(lines, prefix + ".grudge", joinInts(empire.grudge, ","));
List<String> orders = new ArrayList<>();
for (Order order : empire.orders) orders.add(joinInts(concat(new int[] {order.from, order.to, order.dest}, order.ships), ":"));
put(lines, prefix + ".orders", joinOrDash(orders));
}
put(lines, "fleets", world.fleets.size());
for (int i = 0; i < world.fleets.size(); i++) {
Fleet fleet = world.fleets.get(i);
put(lines, "fleet." + i, joinInts(concat(new int[] {fleet.owner, fleet.systemId}, fleet.ships), ","));
}
put(lines, "queue", world.queue.size());
for (int i = 0; i < world.queue.size(); i++) {
QueueItem item = world.queue.get(i);
put(lines, "queue." + i, joinInts(new int[] {item.owner, item.planetId, item.kind, item.count, item.left}, ","));
}
List<String> offers = new ArrayList<>();
for (Offer offer : world.offers) offers.add(offer.from + ":" + offer.to + ":" + offer.turn);
put(lines, "offers", joinOrDash(offers));
Stats s = world.stats;
put(lines, "stats", joinInts(new int[] {s.battles, s.lost, s.colonies, s.techs, s.events}, ","));
String body = String.join("\n", lines) + "\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 {
final int seed;
int turn;
final List<int[]> empires = new ArrayList<>();
Game(int seed, int turn) {
this.seed = seed;
this.turn = turn;
}
}
static String save(Game game) {
List<String> lines = new ArrayList<>();
lines.add("format=COSMOS");
lines.add("version=" + VERSION);
lines.add("seed=" + game.seed);
lines.add("turn=" + game.turn);
lines.add("empires=" + game.empires.size());
for (int i = 0; i < game.empires.size(); i++) {
int[] e = game.empires.get(i);
lines.add("empire." + i + "=" + e[0] + "," + e[1] + "," + e[2]);
}
String body = String.join("\n", lines) + "\n";
return body + "checksum=" + checksum(body) + "\n";
}
System.out.print(save(sample()));
Ausgabe
format=COSMOS
version=1
seed=2026
turn=12
empires=3
empire.0=5,3120,9
empire.1=4,2750,7
empire.2=3,4180,10
checksum=987468948

Das 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:

// Die Summe überschreitet eine Milliarde: sum * 31 passt nicht in ein int, darum ist es ein long.
static long checksum(String text) {
long sum = 0;
for (int i = 0; i < text.length(); i++) sum = (sum * 31 + text.charAt(i)) % 1000000007L;
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.

String[] texts = {"ab", "ba", "turn=12", "turn=21"};
for (String word : texts) {
System.out.println(word + " -> " + checksum(word));
}
Ausgabe
ab -> 3105
ba -> 3135
turn=12 -> 408655515
turn=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 long hält das Zwischenergebnis sum * 31, das über zwei Milliarden liegt.

static long checksum(String text) {
    long sum = 0;
    for (int i = 0; i < text.length(); i++) {
        sum = (sum * 31 + text.charAt(i))  1000000007;
    }
    return sum;
}
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.

static class SaveReader {
Map<String, String> values = new LinkedHashMap<>();
SaveReader(String text) {
List<String> lines = split(text, "\n");
while (lines.size() > 0 && lines.get(lines.size() - 1).length() == 0) lines.remove(lines.size() - 1);
if (lines.size() == 0 || !lines.get(0).equals("format=" + SAVE_FORMAT)) throw new SaveError(t("load.badHeader"));
for (int i = 0; i < lines.size(); i++) {
int eq = lines.get(i).indexOf('=');
if (eq <= 0) throw new SaveError(t("load.badLine", "line", i + 1));
this.values.put(lines.get(i).substring(0, eq), lines.get(i).substring(eq + 1));
}
String version = this.text("version");
if (!version.equals(String.valueOf(SAVE_VERSION))) throw new SaveError(t("load.badVersion", "found", version, "expected", SAVE_VERSION));
String last = lines.get(lines.size() - 1);
if (!last.startsWith("checksum=")) throw new SaveError(t("load.missing", "key", "checksum"));
String body = String.join("\n", lines.subList(0, lines.size() - 1)) + "\n";
long actual = checksum(body);
Long given = parseIntStrict(last.substring(9));
if (given == null || given != actual) {
throw new SaveError(t("load.badChecksum", "expected", last.substring(9), "actual", actual));
}
}
String text(String key) {
if (!this.values.containsKey(key)) throw new SaveError(t("load.missing", "key", key));
return this.values.get(key);
}
// Eine Zahl in den Grenzen [low, high]. Gibt ein long zurück: der Seed reicht bis 4294967295.
long integer(String key, long low, long high) {
Long 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.
int[] ints(String key, int count, int low, int high) {
List<String> parts = split(this.text(key), ",");
if (parts.size() != count) throw new SaveError(t("load.badValue", "key", key));
int[] result = new int[count];
for (int i = 0; i < count; i++) {
Long value = parseIntStrict(parts.get(i));
if (value == null || value < low || value > high) throw new SaveError(t("load.badValue", "key", key));
result[i] = (int) (long) value;
}
return result;
}
List<String> list(String 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:

In Java und C# liegen die Funktionen in der Klasse des Programms (Main und Program), und SaveReader ist eine eigene Klasse, die sie mit dem Klassennamen aufruft. Im Kanon ist alles in verschachtelten Klassen einer Klasse, und der Name ist nicht nötig.

class SaveReader {
final Map<String, String> values = new HashMap<>();
SaveReader(String text) {
List<String> lines = new ArrayList<>(Arrays.asList(text.split("\n", -1)));
while (!lines.isEmpty() && lines.get(lines.size() - 1).isEmpty()) lines.remove(lines.size() - 1);
if (lines.isEmpty() || !lines.get(0).equals("format=COSMOS")) throw new SaveError("das ist kein Epic-Cosmos-Spielstand");
for (int i = 0; i < lines.size(); i++) {
int eq = lines.get(i).indexOf('=');
if (eq <= 0) throw new SaveError("Zeile " + (i + 1) + " ist nicht „Schlüssel=Wert“");
values.put(lines.get(i).substring(0, eq), lines.get(i).substring(eq + 1));
}
String version = text("version");
if (!version.equals(String.valueOf(Main.VERSION))) throw new SaveError("Version " + version + ", aber das Spiel liest Version " + Main.VERSION);
String last = lines.get(lines.size() - 1);
if (!last.startsWith("checksum=")) throw new SaveError("„" + "checksum" + "“ fehlt");
String body = String.join("\n", lines.subList(0, lines.size() - 1)) + "\n";
long actual = Main.checksum(body);
if (Main.digits(last.substring(9)) != actual) {
throw new SaveError("Prüfsumme falsch (gespeichert " + last.substring(9) + ", berechnet " + actual + "), Datei verändert");
}
}
String text(String key) {
if (!values.containsKey(key)) throw new SaveError("„" + key + "“ fehlt");
return values.get(key);
}
int integer(String key, int low, int high) {
long value = Main.digits(text(key));
if (value < low || value > high) throw new SaveError("falscher Wert bei „" + key + "“");
return (int) value;
}
int[] numbers(String key, int count, int low, int high) {
String[] parts = text(key).split(",", -1);
if (parts.length != count) throw new SaveError("falscher Wert bei „" + key + "“");
int[] result = new int[count];
for (int i = 0; i < count; i++) {
long value = Main.digits(parts[i]);
if (value < low || value > high) throw new SaveError("falscher Wert bei „" + key + "“");
result[i] = (int) value;
}
return result;
}
}
static Game load(String text) {
SaveReader reader = new SaveReader(text);
Game game = new Game(reader.integer("seed", 0, 99999), reader.integer("turn", 1, MAX_TURNS));
int count = reader.integer("empires", 1, 9);
for (int i = 0; i < count; i++) {
game.empires.add(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:

static void showLoad(String label, String text) {
try {
Game game = load(text);
int produced = 0;
for (int[] e : game.empires) produced += e[1];
System.out.println(label + ": " + "Runde " + game.turn + ", Reiche " + game.empires.size() + ", produziert " + produced);
} catch (SaveError error) {
System.out.println(label + ": " + error.getMessage());
}
}

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:

String good = save(sample());
String 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");
Ausgabe
unversehrt: Runde 12, Reiche 3, produziert 10050
von Hand geändert: Prüfsumme falsch (gespeichert 987468948, berechnet 410613307), Datei verändert
andere Version: Version 2, aber das Spiel liest Version 1
Zahl außerhalb der Grenzen: falscher Wert bei „turn“
Zeile fehlt: „seed“ fehlt
kein Spielstand: das ist kein Epic-Cosmos-Spielstand

Das 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:

> slots
Spielstände (save N, load N)
Platz 1: Runde 5, Terranisches Reich, 2 Planeten
Platz 2: leer
Platz 3: Runde 99, ohne Wache
> load 3
Der Spielstand ist beschädigt: Prüfsumme falsch (gespeichert
123456, 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.
static LoadResult loadWorld(String text) {
try {
return new LoadResult(readWorld(text), null);
} catch (SaveError error) {
return new LoadResult(null, error.getMessage());
}
}

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.
static final String[] PERSONALITY_IDS = {"warlord", "builder", "scholar"};
static final String[] VICTORY_KINDS = {"conquest", "science", "economy", "points"};
static void simulate(Io io, int games, long seed) {
io.write(t("sim.title", "games", games, "seed", seed) + "\n");
io.write(t("sim.note1", "turns", MAX_TURNS, "goal", ECON_GOAL) + "\n\n");
int[] played = {0, 0, 0};
int[] wins = {0, 0, 0};
int[] winTurns = {0, 0, 0};
int[] kinds = {0, 0, 0, 0};
int totalTurns = 0;
int totalSystems = 0;
for (int i = 0; i < games; i++) {
String[] order = new String[4];
for (int k = 0; k < 4; k++) order[k] = PERSONALITY_IDS[(i + k) % 3];
World world = newWorld(seed + i, false, order, true);
totalSystems += world.systems.size();
while (world.victory == null) runTurn(world);
for (Empire empire : world.empires) played[empire.personality.index]++;
Empire winner = world.empires.get(world.victory.empire);
wins[winner.personality.index]++;
winTurns[winner.personality.index] += world.victory.turn;
for (int k = 0; k < VICTORY_KINDS.length; k++) {
if (VICTORY_KINDS[k].equals(world.victory.kind)) {
kinds[k]++;
break;
}
}
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 (Personality personality : PERSONALITIES.values()) {
int i = personality.index;
int avg = wins[i] == 0 ? 0 : winTurns[i] / wins[i];
io.write(pad(cap(nm(personality)), 20) + padLeft(String.valueOf(played[i]), 10) + padLeft(String.valueOf(wins[i]), 10) + padLeft(percent(wins[i], played[i]) + "%", 8) + padLeft(String.valueOf(avg), 12) + "\n");
}
List<String> parts = new ArrayList<>();
for (int k = 0; k < VICTORY_KINDS.length; k++) parts.add(t("victory." + VICTORY_KINDS[k]) + " " + kinds[k]);
io.write("\n" + t("sim.kinds", "list", String.join(", ", parts)) + "\n");
io.write(t("sim.averages", "turns", totalTurns / games, "systems", 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:

static int[] playGame(Random rng, int[] seats) {
int[] produced = new int[seats.length];
int turn = 0;
while (true) {
turn++;
for (int k = 0; k < seats.length; k++) {
int p = seats[k];
produced[k] += BASE[p] + rng.nextInt(SPREAD[p]);
}
for (int k = 0; k < seats.length; k++) {
if (produced[k] >= GOAL) return new int[] {seats[k], turn};
}
}
}
int games = 200;
Random rng = new Random(7);
int[] played = new int[3];
int[] wins = new int[3];
int[] winTurns = new int[3];
int totalTurns = 0;
for (int i = 0; i < games; i++) {
int[] seats = new int[4];
for (int k = 0; k < 4; k++) {
seats[k] = (i + k) % 3;
played[seats[k]]++;
}
int[] result = playGame(rng, seats);
wins[result[0]]++;
winTurns[result[0]] += result[1];
totalTurns += result[1];
}
String[] names = {"Kriegsherr", "Baumeister", "Gelehrter"};
System.out.println("Balance-Simulation: " + games + " Partien, Seed 7, Ziel " + GOAL + ".");
System.out.println();
System.out.println(pad("Charakter", 20) + padLeft("Partien", 10) + padLeft("Siege", 10) + padLeft("Anteil", 8) + padLeft("Ø Runde", 12));
for (int i = 0; i < 3; i++) {
int share = wins[i] * 100 / played[i];
int avg = wins[i] == 0 ? 0 : winTurns[i] / wins[i];
System.out.println(pad(names[i], 20) + padLeft("" + played[i], 10) + padLeft("" + wins[i], 10) + padLeft(share + "%", 8) + padLeft("" + avg, 12));
}
System.out.println();
System.out.println("Durchschnittliche Siegrunde: " + totalTurns / games + ".");
Ausgabe
Balance-Simulation: 200 Partien, Seed 7, Ziel 600.
Charakter Partien Siege Anteil Ø Runde
Kriegsherr 267 79 29% 19
Baumeister 267 46 17% 20
Gelehrter 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:

CharakterPartienSiegeAnteilØ Runde
Kriegsherr2236127%47
Baumeister2205926%57
Gelehrter2138037%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:

Terminal
java Cosmos.java

Mit --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.

Java 21 startet eine Datei mit vielen Klassen direkt, ohne javac.

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 end in 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=1000 mit 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?

static long checksum(String text) {
long sum = 0;
for (int i = 0; i < text.length(); i++) {
sum = (sum * 31 + text.charAt(i)) % 1000000007;
}
return sum;
}
System.out.println(checksum("ab") + ", " + checksum("ba") + ", " + checksum("abc"));
Antwort

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.

Ü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:

Ausgabe
turn=12 408655515
turn=13 408655516
seed=2026 801283199
COSMOS 993660451

Prüfe mit „ab“ und „ba“: 3105 und 3135. Achte auf das Zwischenergebnis sum * 31: In Java und C# muss es long sein, sonst läuft es bei einer längeren Zeile über.

Weitere Aufgaben

  • Ein neuer Sieg. Füge dem Programm victory einen 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 difficulty hinzu und hebe VERSION auf 2. load soll auch Version 1 mit Standardwert 1 annehmen. Was passiert mit der Prüfsumme eines alten Spielstands?
  • Gleiche Balance. Wähle im Programm balance die 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

1. Ein Reich hat das Sternentor erforscht und in derselben Runde 9800 produziert. Welcher Sieg zählt?
2. Am Ende von Runde 150 haben zwei lebende Reiche je 219 Punkte. Welches gewinnt?
3. Warum ist die Prüfsumme sum × 31 + Zeichencode und nicht einfach die Summe der Codes?
4. Jemand hat einen Wert im Spielstand von Hand geändert und die Prüfsumme selbst neu berechnet. Was tut das Laden?
5. Warum dreht die Simulation die Charaktere durch die Plätze ((i + k) % 3), statt sie immer in derselben Reihenfolge zu setzen?
6. Die Simulation von Cosmos zeigt „Punkte 0“ bei 200 Partien. Was bedeutet das?

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