Skip to content
wannacode

The turn of the galaxy

In short: The turn of the galaxy is a sequence of phases in a fixed order; fleets move simultaneously (first all of them leave, then they arrive), events are drawn by weight, peace and war have a rule of at least MIN_PEACE turns, and the news and the grudge listen to the event bus without the publisher knowing about them.

Problem

Everyone acts at once, and the order decides

In a turn-based strategy game the player types end (or wait 3 for three turns in a row) and the whole galaxy moves: the rivals decide what to do, fleets set off, battles happen, planets produce, sometimes pirates show up. What is one key press for the player is a turn in the code: a sequence of phases in a fixed order.

The order is not decoration. Take the simplest example: three fleets in a chain, A→B, B→C, C→D, and all 3 ships are in A. If you process the orders one after another and move the ships in the same array, the ships get from A to D in one turn: the first order puts them in B, the second sees them there and sends them on to C, the third to D. A player who sees this understands at once that the fleets are teleports.

This lesson builds the turn of a small galaxy of three empires (A, B, C) and four systems in a line, with the same four ideas as in the canon:

  • the phases of a turn and simultaneous movement;
  • random events: how often they come and which ones more often;
  • peace and war: a rule that does not allow declaring war and making peace every turn;
  • the event bus: how the news, the grudge and the alerts learn what happened, without anyone calling anyone.

At the end there is a whole turn of phases that prints what happens, and a scene that shows it.

The idea

Phases and simultaneous movement

A turn is a function that calls phases one after another. In the canon they are: the AI’s diplomacy, its orders, the movement of all fleets, battles, conquest, production and research, the event, the end of the turn. In our small galaxy there are five: diplomacy, movement, battles, production, event.

Movement is split in two: first all the fleets leave their systems, only then do they arrive. Here are the two ways on the chain from above. An order is [from, to, ships]; the 3 ships are in system 0:

// [from, to, ships]
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 = [];
// First every fleet leaves its system...
for (const [source, target, count] of orders) {
const take = Math.min(count, ships[source]);
ships[source] -= take;
takes.push(take);
}
// ...and only then do they arrive.
orders.forEach(([, target], i) => {
ships[target] += takes[i];
});
return ships;
}
const start = [3, 0, 0, 0];
console.log("one by one: " + show(moveOneByOne(start, ORDERS)));
console.log("at the same time: " + show(moveTogether(start, ORDERS)));
Output
one by one: 0 0 0 3
at the same time: 0 3 0 0

The one-by-one version is quietly wrong: the program does not crash, the fleets just become fast. The simultaneous one takes, for each order, min(ships of the order, ships in the system at the start) and removes them (leaving), and then adds them (arriving). The second order (B→C) sees zero ships in B, because the first ones have not arrived yet, and moves nothing. That is why the chain is 0 3 0 0: the ships have only got as far as B.

start.slice() is a copy of the array: without it the function would change the caller’s array.
More precisely, if you are curious

The same rule applies to everything that happens “at once”: two empires swapping systems, two fleets passing each other. If you move them one by one, the second sees the world the first has already changed, and the result depends on the order of the players. The rule of two steps (take everything, then put everything back) is the same as swapping two variables with a helper: you need memory for the “old”.

In the canon the movement is even closer to ours, with one addition: a long journey continues by itself. The order remembers the final destination (dest), and after arrival a new order is recorded for the next step. So a fleet sent across five systems travels for five turns, one system per turn.

The idea

Random events by weight

On every turn there is a 40% chance that one event happens to a random empire. Which one? If all kinds had an equal chance, pirates would come on the first turn, when the empire does not have a single ship. So each kind has a weight (how common it is) and an earliest turn (from when it is allowed). Here is the canon’s table and the choice:

// id, weight, earliest turn
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 };
}

Choosing by weight is a “roulette”: a number from 0 up to the sum of the weights is drawn, and you walk along the kinds, subtracting the weight of each one, until the number is smaller than the current weight. A kind that is not allowed yet gets weight 0 and never comes up, and the sum shrinks, so the others become more likely. The program runs twelve turns and then a thousand more, to show how many times each kind comes up:

