Zum Inhalt
wannacode

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]
const ORDERS = [[0, 1, 3], [1, 2, 3], [2, 3, 3]];
function moveOneByOne(start, orders) {
const ships = start.slice();
for (const [source, target, count] of orders) {
const take = Math.min(count, ships[source]);
ships[source] -= take;
ships[target] += take;
}
return ships;
}
function moveTogether(start, orders) {
const ships = start.slice();
const takes = [];
// Zuerst verlassen alle Flotten ihre Systeme...
for (const [source, target, count] of orders) {
const take = Math.min(count, ships[source]);
ships[source] -= take;
takes.push(take);
}
// ...und erst dann kommen sie an.
orders.forEach(([, target], i) => {
ships[target] += takes[i];
});
return ships;
}
const start = [3, 0, 0, 0];
console.log("nacheinander: " + show(moveOneByOne(start, ORDERS)));
console.log("gleichzeitig: " + show(moveTogether(start, ORDERS)));
Ausgabe
nacheinander: 0 0 0 3
gleichzeitig: 0 3 0 0

Die 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.slice() ist eine Kopie des Arrays: Ohne sie würde die Funktion das Array 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 Zug
const EVENT_DEFS = [
{ id: "meteor", weight: 20, minTurn: 1 },
{ id: "vein", weight: 15, minTurn: 1 },
{ id: "pirates", weight: 20, minTurn: 6 },
{ id: "breakthrough", weight: 15, minTurn: 1 },
{ id: "unrest", weight: 10, minTurn: 5 },
{ id: "boom", weight: 15, minTurn: 1 },
{ id: "flare", weight: 10, minTurn: 1 },
{ id: "derelict", weight: 8, minTurn: 3 }
];
function pickWeighted(rng, weights) {
let total = 0;
for (const weight of weights) total += weight;
let roll = rng.nextInt(total);
for (let i = 0; i < weights.length; i++) {
if (roll < weights[i]) return i;
roll -= weights[i];
}
return weights.length - 1;
}
function rollEvent(rng, turn) {
if (!rng.chance(EVENT_CHANCE)) return null;
const empire = rng.nextInt(NAMES.length);
const weights = EVENT_DEFS.map((def) => (turn >= def.minTurn ? def.weight : 0));
return { kind: pickWeighted(rng, weights), empire: 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:

const rng = new Rng(7);
for (let turn = 1; turn <= 12; turn++) {
const roll = rollEvent(rng, turn);
if (roll !== null) console.log("Zug " + turn + ": " + EVENT_DEFS[roll.kind].id + " → " + NAMES[roll.empire]);
}
const counts = new Array(EVENT_DEFS.length).fill(0);
for (let i = 0; i < 1000; i++) {
const roll = rollEvent(rng, 99);
if (roll !== null) counts[roll.kind]++;
}
console.log("Ereignisse in 1000 Zügen:");
for (let i = 0; i < counts.length; i++) {
console.log(EVENT_DEFS[i].id + " " + EVENT_DEFS[i].weight + ": " + counts[i]);
}
Ausgabe
Zug 1: boom → Lyra-Union
Zug 2: breakthrough → Vega-Akademie
Zug 3: vein → Vega-Akademie
Zug 4: boom → Vega-Akademie
Zug 8: pirates → Lyra-Union
Zug 9: breakthrough → Terranisches Reich
Ereignisse in 1000 Zügen:
meteor 20: 70
vein 15: 53
pirates 20: 78
breakthrough 15: 58
unrest 10: 30
boom 15: 50
flare 10: 28
derelict 8: 28

Die 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.
const EVENT_CHANCE = 40;
function pickEmpirePlanet(world, empire) {
const list = ownedPlanets(world, empire.id);
if (list.length === 0) return null;
return list[world.rng.nextInt(list.length)];
}
function pirateFleet(world) {
const strength = Math.floor(world.turn / 6);
return [2 + strength, Math.floor(strength / 2), Math.floor(strength / 3), 0, 0];
}
function applyEvent(world, kind, empire) {
const rng = world.rng;
const vars = { empire: empireName(empire) };
if (kind === 'meteor') {
const planet = pickEmpirePlanet(world, empire);
if (planet === null) return null;
const loss = minOf(empire.res[0], 8 + rng.nextInt(8));
empire.res[0] -= loss;
vars.planet = planetName(world, planet);
vars.n = loss;
} else if (kind === 'vein') {
const planet = pickEmpirePlanet(world, empire);
if (planet === null) return null;
let r = rng.nextInt(RES_COUNT);
let tries = 0;
while (planet.rich[r] >= 5 && tries < RES_COUNT) {
r = (r + 1) % RES_COUNT;
tries++;
}
if (planet.rich[r] >= 5) return null;
planet.rich[r]++;
vars.planet = planetName(world, planet);
vars.res = t('res.' + r);
} else if (kind === 'pirates') {
const planet = pickEmpirePlanet(world, empire);
if (planet === null) return null;
const systemId = planet.systemId;
const mine = emptyCounts();
const fleet = fleetAt(world, empire.id, systemId);
for (let k = 0; k < SHIP_KIND_COUNT; k++) mine[k] = fleet === null ? 0 : fleet.ships[k];
mine[FORT_KIND] = fortsIn(world, empire.id, systemId);
const result = runBattle(sideStats(empire), mine, sideStats(null), pirateFleet(world));
for (let k = 0; k < SHIP_KIND_COUNT; k++) {
if (fleet !== null) fleet.ships[k] = result.a[k];
}
vars.system = world.systems[systemId].name;
vars.fleet = countsText(pirateFleet(world));
if (result.winner === 'a') {
empire.res[0] += 20;
empire.res[1] += 10;
vars.outcome = t('event.pirates.win');
} else {
const m = Math.floor(empire.res[0] / 5);
const g = Math.floor(empire.res[1] / 5);
empire.res[0] -= m;
empire.res[1] -= g;
vars.outcome = t('event.pirates.lose', { m: m, g: g });
}
if (!world.quiet) world.last.battle = battleLines(vars.system, empireName(empire), t('pirates'), result);
world.stats.battles++;
world.stats.lost += totalOf(mine) - totalOf(result.a) + totalOf(pirateFleet(world)) - totalOf(result.b);
} else if (kind === 'breakthrough') {
const n = 6 + rng.nextInt(7);
empire.progress += n;
vars.n = n;
} else if (kind === 'unrest') {
const planet = pickEmpirePlanet(world, empire);
if (planet === null || planet.mines === 0) return null;
planet.mines--;
vars.planet = planetName(world, planet);
} else if (kind === 'boom') {
empire.res[1] += 20;
empire.res[2] += 10;
} else if (kind === 'flare') {
empire.flare = 1;
} else if (kind === 'derelict') {
const planet = pickEmpirePlanet(world, empire);
if (planet === null) return null;
addShips(world, empire.id, planet.systemId, [0, 0, 2, 0]);
vars.system = world.systems[planet.systemId].name;
}
return vars;
}
function rollEvent(world) {
const rng = world.rng;
if (!rng.chance(EVENT_CHANCE)) return;
const living = [];
for (const e of world.empires) {
if (e.alive) living.push(e);
}
if (living.length === 0) return;
const empire = living[rng.nextInt(living.length)];
const allowed = [];
const weights = [];
for (const def of EVENT_DEFS) {
if (world.turn >= def.minTurn) {
allowed.push(def);
weights.push(def.weight);
}
}
const def = allowed[pickWeighted(rng, weights)];
const vars = applyEvent(world, def.id, empire);
if (vars === null) return;
world.stats.events++;
world.bus.publish({ type: 'event', kind: def.id, empire: empire.id, vars: vars });
}
// Die Abonnenten: die Nachrichten für den Spieler, der Groll nach Kämpfen und Eroberung.
function addNews(world, text) {
world.news.push(world.turn + ': ' + text);
while (world.news.length > 60) world.news.shift();
world.turnLines.push(text);
}
function installSubscribers(world, quiet) {
const bus = world.bus;
const involvesHuman = (a, b) => world.empires[a].human || world.empires[b].human;
bus.subscribe('battle', (e) => {
if (e.lostA > 0) world.empires[e.b].grudge[e.a]++;
if (e.lostB > 0) world.empires[e.a].grudge[e.b]++;
});
bus.subscribe('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.
bus.subscribe('battle', (e) => {
if (involvesHuman(e.a, e.b)) world.alert++;
});
bus.subscribe('conquest', (e) => {
if (world.empires[e.empire].human || (e.from >= 0 && world.empires[e.from].human)) world.alert++;
});
bus.subscribe('war', (e) => {
if (involvesHuman(e.a, e.b)) world.alert++;
});
bus.subscribe('siege', (e) => {
if (world.empires[e.from].human) world.alert++;
});
bus.subscribe('offer', (e) => {
if (world.empires[e.to].human) world.alert++;
});
if (quiet) return;
bus.subscribe('battle', (e) => {
if (involvesHuman(e.a, e.b)) {
for (const line of e.lines) addNews(world, line);
} else {
addNews(world, t('news.battle', {
system: world.systems[e.systemId].name, a: empireName(world.empires[e.a]), b: empireName(world.empires[e.b]),
la: e.lostA, lb: e.lostB,
}));
}
});
bus.subscribe('colony', (e) => {
addNews(world, t('news.colony', { empire: empireName(world.empires[e.empire]), planet: planetName(world, world.planets[e.planetId]) }));
});
bus.subscribe('tech', (e) => {
if (world.empires[e.empire].human) addNews(world, t('news.tech', { tech: nm(TECHS.get(e.techId)) }));
});
bus.subscribe('war', (e) => {
addNews(world, t('news.war', { a: empireName(world.empires[e.a]), b: empireName(world.empires[e.b]) }));
});
bus.subscribe('peace', (e) => {
addNews(world, t('news.peace', { a: empireName(world.empires[e.a]), b: empireName(world.empires[e.b]) }));
});
bus.subscribe('offer', (e) => {
addNews(world, t('news.offer', { empire: empireName(world.empires[e.from]), letter: world.empires[e.from].def.letter }));
});
bus.subscribe('siege', (e) => {
addNews(world, t('news.siege', { empire: empireName(world.empires[e.empire]), planet: planetName(world, world.planets[e.planetId]) }));
});
bus.subscribe('conquest', (e) => {
addNews(world, t('news.conquest', { empire: empireName(world.empires[e.empire]), planet: planetName(world, world.planets[e.planetId]) }));
});
bus.subscribe('eliminated', (e) => {
addNews(world, t('news.eliminated', { empire: empireName(world.empires[e.empire]) }));
});
bus.subscribe('event', (e) => {
const empire = world.empires[e.empire];
addNews(world, t('event.' + e.kind, e.vars));
});
}

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 = 4 Zü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 {
constructor() {
this.turn = 1;
this.ships = [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]];
this.war = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
this.since = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
this.grudge = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
}
}
function atWar(world, a, b) {
return world.war[a][b] === 1;
}
function power(world, empire) {
let total = 0;
for (const n of world.ships[empire]) total += n;
return total;
}
function setStance(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;
}
function declareWar(world, a, b) {
if (atWar(world, a, b)) return "alreadyWar";
// Frieden gilt mindestens MIN_PEACE Züge.
if (world.turn - world.since[a][b] < MIN_PEACE && world.turn > MIN_PEACE) return "tooSoon";
setStance(world, a, b, 1);
return null;
}
function makePeace(world, a, b) {
setStance(world, a, b, 0);
world.grudge[a][b] = 0;
world.grudge[b][a] = 0;
}
function peaceWish(world, me, other) {
// Wie viel schwächer das Reich als das andere ist.
const ratio = Math.min(150, Math.floor((power(world, me) * 100) / (power(world, other) + 1)));
let otherWars = 0;
for (let x = 0; x < 3; x++) {
if (x !== me && x !== other && atWar(world, me, x)) otherWars++;
}
return Math.max(0, PEACE[me] + Math.floor((150 - ratio) / 2) + otherWars * 15 - world.grudge[me][other] * 3);
}
function proposePeace(world, a, b) {
if (!atWar(world, a, b)) return "noWar";
if (world.turn - world.since[a][b] < 2) return "tooSoon";
// Ab 90 nimmt das Reich Frieden an.
if (peaceWish(world, b, a) >= 90) {
makePeace(world, a, b);
return null;
}
// Eine Ablehnung macht nachtragend: der nächste Frieden wird schwerer.
world.grudge[b][a]++;
return "refused";
}
function step(world, turn, kind, a, b) {
world.turn = turn;
const wish = kind === "peace" ? peaceWish(world, b, a) : 0;
const error = kind === "war" ? declareWar(world, a, b) : proposePeace(world, a, b);
const shown = kind === "peace" && (error === null || error === "refused") ? " (Wunsch " + wish + ")" : "";
console.log("Zug " + turn + ": " + kind + " " + NAMES[a] + " → " + NAMES[b] + ": " + (error ?? "ok") + shown);
}
const world = new World();
world.ships[1][1] = 1;
world.grudge[1][0] = 1;
step(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] = 0;
step(world, 4, "peace", 0, 1);
step(world, 5, "war", 1, 0);
step(world, 9, "war", 1, 0);
Ausgabe
Zug 1: war Orion-Hegemonie → Terranisches Reich: ok
Zug 2: war Orion-Hegemonie → Terranisches Reich: alreadyWar
Zug 2: peace Terranisches Reich → Orion-Hegemonie: tooSoon
Zug 3: war Lyra-Union → Orion-Hegemonie: ok
Zug 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: tooSoon
Zug 9: war Orion-Hegemonie → Terranisches Reich: ok

Zug 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).

function atWar(world, a, b) {
  return world.war[a][b] === 1;
}

function power(world, empire) {
  let total = 0;
  for (const n of world.ships[empire]) total += n;
  return total;
}

function setStance(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;
}

function declareWar(world, a, b) {
  if (atWar(world, a, b)) return "alreadyWar";
  // Frieden gilt mindestens MIN_PEACE Züge.
  if (world.turn - world.since[a][b] < MIN_PEACE && world.turn > MIN_PEACE) return "tooSoon";
  setStance(world, a, b, 1);
  return null;
}

function makePeace(world, a, b) {
  setStance(world, a, b, 0);
  world.grudge[a][b] = 0;
  world.grudge[b][a] = 0;
}

function peaceWish(world, me, other) {
  // Wie viel schwächer das Reich als das andere ist.
  const ratio = Math.min(150, Math.floor((power(world, me) * 100) / (power(world, other) + 1)));
  let otherWars = 0;
  for (let x = 0; x < 3; x++) {
    if (x !== me && x !== other && atWar(world, me, x)) otherWars++;
  }
  return Math.max(0, PEACE[me] + Math.floor((150 - ratio) / 2) + otherWars * 15 - world.grudge[me][other] * 3);
}

function proposePeace(world, a, b) {
  if (!atWar(world, a, b)) return "noWar";
  if (world.turn - world.since[a][b] < 2) return "tooSoon";
  // Ab 90 nimmt das Reich Frieden an.
  if (peaceWish(world, b, a)  90) {
    makePeace(world, a, b);
    return null;
  }
  // Eine Ablehnung macht nachtragend: der nächste Frieden wird schwerer.
  world.grudge[b][a]++;
  return "refused";
}
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.
function atWar(world, a, b) {
return world.empires[a].war[b] === 1;
}
function setStance(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;
}
function declareWar(world, a, b) {
if (a === b) return 'dip.self';
if (!world.empires[b].alive) return 'dip.dead';
if (atWar(world, a, b)) return 'dip.alreadyWar';
if (world.turn - world.empires[a].since[b] < MIN_PEACE && world.turn > MIN_PEACE) return 'dip.tooSoon';
setStance(world, a, b, 1);
world.bus.publish({ type: 'war', a: a, b: b });
return null;
}
function makePeace(world, a, b) {
setStance(world, a, b, 0);
world.empires[a].grudge[b] = 0;
world.empires[b].grudge[a] = 0;
world.bus.publish({ type: 'peace', a: a, b: b });
}
// Der kleinste Abstand in Wegen zwischen Systemen mit Planeten der beiden Reiche.
function empireDistance(world, a, b) {
let best = 99;
for (const pa of world.planets) {
if (pa.owner !== a) continue;
const dist = bfs(world.systems, pa.systemId).dist;
for (const pb of world.planets) {
if (pb.owner === b && dist[pb.systemId] >= 0 && dist[pb.systemId] < best) best = dist[pb.systemId];
}
}
return best;
}
function powerRatio(world, a, b, top) {
return clamp(percent(empirePower(world, a) * 100, empirePower(world, b) * 100 + 100), 0, top);
}
// Wie sehr das Reich Frieden mit dem anderen will (0 und mehr; ab 90 nimmt es an).
function peaceWish(world, me, other) {
const e = world.empires[me];
const weaker = 150 - powerRatio(world, me, other, 150);
let otherWars = 0;
for (const x of world.empires) {
if (x.alive && x.id !== me && x.id !== other && e.war[x.id] === 1) otherWars++;
}
const wish = e.personality.peace + Math.floor(weaker / 2) + otherWars * 15 - e.grudge[other] * 3;
return maxOf(0, wish);
}
// Wie sehr das Reich Krieg mit dem anderen will.
function warWish(world, me, other) {
const e = world.empires[me];
const ratio = powerRatio(world, me, other, 300);
let wish = Math.floor(e.personality.attack / 2);
if (ratio > 100) wish += Math.floor((ratio - 100) / 4);
else wish -= Math.floor((100 - ratio) / 2);
if (empireDistance(world, me, other) <= 3) wish += 25;
for (const x of world.empires) {
if (x.alive && x.id !== me && e.war[x.id] === 1) wish -= 30;
}
return wish;
}
function hasOffer(world, from, to) {
for (const offer of world.offers) {
if (offer.from === from && offer.to === to && world.turn - offer.turn <= 3) return true;
}
return false;
}
// Ein Friedensangebot von a an b. Gibt einen Fehlerschlüssel oder null zurück (Frieden ist geschlossen).
function proposePeace(world, a, b) {
if (a === b) return 'dip.self';
if (!world.empires[b].alive) return 'dip.dead';
if (!atWar(world, a, b)) return 'dip.noWar';
if (world.turn - world.empires[a].since[b] < 2) return 'dip.tooSoon';
if (world.empires[b].human) {
if (hasOffer(world, b, a)) {
makePeace(world, a, b);
return null;
}
if (!hasOffer(world, a, b)) world.offers.push({ from: a, to: b, turn: world.turn });
return 'dip.sent';
}
if (hasOffer(world, b, a) || peaceWish(world, b, a) >= 90) {
makePeace(world, a, b);
return null;
}
world.empires[b].grudge[a]++;
return 'dip.refused';
}
// Die Diplomatie der künstlichen Intelligenz am Rundenende: Frieden, wenn schwächer, Krieg, wenn stärker.
function aiDiplomacy(world, empire) {
for (const other of world.empires) {
if (!other.alive || other.id === empire.id) continue;
if (atWar(world, empire.id, other.id)) {
if (world.turn - empire.since[other.id] >= 3 && peaceWish(world, empire.id, other.id) >= 90) {
if (other.human) {
if (!hasOffer(world, empire.id, other.id)) {
world.offers.push({ from: empire.id, to: other.id, turn: world.turn });
world.bus.publish({ type: 'offer', from: empire.id, to: other.id });
}
} else {
proposePeace(world, empire.id, other.id);
}
}
} else if (world.turn - empire.since[other.id] >= MIN_PEACE + 2 && world.turn > MIN_PEACE + 2) {
if (warWish(world, empire.id, other.id) >= 95 && world.rng.chance(40)) declareWar(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.

function event(type, a, b, place = 0, n = 0) {
return { type: type, a: a, b: b, place: place, n: n };
}
class EventBus {
constructor() {
this.handlers = new Map();
}
subscribe(type, handler) {
if (!this.handlers.has(type)) this.handlers.set(type, []);
this.handlers.get(type).push(handler);
}
publish(e) {
if (!this.handlers.has(e.type)) return;
for (const handler of this.handlers.get(e.type).slice()) handler(e);
}
}

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:

function declareWar(world, a, b) {
if (atWar(world, a, b)) return "alreadyWar";
// Frieden gilt mindestens MIN_PEACE Züge.
if (world.turn - world.since[a][b] < MIN_PEACE && world.turn > MIN_PEACE) return "tooSoon";
setStance(world, a, b, 1);
world.bus.publish(event("war", a, b));
return null;
}
function makePeace(world, a, b) {
setStance(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:

const world = new World();
world.ships[1][1] = 1;
world.grudge[1][0] = 1;
// Die Nachrichten hören auf jede Art von Ereignis, der Groll nur auf Schlachten.
for (const type of NEWS_TYPES) {
world.bus.subscribe(type, (e) => console.log(newsText(e)));
}
world.bus.subscribe("battle", (e) => {
world.grudge[e.a][e.b]++;
world.grudge[e.b][e.a]++;
});
world.turn = 1;
report(world, declareWar(world, 1, 0), 1, 0);
world.bus.publish(event("battle", 1, 0, 1, 2));
world.turn = 3;
report(world, proposePeace(world, 0, 1), 0, 1);
report(world, declareWar(world, 1, 0), 1, 0);
console.log("Groll " + NAMES[0] + " → " + NAMES[1] + ": " + world.grudge[0][1]);
console.log("Groll " + NAMES[1] + " → " + NAMES[0] + ": " + world.grudge[1][0]);
Ausgabe
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: 1
Groll Orion-Hegemonie → Terranisches Reich: 3

Der 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).

Der Handler ist eine Funktion (ein Pfeil (e) => ...); EventBus weiß nichts über ihn, außer dass er 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 {
constructor(seed) {
this.turn = 1;
this.phase = "";
this.rng = new Rng(seed);
this.bus = new EventBus();
this.ships = [[3, 0, 0, 0], [0, 0, 0, 4], [0, 2, 0, 0]];
this.metal = [50, 50, 50];
this.war = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
this.since = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
this.grudge = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
}
}

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]
const ORDERS = [[1, 1, 3, 2, 4], [1, 0, 0, 1, 3], [2, 1, 2, 1, 4]];
// [Zug, Art (0 Krieg, 1 Frieden), von, nach]
const 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):

function scriptedDiplomacy(world) {
world.phase = "Diplomatie";
for (const [turn, kind, a, b] of DIPLO) {
if (turn !== world.turn) continue;
const error = kind === 0 ? declareWar(world, a, b) : proposePeace(world, a, b);
report(world, error, a, b);
}
}
function resolveMovement(world) {
world.phase = "Bewegung";
const moving = [];
// Zuerst verlassen alle Flotten ihre Systeme...
for (const [turn, empire, source, target, count] of ORDERS) {
if (turn !== world.turn) continue;
const take = Math.min(count, world.ships[empire][source]);
world.ships[empire][source] -= take;
if (take > 0) moving.push({ empire: empire, source: source, target: target, take: take });
}
// ...und erst dann kommen sie an.
for (const move of moving) {
world.ships[move.empire][move.target] += move.take;
world.bus.publish(event("move", move.empire, move.source, move.target, move.take));
}
}
function resolveBattles(world) {
world.phase = "Schlachten";
for (let system = 0; system < 4; system++) {
for (let a = 0; a < 3; a++) {
for (let b = a + 1; b < 3; b++) {
if (!atWar(world, a, b) || world.ships[a][system] === 0 || world.ships[b][system] === 0) continue;
const lost = Math.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));
}
}
}
}
function produce(world) {
world.phase = "Produktion";
for (let empire = 0; empire < 3; empire++) world.metal[empire] += 10;
}

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

function rollEvent(world) {
world.phase = "Ereignis";
if (!world.rng.chance(EVENT_CHANCE)) return;
const empire = world.rng.nextInt(3);
const weights = EVENT_DEFS.map((def) => (world.turn >= def.minTurn ? def.weight : 0));
const kind = EVENT_DEFS[pickWeighted(world.rng, weights)].id;
if (kind === "meteor") {
const loss = Math.min(world.metal[empire], 8 + world.rng.nextInt(8));
world.metal[empire] -= loss;
world.bus.publish(event("meteor", empire, 0, 0, loss));
} else if (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));
}
}
function runTurn(world) {
// Die Phasen laufen in dieser Reihenfolge, und die Reihenfolge ist wichtig.
scriptedDiplomacy(world);
resolveMovement(world);
resolveBattles(world);
produce(world);
rollEvent(world);
world.turn++;
}

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

