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:
@dataclass(frozen=True)class Option: kind: str target: str utility: int
class Planet: def __init__(self, name, rich, mines, yard): self.name = name self.rich = rich self.mines = mines self.lab = False self.yard = yard
class Empire: def __init__(self): self.metal = 100 self.power = 10 self.turn = 5 self.owned = [] self.free = []MAX_MINE = 3LAB_COST = 40SHIP_COST = 8SHIP_POWER = 5
def new_empire(): empire = Empire() empire.owned.append(Planet("Kora I", 4, 0, True)) empire.owned.append(Planet("Kora II", 3, 1, False)) empire.free.append(Planet("Vesta I", 3, 0, False)) empire.free.append(Planet("Vesta II", 1, 0, False)) return empireWhat 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.
def options(empire, p): # Every action the empire can pay for gets a score. result = [] labs = 0 for planet in empire.owned: if planet.lab: labs += 1 for planet in empire.owned: if planet.mines < MAX_MINE and empire.metal >= mine_cost(planet): result.append(Option("mine", planet.name, p.build * planet.rich // (4 * (planet.mines + 1)))) if not planet.lab and empire.metal >= LAB_COST: result.append(Option("lab", planet.name, p.research * 6 // 5 - 40 * labs)) cost = colony_cost(empire) if empire.metal >= cost: for planet in empire.free: value = planet.rich + 6 result.append(Option("colony", planet.name, p.expand * value * 50 // (cost * 6))) want = wanted_power(empire, p) for planet in empire.owned: if planet.yard and empire.power < want and ship_count(empire) > 0: urge = min(100, (want - empire.power) // 2) result.append(Option("ships", planet.name, 60 + p.attack // 2 + urge)) break return resultThat 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 @dataclass(frozen=True): three fields and nothing more, while Planet and Empire are ordinary classes. Integer division is // and cuts off the remainder: all the numbers are positive, so there are no surprises with negatives.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).
def best(candidates, floor): # Only a strictly higher score wins, so on a tie the first one stays. pick = None best_u = floor for option in candidates: if option.utility > best_u: pick = option best_u = option.utility return pick
def label(option): if option.kind == "mine": return "mine " + option.target if option.kind == "lab": return "lab " + option.target if option.kind == "colony": return "colony " + option.target return "ships " + option.targetThe 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 p in PERSONALITIES: options_list = options(new_empire(), p) print(p.name) for option in options_list: print(" " + pad(label(option), 21) + pad_left(str(option.utility), 4)) pick = best(options_list, 0) print(" → " + label(pick) + " (" + str(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.
def options(empire, p): # Every action the empire can pay for gets a score. result = [] labs = 0 for planet in empire.owned: if planet.lab: labs += 1 for planet in empire.owned: if planet.mines < MAX_MINE and empire.metal >= mine_cost(planet): result.append(Option("mine", planet.name, p.build * planet.rich // (4 * (planet.mines + 1)))) if not planet.lab and empire.metal >= LAB_COST: result.append(Option("lab", planet.name, p.research * 6 // 5 - 40 * labs)) cost = colony_cost(empire) if empire.metal >= cost: for planet in empire.free: value = planet.rich + 6 result.append(Option("colony", planet.name, p.expand * value * 50 // (cost * 6))) want = wanted_power(empire, p) for planet in empire.owned: if planet.yard and empire.power < want and ship_count(empire) > 0: urge = min(100, (want - empire.power) // 2) result.append(Option("ships", planet.name, 60 + p.attack // 2 + urge)) break return result
def best(candidates, floor): # Only a strictly higher score wins, so on a tie the first one stays. pick = None best_u = floor for option in candidates: if option.utility > best_u: pick = option best_u = option.utility return pickStep 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.
def find(planets, name): for planet in planets: if planet.name == name: return planet return None
def apply(empire, option): if option.kind == "mine": planet = find(empire.owned, option.target) empire.metal -= mine_cost(planet) planet.mines += 1 elif option.kind == "lab": empire.metal -= LAB_COST find(empire.owned, option.target).lab = True elif option.kind == "colony": planet = find(empire.free, option.target) empire.metal -= colony_cost(empire) empire.free.remove(planet) empire.owned.append(planet) else: count = ship_count(empire) empire.metal -= count * SHIP_COST empire.power += count * SHIP_POWER
def end_turn(empire): income = 4 for planet in empire.owned: income += planet.rich * (2 + planet.mines) // 2 empire.metal += income empire.turn += 1
def play_turn(empire, p, log): # Pay, score again, and repeat while there is something to do (at most 6 times). for step in range(1, 7): option = best(options(empire, p), 0) if option is None: if log: print(" nothing more to do: metal " + str(empire.metal)) return before = empire.metal apply(empire, option) if log: print(" " + str(step) + ". " + label(option) + " (" + str(option.utility) + "): metal " + str(before) + " → " + str(empire.metal))
def summarize(p): empire = new_empire() for _ in range(6): play_turn(empire, p, False) end_turn(empire) mines = 0 labs = 0 for planet in empire.owned: mines += planet.mines if planet.lab: labs += 1 print(p.name + ": planets " + str(len(empire.owned)) + ", mines " + str(mines) + ", labs " + str(labs) + ", power " + str(empire.power) + ", metal " + str(empire.metal))Here is one turn of the builder, action by action, and then what the three personalities have achieved in six turns:
builder = PERSONALITIES[1]empire = new_empire()print(builder.name + ", turn " + str(empire.turn) + ":")play_turn(empire, builder, True)print()print("After 6 turns:")for p in 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.
FLEET_POWER = 40STAY = 20SPOTS = [ Spot("Brisa", 1, 12, 0, 0), Spot("Altan", 5, 0, 12, 10), Spot("Cygra", 3, 0, 18, 60),]
def target_options(power, p): result = [] for spot in SPOTS: steps = spot.distance + 1 if spot.free_value > 0 and spot.enemy_power == 0 and spot.distance <= 5: result.append(Option("settle", spot.name, p.expand * spot.free_value // (6 * steps))) ratio = power * 100 * 100 // (spot.enemy_power * 100 + 100) if spot.enemy_value > 0 and ratio >= 130 and spot.distance <= 6: sure = min(ratio, 300) result.append(Option("attack", spot.name, p.attack * spot.enemy_value * sure // (4 * steps * 150))) return resultThe 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 p in PERSONALITIES: options_list = target_options(FLEET_POWER, p) print(p.name) for option in options_list: print(" " + pad(label(option), 18) + pad_left(str(option.utility), 4)) pick = best(options_list, STAY) print(" → " + ("stay" if pick is None else label(pick) + " (" + str(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: the best by utility, or None.def ai_best_action(world, empire): p = empire.personality best = None best_u = 0
def consider(u, action): nonlocal best, best_u if u > best_u: best_u = u best = action
mine = owned_planets(world, empire.id) labs = 0 yards = 0 for planet in mine: labs += planet.lab yards += planet.yard mods = tech_mods(empire) for planet in mine: if planet.mines < MAX_MINE and can_pay(empire, build_cost('mine', planet)): consider((p.build * rich_sum(planet)) // (4 * (planet.mines + 1)), SimpleNamespace(kind='mine', planet_id=planet.id)) if planet.lab == 0 and can_pay(empire, build_cost('lab', planet)): consider((p.research * 6) // 5 - 40 * labs, SimpleNamespace(kind='lab', planet_id=planet.id)) if planet.yard == 0 and can_pay(empire, build_cost('yard', planet)): consider(250 if yards == 0 else (60 if len(mine) >= 5 and yards < 2 else 0), SimpleNamespace(kind='yard', planet_id=planet.id)) if mods['fort'] > 0 and planet.fort < MAX_FORT and can_pay(empire, build_cost('fort', planet)): threat = enemy_power_at(world, empire, planet.system_id) for nxt in world.systems[planet.system_id].links: threat += enemy_power_at(world, empire, nxt) if threat > 0: consider(40 + min_of(160, threat) + (30 if planet.id == empire.capital else 0), SimpleNamespace(kind='fort', planet_id=planet.id)) cost = colony_cost(world, empire) if can_pay(empire, cost): for planet in world.planets: if planet.owner >= 0 or not has_presence(world, empire.id, planet.system_id) or hostile_in(world, empire.id, planet.system_id): continue consider((p.expand * (rich_sum(planet) + 6) * 50) // (cost[0] * 6), SimpleNamespace(kind='col', planet_id=planet.id)) # Ships: while the strength is below the one wanted for this turn. want = ((20 + 3 * world.turn) * (140 + p.attack)) // 240 for other in world.empires: if other.alive and other.id != empire.id and empire.war[other.id] == 1: want = (want * 3) // 2 have = empire_power(world, empire.id) + queued_power(world, empire.id) if have < want: for planet in mine: if planet.yard == 0: continue kind = counter_kind(world, empire) if ship_allowed(empire, 3) and can_pay(empire, SHIPS[3].cost) and have >= 30: kind = 3 count = 4 while count > 0 and not can_pay(empire, scale_cost(SHIPS[kind].cost, count)): count -= 1 if count > 0: consider(60 + p.attack // 2 + min_of(100, (want - have) // 2), SimpleNamespace(kind='ship', planet_id=planet.id, ship_kind=kind, count=count)) break return bestAnd 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).
def ai_economy(world, empire): for step in range(6): action = ai_best_action(world, empire) if action is None: return planet = world.planets[action.planet_id] error = None if action.kind == 'col': error = colonize(world, empire, planet) elif action.kind == 'ship': error = queue_ships(world, empire, planet, action.ship_kind, action.count) else: error = build_structure(world, empire, action.kind, planet) if error is not None: return
def ai_play(world, empire): # The artificial intelligence plans again every turn. empire.orders = [] ai_research(world, empire) ai_economy(world, empire) ai_fleets(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:
def ai_fleets(world, empire): p = empire.personality snapshot = [] for fleet in world.fleets: if fleet.owner == empire.id: snapshot.append(fleet) for fleet in snapshot: busy = ordered_ships(empire, fleet.system_id) free = [0, 0, 0, 0] for k in range(SHIP_KIND_COUNT): free[k] = fleet.ships[k] - busy[k] my_power = counts_power(free) if my_power == 0: continue here = fleet.system_id dist = bfs(world.systems, here).dist # Holding: if we are home under threat or besieging an enemy. best_u = 20 target = -1 split = False siege = False for system in world.systems: foe = enemy_power_at(world, empire, system.id) free_value = 0 enemy_value = 0 own_here = False for pid in system.planets: planet = world.planets[pid] if planet.owner < 0: free_value += rich_sum(planet) + 6 elif planet.owner == empire.id: own_here = True elif empire.war[planet.owner] == 1: enemy_value += rich_sum(planet) + 10 d = dist[system.id] if d < 0: continue ratio = percent(my_power * 100, foe * 100 + 100) if system.id == here: if enemy_value > 0 and ratio >= 80: best_u = 250 siege = True elif own_here and foe > 0 and ratio >= 60: best_u = 200 siege = True continue if free_value > 0 and foe == 0 and d <= 5: u = (p.expand * free_value) // (6 * (d + 1)) if u > best_u: best_u = u target = system.id split = total_of(free) >= 6 siege = False if enemy_value > 0 and ratio >= 130 and d <= 6: u = (p.attack * enemy_value * min_of(ratio, 300)) // (4 * (d + 1) * 150) if u > best_u: best_u = u target = system.id split = False siege = False if own_here and foe > 0 and d <= 4 and ratio >= 60: u = 150 - 10 * d if u > best_u: best_u = u target = system.id split = False siege = False if target < 0 or siege: continue step = next_step(world.systems, here, target) if step < 0: continue order_move(world, empire, here, target, half_of(free) if split else None)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.)
options_list = [ Option("mine", "Kora I", 90), Option("lab", "Kora I", 132), Option("lab", "Kora II", 132), Option("colony", "Vesta I", 100),]pick = best(options_list, 0)print(label(pick) + " (" + str(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?
def best(candidates, floor):
# Only a strictly higher score wins, so on a tie the first one stays.
pick = None
best_u =
for option in candidates:
if option.utility > best_u:
pick = option
best_u = option.utility
return pickIt 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 p in PERSONALITIES: options_list = options(new_empire(), p) print(p.name) for option in options_list: print(" " + pad(label(option), 21) + pad_left(str(option.utility), 4)) pick = best(options_list, 0) print(" → " + label(pick) + " (" + str(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: append, remove and sort · dataclass, match and patterns
- Tasks on this topic: Epic Cosmos: the empire