const rng = new Rng(7);
for (let turn = 1; turn <= 12; turn++) {
const roll = rollEvent(rng, turn);
if (roll !== null) console.log("turn " + 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("Events in 1000 turns:");
for (let i = 0; i < counts.length; i++) {
console.log(EVENT_DEFS[i].id + " " + EVENT_DEFS[i].weight + ": " + counts[i]);
}
Output
turn 1: boom → Lyra Union
turn 2: breakthrough → Vega Academy
turn 3: vein → Vega Academy
turn 4: boom → Vega Academy
turn 8: pirates → Lyra Union
turn 9: breakthrough → Terran Empire
Events in 1000 turns:
meteor 20: 70
vein 15: 53
pirates 20: 78
breakthrough 15: 58
unrest 10: 30
boom 15: 50
flare 10: 28
derelict 8: 28

The pirates (earliest turn 6) only show up on turn 8. Over the thousand turns the pirates (78) and the meteors (70), with weight 20, come up most often, and the solar flare (28) and the derelict ships (28), with weights 10 and 8, least often. The counts are not equal to the weights, because it is random, but their order is the same. The chance of an event is 40% (EVENT_CHANCE), and the generator has a seed: the same input gives the same output in all four languages, so a recording of the game can be replayed.

More precisely, if you are curious

In the canon every event does something different: meteors take metal, a vein raises the richness of a planet, pirates fight the empire’s fleet (and resources are won or lost), a breakthrough gives science points, unrest lowers a mine level, a boom gives gas and crystal, a flare silences the labs for one turn, derelict ships give two frigates. Here they all are (the events region of the canon). Look at the end of rollEvent: the result is not written to the screen, it is published. What that means is in the card about the bus.

// The random events: every turn there is a 40% chance that one happens to a random empire. The kind is by
// weights (only from its earliest turn on). Everything is published on the bus; the news, the statistics
// and the grudges are subscribers, and the events do not know about them.
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 });
}
// The subscribers: the news for the player, the grudges after battles and conquest.
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;
});
// A signal to the player: something happened at which "wait" stops.
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));
});
}

The idea

Peace and war: a rule against chaos

Diplomacy in the game is a relation between two empires: a pair of empires is at war or at peace (war[a][b]). What is hard about it? Without a rule an empire would declare war on one turn, make peace on the next and declare war again right after. So:

  • peace lasts at least MIN_PEACE = 4 turns before a new war (turn − since < MIN_PEACE; at the start of the game the rule does not apply, so that someone can begin);
  • a peace offer is allowed at the earliest two turns after the change;
  • the AI accepts peace according to its wish: how much weaker it is than the other, what its character is, how many other wars it is fighting and how resentful it is. From 90 up it accepts.

In the program the wish is simplified: character + (150 − ratio of ships) / 2 + other wars × 15 − grudge × 3, where the character is 40 for Orion (a warlord) and 120 for the other two (builders). A refusal raises the grudge, so the next attempt is harder. The script is the story of the scene: Orion (B) declares war on the Terran Empire (A) on turn 1, Lyra (C) on Orion on turn 3, and A offers peace:

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";
// Peace lasts at least MIN_PEACE turns.
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) {
// How much weaker the empire is than the other one.
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";
// From 90 up the empire accepts peace.
if (peaceWish(world, b, a) >= 90) {
makePeace(world, a, b);
return null;
}
// A refusal makes the empire more resentful: the next peace is harder.
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") ? " (wish " + wish + ")" : "";
console.log("turn " + 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);
Output
turn 1: war Orion Hegemony → Terran Empire: ok
turn 2: war Orion Hegemony → Terran Empire: alreadyWar
turn 2: peace Terran Empire → Orion Hegemony: tooSoon
turn 3: war Lyra Union → Orion Hegemony: ok
turn 3: peace Terran Empire → Orion Hegemony: refused (wish 77)
turn 4: peace Terran Empire → Orion Hegemony: ok (wish 124)
turn 5: war Orion Hegemony → Terran Empire: tooSoon
turn 9: war Orion Hegemony → Terran Empire: ok

Turn 2: Orion declares war on the same opponent again (alreadyWar), and A offers peace only one turn after the war (tooSoon). On turn 3 A offers peace, but Orion’s wish is 40 + 25 + 15 − 3 = 77 (character, one ship against none, one more war with Lyra, grudge 1) and it is below 90: refused, and the grudge becomes 2. On turn 4 Orion has no ships and its wish is 40 + 75 + 15 − 6 = 124: it accepts. Turn 5: Orion wants a new war, but the peace dates from turn 4, and 5 − 4 = 1 is less than 4: tooSoon. On turn 9 it is allowed (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";
  // Peace lasts at least MIN_PEACE turns.
  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) {
  // How much weaker the empire is than the other one.
  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";
  // From 90 up the empire accepts peace.
  if (peaceWish(world, b, a)  90) {
    makePeace(world, a, b);
    return null;
  }
  // A refusal makes the empire more resentful: the next peace is harder.
  world.grudge[b][a]++;
  return "refused";
}
More precisely, if you are curious

The result of these functions is a string or nothing: null if the change happened, and an error key (alreadyWar, tooSoon, refused, noWar) if it did not. The errors are keys, not finished sentences, so the screen can translate them (the game comes in four languages). That way the same function serves the player (the command peace B) and the AI.

The canon also has offers to the player: the AI cannot make peace with a human by itself, so it records an offer (offers) that is valid for three turns and publishes an “offer” event. Here is the whole diplomacy region:

// Diplomacy is peace or war between two empires. Peace holds for at least MIN_PEACE turns before
// a new war. The artificial intelligence accepts peace by a measure: how much weaker it is, what its character is
// and how bitter it is. An offer to the player is recorded and holds for three turns.
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 });
}
// The smallest distance in lanes between systems with planets of the two empires.
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);
}
// How much the empire wants peace with the other (0 and up; from 90 up it accepts).
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);
}
// How much the empire wants war with the other.
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;
}
// A peace offer from a to b. Returns an error key or null (peace is made).
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';
}
// The diplomacy of the artificial intelligence at the end of the turn: peace when weaker, war when stronger.
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);
}
}
}