const world = new World(7);
world.bus.subscribe("battle", (e) => {
world.grudge[e.a][e.b]++;
world.grudge[e.b][e.a]++;
});
for (const type of NEWS_TYPES) {
world.bus.subscribe(type, (e) => console.log(" [" + world.phase + "] " + newsText(e)));
}
for (let i = 0; i < 5; i++) {
console.log(fill("-- Zug {0} --", world.turn));
runTurn(world);
}
console.log("-- Ende --");
for (let empire = 0; empire < 3; empire++) {
console.log(fill("{0}: Schiffe {1}, Metall {2}", NAMES[empire], power(world, empire), world.metal[empire]));
}
Ausgabe
-- 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 100
Orion-Hegemonie: Schiffe 2, Metall 100
Lyra-Union: Schiffe 1, Metall 78

Lies 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“.

function runTurn(world) {
// Die Phasen laufen in dieser Reihenfolge, und die Reihenfolge ist wichtig.
scriptedDiplomacy(world);
resolveMovement(world);
resolveBattles(world);
produce(world);
rollEvent(world);
world.turn++;
}
class EventBus {
constructor() {
this.handlers = new Map();
}
subscribe(type, handler) {
if (!this.handlers.has(type)) this.handlers.set(type, []);
this.handlers.get(type).push(handler);
}
publish(e) {
if (!this.handlers.has(e.type)) return;
for (const handler of this.handlers.get(e.type).slice()) handler(e);
}
}
Schritt für Schritt, in Worten
  1. 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.
  2. Zug 1 · Diplomatie: Kriegserklärung: Orion-Hegemonie → Terranisches Reich.
  3. 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).
  4. Zug 1 · Ereignis: Meteoritenschauer: Lyra-Union verliert 14 Metall.
  5. Zug 2 · Bewegung: Orion-Hegemonie schickt eine Flotte von Altan nach Vesta (Schiffe: 4).
  6. Zug 2 · Schlachten: Schlacht bei Vesta: Terranisches Reich und Orion-Hegemonie verlieren Schiffe (je 3).
  7. Zug 3 · Diplomatie: Kriegserklärung: Lyra-Union → Orion-Hegemonie. Friedensangebot abgelehnt: Terranisches Reich → Orion-Hegemonie.
  8. Zug 3 · Schlachten: Schlacht bei Vesta: Orion-Hegemonie und Lyra-Union verlieren Schiffe (je 1).
  9. Zug 4 · Diplomatie: Frieden geschlossen: Terranisches Reich und Orion-Hegemonie.
  10. Zug 4 · Ereignis: Meteoritenschauer: Lyra-Union verliert 8 Metall.
  11. Zug 5 · Diplomatie: Zu früh für eine Änderung: Orion-Hegemonie → Terranisches Reich.
  12. Zug 5 · Ereignis: Orion-Hegemonie findet verlassene Schiffe bei Brisa (Schiffe: 2).
  13. 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.
