Der Zug der Galaxie
Kurz gesagt: Der Zug der Galaxie ist eine Folge von Phasen in fester Reihenfolge; Flotten bewegen sich gleichzeitig (erst brechen alle auf, dann kommen sie an), Ereignisse werden nach Gewicht gezogen, Frieden und Krieg haben eine Regel von mindestens MIN_PEACE Zügen, und die Nachrichten und der Groll hören am Ereignisbus zu, ohne dass der Veröffentlichende von ihnen weiß.
Problem
Alle handeln gleichzeitig, und die Reihenfolge entscheidet
In einem rundenbasierten Strategiespiel tippt der Spieler end (oder wait 3 für drei Züge hintereinander), und die ganze Galaxie bewegt sich: Die Rivalen entscheiden, was sie tun, Flotten brechen auf, Schlachten finden statt, Planeten produzieren, manchmal tauchen Piraten auf. Was für den Spieler ein Tastendruck ist, ist im Code ein Zug: eine Folge von Phasen in fester Reihenfolge.
Die Reihenfolge ist keine Dekoration. Nimm das einfachste Beispiel: drei Flotten in einer Kette, A→B, B→C, C→D, und alle 3 Schiffe sind in A. Verarbeitest du die Befehle nacheinander und verschiebst die Schiffe im selben Array, kommen die Schiffe in einem Zug von A nach D: Der erste Befehl setzt sie nach B, der zweite sieht sie dort und schickt sie nach C weiter, der dritte nach D. Ein Spieler, der das sieht, versteht sofort, dass die Flotten Teleporter sind.
Diese Lektion baut den Zug einer kleinen Galaxie aus drei Reichen (A, B, C) und vier Systemen in einer Reihe, mit denselben vier Ideen wie im Kanon:
- die Phasen eines Zuges und die gleichzeitige Bewegung;
- Zufallsereignisse: wie oft sie kommen und welche häufiger;
- Frieden und Krieg: eine Regel, die verhindert, dass in jedem Zug Krieg erklärt und Frieden geschlossen wird;
- der Ereignisbus: wie die Nachrichten, der Groll und die Warnungen erfahren, was geschehen ist, ohne dass jemand jemanden aufruft.
Am Ende gibt es einen ganzen Zug aus Phasen, der ausgibt, was passiert, und eine Szene, die ihn zeigt.
Die Idee
Phasen und gleichzeitige Bewegung
Ein Zug ist eine Funktion, die Phasen nacheinander aufruft. Im Kanon sind das: die Diplomatie der KI, ihre Befehle, die Bewegung aller Flotten, Schlachten, Eroberung, Produktion und Forschung, das Ereignis, das Ende des Zuges. In unserer kleinen Galaxie sind es fünf: Diplomatie, Bewegung, Schlachten, Produktion, Ereignis.
Die Bewegung wird in zwei geteilt: Zuerst verlassen alle Flotten ihre Systeme, erst dann kommen sie an. Hier die beiden Wege an der Kette von oben. Ein Befehl ist [von, nach, Schiffe]; die 3 Schiffe sind im System 0:
# [von, nach, Schiffe]ORDERS = [(0, 1, 3), (1, 2, 3), (2, 3, 3)]
def move_one_by_one(start, orders): ships = start[:] for source, target, count in orders: take = min(count, ships[source]) ships[source] -= take ships[target] += take return ships
def move_together(start, orders): ships = start[:] takes = [] # Zuerst verlassen alle Flotten ihre Systeme... for source, target, count in orders: take = min(count, ships[source]) ships[source] -= take takes.append(take) # ...und erst dann kommen sie an. for i, order in enumerate(orders): ships[order[1]] += takes[i] return shipsstart = [3, 0, 0, 0]print("nacheinander: " + show(move_one_by_one(start, ORDERS)))print("gleichzeitig: " + show(move_together(start, ORDERS)))nacheinander: 0 0 0 3gleichzeitig: 0 3 0 0Die Version nacheinander ist leise falsch: Das Programm stürzt nicht ab, die Flotten werden einfach schnell. Die gleichzeitige nimmt für jeden Befehl min(Schiffe des Befehls, Schiffe im System zu Beginn) und entfernt die Schiffe (Aufbrechen), danach fügt sie sie am Ziel hinzu (Ankommen). Der zweite Befehl (B→C) sieht in B null Schiffe, weil die ersten noch nicht angekommen sind, und bewegt nichts. Deshalb ist die Kette 0 3 0 0: Die Schiffe sind nur bis B gekommen.
start[:] ist eine Kopie der Liste: Ohne sie würde die Funktion die Liste des Aufrufers ändern.Genauer, für Neugierige
Dieselbe Regel gilt für alles, was „gleichzeitig“ geschieht: zwei Reiche, die Systeme tauschen, zwei Flotten, die einander passieren. Bewegst du sie nacheinander, sieht die zweite die Welt, die die erste schon verändert hat, und das Ergebnis hängt von der Reihenfolge der Spieler ab. Die Regel der zwei Schritte (erst alles nehmen, dann alles zurücklegen) ist dieselbe wie beim Tauschen zweier Variablen mit einer Hilfsvariable: Man braucht Speicher für das „Alte“.
Im Kanon ist die Bewegung unserer noch ähnlicher, mit einer Ergänzung: Eine lange Reise setzt sich von selbst fort. Der Befehl merkt sich das Endziel (dest), und nach der Ankunft wird ein neuer Befehl für den nächsten Schritt eingetragen. Eine Flotte, die durch fünf Systeme geschickt wird, fliegt also fünf Züge, ein System pro Zug.
Die Idee
Zufallsereignisse nach Gewicht
In jedem Zug tritt mit 40 % Wahrscheinlichkeit ein Ereignis bei einem zufälligen Reich ein. Welches? Hätten alle Arten dieselbe Chance, kämen die Piraten schon im ersten Zug, wenn das Reich noch kein einziges Schiff hat. Deshalb hat jede Art ein Gewicht (wie häufig sie ist) und einen frühesten Zug (ab wann sie erlaubt ist). Hier die Tabelle des Kanons und die Auswahl:
# id, Gewicht, frühester ZugEVENT_DEFS = [ ("meteor", 20, 1), ("vein", 15, 1), ("pirates", 20, 6), ("breakthrough", 15, 1), ("unrest", 10, 5), ("boom", 15, 1), ("flare", 10, 1), ("derelict", 8, 3)]
def pick_weighted(rng, weights): total = sum(weights) roll = rng.next_int(total) for i, weight in enumerate(weights): if roll < weight: return i roll -= weight return len(weights) - 1
def roll_event(rng, turn): if not rng.chance(EVENT_CHANCE): return None empire = rng.next_int(len(NAMES)) weights = [weight if turn >= min_turn else 0 for _, weight, min_turn in EVENT_DEFS] return (pick_weighted(rng, weights), empire)Die Auswahl nach Gewicht ist ein „Roulette“: Eine Zahl von 0 bis zur Summe der Gewichte wird gezogen, und man geht die Arten entlang und zieht jeweils das Gewicht ab, bis die Zahl kleiner als das aktuelle Gewicht ist. Eine Art, die noch nicht erlaubt ist, bekommt das Gewicht 0 und kommt nie vor, und die Summe schrumpft, sodass die anderen wahrscheinlicher werden. Das Programm lässt zwölf Züge laufen und danach noch tausend, um zu zeigen, wie oft jede Art vorkommt:
rng = Rng(7)for turn in range(1, 13): roll = roll_event(rng, turn) if roll is not None: print("Zug " + str(turn) + ": " + EVENT_DEFS[roll[0]][0] + " → " + NAMES[roll[1]])
counts = [0] * len(EVENT_DEFS)for _ in range(1000): roll = roll_event(rng, 99) if roll is not None: counts[roll[0]] += 1print("Ereignisse in 1000 Zügen:")for i, (event_id, weight, _) in enumerate(EVENT_DEFS): print(event_id + " " + str(weight) + ": " + str(counts[i]))Zug 1: boom → Lyra-UnionZug 2: breakthrough → Vega-AkademieZug 3: vein → Vega-AkademieZug 4: boom → Vega-AkademieZug 8: pirates → Lyra-UnionZug 9: breakthrough → Terranisches ReichEreignisse in 1000 Zügen:meteor 20: 70vein 15: 53pirates 20: 78breakthrough 15: 58unrest 10: 30boom 15: 50flare 10: 28derelict 8: 28Die Piraten (frühester Zug 6) zeigen sich erst in Zug 8. In den tausend Zügen kommen die Piraten (78) und die Meteoriten (70) mit Gewicht 20 am häufigsten vor, die Sonneneruption (28) und die verlassenen Schiffe (28) mit den Gewichten 10 und 8 am seltensten. Die Anzahlen sind nicht gleich den Gewichten, weil es Zufall ist, aber ihre Reihenfolge ist dieselbe. Die Chance auf ein Ereignis ist 40 % (EVENT_CHANCE), und der Generator hat einen Startwert: Dieselbe Eingabe gibt in allen vier Sprachen dieselbe Ausgabe, deshalb lässt sich eine Aufzeichnung des Spiels wiederholen.
Genauer, für Neugierige
Im Kanon tut jedes Ereignis etwas anderes: Meteoriten nehmen Metall, eine Ader erhöht den Reichtum eines Planeten, Piraten kämpfen gegen die Flotte des Reiches (und Ressourcen werden gewonnen oder verloren), ein Durchbruch gibt Forschungspunkte, Unruhen senken eine Minenstufe, ein Boom gibt Gas und Kristall, eine Eruption legt die Labore für einen Zug lahm, verlassene Schiffe geben zwei Fregatten. Hier alle (die Region events des Kanons). Sieh dir das Ende von rollEvent an: Das Ergebnis wird nicht auf den Bildschirm geschrieben, es wird veröffentlicht. Was das heißt, steht in der Karte über den Bus.
# Die Zufallsereignisse: in jeder Runde gibt es 40 % Chance, dass eines einem zufälligen Reich geschieht. Die Art ist nach# Gewichten (erst ab ihrer frühesten Runde). Alles wird auf dem Bus veröffentlicht; die Nachrichten, die Statistik# und der Groll sind Abonnenten, und die Ereignisse wissen nichts von ihnen.EVENT_CHANCE = 40
def pick_empire_planet(world, empire): items = owned_planets(world, empire.id) if len(items) == 0: return None return items[world.rng.next_int(len(items))]
def pirate_fleet(world): strength = world.turn // 6 return [2 + strength, strength // 2, strength // 3, 0, 0]
def apply_event(world, kind, empire): rng = world.rng values = {'empire': empire_name(empire)} if kind == 'meteor': planet = pick_empire_planet(world, empire) if planet is None: return None loss = min_of(empire.res[0], 8 + rng.next_int(8)) empire.res[0] -= loss values['planet'] = planet_name(world, planet) values['n'] = loss elif kind == 'vein': planet = pick_empire_planet(world, empire) if planet is None: return None r = rng.next_int(RES_COUNT) tries = 0 while planet.rich[r] >= 5 and tries < RES_COUNT: r = (r + 1) % RES_COUNT tries += 1 if planet.rich[r] >= 5: return None planet.rich[r] += 1 values['planet'] = planet_name(world, planet) values['res'] = t('res.' + str(r)) elif kind == 'pirates': planet = pick_empire_planet(world, empire) if planet is None: return None system_id = planet.system_id mine = empty_counts() fleet = fleet_at(world, empire.id, system_id) for k in range(SHIP_KIND_COUNT): mine[k] = 0 if fleet is None else fleet.ships[k] mine[FORT_KIND] = forts_in(world, empire.id, system_id) result = run_battle(side_stats(empire), mine, side_stats(None), pirate_fleet(world)) for k in range(SHIP_KIND_COUNT): if fleet is not None: fleet.ships[k] = result.a[k] values['system'] = world.systems[system_id].name values['fleet'] = counts_text(pirate_fleet(world)) if result.winner == 'a': empire.res[0] += 20 empire.res[1] += 10 values['outcome'] = t('event.pirates.win') else: m = empire.res[0] // 5 g = empire.res[1] // 5 empire.res[0] -= m empire.res[1] -= g values['outcome'] = t('event.pirates.lose', {'m': m, 'g': g}) if not world.quiet: world.last.battle = battle_lines(values['system'], empire_name(empire), t('pirates'), result) world.stats.battles += 1 world.stats.lost += total_of(mine) - total_of(result.a) + total_of(pirate_fleet(world)) - total_of(result.b) elif kind == 'breakthrough': n = 6 + rng.next_int(7) empire.progress += n values['n'] = n elif kind == 'unrest': planet = pick_empire_planet(world, empire) if planet is None or planet.mines == 0: return None planet.mines -= 1 values['planet'] = planet_name(world, planet) elif kind == 'boom': empire.res[1] += 20 empire.res[2] += 10 elif kind == 'flare': empire.flare = 1 elif kind == 'derelict': planet = pick_empire_planet(world, empire) if planet is None: return None add_ships(world, empire.id, planet.system_id, [0, 0, 2, 0]) values['system'] = world.systems[planet.system_id].name return values
def roll_event(world): rng = world.rng if not rng.chance(EVENT_CHANCE): return living = [] for e in world.empires: if e.alive: living.append(e) if len(living) == 0: return empire = living[rng.next_int(len(living))] allowed = [] weights = [] for definition in EVENT_DEFS: if world.turn >= definition.min_turn: allowed.append(definition) weights.append(definition.weight) definition = allowed[pick_weighted(rng, weights)] values = apply_event(world, definition.id, empire) if values is None: return world.stats.events += 1 world.bus.publish(SimpleNamespace(type='event', kind=definition.id, empire=empire.id, vars=values))
# Die Abonnenten: die Nachrichten für den Spieler, der Groll nach Kämpfen und Eroberung.def add_news(world, text): world.news.append(str(world.turn) + ': ' + text) while len(world.news) > 60: world.news.pop(0) world.turn_lines.append(text)
def install_subscribers(world, quiet): bus = world.bus
def involves_human(a, b): return world.empires[a].human or world.empires[b].human
def grudge_after_battle(e): if e.lost_a > 0: world.empires[e.b].grudge[e.a] += 1 if e.lost_b > 0: world.empires[e.a].grudge[e.b] += 1
def grudge_after_conquest(e): if e.from_ >= 0: world.empires[e.from_].grudge[e.empire] += 3
# Ein Signal an den Spieler: etwas ist geschehen, bei dem „wait“ anhält. def alert_battle(e): if involves_human(e.a, e.b): world.alert += 1
def alert_conquest(e): if world.empires[e.empire].human or (e.from_ >= 0 and world.empires[e.from_].human): world.alert += 1
def alert_war(e): if involves_human(e.a, e.b): world.alert += 1
def alert_siege(e): if world.empires[e.from_].human: world.alert += 1
def alert_offer(e): if world.empires[e.to].human: world.alert += 1
bus.subscribe('battle', grudge_after_battle) bus.subscribe('conquest', grudge_after_conquest) bus.subscribe('battle', alert_battle) bus.subscribe('conquest', alert_conquest) bus.subscribe('war', alert_war) bus.subscribe('siege', alert_siege) bus.subscribe('offer', alert_offer) if quiet: return
def news_battle(e): if involves_human(e.a, e.b): for line in e.lines: add_news(world, line) else: add_news(world, t('news.battle', { 'system': world.systems[e.system_id].name, 'a': empire_name(world.empires[e.a]), 'b': empire_name(world.empires[e.b]), 'la': e.lost_a, 'lb': e.lost_b, }))
def news_colony(e): add_news(world, t('news.colony', {'empire': empire_name(world.empires[e.empire]), 'planet': planet_name(world, world.planets[e.planet_id])}))
def news_tech(e): if world.empires[e.empire].human: add_news(world, t('news.tech', {'tech': nm(TECHS[e.tech_id])}))
def news_war(e): add_news(world, t('news.war', {'a': empire_name(world.empires[e.a]), 'b': empire_name(world.empires[e.b])}))
def news_peace(e): add_news(world, t('news.peace', {'a': empire_name(world.empires[e.a]), 'b': empire_name(world.empires[e.b])}))
def news_offer(e): add_news(world, t('news.offer', {'empire': empire_name(world.empires[e.from_]), 'letter': world.empires[e.from_].def_.letter}))
def news_siege(e): add_news(world, t('news.siege', {'empire': empire_name(world.empires[e.empire]), 'planet': planet_name(world, world.planets[e.planet_id])}))
def news_conquest(e): add_news(world, t('news.conquest', {'empire': empire_name(world.empires[e.empire]), 'planet': planet_name(world, world.planets[e.planet_id])}))
def news_eliminated(e): add_news(world, t('news.eliminated', {'empire': empire_name(world.empires[e.empire])}))
def news_event(e): add_news(world, t('event.' + e.kind, e.vars))
bus.subscribe('battle', news_battle) bus.subscribe('colony', news_colony) bus.subscribe('tech', news_tech) bus.subscribe('war', news_war) bus.subscribe('peace', news_peace) bus.subscribe('offer', news_offer) bus.subscribe('siege', news_siege) bus.subscribe('conquest', news_conquest) bus.subscribe('eliminated', news_eliminated) bus.subscribe('event', news_event)Die Idee
Frieden und Krieg: eine Regel gegen das Chaos
Diplomatie im Spiel ist eine Beziehung zwischen zwei Reichen: Ein Paar von Reichen ist im Krieg oder im Frieden (war[a][b]). Was ist daran schwer? Ohne Regel würde ein Reich in einem Zug Krieg erklären, im nächsten Frieden schließen und gleich danach wieder Krieg erklären. Deshalb:
- Frieden gilt mindestens MIN_PEACE =
4Züge, bevor ein neuer Krieg möglich ist (turn − since < MIN_PEACE; am Anfang des Spiels gilt die Regel nicht, damit jemand beginnen kann); - ein Friedensangebot ist frühestens zwei Züge nach dem Wechsel erlaubt;
- die KI nimmt Frieden nach ihrem Wunsch an: wie viel schwächer sie als der andere ist, welchen Charakter sie hat, wie viele andere Kriege sie führt und wie nachtragend sie ist. Ab 90 nimmt sie an.
Im Programm ist der Wunsch vereinfacht: Charakter + (150 − Verhältnis der Schiffe) / 2 + andere Kriege × 15 − Groll × 3, wobei der Charakter bei Orion (Kriegsherr) 40 ist und bei den beiden anderen (Baumeister) 120. Eine Ablehnung erhöht den Groll, deshalb ist der nächste Versuch schwerer. Das Skript ist die Geschichte der Szene: Orion (B) erklärt dem Terranischen Reich (A) in Zug 1 den Krieg, Lyra (C) Orion in Zug 3, und A bietet Frieden an:
class World: def __init__(self): self.turn = 1 self.ships = [[0] * 4 for _ in range(3)] self.war = [[0] * 3 for _ in range(3)] self.since = [[0] * 3 for _ in range(3)] self.grudge = [[0] * 3 for _ in range(3)]
def at_war(world, a, b): return world.war[a][b] == 1
def power(world, empire): return sum(world.ships[empire])
def set_stance(world, a, b, war): world.war[a][b] = war world.war[b][a] = war world.since[a][b] = world.turn world.since[b][a] = world.turn
def declare_war(world, a, b): if at_war(world, a, b): return "alreadyWar" # Frieden gilt mindestens MIN_PEACE Züge. if world.turn - world.since[a][b] < MIN_PEACE and world.turn > MIN_PEACE: return "tooSoon" set_stance(world, a, b, 1) return None
def make_peace(world, a, b): set_stance(world, a, b, 0) world.grudge[a][b] = 0 world.grudge[b][a] = 0
def peace_wish(world, me, other): # Wie viel schwächer das Reich als das andere ist. ratio = min(150, power(world, me) * 100 // (power(world, other) + 1)) other_wars = 0 for x in range(3): if x != me and x != other and at_war(world, me, x): other_wars += 1 return max(0, PEACE[me] + (150 - ratio) // 2 + other_wars * 15 - world.grudge[me][other] * 3)
def propose_peace(world, a, b): if not at_war(world, a, b): return "noWar" if world.turn - world.since[a][b] < 2: return "tooSoon" # Ab 90 nimmt das Reich Frieden an. if peace_wish(world, b, a) >= 90: make_peace(world, a, b) return None # Eine Ablehnung macht nachtragend: der nächste Frieden wird schwerer. world.grudge[b][a] += 1 return "refused"def step(world, turn, kind, a, b): world.turn = turn wish = peace_wish(world, b, a) if kind == "peace" else 0 error = declare_war(world, a, b) if kind == "war" else propose_peace(world, a, b) shown = " (Wunsch " + str(wish) + ")" if kind == "peace" and error in (None, "refused") else "" print("Zug " + str(turn) + ": " + kind + " " + NAMES[a] + " → " + NAMES[b] + ": " + (error or "ok") + shown)
world = World()world.ships[1][1] = 1world.grudge[1][0] = 1step(world, 1, "war", 1, 0)step(world, 2, "war", 1, 0)step(world, 2, "peace", 0, 1)step(world, 3, "war", 2, 1)step(world, 3, "peace", 0, 1)world.ships[1][1] = 0step(world, 4, "peace", 0, 1)step(world, 5, "war", 1, 0)step(world, 9, "war", 1, 0)Zug 1: war Orion-Hegemonie → Terranisches Reich: okZug 2: war Orion-Hegemonie → Terranisches Reich: alreadyWarZug 2: peace Terranisches Reich → Orion-Hegemonie: tooSoonZug 3: war Lyra-Union → Orion-Hegemonie: okZug 3: peace Terranisches Reich → Orion-Hegemonie: refused (Wunsch 77)Zug 4: peace Terranisches Reich → Orion-Hegemonie: ok (Wunsch 124)Zug 5: war Orion-Hegemonie → Terranisches Reich: tooSoonZug 9: war Orion-Hegemonie → Terranisches Reich: okZug 2: Orion erklärt demselben Gegner noch einmal den Krieg (alreadyWar), und A bietet Frieden schon einen Zug nach dem Krieg an (tooSoon). In Zug 3 bietet A Frieden an, aber Orions Wunsch ist 40 + 25 + 15 − 3 = 77 (Charakter, ein Schiff gegen keines, ein weiterer Krieg mit Lyra, Groll 1) und liegt unter 90: abgelehnt, und der Groll wird 2. In Zug 4 hat Orion keine Schiffe, und sein Wunsch ist 40 + 75 + 15 − 6 = 124: Es nimmt an. Zug 5: Orion will einen neuen Krieg, aber der Frieden stammt aus Zug 4, und 5 − 4 = 1 ist weniger als 4: tooSoon. In Zug 9 ist es erlaubt (9 − 4 = 5).
def at_war(world, a, b):
return world.war[a][b] == 1
def power(world, empire):
return sum(world.ships[empire])
def set_stance(world, a, b, war):
world.war[a][b] = war
world.war[b][a] = war
world.since[a][b] = world.turn
world.since[b][a] = world.turn
def declare_war(world, a, b):
if at_war(world, a, b):
return "alreadyWar"
# Frieden gilt mindestens MIN_PEACE Züge.
if world.turn - world.since[a][b] < MIN_PEACE and world.turn > MIN_PEACE:
return "tooSoon"
set_stance(world, a, b, 1)
return None
def make_peace(world, a, b):
set_stance(world, a, b, 0)
world.grudge[a][b] = 0
world.grudge[b][a] = 0
def peace_wish(world, me, other):
# Wie viel schwächer das Reich als das andere ist.
ratio = min(150, power(world, me) * 100 // (power(world, other) + 1))
other_wars = 0
for x in range(3):
if x != me and x != other and at_war(world, me, x):
other_wars += 1
return max(0, PEACE[me] + (150 - ratio) // 2 + other_wars * 15 - world.grudge[me][other] * 3)
def propose_peace(world, a, b):
if not at_war(world, a, b):
return "noWar"
if world.turn - world.since[a][b] < 2:
return "tooSoon"
# Ab 90 nimmt das Reich Frieden an.
if peace_wish(world, b, a) 90:
make_peace(world, a, b)
return None
# Eine Ablehnung macht nachtragend: der nächste Frieden wird schwerer.
world.grudge[b][a] += 1
return "refused"Die Schwelle ist „ab 90“, d. h. 90 genügt: >=. Mit > wäre ein Wunsch von genau 90 eine Ablehnung, während die Regel im Spiel das Gegenteil sagt.
Genauer, für Neugierige
Das Ergebnis dieser Funktionen ist ein String oder nichts: null, wenn der Wechsel stattgefunden hat, und ein Fehlerschlüssel (alreadyWar, tooSoon, refused, noWar), wenn nicht. Die Fehler sind Schlüssel, keine fertigen Sätze, damit der Bildschirm sie übersetzen kann (das Spiel gibt es in vier Sprachen). So bedient dieselbe Funktion den Spieler (der Befehl peace B) und die KI.
Der Kanon hat auch Angebote an den Spieler: Die KI kann mit einem Menschen nicht von selbst Frieden schließen, deshalb trägt sie ein Angebot ein (offers), das drei Züge gilt, und veröffentlicht ein „offer“-Ereignis. Hier die ganze Region diplomacy:
# Diplomatie ist Frieden oder Krieg zwischen zwei Reichen. Frieden gilt mindestens MIN_PEACE Runden vor# einem neuen Krieg. Die künstliche Intelligenz nimmt Frieden nach einem Maß an: wie viel schwächer sie ist, welchen Charakter sie hat# und wie verbittert sie ist. Ein Angebot an den Spieler wird vermerkt und gilt drei Runden.def at_war(world, a, b): return world.empires[a].war[b] == 1
def set_stance(world, a, b, war): world.empires[a].war[b] = war world.empires[b].war[a] = war world.empires[a].since[b] = world.turn world.empires[b].since[a] = world.turn
def declare_war(world, a, b): if a == b: return 'dip.self' if not world.empires[b].alive: return 'dip.dead' if at_war(world, a, b): return 'dip.alreadyWar' if world.turn - world.empires[a].since[b] < MIN_PEACE and world.turn > MIN_PEACE: return 'dip.tooSoon' set_stance(world, a, b, 1) world.bus.publish(SimpleNamespace(type='war', a=a, b=b)) return None
def make_peace(world, a, b): set_stance(world, a, b, 0) world.empires[a].grudge[b] = 0 world.empires[b].grudge[a] = 0 world.bus.publish(SimpleNamespace(type='peace', a=a, b=b))
# Der kleinste Abstand in Wegen zwischen Systemen mit Planeten der beiden Reiche.def empire_distance(world, a, b): best = 99 for pa in world.planets: if pa.owner != a: continue dist = bfs(world.systems, pa.system_id).dist for pb in world.planets: if pb.owner == b and dist[pb.system_id] >= 0 and dist[pb.system_id] < best: best = dist[pb.system_id] return best
def power_ratio(world, a, b, top): return clamp(percent(empire_power(world, a) * 100, empire_power(world, b) * 100 + 100), 0, top)
# Wie sehr das Reich Frieden mit dem anderen will (0 und mehr; ab 90 nimmt es an).def peace_wish(world, me, other): e = world.empires[me] weaker = 150 - power_ratio(world, me, other, 150) other_wars = 0 for x in world.empires: if x.alive and x.id != me and x.id != other and e.war[x.id] == 1: other_wars += 1 wish = e.personality.peace + weaker // 2 + other_wars * 15 - e.grudge[other] * 3 return max_of(0, wish)
# Wie sehr das Reich Krieg mit dem anderen will.def war_wish(world, me, other): e = world.empires[me] ratio = power_ratio(world, me, other, 300) wish = e.personality.attack // 2 if ratio > 100: wish += (ratio - 100) // 4 else: wish -= (100 - ratio) // 2 if empire_distance(world, me, other) <= 3: wish += 25 for x in world.empires: if x.alive and x.id != me and e.war[x.id] == 1: wish -= 30 return wish
def has_offer(world, src, to): for offer in world.offers: if offer.from_ == src and offer.to == to and world.turn - offer.turn <= 3: return True return False
# Ein Friedensangebot von a an b. Gibt einen Fehlerschlüssel oder None zurück (Frieden ist geschlossen).def propose_peace(world, a, b): if a == b: return 'dip.self' if not world.empires[b].alive: return 'dip.dead' if not at_war(world, a, b): return 'dip.noWar' if world.turn - world.empires[a].since[b] < 2: return 'dip.tooSoon' if world.empires[b].human: if has_offer(world, b, a): make_peace(world, a, b) return None if not has_offer(world, a, b): world.offers.append(SimpleNamespace(from_=a, to=b, turn=world.turn)) return 'dip.sent' if has_offer(world, b, a) or peace_wish(world, b, a) >= 90: make_peace(world, a, b) return None world.empires[b].grudge[a] += 1 return 'dip.refused'
# Die Diplomatie der künstlichen Intelligenz am Rundenende: Frieden, wenn schwächer, Krieg, wenn stärker.def ai_diplomacy(world, empire): for other in world.empires: if not other.alive or other.id == empire.id: continue if at_war(world, empire.id, other.id): if world.turn - empire.since[other.id] >= 3 and peace_wish(world, empire.id, other.id) >= 90: if other.human: if not has_offer(world, empire.id, other.id): world.offers.append(SimpleNamespace(from_=empire.id, to=other.id, turn=world.turn)) world.bus.publish(SimpleNamespace(type='offer', from_=empire.id, to=other.id)) else: propose_peace(world, empire.id, other.id) elif world.turn - empire.since[other.id] >= MIN_PEACE + 2 and world.turn > MIN_PEACE + 2: if war_wish(world, empire.id, other.id) >= 95 and world.rng.chance(40): declare_war(world, empire.id, other.id)Die Idee
Ein Ereignisbus: Wer veröffentlicht, weiß nicht, wer zuhört
Wird Krieg erklärt, muss viel geschehen: Eine Nachricht muss geschrieben werden, der Groll der beiden Reiche muss wachsen, für den Spieler muss eine Warnung aufleuchten, der Krieg muss in der Statistik gezählt werden. Am einfachsten ruft declareWar all das auf. Dann kennt declareWar die Nachrichten, den Groll, die Warnung und die Statistik, und jede neue Fähigkeit ist eine weitere Zeile darin und ein weiterer Grund, dass es kaputtgeht. Der Bus kehrt die Richtung um: declareWar sagt „zwischen A und B wurde Krieg erklärt“ und ist fertig. Wer sich dafür interessiert, hat „war“ abonniert.
@dataclass(frozen=True)class Event: type: str a: int b: int place: int = 0 n: int = 0
class EventBus: def __init__(self): self.handlers = {}
def subscribe(self, event_type, handler): if event_type not in self.handlers: self.handlers[event_type] = [] self.handlers[event_type].append(handler)
def publish(self, event): if event.type not in self.handlers: return for handler in list(self.handlers[event.type]): handler(event)subscribe trägt einen Handler für eine Ereignisart ein, publish ruft alle Handler dieser Art auf. Eine Art ohne Abonnenten ist kein Fehler: Das Ereignis verpufft. Hier die Veröffentlichung an den beiden Stellen, an denen sie geschieht (Kriegserklärung und Friedensschluss). Neu ist nur die Zeile mit publish:
def declare_war(world, a, b): if at_war(world, a, b): return "alreadyWar" # Frieden gilt mindestens MIN_PEACE Züge. if world.turn - world.since[a][b] < MIN_PEACE and world.turn > MIN_PEACE: return "tooSoon" set_stance(world, a, b, 1) world.bus.publish(Event("war", a, b)) return None
def make_peace(world, a, b): set_stance(world, a, b, 0) world.grudge[a][b] = 0 world.grudge[b][a] = 0 world.bus.publish(Event("peace", a, b))Und hier das Programm. Die Nachrichten hören auf alle Arten, der Groll nur auf Schlachten, und report veröffentlicht auch die gescheiterten Versuche (refused, tooSoon) als Ereignisse:
world = World()world.ships[1][1] = 1world.grudge[1][0] = 1
def grudge(event): world.grudge[event.a][event.b] += 1 world.grudge[event.b][event.a] += 1
# Die Nachrichten hören auf jede Art von Ereignis, der Groll nur auf Schlachten.for event_type in NEWS_TYPES: world.bus.subscribe(event_type, lambda event: print(news_text(event)))world.bus.subscribe("battle", grudge)
world.turn = 1report(world, declare_war(world, 1, 0), 1, 0)world.bus.publish(Event("battle", 1, 0, 1, 2))world.turn = 3report(world, propose_peace(world, 0, 1), 0, 1)report(world, declare_war(world, 1, 0), 1, 0)print("Groll " + NAMES[0] + " → " + NAMES[1] + ": " + str(world.grudge[0][1]))print("Groll " + NAMES[1] + " → " + NAMES[0] + ": " + str(world.grudge[1][0]))Kriegserklärung: Orion-Hegemonie → Terranisches Reich.Schlacht bei Vesta: Orion-Hegemonie und Terranisches Reich verlieren Schiffe (je 2).Friedensangebot abgelehnt: Terranisches Reich → Orion-Hegemonie.Schon im Krieg: Orion-Hegemonie → Terranisches Reich.Groll Terranisches Reich → Orion-Hegemonie: 1Groll Orion-Hegemonie → Terranisches Reich: 3Der Versuch in Zug 1 gelingt, die Schlacht wird von Hand veröffentlicht, die Ablehnung in Zug 3 ist ein Ereignis (refused), und der wiederholte Krieg ist alreadyWar. Der Groll von A gegenüber B ist 1 (von der Schlacht), der von B gegenüber A ist 3: einer alt, einer von der Schlacht und einer von der Ablehnung (eine Ablehnung erhöht den Groll dessen, der ablehnt, gegenüber dem, der angeboten hat).
lambda); die Klasse EventBus weiß nichts über ihn, außer dass sie ihn aufrufen kann.Genauer, für Neugierige
Ohne Bus braucht alles Neue, das von einem Krieg erfahren soll, eine Änderung in declareWar. Mit dem Bus ist ein neuer Abonnent nur eine neue Zeile beim Erzeugen der Welt, und die Kriegsfunktion wird nicht angefasst. Das ist das Muster Beobachter (Observer) aus dem Modul über Entwurfsmuster.
Ein kleines, aber wichtiges Detail: publish läuft über eine Kopie der Liste der Abonnenten. Ein Handler kann ein anderes Ereignis abonnieren, während er aufgerufen wird; ändert sich die Liste während der Schleife, werden Abonnenten übersprungen oder wiederholt. Die Kopie macht die Regel klar: Ein neuer Abonnent hört ab der nächsten Veröffentlichung.
Die Reihenfolge der Abonnenten ist die Reihenfolge von subscribe. Hier spielt sie keine Rolle (die Nachrichten und der Groll stören einander nicht), aber wenn zwei Abonnenten voneinander abhängen, entscheidet die Reihenfolge über den Ausgang, und das ist ein Grund, sie unabhängig zu halten.
Die Idee
Der ganze Zug
Jetzt alles zusammen. Die Welt ist World: Zug, Phase, Generator, Bus, die Flotten der drei Reiche (nach Systemen), Metall und die Tabellen für Krieg (war), seit welchem Zug (since) und Groll (grudge):
class World: def __init__(self, seed): self.turn = 1 self.phase = "" self.rng = Rng(seed) self.bus = EventBus() self.ships = [[3, 0, 0, 0], [0, 0, 0, 4], [0, 2, 0, 0]] self.metal = [50, 50, 50] self.war = [[0] * 3 for _ in range(3)] self.since = [[0] * 3 for _ in range(3)] self.grudge = [[0] * 3 for _ in range(3)]Die Befehle und die Diplomatie jedes Zuges sind ein Skript (im Spiel entscheiden sie die KI oder der Spieler): Ein Befehl ist [Zug, Reich, von, nach, Schiffe], ein Diplomatie-Eintrag [Zug, Art, von, nach].
# [Zug, Reich, von, nach, Schiffe]ORDERS = [(1, 1, 3, 2, 4), (1, 0, 0, 1, 3), (2, 1, 2, 1, 4)]# [Zug, Art (0 Krieg, 1 Frieden), von, nach]DIPLO = [(1, 0, 1, 0), (3, 0, 2, 1), (3, 1, 0, 1), (4, 1, 0, 1), (5, 0, 1, 0)]Die Phasen sind Funktionen. Die Diplomatie führt das Skript des Zuges aus und veröffentlicht die Fehler. Die Bewegung sind genau die zwei Schritte aus der ersten Karte. Die Schlachten laufen über die Systeme: Wo zwei Reiche im Krieg Schiffe haben, verliert jedes so viele, wie die kleinere Flotte hat, und eine „battle“ wird veröffentlicht (der Groll abonniert sie):
def scripted_diplomacy(world): world.phase = "Diplomatie" for turn, kind, a, b in DIPLO: if turn != world.turn: continue error = declare_war(world, a, b) if kind == 0 else propose_peace(world, a, b) report(world, error, a, b)def resolve_movement(world): world.phase = "Bewegung" moving = [] # Zuerst verlassen alle Flotten ihre Systeme... for turn, empire, source, target, count in ORDERS: if turn != world.turn: continue take = min(count, world.ships[empire][source]) world.ships[empire][source] -= take if take > 0: moving.append((empire, source, target, take)) # ...und erst dann kommen sie an. for empire, source, target, take in moving: world.ships[empire][target] += take world.bus.publish(Event("move", empire, source, target, take))def resolve_battles(world): world.phase = "Schlachten" for system in range(4): for a in range(3): for b in range(a + 1, 3): if not at_war(world, a, b) or world.ships[a][system] == 0 or world.ships[b][system] == 0: continue lost = min(world.ships[a][system], world.ships[b][system]) world.ships[a][system] -= lost world.ships[b][system] -= lost world.bus.publish(Event("battle", a, b, system, lost))
def produce(world): world.phase = "Produktion" for empire in range(3): world.metal[empire] += 10Danach kommt das Ereignis: eine Ziehung nach den Gewichten aus der zweiten Karte, mit diesen Wirkungen: Ein Meteorit nimmt Metall, ein Boom gibt Metall, verlassene Schiffe geben zwei Schiffe in der Hauptstadt. Schließlich ruft runTurn sie der Reihe nach auf:
def roll_event(world): world.phase = "Ereignis" if not world.rng.chance(EVENT_CHANCE): return empire = world.rng.next_int(3) weights = [weight if world.turn >= min_turn else 0 for _, weight, min_turn in EVENT_DEFS] kind = EVENT_DEFS[pick_weighted(world.rng, weights)][0] if kind == "meteor": loss = min(world.metal[empire], 8 + world.rng.next_int(8)) world.metal[empire] -= loss world.bus.publish(Event("meteor", empire, 0, 0, loss)) elif kind == "boom": world.metal[empire] += 20 world.bus.publish(Event("boom", empire, 0, 0, 20)) else: world.ships[empire][HOME[empire]] += 2 world.bus.publish(Event("derelict", empire, 0, HOME[empire], 2))def run_turn(world): # Die Phasen laufen in dieser Reihenfolge, und die Reihenfolge ist wichtig. scripted_diplomacy(world) resolve_movement(world) resolve_battles(world) produce(world) roll_event(world) world.turn += 1Das Programm abonniert die Nachrichten (sie setzen vor jede Zeile die Phase, in der das Ereignis geschah) und den Groll, lässt fünf Züge laufen und gibt das Ergebnis aus. Die Zeilen sind genau die Nachrichten des Spiels:
world = World(7)
def grudge(event): world.grudge[event.a][event.b] += 1 world.grudge[event.b][event.a] += 1
def news(event): print(" [" + world.phase + "] " + news_text(event))
world.bus.subscribe("battle", grudge)for event_type in NEWS_TYPES: world.bus.subscribe(event_type, news)
for _ in range(5): print(fill("-- Zug {0} --", world.turn)) run_turn(world)print("-- Ende --")for empire in range(3): print(fill("{0}: Schiffe {1}, Metall {2}", NAMES[empire], power(world, empire), world.metal[empire]))-- Zug 1 -- [Diplomatie] Kriegserklärung: Orion-Hegemonie → Terranisches Reich. [Bewegung] Orion-Hegemonie schickt eine Flotte von Brisa nach Altan (Schiffe: 4). [Bewegung] Terranisches Reich schickt eine Flotte von Kora nach Vesta (Schiffe: 3). [Ereignis] Meteoritenschauer: Lyra-Union verliert 14 Metall.-- Zug 2 -- [Bewegung] Orion-Hegemonie schickt eine Flotte von Altan nach Vesta (Schiffe: 4). [Schlachten] Schlacht bei Vesta: Terranisches Reich und Orion-Hegemonie verlieren Schiffe (je 3).-- Zug 3 -- [Diplomatie] Kriegserklärung: Lyra-Union → Orion-Hegemonie. [Diplomatie] Friedensangebot abgelehnt: Terranisches Reich → Orion-Hegemonie. [Schlachten] Schlacht bei Vesta: Orion-Hegemonie und Lyra-Union verlieren Schiffe (je 1).-- Zug 4 -- [Diplomatie] Frieden geschlossen: Terranisches Reich und Orion-Hegemonie. [Ereignis] Meteoritenschauer: Lyra-Union verliert 8 Metall.-- Zug 5 -- [Diplomatie] Zu früh für eine Änderung: Orion-Hegemonie → Terranisches Reich. [Ereignis] Orion-Hegemonie findet verlassene Schiffe bei Brisa (Schiffe: 2).-- Ende --Terranisches Reich: Schiffe 0, Metall 100Orion-Hegemonie: Schiffe 2, Metall 100Lyra-Union: Schiffe 1, Metall 78Lies die Ausgabe als Geschichte. Zug 1: Orion erklärt den Krieg, die beiden Flotten (die von Orion und die des Terranischen Reiches) brechen gleichzeitig auf, und Lyra verliert Metall durch einen Meteoriten. Zug 2: Orions Flotte kommt in Vesta an, wo das Terranische Reich schon ist, und beide verlieren 3 Schiffe. Zug 3: Lyra tritt in den Krieg ein, und in der Schlacht bei Vesta verlieren sie je 1; davor bietet A Frieden an und wird abgelehnt. Zug 4: Orion hat keine Schiffe, sein Wunsch ist 124, und der Frieden ist geschlossen. Zug 5: Ein neuer Krieg ist „zu früh“, und Orion findet zwei verlassene Schiffe in seiner Hauptstadt.
Sieh es dir an
Ein Zug der Galaxie
Hier dasselbe Zug für Zug. Oben leuchtet die Phase auf, in der etwas geschehen ist. In der Mitte sind die Systeme und die Flotten der drei Reiche: Bei der Bewegung starten alle Flotten gleichzeitig und landen erst dann. Darunter siehst du, ob jedes Paar im Krieg ist, und ganz unten den Bus: Das veröffentlichte Ereignis lässt den Abonnenten „Nachrichten“ aufleuchten, und wenn es eine Schlacht ist, auch „Groll“.
def run_turn(world): # Die Phasen laufen in dieser Reihenfolge, und die Reihenfolge ist wichtig. scripted_diplomacy(world) resolve_movement(world) resolve_battles(world) produce(world) roll_event(world) world.turn += 1
class EventBus: def __init__(self): self.handlers = {}
def subscribe(self, event_type, handler): if event_type not in self.handlers: self.handlers[event_type] = [] self.handlers[event_type].append(handler)
def publish(self, event): if event.type not in self.handlers: return for handler in list(self.handlers[event.type]): handler(event)Schritt für Schritt, in Worten
- A = Terranisches Reich, B = Orion-Hegemonie, C = Lyra-Union. Vier Systeme in einer Reihe; der Zug läuft die Phasen von oben nach unten durch.
- Zug 1 · Diplomatie: Kriegserklärung: Orion-Hegemonie → Terranisches Reich.
- Zug 1 · Bewegung: Orion-Hegemonie schickt eine Flotte von Brisa nach Altan (Schiffe: 4). Terranisches Reich schickt eine Flotte von Kora nach Vesta (Schiffe: 3).
- Zug 1 · Ereignis: Meteoritenschauer: Lyra-Union verliert 14 Metall.
- Zug 2 · Bewegung: Orion-Hegemonie schickt eine Flotte von Altan nach Vesta (Schiffe: 4).
- Zug 2 · Schlachten: Schlacht bei Vesta: Terranisches Reich und Orion-Hegemonie verlieren Schiffe (je 3).
- Zug 3 · Diplomatie: Kriegserklärung: Lyra-Union → Orion-Hegemonie. Friedensangebot abgelehnt: Terranisches Reich → Orion-Hegemonie.
- Zug 3 · Schlachten: Schlacht bei Vesta: Orion-Hegemonie und Lyra-Union verlieren Schiffe (je 1).
- Zug 4 · Diplomatie: Frieden geschlossen: Terranisches Reich und Orion-Hegemonie.
- Zug 4 · Ereignis: Meteoritenschauer: Lyra-Union verliert 8 Metall.
- Zug 5 · Diplomatie: Zu früh für eine Änderung: Orion-Hegemonie → Terranisches Reich.
- Zug 5 · Ereignis: Orion-Hegemonie findet verlassene Schiffe bei Brisa (Schiffe: 2).
- Nach 5 Zügen: Terranisches Reich: Schiffe 0, Metall 100 · Orion-Hegemonie: Schiffe 2, Metall 100 · Lyra-Union: Schiffe 1, Metall 78
Der Code neben der Szene ist runTurn und EventBus aus dem Programm. Es leuchten die Zeilen, die gerade arbeiten.
Die Idee
Im Spiel
Was wir gebaut haben, ist dasselbe wie im Kanon, nur kleiner. Hier der echte Zug (die Region turn). Über runTurn stehen die Bewegung, die dieselben zwei Schritte macht (Aufbrechen, Ankommen), die Schlachten und die Eroberung von Planeten; runTurn ruft die Phasen in fester Reihenfolge auf, und am Ende des Zuges klingt der Groll ab (jeden fünften Zug sinkt er um 1) und alte Friedensangebote werden weggeworfen.
# Eine Runde der Galaxie hat Phasen: Diplomatie der künstlichen Intelligenz, ihre Aufträge, Bewegung aller# Flotten auf einmal, Kämpfe, Eroberung, Produktion und Forschung, ein Ereignis, Ende der Runde.# Die Bewegung ist gleichzeitig: zuerst verlassen alle Schiffe ihre Systeme, erst dann kommen sie an.def resolve_movement(world): moving = [] for empire in world.empires: if not empire.alive: continue for order in empire.orders: fleet = fleet_at(world, empire.id, order.from_) if fleet is None: continue ships = [0, 0, 0, 0] for k in range(SHIP_KIND_COUNT): ships[k] = min_of(order.ships[k], fleet.ships[k]) fleet.ships[k] -= ships[k] if total_of(ships) > 0: moving.append(SimpleNamespace(owner=empire.id, to=order.to, dest=order.dest, ships=ships)) empire.orders = [] for move in moving: add_ships(world, move.owner, move.to, move.ships) # der lange Weg geht von selbst weiter: ein neuer Auftrag von dem Ort, wo sie angekommen sind if move.to != move.dest: world.empires[move.owner].orders.append(SimpleNamespace(from_=move.to, to=next_step(world.systems, move.to, move.dest), dest=move.dest, ships=list(move.ships))) remove_empty_fleets(world)
def has_units(world, owner, system_id): fleet = fleet_at(world, owner, system_id) return (fleet is not None and total_of(fleet.ships) > 0) or forts_now(world, owner, system_id) > 0
def resolve_battles(world): for system in world.systems: for a in range(len(world.empires)): for b in range(a + 1, len(world.empires)): if not world.empires[a].alive or not world.empires[b].alive or not at_war(world, a, b): continue if has_units(world, a, system.id) and has_units(world, b, system.id): fight_at(world, system.id, a, b) remove_empty_fleets(world)
# Ein Planet fällt, wenn ein Feind mit seinem Besitzer im Krieg ist, Schiffe im System hat und der Besitzer dort# weder Schiffe noch überlebende Festungen hat, SIEGE_TURNS Runden hintereinander: die ersten Runden sind Belagerung,# die letzte ist die Einnahme. Der Verteidiger hat Zeit, eine Flotte heranzuführen. Die Gebäude werden zerstört.def resolve_conquest(world): for system in world.systems: for pid in system.planets: planet = world.planets[pid] if planet.owner < 0: continue owner = planet.owner attacker = -1 if not has_units(world, owner, system.id): for e in world.empires: if not e.alive or e.id == owner or not at_war(world, e.id, owner): continue fleet = fleet_at(world, e.id, system.id) if fleet is not None and total_of(fleet.ships) > 0: attacker = e.id break if attacker < 0: planet.siege = 0 continue planet.siege += 1 if planet.siege < SIEGE_TURNS: world.bus.publish(SimpleNamespace(type='siege', empire=attacker, from_=owner, planet_id=planet.id)) continue planet.siege = 0 planet.owner = attacker planet.mines = max_of(0, planet.mines - 1) planet.lab = 0 planet.yard = 0 planet.fort = 0 world.bus.publish(SimpleNamespace(type='conquest', empire=attacker, from_=owner, planet_id=planet.id)) for empire in world.empires: if not empire.alive or len(owned_planets(world, empire.id)) > 0: continue empire.alive = False kept = [] for fleet in world.fleets: if fleet.owner != empire.id: kept.append(fleet) world.fleets = kept empire.orders = [] world.bus.publish(SimpleNamespace(type='eliminated', empire=empire.id))
def run_turn(world): world.turn_lines = [] world.fort_left = {} for empire in world.empires: if empire.alive and not empire.human: ai_diplomacy(world, empire) for empire in world.empires: if empire.alive and not empire.human: ai_play(world, empire) resolve_movement(world) resolve_battles(world) resolve_conquest(world) for empire in world.empires: if not empire.alive: continue produce(world, empire) complete_research(world, empire) finish_queue(world) roll_event(world) check_victory(world) world.turn += 1 if world.turn % 5 == 0: for empire in world.empires: for i in range(len(empire.grudge)): empire.grudge[i] = max_of(0, empire.grudge[i] - 1) fresh = [] for offer in world.offers: if world.turn - offer.turn <= 3: fresh.append(offer) world.offers = freshUnd hier der Bus, auf den die Nachrichten, der Groll und die Warnung an den Spieler hören. Die Ereignisse sind eine Klasse mit mehr Feldern (in JavaScript ein Objekt), und der Bus ist derselbe:
# Der Ereignisbus: wer veröffentlicht, weiß nicht, wer zuhört. Die Nachrichten, der Groll der Reiche und# die Statistik abonnieren selbst, und der Kampf sagt nur „eine Flotte hat bei diesem System gekämpft“.class EventBus: def __init__(self): self.handlers = {}
def subscribe(self, event_type, handler): if event_type not in self.handlers: self.handlers[event_type] = [] self.handlers[event_type].append(handler)
def publish(self, event): if event.type not in self.handlers: return for handler in list(self.handlers[event.type]): handler(event)Die Unterschiede zu unserem Programm sind viele, und deshalb wichtig:
- Es gibt kein Skript. Befehle und Diplomatie kommen von
aiPlayundaiDiplomacy(die Lektion über die Rivalen) und von den Befehlen des Spielers. - Die Galaxie ist ein Graph (die Lektion über ihre Erzeugung), keine Linie. Ein Befehl merkt sich sein Endziel und setzt sich von selbst fort.
- Die Schlacht (
fightAt, die Lektion über Flotten) hat Schiffsrollen und sechs Runden, nicht „jeder verliert die kleinere Zahl“. Es gibt auch Festungen und die Eroberung eines Planeten (zwei Belagerungen). - Der Friedenswunsch nutzt die Stärke des Reiches (nicht die Zahl der Schiffe), seinen Charakter und seinen Kriegswunsch (
warWish). Ein Angebot an den Spieler wird für drei Züge eingetragen. - Die Produktion ist hier pauschal 10 Metall pro Reich; im Kanon hängt sie von Planeten, Minen und Technologien ab.
- Die Ereignisse sind acht Arten, mit vielfältigeren Wirkungen (Piraten kämpfen mit demselben Schlachtmodell).
- Der Bus trägt auch andere Arten: Eroberung, Belagerung, eine entdeckte Technologie, eine Kolonie, ein zerstörtes Reich. Die Nachrichten und die Warnung an den Spieler hören darauf, und
waithält an, sobald die Warnung wächst.
Probier es aus
Sage die Ausgabe voraus
Zwei Flotten: Der erste Befehl schickt die 3 Schiffe von System 0 nach 1, der zweite 2 Schiffe von System 1 nach 2. Die Befehle werden gleichzeitig verarbeitet. Was gibt das Programm aus?
def move_together(start, orders): ships = start[:] takes = [] # Zuerst verlassen alle Flotten ihre Systeme... for source, target, count in orders: take = min(count, ships[source]) ships[source] -= take takes.append(take) # ...und erst dann kommen sie an. for i, order in enumerate(orders): ships[order[1]] += takes[i] return ships
def show(ships): return " ".join(str(n) for n in ships)
orders = [(0, 1, 3), (1, 2, 2)]print(show(move_together([3, 0, 0, 0], orders)))Zuerst brechen alle auf: Der erste Befehl nimmt 3 Schiffe aus System 0, und der zweite sieht in System 1 null Schiffe (die ersten sind noch nicht angekommen) und nimmt 0. Dann kommen sie an: 3 Schiffe in System 1, 0 in System 2. Die zweite Möglichkeit ist das Ergebnis bei Befehlen nacheinander: 2 der angekommenen Schiffe brechen im selben Zug erneut auf; die dritte ist der Anfangszustand, ohne Bewegung.
Probier es aus
Finde den Fehler
Ein Kollege hat die gleichzeitige Bewegung „repariert“, aber damit fliegen die Schiffe immer noch durch mehrere Systeme pro Zug:
start = [3, 0, 0, 0]print("gleichzeitig: " + show(move_together(start, ORDERS)))gleichzeitig: 0 0 0 3Das Ergebnis ist wie bei der Bewegung nacheinander. Welche Zeile schickt die Schiffe zu früh zum Ziel?
Wähle die Zeile mit dem Fehler.
Die Schiffe kommen an (+= am Ziel) schon in der Schleife für das Aufbrechen. Wenn der zweite Befehl kommt, sind die Schiffe des ersten schon da, und er nimmt sie. Richtig: In der ersten Schleife werden sie nur genommen (-=) und in takes gemerkt; das Ankommen ist eine zweite Schleife, nachdem alle aufgebrochen sind.
Genauer
Genauer: Reihenfolge, Fairness und Zufall
Die Reihenfolge der Spieler. Bei gleichzeitiger Bewegung ändert die Reihenfolge der Befehle das Ergebnis nicht, weil alle zuerst vom Beginn des Zuges nehmen. Bei den Schlachten gibt es dagegen eine Reihenfolge: System für System, Paar für Paar (a < b). Sind drei Reiche im Krieg in einem System, kämpft das erste Paar in der Reihenfolge zuerst, und die Überlebenden gehen in den nächsten Kampf. Die Reihenfolge ist fest, deshalb bleibt das Spiel wiederholbar.
Die Phasen sind ein Vertrag. Die Diplomatie kommt vor der Bewegung, denn dann kann ein neu erklärter Krieg im selben Zug eine Schlacht auslösen. Die Produktion kommt nach der Eroberung: Ein in diesem Zug eroberter Planet produziert nicht mehr für seinen alten Herrn. Das Ereignis kommt fast zuletzt und arbeitet mit der Welt, wie sie geworden ist (der Meteorit nimmt von dem Metall, das schon produziert wurde). Vertausche zwei Phasen, und das Spiel bleibt „dasselbe“, aber der Ausgang ist ein anderer. Deshalb steht die Reihenfolge an einer Stelle (runTurn) und liest sich wie eine Liste.
Zufall und Wiederholbarkeit. Alles Zufällige (die Chance auf ein Ereignis, welches Reich, welche Art, wie viel Metall) läuft über einen Generator mit Startwert. Tauschen zwei Phasen den Platz und ziehen beide Zahlen, ist die Folge eine andere. In unserem Programm benutzt nur rollEvent den Generator, deshalb ist die Ausgabe stabil; im Kanon ziehen auch die Diplomatie und die KI Zahlen, und die Reihenfolge der Phasen entscheidet, wer welche Zahl bekommt.
Was nicht in den Bus gehört. Der Bus ist für Nachrichten: „das ist geschehen“. Er ist nicht für Befehle („tu das“): Der Veröffentlichende weiß nicht, ob jemand es gehört hat, und wartet nicht auf eine Antwort. Veröffentliche Tatsachen, keine Anordnungen.
Übung
Eine Warnung für den Spieler
Löse die Aufgabe in deinem Editor. Beginne mit dem Programm für den ganzen Zug.
Warnung. Im Spiel hält wait an, sobald etwas dem Spieler geschieht. Füge einen Abonnenten „Warnung“ hinzu, der die Ereignisse war, peace und battle zählt, an denen Reich 0 (der Spieler) beteiligt ist, als a oder als b. Nach der Zusammenfassung gibt das Programm noch eine Zeile mit den Warnungen aus (Warnungen: …). Fass declareWar, makePeace und resolveBattles nicht an: Der Abonnent ist nur eine neue Zeile beim Erzeugen der Welt und eine Funktion.
Die Ausgabe ist (die letzte Zeile ist die neue):
-- Zug 1 -- [Diplomatie] Kriegserklärung: Orion-Hegemonie → Terranisches Reich. [Bewegung] Orion-Hegemonie schickt eine Flotte von Brisa nach Altan (Schiffe: 4). [Bewegung] Terranisches Reich schickt eine Flotte von Kora nach Vesta (Schiffe: 3). [Ereignis] Meteoritenschauer: Lyra-Union verliert 14 Metall.-- Zug 2 -- [Bewegung] Orion-Hegemonie schickt eine Flotte von Altan nach Vesta (Schiffe: 4). [Schlachten] Schlacht bei Vesta: Terranisches Reich und Orion-Hegemonie verlieren Schiffe (je 3).-- Zug 3 -- [Diplomatie] Kriegserklärung: Lyra-Union → Orion-Hegemonie. [Diplomatie] Friedensangebot abgelehnt: Terranisches Reich → Orion-Hegemonie. [Schlachten] Schlacht bei Vesta: Orion-Hegemonie und Lyra-Union verlieren Schiffe (je 1).-- Zug 4 -- [Diplomatie] Frieden geschlossen: Terranisches Reich und Orion-Hegemonie. [Ereignis] Meteoritenschauer: Lyra-Union verliert 8 Metall.-- Zug 5 -- [Diplomatie] Zu früh für eine Änderung: Orion-Hegemonie → Terranisches Reich. [Ereignis] Orion-Hegemonie findet verlassene Schiffe bei Brisa (Schiffe: 2).-- Ende --Terranisches Reich: Schiffe 0, Metall 100Orion-Hegemonie: Schiffe 2, Metall 100Lyra-Union: Schiffe 1, Metall 78Warnungen: 3Weitere Aufgaben
- Statistik. Füge einen weiteren Abonnenten hinzu, der die Schlachten und die Summe der verlorenen Schiffe zählt (jede Schlacht verliert
nauf jeder der beiden Seiten). Er gibt sie nach den Warnungen aus. - Waffenruhe. Senke
MIN_PEACEauf 2 und lass das Programm laufen. Welche Zeilen der Ausgabe ändern sich, und warum ist Zug 5 nicht mehr „zu früh“? - Eine neue Ereignisart. Füge
plaguemit frühestem Zug 4 und Gewicht 10 hinzu: Das Reich verliert 5 Metall. Welche Stellen änderst du: die Tabelle,rollEvent,newsText, die Liste der Arten für die Nachrichten? Und den Bus?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Listen: append, remove und sort · dict und set · Lambdas · dataclass, match und Muster
- Aufgaben zum Thema: Epic Cosmos: das Imperium