The idea

An event bus: whoever publishes does not know who is listening

When war is declared, a lot has to happen: a news item has to be written, the grudge of the two empires has to grow, an alert has to light up for the player, the war has to be counted in the statistics. The simplest way is for declareWar to call all of that. Then declareWar knows about the news, the grudge, the alert and the statistics, and every new feature is one more line in it and one more reason for it to break. The bus reverses the direction: declareWar says “war has been declared between A and B” and is done. Whoever is interested has subscribed to “war”.

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 records a handler for a kind of event, publish calls all the handlers of that kind. A kind without subscribers is not an error: the event goes nowhere. Here is the publishing in the two places where it happens (declaring war and making peace). The only new thing is the line with publish:

function declareWar(world, a, b) {
if (atWar(world, a, b)) return "alreadyWar";
// Peace lasts at least MIN_PEACE turns.
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));
}

And here is the program. The news listen to all kinds, the grudge only to battles, and report also publishes the failed attempts (refused, tooSoon) as events:

const world = new World();
world.ships[1][1] = 1;
world.grudge[1][0] = 1;
// The news listen to every kind of event, the grudge only to battles.
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("grudge " + NAMES[0] + " → " + NAMES[1] + ": " + world.grudge[0][1]);
console.log("grudge " + NAMES[1] + " → " + NAMES[0] + ": " + world.grudge[1][0]);
Output
Orion Hegemony declares war on Terran Empire.
Battle at Vesta: Orion Hegemony and Terran Empire lose ships (2 each).
Orion Hegemony refuses peace with Terran Empire.
Orion Hegemony is already at war with Terran Empire.
grudge Terran Empire → Orion Hegemony: 1
grudge Orion Hegemony → Terran Empire: 3

The attempt on turn 1 succeeds, the battle is published by hand, the refusal on turn 3 is an event (refused), and the repeated war is alreadyWar. The grudge of A toward B is 1 (from the battle), and that of B toward A is 3: one old, one from the battle and one from the refusal (a refusal raises the grudge of the one who refuses toward the one who offered).

The handler is a function (an arrow (e) => ...); EventBus knows nothing about it except that it can call it.
More precisely, if you are curious

Without a bus, everything new that has to learn about a war needs a change in declareWar. With the bus, a new subscriber is just a new line when the world is created, and the war function is not touched. This is the observer pattern from the design patterns module.

A small but important detail: publish loops over a copy of the list of subscribers. A handler may subscribe to another event while it is being called; if the list changes during the loop, subscribers are skipped or repeated. The copy makes the rule clear: a new subscriber hears from the next publication on.

The order of the subscribers is the order of subscribe. Here it does not matter (the news and the grudge do not get in each other’s way), but if two subscribers depend on each other, the order will decide the outcome, and that is a reason to keep them independent.

The idea

The whole turn