function resolveMovement(world) {
const moving = [];
for (const empire of world.empires) {
if (!empire.alive) continue;
for (const order of empire.orders) {
const fleet = fleetAt(world, empire.id, order.from);
if (fleet === null) continue;
const ships = [0, 0, 0, 0];
for (let k = 0; k < SHIP_KIND_COUNT; k++) {
ships[k] = minOf(order.ships[k], fleet.ships[k]);
fleet.ships[k] -= ships[k];
}
if (totalOf(ships) > 0) moving.push({ owner: empire.id, to: order.to, dest: order.dest, ships: ships });
}
empire.orders = [];
}
for (const move of moving) {
addShips(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.push({ from: move.to, to: nextStep(world.systems, move.to, move.dest), dest: move.dest, ships: move.ships.slice() });
}
}
removeEmptyFleets(world);
}
function hasUnits(world, owner, systemId) {
const fleet = fleetAt(world, owner, systemId);
return (fleet !== null && totalOf(fleet.ships) > 0) || fortsNow(world, owner, systemId) > 0;
}
function resolveBattles(world) {
for (const system of world.systems) {
for (let a = 0; a < world.empires.length; a++) {
for (let b = a + 1; b < world.empires.length; b++) {
if (!world.empires[a].alive || !world.empires[b].alive || !atWar(world, a, b)) continue;
if (hasUnits(world, a, system.id) && hasUnits(world, b, system.id)) fightAt(world, system.id, a, b);
}
}
}
removeEmptyFleets(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.
function resolveConquest(world) {
for (const system of world.systems) {
for (const pid of system.planets) {
const planet = world.planets[pid];
if (planet.owner < 0) continue;
const owner = planet.owner;
let attacker = -1;
if (!hasUnits(world, owner, system.id)) {
for (const e of world.empires) {
if (!e.alive || e.id === owner || !atWar(world, e.id, owner)) continue;
const fleet = fleetAt(world, e.id, system.id);
if (fleet !== null && totalOf(fleet.ships) > 0) {
attacker = e.id;
break;
}
}
}
if (attacker < 0) {
planet.siege = 0;
continue;
}
planet.siege++;
if (planet.siege < SIEGE_TURNS) {
world.bus.publish({ type: 'siege', empire: attacker, from: owner, planetId: planet.id });
continue;
}
planet.siege = 0;
planet.owner = attacker;
planet.mines = maxOf(0, planet.mines - 1);
planet.lab = 0;
planet.yard = 0;
planet.fort = 0;
world.bus.publish({ type: 'conquest', empire: attacker, from: owner, planetId: planet.id });
}
}
for (const empire of world.empires) {
if (!empire.alive || ownedPlanets(world, empire.id).length > 0) continue;
empire.alive = false;
const kept = [];
for (const fleet of world.fleets) {
if (fleet.owner !== empire.id) kept.push(fleet);
}
world.fleets = kept;
empire.orders = [];
world.bus.publish({ type: 'eliminated', empire: empire.id });
}
}
function runTurn(world) {
world.turnLines = [];
world.fortLeft = new Map();
for (const empire of world.empires) {
if (empire.alive && !empire.human) aiDiplomacy(world, empire);
}
for (const empire of world.empires) {
if (empire.alive && !empire.human) aiPlay(world, empire);
}
resolveMovement(world);
resolveBattles(world);
resolveConquest(world);
for (const empire of world.empires) {
if (!empire.alive) continue;
produce(world, empire);
completeResearch(world, empire);
}
finishQueue(world);
rollEvent(world);
checkVictory(world);
world.turn++;
if (world.turn % 5 === 0) {
for (const empire of world.empires) {
for (let i = 0; i < empire.grudge.length; i++) empire.grudge[i] = maxOf(0, empire.grudge[i] - 1);
}
}
const fresh = [];
for (const offer of world.offers) {
if (world.turn - offer.turn <= 3) fresh.push(offer);
}
world.offers = fresh;
}

Und 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 {
constructor() {
this.handlers = new Map();
}
subscribe(type, handler) {
if (!this.handlers.has(type)) this.handlers.set(type, []);
this.handlers.get(type).push(handler);
}
publish(event) {
if (!this.handlers.has(event.type)) return;
for (const handler of this.handlers.get(event.type).slice()) handler(event);
}
}

Die Unterschiede zu unserem Programm sind viele, und deshalb wichtig:

  • Es gibt kein Skript. Befehle und Diplomatie kommen von aiPlay und aiDiplomacy (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 wait hä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?

function moveTogether(start, orders) {
const ships = start.slice();
const takes = [];
// Zuerst verlassen alle Flotten ihre Systeme...
for (const [source, target, count] of orders) {
const take = Math.min(count, ships[source]);
ships[source] -= take;
takes.push(take);
}
// ...und erst dann kommen sie an.
orders.forEach(([, target], i) => {
ships[target] += takes[i];
});
return ships;
}
function show(ships) {
return ships.join(" ");
}
const orders = [[0, 1, 3], [1, 2, 2]];
console.log(show(moveTogether([3, 0, 0, 0], orders)));
Antwort

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:

const start = [3, 0, 0, 0];
console.log("gleichzeitig: " + show(moveTogether(start, ORDERS)));
Ausgabe
gleichzeitig: 0 0 0 3

Das Ergebnis ist wie bei der Bewegung nacheinander. Welche Zeile schickt die Schiffe zu früh zum Ziel?

Wähle die Zeile mit dem Fehler.

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

Ausgabe
-- 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 100
Orion-Hegemonie: Schiffe 2, Metall 100
Lyra-Union: Schiffe 1, Metall 78
Warnungen: 3

Weitere Aufgaben

  • Statistik. Füge einen weiteren Abonnenten hinzu, der die Schlachten und die Summe der verlorenen Schiffe zählt (jede Schlacht verliert n auf jeder der beiden Seiten). Er gibt sie nach den Warnungen aus.
  • Waffenruhe. Senke MIN_PEACE auf 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 plague mit 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

1. Drei Befehle bilden eine Kette: A→B, B→C, C→D, mit je 3 Schiffen. Am Anfang sind die 3 Schiffe in A. Befehl für Befehl werden die Schiffe in den Systemen A, B, C, D zu 0 0 0 3 (alle in D), gleichzeitig zu 0 3 0 0 (alle in B). Warum?
2. In der Tabelle des Kanons haben 8 Ereignisarten Gewichte mit der Summe 113. In Zug 3 sind die Piraten (frühester Zug 6) und die Unruhen (5) noch nicht erlaubt. Wie wahrscheinlich ist ein Meteoritenschauer (Gewicht 20) als Ereignis, wenn in diesem Zug ein Ereignis eintritt?
3. Orion und das Terranische Reich schließen in Zug 4 Frieden. In Zug 5 will Orion wieder Krieg. Warum heißt es „zu früh“?
4. Warum läuft publish über eine Kopie der Liste der Abonnenten und nicht über die Liste selbst?
5. Du willst zählen, wie oft Krieg erklärt wurde. Was änderst du in declareWar?

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