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.
def empire_score(world, empire):
return len(owned_planets(world, empire.id)) * 10 + len(empire.techs) * 6 + empire_power(world, empire.id) // 10 + empire.produced // 40
def best_scoring(world):
best = None
best_score = -1
for e in world.empires:
if not e.alive:
continue
score = empire_score(world, e)
if score > best_score:
best_score = score
best = e
return best
def check_victory(world):
if world.victory is not None:
return
if world.living_count() == 1:
for e in world.empires:
if e.alive:
world.victory = SimpleNamespace(empire=e.id, kind='conquest', turn=world.turn)
return
for e in world.empires:
if e.human and not e.alive:
world.victory = SimpleNamespace(empire=best_scoring(world).id, kind='lost', turn=world.turn)
return
for e in world.empires:
if e.alive and knows(e, 'stargate'):
world.victory = SimpleNamespace(empire=e.id, kind='science', turn=world.turn)
return
for e in world.empires:
if e.alive and e.produced >= ECON_GOAL:
world.victory = SimpleNamespace(empire=e.id, kind='economy', turn=world.turn)
return
if world.turn >= MAX_TURNS:
world.victory = SimpleNamespace(empire=best_scoring(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:
def __init__(self, name, human, planets, techs, power, produced):
self.name = name
self.human = human
self.alive = True
self.stargate = False
self.planets = planets
self.techs = techs
self.power = power
self.produced = produced
def score(self):
return self.planets * 10 + self.techs * 6 + self.power // 10 + self.produced // 40
class Victory:
def __init__(self, kind, winner):
self.kind = kind
self.winner = winner

Die Prüfungen sind dieselben fünf, in derselben Reihenfolge (ECON_GOAL ist 9800, MAX_TURNS ist 150):

def check_victory(empires, turn):
living = [e for e in empires if e.alive]
if len(living) == 1:
return Victory("Eroberung", living[0].name)
for e in empires:
if e.human and not e.alive:
return Victory("Niederlage", best_scoring(empires).name)
for e in empires:
if e.alive and e.stargate:
return Victory("Forschung", e.name)
for e in empires:
if e.alive and e.produced >= ECON_GOAL:
return Victory("Wirtschaft", e.name)
if turn >= MAX_TURNS:
return Victory("Punkte", best_scoring(empires).name)
return None

Sieben Welten, sieben Antworten. Die letzten beiden zeigen das Wichtigste: Ein vernichteter Spieler ergibt Niederlage, und Runde 150 ohne anderen Sieg ergibt Punkte.

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

def check_victory(empires, turn):
living = [e for e in empires if e.alive]
if len(living) == 1:
return Victory("Eroberung", living[0].name)
for e in empires:
if e.human and not e.alive:
return Victory("Niederlage", best_scoring(empires).name)
for e in empires:
if e.alive and e.stargate:
return Victory("Forschung", e.name)
for e in empires:
if e.alive and e.produced >= ECON_GOAL:
return Victory("Wirtschaft", e.name)
if turn >= MAX_TURNS:
return Victory("Punkte", best_scoring(empires).name)
return None
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.

def best_scoring(empires):
best = None
best_score = -1
for e in empires:
if not e.alive:
continue
score = e.score()
if score > best_score:
best_score = 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:

rivals = [
Empire("Terranisches Reich", False, 5, 9, 400, 3000),
Empire("Orion-Hegemonie", False, 3, 11, 450, 3120),
Empire("Vega-Akademie", False, 4, 8, 300, 2000),
]
lyra = Empire("Lyra-Union", False, 8, 14, 900, 6000)
lyra.alive = False
rivals.append(lyra)
for e in rivals:
note = "" if e.alive else " (zerstört)"
print(e.name + ": " + str(e.score()) + note)
print("Meiste Punkte: " + best_scoring(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 (// in Python), 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.

def save_world(world):
lines = []
def put(key, value):
lines.append(key + '=' + num_str(value))
put('format', SAVE_FORMAT)
put('version', SAVE_VERSION)
put('summary', save_summary(world))
put('seed', world.seed)
put('rng', world.rng.state)
put('turn', world.turn)
put('systems', len(world.systems))
put('planets', len(world.planets))
put('empires', len(world.empires))
for planet in world.planets:
put('planet.' + str(planet.id), join_ints([planet.owner, planet.mines, planet.lab, planet.yard, planet.fort, planet.siege] + planet.rich, ','))
for empire in world.empires:
prefix = 'empire.' + str(empire.id)
put(prefix, join_ints([empire.def_.id, empire.personality.id, 1 if empire.alive else 0, empire.progress, empire.produced, empire.flare] + empire.res, ','))
put(prefix + '.researching', '-' if empire.researching == '' else empire.researching)
put(prefix + '.techs', join_or_dash(empire.techs))
put(prefix + '.war', join_ints(empire.war, ','))
put(prefix + '.since', join_ints(empire.since, ','))
put(prefix + '.grudge', join_ints(empire.grudge, ','))
orders = []
for order in empire.orders:
orders.append(join_ints([order.from_, order.to, order.dest] + order.ships, ':'))
put(prefix + '.orders', join_or_dash(orders))
put('fleets', len(world.fleets))
for i in range(len(world.fleets)):
fleet = world.fleets[i]
put('fleet.' + str(i), join_ints([fleet.owner, fleet.system_id] + fleet.ships, ','))
put('queue', len(world.queue))
for i in range(len(world.queue)):
item = world.queue[i]
put('queue.' + str(i), join_ints([item.owner, item.planet_id, item.kind, item.count, item.left], ','))
offers = []
for offer in world.offers:
offers.append(str(offer.from_) + ':' + str(offer.to) + ':' + str(offer.turn))
put('offers', join_or_dash(offers))
s = world.stats
put('stats', join_ints([s.battles, s.lost, s.colonies, s.techs, s.events], ','))
body = '\n'.join(lines) + '\n'
return body + 'checksum=' + str(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:
def __init__(self, seed, turn):
self.seed = seed
self.turn = turn
self.empires = []
def save(game):
lines = ["format=COSMOS", "version=" + str(VERSION)]
lines.append("seed=" + str(game.seed))
lines.append("turn=" + str(game.turn))
lines.append("empires=" + str(len(game.empires)))
for i, e in enumerate(game.empires):
lines.append("empire." + str(i) + "=" + ",".join(str(x) for x in e))
body = "\n".join(lines) + "\n"
return body + "checksum=" + str(checksum(body)) + "\n"
print(save(sample()), end="")
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:

def checksum(text):
total = 0
for i in range(len(text)):
total = (total * 31 + ord(text[i])) % 1000000007
return total

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.

texts = ["ab", "ba", "turn=12", "turn=21"]
for word in texts:
print(word + " -> " + str(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 Python kennt keinen Überlauf.

def checksum(text):
    total = 0
    for ch in text:
        total = (total * 31 + ord(ch))  1000000007
    return total
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:
def __init__(self, text):
self.values = {}
lines = text.split('\n')
while len(lines) > 0 and len(lines[-1]) == 0:
lines.pop()
if len(lines) == 0 or lines[0] != 'format=' + SAVE_FORMAT:
raise SaveError(t('load.badHeader'))
for i in range(len(lines)):
eq = lines[i].find('=')
if eq <= 0:
raise SaveError(t('load.badLine', {'line': i + 1}))
self.values[lines[i][:eq]] = lines[i][eq + 1:]
version = self.text('version')
if version != str(SAVE_VERSION):
raise SaveError(t('load.badVersion', {'found': version, 'expected': SAVE_VERSION}))
last = lines[-1]
if not last.startswith('checksum='):
raise SaveError(t('load.missing', {'key': 'checksum'}))
body = '\n'.join(lines[:-1]) + '\n'
actual = checksum(body)
if parse_int_strict(last[9:]) != actual:
raise SaveError(t('load.badChecksum', {'expected': last[9:], 'actual': actual}))
def text(self, key):
if key not in self.values:
raise SaveError(t('load.missing', {'key': key}))
return self.values[key]
# Eine Zahl in den Grenzen [low, high].
def integer(self, key, low, high):
value = parse_int_strict(self.text(key))
if value is None or value < low or value > high:
raise SaveError(t('load.badValue', {'key': key}))
return value
# Eine Liste von genau count Zahlen, jede in ihren Grenzen.
def ints(self, key, count, low, high):
parts = self.text(key).split(',')
if len(parts) != count:
raise SaveError(t('load.badValue', {'key': key}))
result = []
for part in parts:
value = parse_int_strict(part)
if value is None or value < low or value > high:
raise SaveError(t('load.badValue', {'key': key}))
result.append(value)
return result
def items(self, key):
return split_list(self.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:
def __init__(self, text):
self.values = {}
lines = text.split("\n")
while len(lines) > 0 and len(lines[-1]) == 0:
lines.pop()
if len(lines) == 0 or lines[0] != "format=COSMOS":
raise SaveError("das ist kein Epic-Cosmos-Spielstand")
for i, line in enumerate(lines):
eq = line.find("=")
if eq <= 0:
raise SaveError("Zeile " + str((i + 1)) + " ist nicht „Schlüssel=Wert“")
self.values[line[:eq]] = line[eq + 1:]
version = self.text("version")
if version != str(VERSION):
raise SaveError("Version " + version + ", aber das Spiel liest Version " + str(VERSION))
last = lines[-1]
if not last.startswith("checksum="):
raise SaveError("„" + "checksum" + "“ fehlt")
body = "\n".join(lines[:-1]) + "\n"
actual = checksum(body)
if digits(last[9:]) != actual:
raise SaveError("Prüfsumme falsch (gespeichert " + last[9:] + ", berechnet " + str(actual) + "), Datei verändert")
def text(self, key):
if key not in self.values:
raise SaveError("„" + key + "“ fehlt")
return self.values[key]
def integer(self, key, low, high):
value = digits(self.text(key))
if value < low or value > high:
raise SaveError("falscher Wert bei „" + key + "“")
return value
def numbers(self, key, count, low, high):
parts = self.text(key).split(",")
if len(parts) != count:
raise SaveError("falscher Wert bei „" + key + "“")
result = []
for part in parts:
value = digits(part)
if value < low or value > high:
raise SaveError("falscher Wert bei „" + key + "“")
result.append(value)
return result
def load(text):
reader = SaveReader(text)
game = Game(reader.integer("seed", 0, 99999), reader.integer("turn", 1, MAX_TURNS))
count = reader.integer("empires", 1, 9)
for i in range(count):
game.empires.append(reader.numbers("empire." + str(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:

def show_load(label, text):
try:
game = load(text)
produced = 0
for e in game.empires:
produced += e[1]
print(label + ": " + "Runde " + str(game.turn) + ", Reiche " + str(len(game.empires)) + ", produziert " + str(produced))
except SaveError as error:
print(label + ": " + str(error))

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:

good = save(sample())
body = good[:good.index("checksum=")]
show_load("unversehrt", good)
show_load("von Hand geändert", good.replace("3120", "99999"))
show_load("andere Version", good.replace("version=1", "version=2"))
show_load("Zahl außerhalb der Grenzen", reseal(body.replace("turn=12", "turn=999")))
show_load("Zeile fehlt", reseal(body.replace("seed=2026\n", "")))
show_load("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.
def load_world(text):
try:
return SimpleNamespace(world=read_world(text), error=None)
except SaveError as error:
return SimpleNamespace(world=None, error=str(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.
PERSONALITY_IDS = ['warlord', 'builder', 'scholar']
VICTORY_KINDS = ['conquest', 'science', 'economy', 'points']
def simulate(io, games, seed):
io.write(t('sim.title', {'games': num_str(games), 'seed': num_str(seed)}) + '\n')
io.write(t('sim.note1', {'turns': MAX_TURNS, 'goal': ECON_GOAL}) + '\n\n')
played = [0, 0, 0]
wins = [0, 0, 0]
win_turns = [0, 0, 0]
kinds = [0, 0, 0, 0]
total_turns = 0
total_systems = 0
i = 0
while i < games:
order = []
for k in range(4):
order.append(PERSONALITY_IDS[(i + k) % 3])
world = new_world(seed + i, False, order, True)
total_systems += len(world.systems)
while world.victory is None:
run_turn(world)
for empire in world.empires:
played[empire.personality.index] += 1
winner = world.empires[world.victory.empire]
wins[winner.personality.index] += 1
win_turns[winner.personality.index] += world.victory.turn
kinds[VICTORY_KINDS.index(world.victory.kind)] += 1
total_turns += world.victory.turn
i += 1
io.write(pad(t('sim.personality'), 20) + pad_left(t('sim.played'), 10) + pad_left(t('sim.wins'), 10) + pad_left(t('sim.share'), 8) + pad_left(t('sim.avgTurn'), 12) + '\n')
for personality in PERSONALITIES.values():
i = personality.index
avg = 0 if wins[i] == 0 else win_turns[i] // wins[i]
io.write(pad(cap(nm(personality)), 20) + pad_left(str(played[i]), 10) + pad_left(str(wins[i]), 10) + pad_left(str(percent(wins[i], played[i])) + '%', 8) + pad_left(str(avg), 12) + '\n')
parts = []
for k in range(len(VICTORY_KINDS)):
parts.append(t('victory.' + VICTORY_KINDS[k]) + ' ' + str(kinds[k]))
io.write('\n' + t('sim.kinds', {'list': ', '.join(parts)}) + '\n')
io.write(t('sim.averages', {'turns': math.floor(total_turns / games), 'systems': math.floor(total_systems / 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:

def play_game(rng, seats):
produced = [0] * len(seats)
turn = 0
while True:
turn += 1
for k in range(len(seats)):
p = seats[k]
produced[k] += BASE[p] + rng.next_int(SPREAD[p])
for k in range(len(seats)):
if produced[k] >= GOAL:
return [seats[k], turn]
games = 200
rng = Random(7)
played = [0, 0, 0]
wins = [0, 0, 0]
win_turns = [0, 0, 0]
total_turns = 0
for i in range(games):
seats = [(i + k) % 3 for k in range(4)]
for p in seats:
played[p] += 1
winner, turn = play_game(rng, seats)
wins[winner] += 1
win_turns[winner] += turn
total_turns += turn
names = ["Kriegsherr", "Baumeister", "Gelehrter"]
print("Balance-Simulation: " + str(games) + " Partien, Seed 7, Ziel " + str(GOAL) + ".")
print()
print(pad("Charakter", 20) + pad_left("Partien", 10) + pad_left("Siege", 10) + pad_left("Anteil", 8) + pad_left("Ø Runde", 12))
for i in range(3):
share = wins[i] * 100 // played[i]
avg = 0 if wins[i] == 0 else win_turns[i] // wins[i]
print(pad(names[i], 20) + pad_left(str(played[i]), 10) + pad_left(str(wins[i]), 10) + pad_left(str(share) + "%", 8) + pad_left(str(avg), 12))
print()
print("Durchschnittliche Siegrunde: " + str(total_turns // 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
python cosmos.py

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.

Du brauchst Python 3.12 oder neuer.

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?

def checksum(text):
total = 0
for ch in text:
total = (total * 31 + ord(ch)) % 1000000007
return total
print(checksum("ab"), checksum("ba"), checksum("abc"), sep=", ")
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 Python und JavaScript rechnen es ohne Überlauf, aber die Formel bleibt dieselbe.

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 ·