Now everything together. The world is World: turn, phase, generator, bus, the fleets of the three empires (by system), metal, and the tables for war (war), since which turn (since) and grudge (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]];
}
}

The orders and the diplomacy of each turn are a script (in the game the AI or the player decides them): an order is [turn, empire, from, to, ships], and a diplomacy entry is [turn, kind, from, to].

// [turn, empire, from, to, ships]
const ORDERS = [[1, 1, 3, 2, 4], [1, 0, 0, 1, 3], [2, 1, 2, 1, 4]];
// [turn, kind (0 war, 1 peace), from, to]
const DIPLO = [[1, 0, 1, 0], [3, 0, 2, 1], [3, 1, 0, 1], [4, 1, 0, 1], [5, 0, 1, 0]];

The phases are functions. Diplomacy carries out the script for the turn and publishes the errors. Movement is exactly the two steps from the first card. Battles walk over the systems: where two empires at war have ships, each loses as many as the smaller fleet, and a “battle” is published (the grudge subscribes to it):

function scriptedDiplomacy(world) {
world.phase = "diplomacy";
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 = "movement";
const moving = [];
// First every fleet leaves its system...
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 });
}
// ...and only then do they arrive.
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 = "battles";
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 = "production";
for (let empire = 0; empire < 3; empire++) world.metal[empire] += 10;
}

After them comes the event: a draw by the weights from the second card, with these effects: a meteor takes metal, a boom gives metal, derelict ships give two ships at the capital. At last runTurn calls them in order:

function rollEvent(world) {
world.phase = "event";
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) {
// The phases run in this order, and the order matters.
scriptedDiplomacy(world);
resolveMovement(world);
resolveBattles(world);
produce(world);
rollEvent(world);
world.turn++;
}

The program subscribes the news (they put the phase in which the event happened in front of every line) and the grudge, runs five turns and prints the result. The lines are exactly the news of the game:

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("-- turn {0} --", world.turn));
runTurn(world);
}
console.log("-- end --");
for (let empire = 0; empire < 3; empire++) {
console.log(fill("{0}: ships {1}, metal {2}", NAMES[empire], power(world, empire), world.metal[empire]));
}
Output
-- turn 1 --
[diplomacy] Orion Hegemony declares war on Terran Empire.
[movement] Orion Hegemony sends a fleet from Brisa to Altan (ships: 4).
[movement] Terran Empire sends a fleet from Kora to Vesta (ships: 3).
[event] Meteor shower: Lyra Union loses 14 metal.
-- turn 2 --
[movement] Orion Hegemony sends a fleet from Altan to Vesta (ships: 4).
[battles] Battle at Vesta: Terran Empire and Orion Hegemony lose ships (3 each).
-- turn 3 --
[diplomacy] Lyra Union declares war on Orion Hegemony.
[diplomacy] Orion Hegemony refuses peace with Terran Empire.
[battles] Battle at Vesta: Orion Hegemony and Lyra Union lose ships (1 each).
-- turn 4 --
[diplomacy] Peace between Terran Empire and Orion Hegemony.
[event] Meteor shower: Lyra Union loses 8 metal.
-- turn 5 --
[diplomacy] Orion Hegemony cannot change its relations with Terran Empire yet: too soon.
[event] Orion Hegemony finds derelict ships at Brisa (ships: 2).
-- end --
Terran Empire: ships 0, metal 100
Orion Hegemony: ships 2, metal 100
Lyra Union: ships 1, metal 78

Read the output as a story. Turn 1: Orion declares war, the two fleets (Orion’s and the Terran Empire’s) set off simultaneously, and Lyra loses metal to a meteor. Turn 2: Orion’s fleet arrives at Vesta, where the Terran Empire already is, and both lose 3 ships. Turn 3: Lyra joins the war and in the battle at Vesta they lose 1 each; before that, A offers peace and is refused. Turn 4: Orion has no ships, its wish is 124 and peace is made. Turn 5: a new war is “too soon”, and Orion finds two derelict ships at its capital.

See it

One turn of the galaxy

Here is the same thing turn by turn. At the top the phase in which something happened lights up. In the middle are the systems and the fleets of the three empires: during movement all fleets take off at once and only then land. Below them you can see whether each pair is at war, and at the very bottom the bus: the published event lights up the “news” subscriber, and if it is a battle, “grudge” as well.

