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]static final int[][] ORDERS = {{0, 1, 3}, {1, 2, 3}, {2, 3, 3}};
static int[] moveOneByOne(int[] start, int[][] orders) { int[] ships = start.clone(); for (int[] order : orders) { int take = Math.min(order[2], ships[order[0]]); ships[order[0]] -= take; ships[order[1]] += take; } return ships;}
static int[] moveTogether(int[] start, int[][] orders) { int[] ships = start.clone(); int[] takes = new int[orders.length]; // First every fleet leaves its system... for (int i = 0; i < orders.length; i++) { takes[i] = Math.min(orders[i][2], ships[orders[i][0]]); ships[orders[i][0]] -= takes[i]; } // ...and only then do they arrive. for (int i = 0; i < orders.length; i++) ships[orders[i][1]] += takes[i]; return ships;}int[] start = {3, 0, 0, 0};System.out.println("one by one: " + show(moveOneByOne(start, ORDERS)));System.out.println("at the same time: " + show(moveTogether(start, ORDERS)));one by one: 0 0 0 3at the same time: 0 3 0 0The 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.
int[] takes remembers how many ships each order took: between the two loops it is the only memory.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 turnstatic final EventDef[] EVENT_DEFS = { new EventDef("meteor", 20, 1), new EventDef("vein", 15, 1), new EventDef("pirates", 20, 6), new EventDef("breakthrough", 15, 1), new EventDef("unrest", 10, 5), new EventDef("boom", 15, 1), new EventDef("flare", 10, 1), new EventDef("derelict", 8, 3)};
static int pickWeighted(Rng rng, int[] weights) { int total = 0; for (int weight : weights) total += weight; int roll = rng.nextInt(total); for (int i = 0; i < weights.length; i++) { if (roll < weights[i]) return i; roll -= weights[i]; } return weights.length - 1;}
static Roll rollEvent(Rng rng, int turn) { if (!rng.chance(EVENT_CHANCE)) return null; int empire = rng.nextInt(NAMES.length); int[] weights = new int[EVENT_DEFS.length]; for (int i = 0; i < weights.length; i++) { weights[i] = turn >= EVENT_DEFS[i].minTurn() ? EVENT_DEFS[i].weight() : 0; } return new Roll(pickWeighted(rng, weights), 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:
Rng rng = new Rng(7);for (int turn = 1; turn <= 12; turn++) { Roll roll = rollEvent(rng, turn); if (roll != null) System.out.println("turn " + turn + ": " + EVENT_DEFS[roll.kind()].id() + " → " + NAMES[roll.empire()]);}
int[] counts = new int[EVENT_DEFS.length];for (int i = 0; i < 1000; i++) { Roll roll = rollEvent(rng, 99); if (roll != null) counts[roll.kind()]++;}System.out.println("Events in 1000 turns:");for (int i = 0; i < counts.length; i++) { System.out.println(EVENT_DEFS[i].id() + " " + EVENT_DEFS[i].weight() + ": " + counts[i]);}turn 1: boom → Lyra Unionturn 2: breakthrough → Vega Academyturn 3: vein → Vega Academyturn 4: boom → Vega Academyturn 8: pirates → Lyra Unionturn 9: breakthrough → Terran EmpireEvents in 1000 turns:meteor 20: 70vein 15: 53pirates 20: 78breakthrough 15: 58unrest 10: 30boom 15: 50flare 10: 28derelict 8: 28The 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.static final int EVENT_CHANCE = 40;
static Planet pickEmpirePlanet(World world, Empire empire) { List<Planet> list = ownedPlanets(world, empire.id); if (list.size() == 0) return null; return list.get(world.rng.nextInt(list.size()));}
static int[] pirateFleet(World world) { int strength = world.turn / 6; return new int[] {2 + strength, strength / 2, strength / 3, 0, 0};}
static Map<String, Object> applyEvent(World world, String kind, Empire empire) { Random rng = world.rng; Map<String, Object> vars = new LinkedHashMap<>(); vars.put("empire", empireName(empire)); if (kind.equals("meteor")) { Planet planet = pickEmpirePlanet(world, empire); if (planet == null) return null; int loss = minOf(empire.res[0], 8 + rng.nextInt(8)); empire.res[0] -= loss; vars.put("planet", planetName(world, planet)); vars.put("n", loss); } else if (kind.equals("vein")) { Planet planet = pickEmpirePlanet(world, empire); if (planet == null) return null; int r = rng.nextInt(RES_COUNT); int 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.put("planet", planetName(world, planet)); vars.put("res", t("res." + r)); } else if (kind.equals("pirates")) { Planet planet = pickEmpirePlanet(world, empire); if (planet == null) return null; int systemId = planet.systemId; int[] mine = emptyCounts(); Fleet fleet = fleetAt(world, empire.id, systemId); for (int k = 0; k < SHIP_KIND_COUNT; k++) mine[k] = fleet == null ? 0 : fleet.ships[k]; mine[FORT_KIND] = fortsIn(world, empire.id, systemId); BattleResult result = runBattle(sideStats(empire), mine, sideStats(null), pirateFleet(world)); for (int k = 0; k < SHIP_KIND_COUNT; k++) { if (fleet != null) fleet.ships[k] = result.a[k]; } vars.put("system", world.systems.get(systemId).name); vars.put("fleet", countsText(pirateFleet(world))); if (result.winner.equals("a")) { empire.res[0] += 20; empire.res[1] += 10; vars.put("outcome", t("event.pirates.win")); } else { int m = empire.res[0] / 5; int g = empire.res[1] / 5; empire.res[0] -= m; empire.res[1] -= g; vars.put("outcome", t("event.pirates.lose", "m", m, "g", g)); } if (!world.quiet) world.last.battle = battleLines((String) vars.get("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.equals("breakthrough")) { int n = 6 + rng.nextInt(7); empire.progress += n; vars.put("n", n); } else if (kind.equals("unrest")) { Planet planet = pickEmpirePlanet(world, empire); if (planet == null || planet.mines == 0) return null; planet.mines--; vars.put("planet", planetName(world, planet)); } else if (kind.equals("boom")) { empire.res[1] += 20; empire.res[2] += 10; } else if (kind.equals("flare")) { empire.flare = 1; } else if (kind.equals("derelict")) { Planet planet = pickEmpirePlanet(world, empire); if (planet == null) return null; addShips(world, empire.id, planet.systemId, new int[] {0, 0, 2, 0}); vars.put("system", world.systems.get(planet.systemId).name); } return vars;}
static void rollEvent(World world) { Random rng = world.rng; if (!rng.chance(EVENT_CHANCE)) return; List<Empire> living = new ArrayList<>(); for (Empire e : world.empires) { if (e.alive) living.add(e); } if (living.size() == 0) return; Empire empire = living.get(rng.nextInt(living.size())); List<EventDef> allowed = new ArrayList<>(); List<Integer> weightList = new ArrayList<>(); for (EventDef def : EVENT_DEFS) { if (world.turn >= def.minTurn) { allowed.add(def); weightList.add(def.weight); } } int[] weights = new int[weightList.size()]; for (int i = 0; i < weights.length; i++) weights[i] = weightList.get(i); EventDef def = allowed.get(pickWeighted(rng, weights)); Map<String, Object> vars = applyEvent(world, def.id, empire); if (vars == null) return; world.stats.events++; GameEvent event = new GameEvent("event"); event.kind = def.id; event.empire = empire.id; event.vars = vars; world.bus.publish(event);}
// The subscribers: the news for the player, the grudges after battles and conquest.static void addNews(World world, String text) { world.news.add(world.turn + ": " + text); while (world.news.size() > 60) world.news.remove(0); world.turnLines.add(text);}
static void installSubscribers(World world, boolean quiet) { EventBus bus = world.bus; BiPredicate<Integer, Integer> involvesHuman = (a, b) -> world.empires.get(a).human || world.empires.get(b).human; bus.subscribe("battle", e -> { if (e.lostA > 0) world.empires.get(e.b).grudge[e.a]++; if (e.lostB > 0) world.empires.get(e.a).grudge[e.b]++; }); bus.subscribe("conquest", e -> { if (e.from >= 0) world.empires.get(e.from).grudge[e.empire] += 3; }); // A signal to the player: something happened at which "wait" stops. bus.subscribe("battle", e -> { if (involvesHuman.test(e.a, e.b)) world.alert++; }); bus.subscribe("conquest", e -> { if (world.empires.get(e.empire).human || (e.from >= 0 && world.empires.get(e.from).human)) world.alert++; }); bus.subscribe("war", e -> { if (involvesHuman.test(e.a, e.b)) world.alert++; }); bus.subscribe("siege", e -> { if (world.empires.get(e.from).human) world.alert++; }); bus.subscribe("offer", e -> { if (world.empires.get(e.to).human) world.alert++; }); if (quiet) return; bus.subscribe("battle", e -> { if (involvesHuman.test(e.a, e.b)) { for (String line : e.lines) addNews(world, line); } else { addNews(world, t("news.battle", "system", world.systems.get(e.systemId).name, "a", empireName(world.empires.get(e.a)), "b", empireName(world.empires.get(e.b)), "la", e.lostA, "lb", e.lostB)); } }); bus.subscribe("colony", e -> { addNews(world, t("news.colony", "empire", empireName(world.empires.get(e.empire)), "planet", planetName(world, world.planets.get(e.planetId)))); }); bus.subscribe("tech", e -> { if (world.empires.get(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.get(e.a)), "b", empireName(world.empires.get(e.b)))); }); bus.subscribe("peace", e -> { addNews(world, t("news.peace", "a", empireName(world.empires.get(e.a)), "b", empireName(world.empires.get(e.b)))); }); bus.subscribe("offer", e -> { addNews(world, t("news.offer", "empire", empireName(world.empires.get(e.from)), "letter", world.empires.get(e.from).def.letter)); }); bus.subscribe("siege", e -> { addNews(world, t("news.siege", "empire", empireName(world.empires.get(e.empire)), "planet", planetName(world, world.planets.get(e.planetId)))); }); bus.subscribe("conquest", e -> { addNews(world, t("news.conquest", "empire", empireName(world.empires.get(e.empire)), "planet", planetName(world, world.planets.get(e.planetId)))); }); bus.subscribe("eliminated", e -> { addNews(world, t("news.eliminated", "empire", empireName(world.empires.get(e.empire)))); }); bus.subscribe("event", e -> { Empire empire = world.empires.get(e.empire); addNews(world, tm("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 =
4turns 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 { int turn = 1; int[][] ships = new int[3][4]; int[][] war = new int[3][3]; int[][] since = new int[3][3]; int[][] grudge = new int[3][3];}
static boolean atWar(World world, int a, int b) { return world.war[a][b] == 1;}
static int power(World world, int empire) { int total = 0; for (int n : world.ships[empire]) total += n; return total;}
static void setStance(World world, int a, int b, int war) { world.war[a][b] = war; world.war[b][a] = war; world.since[a][b] = world.turn; world.since[b][a] = world.turn;}
static String declareWar(World world, int a, int 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;}
static void makePeace(World world, int a, int b) { setStance(world, a, b, 0); world.grudge[a][b] = 0; world.grudge[b][a] = 0;}
static int peaceWish(World world, int me, int other) { // How much weaker the empire is than the other one. int ratio = Math.min(150, power(world, me) * 100 / (power(world, other) + 1)); int otherWars = 0; for (int x = 0; x < 3; x++) { if (x != me && x != other && atWar(world, me, x)) otherWars++; } return Math.max(0, PEACE[me] + (150 - ratio) / 2 + otherWars * 15 - world.grudge[me][other] * 3);}
static String proposePeace(World world, int a, int 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";}static void step(World world, int turn, String kind, int a, int b) { world.turn = turn; int wish = kind.equals("peace") ? peaceWish(world, b, a) : 0; String error = kind.equals("war") ? declareWar(world, a, b) : proposePeace(world, a, b); String shown = kind.equals("peace") && (error == null || error.equals("refused")) ? " (wish " + wish + ")" : ""; System.out.println("turn " + turn + ": " + kind + " " + NAMES[a] + " → " + NAMES[b] + ": " + (error == null ? "ok" : error) + shown);}
World 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);turn 1: war Orion Hegemony → Terran Empire: okturn 2: war Orion Hegemony → Terran Empire: alreadyWarturn 2: peace Terran Empire → Orion Hegemony: tooSoonturn 3: war Lyra Union → Orion Hegemony: okturn 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: tooSoonturn 9: war Orion Hegemony → Terran Empire: okTurn 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).
static boolean atWar(World world, int a, int b) {
return world.war[a][b] == 1;
}
static int power(World world, int empire) {
int total = 0;
for (int n : world.ships[empire]) total += n;
return total;
}
static void setStance(World world, int a, int b, int war) {
world.war[a][b] = war;
world.war[b][a] = war;
world.since[a][b] = world.turn;
world.since[b][a] = world.turn;
}
static String declareWar(World world, int a, int 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;
}
static void makePeace(World world, int a, int b) {
setStance(world, a, b, 0);
world.grudge[a][b] = 0;
world.grudge[b][a] = 0;
}
static int peaceWish(World world, int me, int other) {
// How much weaker the empire is than the other one.
int ratio = Math.min(150, power(world, me) * 100 / (power(world, other) + 1));
int otherWars = 0;
for (int x = 0; x < 3; x++) {
if (x != me && x != other && atWar(world, me, x)) otherWars++;
}
return Math.max(0, PEACE[me] + (150 - ratio) / 2 + otherWars * 15 - world.grudge[me][other] * 3);
}
static String proposePeace(World world, int a, int 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";
}The threshold is “from 90 up”, i.e. 90 is enough: >=. With > a wish of exactly 90 would be a refusal, while the rule in the game says the opposite.
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.static boolean atWar(World world, int a, int b) { return world.empires.get(a).war[b] == 1;}
static void setStance(World world, int a, int b, int war) { world.empires.get(a).war[b] = war; world.empires.get(b).war[a] = war; world.empires.get(a).since[b] = world.turn; world.empires.get(b).since[a] = world.turn;}
static String declareWar(World world, int a, int b) { if (a == b) return "dip.self"; if (!world.empires.get(b).alive) return "dip.dead"; if (atWar(world, a, b)) return "dip.alreadyWar"; if (world.turn - world.empires.get(a).since[b] < MIN_PEACE && world.turn > MIN_PEACE) return "dip.tooSoon"; setStance(world, a, b, 1); GameEvent event = new GameEvent("war"); event.a = a; event.b = b; world.bus.publish(event); return null;}
static void makePeace(World world, int a, int b) { setStance(world, a, b, 0); world.empires.get(a).grudge[b] = 0; world.empires.get(b).grudge[a] = 0; GameEvent event = new GameEvent("peace"); event.a = a; event.b = b; world.bus.publish(event);}
// The smallest distance in lanes between systems with planets of the two empires.static int empireDistance(World world, int a, int b) { int best = 99; for (Planet pa : world.planets) { if (pa.owner != a) continue; int[] dist = bfs(world.systems, pa.systemId).dist; for (Planet pb : world.planets) { if (pb.owner == b && dist[pb.systemId] >= 0 && dist[pb.systemId] < best) best = dist[pb.systemId]; } } return best;}
static int powerRatio(World world, int a, int b, int top) { return clamp(percent(100L * empirePower(world, a), 100L * empirePower(world, b) + 100), 0, top);}
// How much the empire wants peace with the other (0 and up; from 90 up it accepts).static int peaceWish(World world, int me, int other) { Empire e = world.empires.get(me); int weaker = 150 - powerRatio(world, me, other, 150); int otherWars = 0; for (Empire x : world.empires) { if (x.alive && x.id != me && x.id != other && e.war[x.id] == 1) otherWars++; } int wish = e.personality.peace + weaker / 2 + otherWars * 15 - e.grudge[other] * 3; return maxOf(0, wish);}
// How much the empire wants war with the other.static int warWish(World world, int me, int other) { Empire e = world.empires.get(me); int ratio = powerRatio(world, me, other, 300); int wish = e.personality.attack / 2; if (ratio > 100) wish += (ratio - 100) / 4; else wish -= (100 - ratio) / 2; if (empireDistance(world, me, other) <= 3) wish += 25; for (Empire x : world.empires) { if (x.alive && x.id != me && e.war[x.id] == 1) wish -= 30; } return wish;}
static boolean hasOffer(World world, int from, int to) { for (Offer offer : 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).static String proposePeace(World world, int a, int b) { if (a == b) return "dip.self"; if (!world.empires.get(b).alive) return "dip.dead"; if (!atWar(world, a, b)) return "dip.noWar"; if (world.turn - world.empires.get(a).since[b] < 2) return "dip.tooSoon"; if (world.empires.get(b).human) { if (hasOffer(world, b, a)) { makePeace(world, a, b); return null; } if (!hasOffer(world, a, b)) world.offers.add(new Offer(a, b, world.turn)); return "dip.sent"; } if (hasOffer(world, b, a) || peaceWish(world, b, a) >= 90) { makePeace(world, a, b); return null; } world.empires.get(b).grudge[a]++; return "dip.refused";}
// The diplomacy of the artificial intelligence at the end of the turn: peace when weaker, war when stronger.static void aiDiplomacy(World world, Empire empire) { for (Empire other : 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.add(new Offer(empire.id, other.id, world.turn)); GameEvent event = new GameEvent("offer"); event.from = empire.id; event.to = other.id; world.bus.publish(event); } } 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”.
record Event(String type, int a, int b, int place, int n) { Event(String type, int a, int b) { this(type, a, b, 0, 0); }}
class EventBus { Map<String, List<Consumer<Event>>> handlers = new HashMap<>();
void subscribe(String type, Consumer<Event> handler) { if (!handlers.containsKey(type)) handlers.put(type, new ArrayList<>()); handlers.get(type).add(handler); }
void publish(Event event) { if (!handlers.containsKey(event.type())) return; for (Consumer<Event> handler : new ArrayList<>(handlers.get(event.type()))) handler.accept(event); }}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:
static String declareWar(World world, int a, int 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(new Event("war", a, b)); return null;}
static void makePeace(World world, int a, int b) { setStance(world, a, b, 0); world.grudge[a][b] = 0; world.grudge[b][a] = 0; world.bus.publish(new 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:
World 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 (String type : NEWS_TYPES) { world.bus.subscribe(type, event -> System.out.println(newsText(event)));}world.bus.subscribe("battle", event -> { world.grudge[event.a()][event.b()]++; world.grudge[event.b()][event.a()]++;});
world.turn = 1;report(world, declareWar(world, 1, 0), 1, 0);world.bus.publish(new 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);System.out.println("grudge " + NAMES[0] + " → " + NAMES[1] + ": " + world.grudge[0][1]);System.out.println("grudge " + NAMES[1] + " → " + NAMES[0] + ": " + world.grudge[1][0]);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: 1grudge Orion Hegemony → Terran Empire: 3The 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).
Consumer<Event> is a functional interface with one method, accept: the handler is a lambda, event -> ....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 { int turn = 1; String phase = ""; Rng rng; EventBus bus = new EventBus(); int[][] ships = {{3, 0, 0, 0}, {0, 0, 0, 4}, {0, 2, 0, 0}}; int[] metal = {50, 50, 50}; int[][] war = new int[3][3]; int[][] since = new int[3][3]; int[][] grudge = new int[3][3];
World(long seed) { rng = new Rng(seed); }}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]static final int[][] ORDERS = {{1, 1, 3, 2, 4}, {1, 0, 0, 1, 3}, {2, 1, 2, 1, 4}};// [turn, kind (0 war, 1 peace), from, to]static final int[][] 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):
static void scriptedDiplomacy(World world) { world.phase = "diplomacy"; for (int[] step : DIPLO) { if (step[0] != world.turn) continue; String error = step[1] == 0 ? declareWar(world, step[2], step[3]) : proposePeace(world, step[2], step[3]); report(world, error, step[2], step[3]); }}static void resolveMovement(World world) { world.phase = "movement"; List<Move> moving = new ArrayList<>(); // First every fleet leaves its system... for (int[] order : ORDERS) { if (order[0] != world.turn) continue; int take = Math.min(order[4], world.ships[order[1]][order[2]]); world.ships[order[1]][order[2]] -= take; if (take > 0) moving.add(new Move(order[1], order[2], order[3], take)); } // ...and only then do they arrive. for (Move move : moving) { world.ships[move.empire()][move.to()] += move.n(); world.bus.publish(new Event("move", move.empire(), move.from(), move.to(), move.n())); }}static void resolveBattles(World world) { world.phase = "battles"; for (int system = 0; system < 4; system++) { for (int a = 0; a < 3; a++) { for (int b = a + 1; b < 3; b++) { if (!atWar(world, a, b) || world.ships[a][system] == 0 || world.ships[b][system] == 0) continue; int lost = Math.min(world.ships[a][system], world.ships[b][system]); world.ships[a][system] -= lost; world.ships[b][system] -= lost; world.bus.publish(new Event("battle", a, b, system, lost)); } } }}
static void produce(World world) { world.phase = "production"; for (int 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:
static void rollEvent(World world) { world.phase = "event"; if (!world.rng.chance(EVENT_CHANCE)) return; int empire = world.rng.nextInt(3); int[] weights = new int[EVENT_DEFS.length]; for (int i = 0; i < weights.length; i++) { weights[i] = world.turn >= EVENT_DEFS[i].minTurn() ? EVENT_DEFS[i].weight() : 0; } String kind = EVENT_DEFS[pickWeighted(world.rng, weights)].id(); if (kind.equals("meteor")) { int loss = Math.min(world.metal[empire], 8 + world.rng.nextInt(8)); world.metal[empire] -= loss; world.bus.publish(new Event("meteor", empire, 0, 0, loss)); } else if (kind.equals("boom")) { world.metal[empire] += 20; world.bus.publish(new Event("boom", empire, 0, 0, 20)); } else { world.ships[empire][HOME[empire]] += 2; world.bus.publish(new Event("derelict", empire, 0, HOME[empire], 2)); }}static void runTurn(World 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:
World world = new World(7);world.bus.subscribe("battle", event -> { world.grudge[event.a()][event.b()]++; world.grudge[event.b()][event.a()]++;});for (String type : NEWS_TYPES) { world.bus.subscribe(type, event -> System.out.println(" [" + world.phase + "] " + newsText(event)));}
for (int i = 0; i < 5; i++) { System.out.println(fill("-- turn {0} --", world.turn)); runTurn(world);}System.out.println("-- end --");for (int empire = 0; empire < 3; empire++) { System.out.println(fill("{0}: ships {1}, metal {2}", NAMES[empire], power(world, empire), world.metal[empire]));}-- 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 100Orion Hegemony: ships 2, metal 100Lyra Union: ships 1, metal 78Read 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.
static void runTurn(World 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 { Map<String, List<Consumer<Event>>> handlers = new HashMap<>();
void subscribe(String type, Consumer<Event> handler) { if (!handlers.containsKey(type)) handlers.put(type, new ArrayList<>()); handlers.get(type).add(handler); }
void publish(Event event) { if (!handlers.containsKey(event.type())) return; for (Consumer<Event> handler : new ArrayList<>(handlers.get(event.type()))) handler.accept(event); }}Step by step, in words
- A = Terran Empire, B = Orion Hegemony, C = Lyra Union. Four systems in a line; the turn goes through the phases from top to bottom.
- turn 1 · diplomacy: Orion Hegemony declares war on Terran Empire.
- 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).
- turn 1 · event: Meteor shower: Lyra Union loses 14 metal.
- turn 2 · movement: Orion Hegemony sends a fleet from Altan to Vesta (ships: 4).
- turn 2 · battles: Battle at Vesta: Terran Empire and Orion Hegemony lose ships (3 each).
- turn 3 · diplomacy: Lyra Union declares war on Orion Hegemony. Orion Hegemony refuses peace with Terran Empire.
- turn 3 · battles: Battle at Vesta: Orion Hegemony and Lyra Union lose ships (1 each).
- turn 4 · diplomacy: Peace between Terran Empire and Orion Hegemony.
- turn 4 · event: Meteor shower: Lyra Union loses 8 metal.
- turn 5 · diplomacy: Orion Hegemony cannot change its relations with Terran Empire yet: too soon.
- turn 5 · event: Orion Hegemony finds derelict ships at Brisa (ships: 2).
- 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.static class Move { int owner; int to; int dest; int[] ships;
Move(int owner, int to, int dest, int[] ships) { this.owner = owner; this.to = to; this.dest = dest; this.ships = ships; }}
static void resolveMovement(World world) { List<Move> moving = new ArrayList<>(); for (Empire empire : world.empires) { if (!empire.alive) continue; for (Order order : empire.orders) { Fleet fleet = fleetAt(world, empire.id, order.from); if (fleet == null) continue; int[] ships = {0, 0, 0, 0}; for (int 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.add(new Move(empire.id, order.to, order.dest, ships)); } empire.orders = new ArrayList<>(); } for (Move move : 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.get(move.owner).orders.add(new Order(move.to, nextStep(world.systems, move.to, move.dest), move.dest, move.ships.clone())); } } removeEmptyFleets(world);}
static boolean hasUnits(World world, int owner, int systemId) { Fleet fleet = fleetAt(world, owner, systemId); return (fleet != null && totalOf(fleet.ships) > 0) || fortsNow(world, owner, systemId) > 0;}
static void resolveBattles(World world) { for (StarSystem system : world.systems) { for (int a = 0; a < world.empires.size(); a++) { for (int b = a + 1; b < world.empires.size(); b++) { if (!world.empires.get(a).alive || !world.empires.get(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.static void resolveConquest(World world) { for (StarSystem system : world.systems) { for (int pid : system.planets) { Planet planet = world.planets.get(pid); if (planet.owner < 0) continue; int owner = planet.owner; int attacker = -1; if (!hasUnits(world, owner, system.id)) { for (Empire e : world.empires) { if (!e.alive || e.id == owner || !atWar(world, e.id, owner)) continue; Fleet 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) { GameEvent siege = new GameEvent("siege"); siege.empire = attacker; siege.from = owner; siege.planetId = planet.id; world.bus.publish(siege); continue; } planet.siege = 0; planet.owner = attacker; planet.mines = maxOf(0, planet.mines - 1); planet.lab = 0; planet.yard = 0; planet.fort = 0; GameEvent conquest = new GameEvent("conquest"); conquest.empire = attacker; conquest.from = owner; conquest.planetId = planet.id; world.bus.publish(conquest); } } for (Empire empire : world.empires) { if (!empire.alive || ownedPlanets(world, empire.id).size() > 0) continue; empire.alive = false; List<Fleet> kept = new ArrayList<>(); for (Fleet fleet : world.fleets) { if (fleet.owner != empire.id) kept.add(fleet); } world.fleets = kept; empire.orders = new ArrayList<>(); GameEvent eliminated = new GameEvent("eliminated"); eliminated.empire = empire.id; world.bus.publish(eliminated); }}
static void runTurn(World world) { world.turnLines = new ArrayList<>(); world.fortLeft = new LinkedHashMap<>(); for (Empire empire : world.empires) { if (empire.alive && !empire.human) aiDiplomacy(world, empire); } for (Empire empire : world.empires) { if (empire.alive && !empire.human) aiPlay(world, empire); } resolveMovement(world); resolveBattles(world); resolveConquest(world); for (Empire empire : 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 (Empire empire : world.empires) { for (int i = 0; i < empire.grudge.length; i++) empire.grudge[i] = maxOf(0, empire.grudge[i] - 1); } } List<Offer> fresh = new ArrayList<>(); for (Offer offer : world.offers) { if (world.turn - offer.turn <= 3) fresh.add(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".// In JavaScript the event is an object with different fields; here it is one class, and the fields the kind// does not use stay empty.static class GameEvent { String type; int systemId = 0; int a = 0; int b = 0; int lostA = 0; int lostB = 0; String winner = ""; List<String> lines = new ArrayList<>(); int empire = 0; int planetId = 0; String techId = ""; int from = 0; int to = 0; String kind = ""; Map<String, Object> vars = new LinkedHashMap<>();
GameEvent(String type) { this.type = type; }}
static class EventBus { Map<String, List<Consumer<GameEvent>>> handlers = new LinkedHashMap<>();
void subscribe(String type, Consumer<GameEvent> handler) { if (!this.handlers.containsKey(type)) this.handlers.put(type, new ArrayList<>()); this.handlers.get(type).add(handler); }
void publish(GameEvent event) { if (!this.handlers.containsKey(event.type)) return; for (Consumer<GameEvent> handler : new ArrayList<>(this.handlers.get(event.type))) handler.accept(event); }}The differences from our program are many, and that is why they matter:
- There is no script. Orders and diplomacy come from
aiPlayandaiDiplomacy(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
waitstops 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?
static int[] moveTogether(int[] start, int[][] orders) { int[] ships = start.clone(); int[] takes = new int[orders.length]; // First every fleet leaves its system... for (int i = 0; i < orders.length; i++) { takes[i] = Math.min(orders[i][2], ships[orders[i][0]]); ships[orders[i][0]] -= takes[i]; } // ...and only then do they arrive. for (int i = 0; i < orders.length; i++) ships[orders[i][1]] += takes[i]; return ships;}
static String show(int[] ships) { String text = ""; for (int i = 0; i < ships.length; i++) text += (i > 0 ? " " : "") + ships[i]; return text;}
int[][] orders = {{0, 1, 3}, {1, 2, 2}};System.out.println(show(moveTogether(new int[] {3, 0, 0, 0}, orders)));First everyone leaves: the first order takes 3 ships from system 0, and the second sees zero ships in system 1 (the first ones have not arrived yet) and takes 0. Then they arrive: 3 ships in system 1, 0 in system 2. The second option is the result with orders one after another: 2 of the arrived ships leave again the same turn; the third is the initial state, without movement.
Try it
Find the bug
A colleague “fixed” the simultaneous movement, but with it the ships still pass through several systems per turn:
int[] start = {3, 0, 0, 0};System.out.println("at the same time: " + show(moveTogether(start, ORDERS)));at the same time: 0 0 0 3The 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.
The ships arrive (+= at the destination) already in the loop for leaving. When the second order comes, the ships of the first are already there and it takes them. Correct: in the first loop they are only taken (-=) and remembered in takes; arriving is a second loop, after everyone has left.
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):
-- 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 100Orion Hegemony: ships 2, metal 100Lyra Union: ships 1, metal 78alerts: 3More tasks
- Statistics. Add one more subscriber that counts the battles and the total of ships lost (each battle loses
non each of the two sides). It prints them after the alerts. - Truce. Lower
MIN_PEACEto 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
plaguewith 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
Related lessons
Section titled "Related lessons"- Builds on: Lists · HashMap and HashSet · Lambdas · record, switch and patterns
- Tasks on this topic: Epic Cosmos: the empire