The rivals
In short: A rival gives every action it can pay for a number, takes the highest, pays and scores again. The empire’s personality is the weights in the same formulas.
Problem
A rival that does not think
In Epic Cosmos you play against three empires. While you type commands, each of them decides what to do this turn: place a mine, build a lab, settle a new planet, order ships, send a fleet. How do you make such a rival?
The first attempt is a script: “turn 1 mine, turn 2 mine, turn 3 colony…”. The player figures it out in three games and then beats it every time. The second attempt is a chain of “if … else”: if there is money for a colony, colonize; else if in danger, build ships; else… Every new action needs a new line and new exceptions to the others, and the rival is still predictable and does silly things, because nobody foresaw exactly this situation.
There is a better way. Give every possible action a number: how good it is for this empire right now. Do the action with the highest number. “Colony or ships?” becomes a comparison of two numbers, not a rule for this pair. A new kind of action is a new line in the list of scores, and nothing else is touched.
And one more thing that makes the game interesting: personality. The warlord, the builder and the scholar are not three programs, but the same code with different weights in the formulas.
The idea
A number for every action
Here is the situation the whole lesson works with. Turn 5, one empire with two planets (Kora I with a shipyard and Kora II with one mine), two more free planets in the same system (Vesta I and II), 100 metal and a fleet power of 10:
record Option(String kind, String target, int utility) {}
class Planet { final String name; final int rich; int mines; boolean lab; final boolean yard;
Planet(String name, int rich, int mines, boolean yard) { this.name = name; this.rich = rich; this.mines = mines; this.yard = yard; }}
class Empire { int metal = 100; int power = 10; int turn = 5; final List<Planet> owned = new ArrayList<>(); final List<Planet> free = new ArrayList<>();}static final int MAX_MINE = 3;static final int LAB_COST = 40;static final int SHIP_COST = 8;static final int SHIP_POWER = 5;
static Empire newEmpire() { Empire empire = new Empire(); empire.owned.add(new Planet("Kora I", 4, 0, true)); empire.owned.add(new Planet("Kora II", 3, 1, false)); empire.free.add(new Planet("Vesta I", 3, 0, false)); empire.free.add(new Planet("Vesta II", 1, 0, false)); return empire;}What can this empire do? Build a mine on a planet (it costs 15 × (mines + 1)), build a lab (40), settle a colony on a free planet (it costs more with every planet: now 52) or order ships. Each of them gets a score, and the scores are formulas. First the two buildings:
- mine =
build × richness / (4 × (mines + 1)): a rich planet is worth more, and every new mine on the same planet is worth less (the divisor grows); - lab =
research × 6 / 5 − 40 × labs: the second lab is worth 40 less than the first.
Then the colonies and the ships:
- colony =
expand × (richness + 6) × 50 / (cost × 6): a free rich planet is tempting, and an expensive colony is less so; - ships =
60 + attack / 2 + (wanted power − power) / 2(the last term is at most 100), but only while the power is below the wanted one. The wanted power grows with the turn:(20 + 3 × turn) × (140 + attack) / 240.
static List<Option> options(Empire empire, Personality p) { // Every action the empire can pay for gets a score. List<Option> list = new ArrayList<>(); int labs = 0; for (Planet planet : empire.owned) { if (planet.lab) labs++; } for (Planet planet : empire.owned) { if (planet.mines < MAX_MINE && empire.metal >= mineCost(planet)) { list.add(new Option("mine", planet.name, p.build() * planet.rich / (4 * (planet.mines + 1)))); } if (!planet.lab && empire.metal >= LAB_COST) { list.add(new Option("lab", planet.name, p.research() * 6 / 5 - 40 * labs)); } } int cost = colonyCost(empire); if (empire.metal >= cost) { for (Planet planet : empire.free) { int value = planet.rich + 6; list.add(new Option("colony", planet.name, p.expand() * value * 50 / (cost * 6))); } } int want = wantedPower(empire, p); for (Planet planet : empire.owned) { if (planet.yard && empire.power < want && shipCount(empire) > 0) { int urge = Math.min(100, (want - empire.power) / 2); list.add(new Option("ships", planet.name, 60 + p.attack() / 2 + urge)); break; } } return list;}That is the whole function. It does not choose: it only collects into a list everything the empire can pay for, with the scores. The condition “can pay” comes before the score: an action that cannot be paid for does not enter the list.
Option is a record: three fields and nothing more, while Planet and Empire are classes with mutable fields. Integer division cuts off the remainder, which is exactly what we want here: all the numbers are positive.More precisely, if you are curious
The formulas are tuned, not derived: the coefficients 4, 50, 6 and 60 are chosen so that the scores of a mine, a colony and ships are comparable (hundreds), and one is not a thousand times heavier than another. That is why the last lesson runs hundreds of AI-against-AI games and measures whether any personality is out of balance.
In the game there are four resources (metal, gas, crystal, uranium) and the richness of a planet is four numbers. Here there is metal and one richness, so that every number can be seen.
The idea
The highest number wins: strictly
Once the list is ready, the choice is a simple loop. A strictly higher score replaces the choice. So when the highest scores are equal, the one looked at first wins. The starting value is the floor: a score that does not beat it is not worth doing. For the economy it is 0 (an action with the score 0 is “pointless”), and for the fleet it will be 20 (it is better to stay).
static Option best(List<Option> list, int floor) { // Only a strictly higher score wins, so on a tie the first one stays. Option pick = null; int bestU = floor; for (Option option : list) { if (option.utility() > bestU) { pick = option; bestU = option.utility(); } } return pick;}
static String label(Option option) { String kind = option.kind(); if (kind.equals("mine")) return "mine " + option.target(); if (kind.equals("lab")) return "lab " + option.target(); if (kind.equals("colony")) return "colony " + option.target(); return "ships " + option.target();}The situation from above, run for the three personalities (each gets a new empire with the same planets and money). For each, the scores are listed in the order of the list, and at the end the choice:
for (Personality p : PERSONALITIES) { List<Option> list = options(newEmpire(), p); System.out.println(p.name()); for (Option option : list) { System.out.println(" " + pad(label(option), 21) + padLeft(String.valueOf(option.utility()), 4)); } Option pick = best(list, 0); System.out.println(" → " + label(pick) + " (" + pick.utility() + ")");}Warlord mine Kora I 90 lab Kora I 72 mine Kora II 33 lab Kora II 72 colony Vesta I 115 colony Vesta II 89 ships Kora I 162 → ships Kora I (162)Builder mine Kora I 140 lab Kora I 96 mine Kora II 52 lab Kora II 96 colony Vesta I 230 colony Vesta II 179 ships Kora I 93 → colony Vesta I (230)Scholar mine Kora I 100 lab Kora I 132 mine Kora II 37 lab Kora II 132 colony Vesta I 100 colony Vesta II 78 ships Kora I 99 → lab Kora I (132)One situation, three different choices. The warlord picks the ships (162): its wanted power is 45, and the fleet is only 10. The builder picks the colony on Vesta I (230), because its expansion weighs 160. The scholar picks the lab on Kora I (132): the lab on Kora II has the same score and stays second.
The idea
A personality is a table of weights
What differs between the three empires in the code? Only four numbers. Here they are from the game’s canon, PERSONALITIES_DATA:
| Personality | expand | build | attack | research | peace |
|---|---|---|---|---|---|
| Warlord | 80 | 90 | 170 | 60 | 40 |
| Builder | 160 | 140 | 50 | 80 | 120 |
| Scholar | 70 | 100 | 60 | 110 | 110 |
The column “peace” is for diplomacy (lesson 5). The other four are multiplied into the scores: expand in colonies, build in mines, attack in ships and in the fleet’s attack, research in labs. The warlord is not programmed to attack: its attack simply weighs 170, and the builder’s 50. A new personality is a new row in the table, with no new code.
In the game the rivals are visible in the command emp (the personality is the second column):
Empire Character Pl Power Tec Score To youA Terran Empire Builder 1 109 0 20C Lyra Union Builder 1 109 0 20 peaceD Vega Academy Scholar 1 109 0 20 peaceB Orion Hegemony Warlord 1 109 0 20 peaceSee it
Scoring in action
The scene goes through four stages. The first three are the same empire on turn 5, scored by the warlord, the builder and the scholar. The rows are the actions in the order of the list, the bars are the scores, and the orange line is the “best so far”: it moves only for a strictly higher number. In the fourth stage the builder has already paid for the colony and scores again.
static List<Option> options(Empire empire, Personality p) { // Every action the empire can pay for gets a score. List<Option> list = new ArrayList<>(); int labs = 0; for (Planet planet : empire.owned) { if (planet.lab) labs++; } for (Planet planet : empire.owned) { if (planet.mines < MAX_MINE && empire.metal >= mineCost(planet)) { list.add(new Option("mine", planet.name, p.build() * planet.rich / (4 * (planet.mines + 1)))); } if (!planet.lab && empire.metal >= LAB_COST) { list.add(new Option("lab", planet.name, p.research() * 6 / 5 - 40 * labs)); } } int cost = colonyCost(empire); if (empire.metal >= cost) { for (Planet planet : empire.free) { int value = planet.rich + 6; list.add(new Option("colony", planet.name, p.expand() * value * 50 / (cost * 6))); } } int want = wantedPower(empire, p); for (Planet planet : empire.owned) { if (planet.yard && empire.power < want && shipCount(empire) > 0) { int urge = Math.min(100, (want - empire.power) / 2); list.add(new Option("ships", planet.name, 60 + p.attack() / 2 + urge)); break; } } return list;}
static Option best(List<Option> list, int floor) { // Only a strictly higher score wins, so on a tie the first one stays. Option pick = null; int bestU = floor; for (Option option : list) { if (option.utility() > bestU) { pick = option; bestU = option.utility(); } } return pick;}Step by step, in words
- Turn 5: 100 metal, power 10. The empire scores every action it can pay for. First comes the warlord with weights 80/90/170/60: build 90, attack 170.
- Warlord: a mine on Kora I = build × richness / (4 × (mines + 1)) = 90; on Kora II 33, because it already has one mine. A lab is research × 6 / 5 = 72, the same on both planets.
- A colony on Vesta I = expand × (richness + 6) × 50 / (cost × 6) = 115 at cost 52. Ships: the wanted power is 45 and it has 10, so 60 + attack / 2 + (45 − 10) / 2 = 162.
- The highest score is 162: the warlord builds ships. The “best so far” line moved three times (mine 90, colony 115, ships 162), each time for a strictly higher score.
- The same world, other weights. The builder (160/140/50/80) values building: a mine on Kora I 140, a lab 96. The planets, the money and the formulas are the same.
- The colonies score 230 and 179: expansion weighs 160. Ships are only 93, because the builder’s wanted power is 27 (its attack is 50).
- The builder picks the colony on Vesta I (230). The warlord, with exactly the same numbers, picked ships.
- The scholar (70/100/60/110) values research: a lab scores 132 on both planets, a mine on Kora I 100.
- The colonies score 100 and 78, ships 99: the wanted power is 29.
- Two labs with the same score 132. The second is not strictly higher, so the line stays on the first: the lab on Kora I wins.
- The builder paid for the colony: 48 metal left. The next colony now costs 64 (every planet makes it dearer), cannot be paid and gets no score: its row is empty. Everything is scored again.
- The mine on Kora I is again 140, ships 93. Now 48 metal is enough for a mine, a lab or ships, but not for a colony.
- The second step of the same turn: the builder builds a mine on Kora I (140). The loop is: score everything, do the best, pay, and score again.
The code next to the scene is options and best from the program. At each step the lines that are working at that moment light up.
The idea
Pay, then score again
A score is valid for the situation right now. As soon as the empire does the best action, the situation changes: the money goes down, the price of the next colony goes up, a new mine makes the next one less valuable, a new colony opens new actions. So the rival does not take the list once, but repeats: score everything, do the best, pay, score again. At most 6 times per turn (the canon does the same) or until there is nothing left to do.
static Planet find(List<Planet> planets, String name) { for (Planet planet : planets) { if (planet.name.equals(name)) return planet; } return null;}
static void apply(Empire empire, Option option) { String kind = option.kind(); if (kind.equals("mine")) { Planet planet = find(empire.owned, option.target()); empire.metal -= mineCost(planet); planet.mines++; } else if (kind.equals("lab")) { empire.metal -= LAB_COST; find(empire.owned, option.target()).lab = true; } else if (kind.equals("colony")) { Planet planet = find(empire.free, option.target()); empire.metal -= colonyCost(empire); empire.free.remove(planet); empire.owned.add(planet); } else { int count = shipCount(empire); empire.metal -= count * SHIP_COST; empire.power += count * SHIP_POWER; }}
static void endTurn(Empire empire) { int income = 4; for (Planet planet : empire.owned) { income += planet.rich * (2 + planet.mines) / 2; } empire.metal += income; empire.turn++;}
static void playTurn(Empire empire, Personality p, boolean log) { // Pay, score again, and repeat while there is something to do (at most 6 times). for (int step = 1; step <= 6; step++) { Option option = best(options(empire, p), 0); if (option == null) { if (log) System.out.println(" nothing more to do: metal " + empire.metal); return; } int before = empire.metal; apply(empire, option); if (log) { System.out.println(" " + step + ". " + label(option) + " (" + option.utility() + "): metal " + before + " → " + empire.metal); } }}
static void summarize(Personality p) { Empire empire = newEmpire(); for (int turn = 0; turn < 6; turn++) { playTurn(empire, p, false); endTurn(empire); } int mines = 0; int labs = 0; for (Planet planet : empire.owned) { mines += planet.mines; if (planet.lab) labs++; } System.out.println(p.name() + ": planets " + empire.owned.size() + ", mines " + mines + ", labs " + labs + ", power " + empire.power + ", metal " + empire.metal);}Here is one turn of the builder, action by action, and then what the three personalities have achieved in six turns:
Personality builder = PERSONALITIES.get(1);Empire empire = newEmpire();System.out.println(builder.name() + ", turn " + empire.turn + ":");playTurn(empire, builder, true);System.out.println();System.out.println("After 6 turns:");for (Personality p : PERSONALITIES) summarize(p);Builder, turn 5: 1. colony Vesta I (230): metal 100 → 48 2. mine Kora I (140): metal 48 → 33 3. mine Vesta I (105): metal 33 → 18 4. ships Kora I (93): metal 18 → 2 nothing more to do: metal 2
After 6 turns:Warlord: planets 2, mines 4, labs 0, power 75, metal 19Builder: planets 3, mines 3, labs 1, power 40, metal 38Scholar: planets 2, mines 4, labs 1, power 45, metal 21The first action is the colony (230), which takes 52 metal. Then the scores are different: the next colony can no longer be paid for, and the best is the mine on Kora I (140). After 4 actions the money is not enough for anything and the turn ends. After six turns the differences are real but not dramatic: the warlord has the highest power (75) and no lab, the builder has the most planets (3), the scholar has a lab and 4 mines. The weights exclude nothing: the builder also builds a lab when that is the best thing left.
There is also a limit you should know about: the choice is greedy. The rival does not think “I will save up for the colony”, but takes the best thing now: the builder buys two small ships (score 93) instead of waiting.
The idea
The fleet: settle or attack
Building is half of it. The other half is the fleets: where should the fleet go? The same technique, another list. The candidates are the systems around, and there are two kinds of actions: settling (the system has free planets and no enemy) and attacking (it has enemy planets and we are strong enough). Before the score there are filters: an attack where the power ratio is below 130% or the target is farther than 6 is not considered at all.
static final int FLEET_POWER = 40;static final int STAY = 20;static final List<Spot> SPOTS = List.of( new Spot("Brisa", 1, 12, 0, 0), new Spot("Altan", 5, 0, 12, 10), new Spot("Cygra", 3, 0, 18, 60));
static List<Option> targetOptions(int power, Personality p) { List<Option> list = new ArrayList<>(); for (Spot spot : SPOTS) { int steps = spot.distance() + 1; if (spot.freeValue() > 0 && spot.enemyPower() == 0 && spot.distance() <= 5) { list.add(new Option("settle", spot.name(), p.expand() * spot.freeValue() / (6 * steps))); } int ratio = power * 100 * 100 / (spot.enemyPower() * 100 + 100); if (spot.enemyValue() > 0 && ratio >= 130 && spot.distance() <= 6) { int sure = Math.min(ratio, 300); list.add(new Option("attack", spot.name(), p.attack() * spot.enemyValue() * sure / (4 * steps * 150))); } } return list;}The fleet has power 40. The system Brisa is free and near (distance 1), Altan is enemy and far (5, but the enemy is weak), and Cygra is enemy with power 60. The power ratio against Altan is 363%, but in the score it is raised to at most 300%. Against Cygra it is 65%, below 130%: an attack there is not scored. This time the floor is STAY, 20: if nothing beats it, the fleet stays.
for (Personality p : PERSONALITIES) { List<Option> list = targetOptions(FLEET_POWER, p); System.out.println(p.name()); for (Option option : list) { System.out.println(" " + pad(label(option), 18) + padLeft(String.valueOf(option.utility()), 4)); } Option pick = best(list, STAY); System.out.println(" → " + (pick == null ? "stay" : label(pick) + " (" + pick.utility() + ")"));}Warlord settle Brisa 80 attack Altan 170 → attack Altan (170)Builder settle Brisa 160 attack Altan 50 → settle Brisa (160)Scholar settle Brisa 70 attack Altan 60 → settle Brisa (70)The warlord attacks Altan (170), despite the distance. The builder settles Brisa (160). The scholar settles Brisa too, but only just: 70 against 60. The formulas are the same, only the weights differ.
The idea
In the game
What we built is the same as in the canon of Epic Cosmos, only smaller. Here is the real scoring of the economy. You can recognize the mine, the lab, the colony and the ships from our program. Besides them there are two more actions: a shipyard (yard, score 250 for the first one, because without it there is no fleet) and a fortress (fort, only when enemy power is nearby). The choice is the same idea, but with consider(score, action) instead of a list:
// One action in the economy: building, a colony or an order of ships.static class Action { String kind; int planetId; int shipKind = 0; int count = 0;
Action(String kind, int planetId) { this.kind = kind; this.planetId = planetId; }}
// The best action so far: strictly the greater score wins, so on ties the first one.static class Best { Action action = null; int u = 0;
void consider(int u, Action action) { if (u > this.u) { this.u = u; this.action = action; } }}
// One action in the economy: the best by utility, or null.static Action aiBestAction(World world, Empire empire) { Personality p = empire.personality; Best pick = new Best(); List<Planet> mine = ownedPlanets(world, empire.id); int labs = 0; int yards = 0; for (Planet planet : mine) { labs += planet.lab; yards += planet.yard; } TechMods mods = techMods(empire); for (Planet planet : mine) { if (planet.mines < MAX_MINE && canPay(empire, buildCost("mine", planet))) { pick.consider((p.build * richSum(planet)) / (4 * (planet.mines + 1)), new Action("mine", planet.id)); } if (planet.lab == 0 && canPay(empire, buildCost("lab", planet))) { pick.consider((p.research * 6) / 5 - 40 * labs, new Action("lab", planet.id)); } if (planet.yard == 0 && canPay(empire, buildCost("yard", planet))) { pick.consider(yards == 0 ? 250 : mine.size() >= 5 && yards < 2 ? 60 : 0, new Action("yard", planet.id)); } if (mods.fort > 0 && planet.fort < MAX_FORT && canPay(empire, buildCost("fort", planet))) { int threat = enemyPowerAt(world, empire, planet.systemId); for (int next : world.systems.get(planet.systemId).links) threat += enemyPowerAt(world, empire, next); if (threat > 0) pick.consider(40 + minOf(160, threat) + (planet.id == empire.capital ? 30 : 0), new Action("fort", planet.id)); } } int[] cost = colonyCost(world, empire); if (canPay(empire, cost)) { for (Planet planet : world.planets) { if (planet.owner >= 0 || !hasPresence(world, empire.id, planet.systemId) || hostileIn(world, empire.id, planet.systemId)) continue; pick.consider((p.expand * (richSum(planet) + 6) * 50) / (cost[0] * 6), new Action("col", planet.id)); } } // Ships: while the strength is below the one wanted for this turn. int want = ((20 + 3 * world.turn) * (140 + p.attack)) / 240; for (Empire other : world.empires) { if (other.alive && other.id != empire.id && empire.war[other.id] == 1) want = (want * 3) / 2; } int have = empirePower(world, empire.id) + queuedPower(world, empire.id); if (have < want) { for (Planet planet : mine) { if (planet.yard == 0) continue; int kind = counterKind(world, empire); if (shipAllowed(empire, 3) && canPay(empire, SHIPS.get(3).cost) && have >= 30) kind = 3; int count = 4; while (count > 0 && !canPay(empire, scaleCost(SHIPS.get(kind).cost, count))) count--; if (count > 0) { Action action = new Action("ship", planet.id); action.shipKind = kind; action.count = count; pick.consider(60 + p.attack / 2 + minOf(100, (want - have) / 2), action); } break; } } return pick.action;}And here is the loop “score, do, repeat” (aiEconomy) and the whole turn of one rival (aiPlay): research, economy, fleets. Research is the same technique in small: aiResearch scores every available technology with research × (100 + personality bonus) × 10 / (cost + 5) and takes the highest (the tech tree is in lesson 2).
static void aiEconomy(World world, Empire empire) { for (int step = 0; step < 6; step++) { Action action = aiBestAction(world, empire); if (action == null) return; Planet planet = world.planets.get(action.planetId); String error = null; if (action.kind.equals("col")) error = colonize(world, empire, planet); else if (action.kind.equals("ship")) error = queueShips(world, empire, planet, action.shipKind, action.count); else error = buildStructure(world, empire, action.kind, planet); if (error != null) return; }}
static void aiPlay(World world, Empire empire) { // The artificial intelligence plans again every turn. empire.orders = new ArrayList<>(); aiResearch(world, empire); aiEconomy(world, empire); aiFleets(world, empire);}The differences from our program are few:
- Four resources instead of one.
canPaycompares four numbers, and the richness of a planet is four numbers (richSumadds them up and weighs crystal and uranium double). - The same functions as the player. The rival calls
colonize,queueShipsandbuildStructure, which the player calls with the commandscol,shipandb. It pays the same prices under the same rules. - War changes the wanted power. For every empire we are at war with, the wanted power is multiplied by 3/2, and the kind of ships is chosen by the enemy (
counterKind: interceptor beats bomber, bomber beats frigate, frigate beats interceptor; the roles are from lesson 3). - Defense is in the scores. A fortress is built if an enemy is near, and the fleet goes back to defend its own system (
ownHere). - No randomness. Not a single die in these decisions: the same input gives the same move in Java, C#, Python and JavaScript.
And here is the choice of the fleet’s target:
static void aiFleets(World world, Empire empire) { Personality p = empire.personality; List<Fleet> snapshot = new ArrayList<>(); for (Fleet fleet : world.fleets) { if (fleet.owner == empire.id) snapshot.add(fleet); } for (Fleet fleet : snapshot) { int[] busy = orderedShips(empire, fleet.systemId); int[] free = {0, 0, 0, 0}; for (int k = 0; k < SHIP_KIND_COUNT; k++) free[k] = fleet.ships[k] - busy[k]; int myPower = countsPower(free); if (myPower == 0) continue; int here = fleet.systemId; int[] dist = bfs(world.systems, here).dist; // Holding: if we are home under threat or besieging an enemy. int bestU = 20; int target = -1; boolean split = false; boolean siege = false; for (StarSystem system : world.systems) { int foe = enemyPowerAt(world, empire, system.id); int freeValue = 0; int enemyValue = 0; boolean ownHere = false; for (int pid : system.planets) { Planet planet = world.planets.get(pid); if (planet.owner < 0) freeValue += richSum(planet) + 6; else if (planet.owner == empire.id) ownHere = true; else if (empire.war[planet.owner] == 1) enemyValue += richSum(planet) + 10; } int d = dist[system.id]; if (d < 0) continue; int ratio = percent(100L * myPower, 100L * foe + 100); if (system.id == here) { if (enemyValue > 0 && ratio >= 80) { bestU = 250; siege = true; } else if (ownHere && foe > 0 && ratio >= 60) { bestU = 200; siege = true; } continue; } if (freeValue > 0 && foe == 0 && d <= 5) { int u = (p.expand * freeValue) / (6 * (d + 1)); if (u > bestU) { bestU = u; target = system.id; split = totalOf(free) >= 6; siege = false; } } if (enemyValue > 0 && ratio >= 130 && d <= 6) { int u = (p.attack * enemyValue * minOf(ratio, 300)) / (4 * (d + 1) * 150); if (u > bestU) { bestU = u; target = system.id; split = false; siege = false; } } if (ownHere && foe > 0 && d <= 4 && ratio >= 60) { int u = 150 - 10 * d; if (u > bestU) { bestU = u; target = system.id; split = false; siege = false; } } } if (target < 0 || siege) continue; int step = nextStep(world.systems, here, target); if (step < 0) continue; orderMove(world, empire, here, target, split ? halfOf(free) : null); }}It looks like our program: the same two formulas (settle and attack), the filter 130%, the floor 20. In addition there is “holding” (in a system with enemies and a good ratio the fleet stays and besieges: 250), defense of its own system (150 − 10 × distance) and splitting the fleet in two when it is big.
Try it
Predict the output
Four actions, two of them with the highest score 132. What does the program print? (The functions best and label are from the lesson.)
List<Option> list = List.of( new Option("mine", "Kora I", 90), new Option("lab", "Kora I", 132), new Option("lab", "Kora II", 132), new Option("colony", "Vesta I", 100));Option pick = best(list, 0);System.out.println(label(pick) + " (" + pick.utility() + ")");The lab on Kora I is the first with the score 132. The lab on Kora II has the same score, but it is not strictly higher and does not change the choice. The colony (100) is lower. The second answer would come out with >=: then every equal score changes the choice and the last one wins.
Try it
Fill in the blank
The starting value of the best is the parameter of the function: 0 for the economy, 20 for the fleet. What does bestU start with?
static Option best(List<Option> list, int floor) {
// Only a strictly higher score wins, so on a tie the first one stays.
Option pick = null;
int bestU = ;
for (Option option : list) {
if (option.utility() > bestU) {
pick = option;
bestU = option.utility();
}
}
return pick;
}It starts from the floor: floor. That way the same function works for the economy (0) and for the fleet (20), and a score that does not beat it wins nothing: the result is null. If everything started from 0, the fleet would set out on an attack whose score is 5 instead of staying.
Try it
Find the bug
A colleague computes the colonies without parentheses, and now every empire, even the warlord, only colonizes. Here are the builder’s scores:
for (Personality p : PERSONALITIES) { List<Option> list = options(newEmpire(), p); System.out.println(p.name()); for (Option option : list) { System.out.println(" " + pad(label(option), 21) + padLeft(String.valueOf(option.utility()), 4)); } Option pick = best(list, 0); System.out.println(" → " + label(pick) + " (" + pick.utility() + ")");}Warlord mine Kora I 90 lab Kora I 72 mine Kora II 33 lab Kora II 72 colony Vesta I 4152 colony Vesta II 3228 ships Kora I 162 → colony Vesta I (4152)Builder mine Kora I 140 lab Kora I 96 mine Kora II 52 lab Kora II 96 colony Vesta I 8304 colony Vesta II 6456 ships Kora I 93 → colony Vesta I (8304)Scholar mine Kora I 100 lab Kora I 132 mine Kora II 37 lab Kora II 132 colony Vesta I 3630 colony Vesta II 2826 ships Kora I 99 → colony Vesta I (3630)The colony on Vesta I is 8304 instead of 230. Which line computes it wrongly?
Pick the line with the bug.
In … * 50 / cost * 6 it first divides by the cost (integer division, remainder cut off) and then multiplies by 6: 72000 / 52 = 1384, 1384 × 6 = 8304. The formula is … × 50 / (cost × 6): the divisor is the whole product. Without the parentheses the colony is about 36 times larger than it should be and beats everything. Lesson: integer division is not associative, and a formula score needs its parentheses.
More precisely
More precisely: what a score does not do
It is not a probability and has no unit. 162 does not mean “162 points of benefit”. The number only makes sense for comparison inside one list of one rival. That is why the formulas are tuned (4, 50, 6, 60), not derived. The personality weights are whole numbers “in percent”, as the canon’s table says, so that there are no fractions.
It is greedy. The rival looks only at the next action and does not plan: it does not save up for a colony and does not know that in three turns it will be weaker. That is the price of simplicity. More serious systems look ahead: goal planning (GOAP), search trees or Monte Carlo (MCTS, as in Go). But in a turn-based strategy a greedy choice with good formulas is usually clever enough to be fun. In the battles of Arcanum it is the same technique: Enemies that think.
Scores are tuned, not written. Where do the numbers 80, 90, 170 come from? A first guess, then balance by simulation: AI-against-AI games that count which personality wins how often. That tool is in the last lesson.
The rival does not cheat, quite the opposite. It pays the same prices and calls the same functions as you, but in a game with a human it produces only 80% of normal (RIVAL_PACE, the easy level); in the simulation it is 100%. The line is in empireIncome and it is out in the open.
Equal scores are not left to luck. The strict > and the fixed order of generation make the choice the same in the four languages and on every repetition: for each planet mine, lab, shipyard, fortress, then the colonies, and last the ships. That is why the order is part of the rules, not a detail.
Exercise
The fortress
Solve the task in your editor. Start from the program for the three personalities.
Fortress. Add a new action fort: an orbital fortress on a planet. It costs 25 metal (FORT_COST) and is scored by the danger: 40 + min(160, danger) + 30 if the planet is the capital (the first in the list), otherwise without the +30. The danger is a new value threat of the empire and is 120 in this task. The action is eligible only if the danger is positive and there are 25 metal. The order in the list is: for each planet mine, lab, fortress.
The expected output (add fort to label too):
Warlord mine Kora I 90 lab Kora I 72 fort Kora I 190 mine Kora II 33 lab Kora II 72 fort Kora II 160 colony Vesta I 115 colony Vesta II 89 ships Kora I 162 → fort Kora I (190)Builder mine Kora I 140 lab Kora I 96 fort Kora I 190 mine Kora II 52 lab Kora II 96 fort Kora II 160 colony Vesta I 230 colony Vesta II 179 ships Kora I 93 → colony Vesta I (230)Scholar mine Kora I 100 lab Kora I 132 fort Kora I 190 mine Kora II 37 lab Kora II 132 fort Kora II 160 colony Vesta I 100 colony Vesta II 78 ships Kora I 99 → fort Kora I (190)What changes in the choices? With danger 120 the capital’s fortress scores 190, and it beats the warlord’s ships (162) and the scholar’s lab (132). The builder still colonizes: 230 is more than 190. Danger is not a personality: the fortress has the same score for everyone, so it is not in the table of weights.
More tasks
- Build the fortress. Add building a fortress to
applyand run a whole turn of the warlord with danger 120: how many actions does it take before the money runs out? - A new personality. Add a fourth row (
trader: expand 120, build 120, attack 60, research 70). What does it choose in the starting situation? Does anything change in the code besides the table? - The cap. The fleet’s attack uses
min(ratio, 300). What happens without the cap if the enemy has power 0 (the ratio is 4000)? How does the choice of the warlord and the builder change?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Exceptions · Throwing and your own exceptions · Lists · record, switch and patterns
- Tasks on this topic: Epic Cosmos: the empire