function runTurn(world) {
// The phases run in this order, and the order matters.
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);
}
}
Step by step, in words
  1. A = Terran Empire, B = Orion Hegemony, C = Lyra Union. Four systems in a line; the turn goes through the phases from top to bottom.
  2. turn 1 · diplomacy: Orion Hegemony declares war on Terran Empire.
  3. turn 1 · movement: Orion Hegemony sends a fleet from Brisa to Altan (ships: 4). Terran Empire sends a fleet from Kora to Vesta (ships: 3).
  4. turn 1 · event: Meteor shower: Lyra Union loses 14 metal.
  5. turn 2 · movement: Orion Hegemony sends a fleet from Altan to Vesta (ships: 4).
  6. turn 2 · battles: Battle at Vesta: Terran Empire and Orion Hegemony lose ships (3 each).
  7. turn 3 · diplomacy: Lyra Union declares war on Orion Hegemony. Orion Hegemony refuses peace with Terran Empire.
  8. turn 3 · battles: Battle at Vesta: Orion Hegemony and Lyra Union lose ships (1 each).
  9. turn 4 · diplomacy: Peace between Terran Empire and Orion Hegemony.
  10. turn 4 · event: Meteor shower: Lyra Union loses 8 metal.
  11. turn 5 · diplomacy: Orion Hegemony cannot change its relations with Terran Empire yet: too soon.
  12. turn 5 · event: Orion Hegemony finds derelict ships at Brisa (ships: 2).
  13. After 5 turns: Terran Empire: ships 0, metal 100 · Orion Hegemony: ships 2, metal 100 · Lyra Union: ships 1, metal 78

The code next to the scene is runTurn and EventBus from the program. The lines that are working at the moment light up.

The idea

In the game

What we built is the same as in the canon, only smaller. Here is the real turn (the turn region). Above runTurn are the movement, which makes the same two steps (leaving, arriving), the battles and the conquest of planets; runTurn calls the phases in a fixed order, and at the end of the turn the grudge decays (every fifth turn it drops by 1) and old peace offers are thrown away.

// One turn of the galaxy has phases: diplomacy of the artificial intelligence, its orders, movement of
// all fleets at once, battles, conquest, production and science, an event, end of the turn.
// Movement is simultaneous: first all ships leave their systems, only then do they arrive.
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);
// the long way continues on its own: a new order from the place where they arrived
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);
}
// A planet falls when an enemy is at war with its owner, has ships in the system, and the owner has
// neither ships nor surviving fortresses there, for SIEGE_TURNS turns in a row: the first turns are a siege,
// the last one is the capture. The defender has time to bring a fleet. The buildings are destroyed.
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;
}

And here is the bus that the news, the grudge and the alert to the player listen to. The events are one class with more fields (in JavaScript, an object), and the bus is the same:

// The event bus: whoever publishes does not know who listens. The news, the grudges of the empires and
// the statistics subscribe on their own, and the battle only says "a fleet fought at this system".
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);
}
}

The differences from our program are many, and that is why they matter:

  • There is no script. Orders and diplomacy come from aiPlay and aiDiplomacy (the lesson about the rivals) and from the player’s commands.
  • The galaxy is a graph (the lesson on generating it), not a line. An order remembers its final destination and continues by itself.
  • The battle (fightAt, the lesson about fleets) has ship roles and six rounds, not “each loses the smaller number”. There are also fortresses and the conquest of a planet (two sieges).
  • The wish for peace uses the strength of the empire (not the number of ships), its character and its wish for war (warWish). An offer to the player is recorded for three turns.
  • Production here is a flat 10 metal per empire; in the canon it depends on planets, mines and technologies.
  • Events are eight kinds, with more varied effects (pirates fight with the same battle model).
  • The bus carries other kinds too: conquest, siege, a technology discovered, a colony, a destroyed empire. The news and the alert to the player listen to them, and wait stops when the alert grows.

Try it

Predict the output

Two fleets: the first order sends the 3 ships from system 0 to 1, the second sends 2 ships from system 1 to 2. The orders are processed simultaneously. What does the program print?

function moveTogether(start, orders) {
const ships = start.slice();
const takes = [];
// First every fleet leaves its system...
for (const [source, target, count] of orders) {
const take = Math.min(count, ships[source]);
ships[source] -= take;
takes.push(take);
}
// ...and only then do they arrive.
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)));
Answer

Try it

Find the bug

A colleague “fixed” the simultaneous movement, but with it the ships still pass through several systems per turn:

const start = [3, 0, 0, 0];
console.log("at the same time: " + show(moveTogether(start, ORDERS)));
Output
at the same time: 0 0 0 3

The result is the same as with one-by-one movement. Which line sends the ships to the destination too early?

Pick the line with the bug.

More precisely

More precisely: order, fairness and chance

The order of the players. With simultaneous movement the order of the orders does not change the result, because everyone first takes from the start of the turn. With battles, however, there is an order: system by system, pair by pair (a < b). If three empires at war are in one system, the first pair in the order fights first and the survivors go on to the next. The order is fixed, so the game stays repeatable.

The phases are a contract. Diplomacy comes before movement, because then a newly declared war can start a battle in the same turn. Production comes after conquest: a planet taken this turn does not produce for its old master. The event is almost last and works with the world as it has become (the meteor takes from metal that has already been produced). Swap two phases and the game stays “the same”, but the outcome is different. That is why the order is in one place (runTurn) and reads like a list.

Chance and repeatability. Everything random (the chance of an event, which empire, which kind, how much metal) goes through one generator with a seed. If two phases swap places and both draw numbers, the sequence is different. In our program only rollEvent uses the generator, so the output is stable; in the canon diplomacy and the AI also draw numbers, and the order of the phases decides who gets which number.

What is not for the bus. The bus is for news: “this happened”. It is not for commands (“do this”): the publisher does not know whether anyone heard and does not wait for an answer. Publish facts, not orders.

Exercise

An alert for the player

Solve the task in your editor. Start from the program for the whole turn.

Alert. In the game wait stops as soon as something happens to the player. Add an “alert” subscriber that counts the events war, peace and battle in which empire 0 (the player) takes part, as a or as b. After the summary the program prints one more line with the alerts (alerts: …). Do not touch declareWar, makePeace and resolveBattles: the subscriber is just one new line when the world is created and one function.

The output is (the last line is the new one):

Output
-- turn 1 --
[diplomacy] Orion Hegemony declares war on Terran Empire.
[movement] Orion Hegemony sends a fleet from Brisa to Altan (ships: 4).
[movement] Terran Empire sends a fleet from Kora to Vesta (ships: 3).
[event] Meteor shower: Lyra Union loses 14 metal.
-- turn 2 --
[movement] Orion Hegemony sends a fleet from Altan to Vesta (ships: 4).
[battles] Battle at Vesta: Terran Empire and Orion Hegemony lose ships (3 each).
-- turn 3 --
[diplomacy] Lyra Union declares war on Orion Hegemony.
[diplomacy] Orion Hegemony refuses peace with Terran Empire.
[battles] Battle at Vesta: Orion Hegemony and Lyra Union lose ships (1 each).
-- turn 4 --
[diplomacy] Peace between Terran Empire and Orion Hegemony.
[event] Meteor shower: Lyra Union loses 8 metal.
-- turn 5 --
[diplomacy] Orion Hegemony cannot change its relations with Terran Empire yet: too soon.
[event] Orion Hegemony finds derelict ships at Brisa (ships: 2).
-- end --
Terran Empire: ships 0, metal 100
Orion Hegemony: ships 2, metal 100
Lyra Union: ships 1, metal 78
alerts: 3

More tasks

  • Statistics. Add one more subscriber that counts the battles and the total of ships lost (each battle loses n on each of the two sides). It prints them after the alerts.
  • Truce. Lower MIN_PEACE to 2 and run the program. Which lines of the output change, and why is turn 5 no longer “too soon”?
  • A new kind of event. Add plague with earliest turn 4 and weight 10: the empire loses 5 metal. Which places do you change: the table, rollEvent, newsText, the list of kinds for the news? And the bus?

Test

Test yourself

1. Three orders form a chain: A→B, B→C, C→D, with 3 ships in each. At the start the 3 ships are in A. One order after another, the ships in systems A, B, C, D become 0 0 0 3 (all in D), while simultaneously they become 0 3 0 0 (all in B). Why?
2. In the canon table, 8 kinds of event have weights that add up to 113. On turn 3 the pirates (earliest turn 6) and unrest (5) are not allowed yet. What is the probability that a meteor shower (weight 20) is the event, if an event happens this turn?
3. Orion and the Terran Empire make peace on turn 4. On turn 5 Orion wants war again. Why is it told “too soon”?
4. Why does publish loop over a copy of the list of subscribers instead of the list itself?
5. You want to count how many times war has been declared. What do you change in declareWar?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·