CivBridge is a .NET 8 console program that runs one OpenCiv3 game headlessly (no Godot, no
Civilization III files) and is driven over JSON lines. The Python env (agentenv_openciv3) is its
only client: it renders the facts the bridge returns into text for the agent.
- One request per line on stdin, one response per line on stdout, in order. Nothing else is ever written to stdout; engine and bridge logs go to stderr.
- On start the bridge writes
{"id": 0, "ok": true, "result": {"ready": true, "version": "<v>"}}. - Request:
{"id": 7, "cmd": "state", "args": {...}}.argsmay be omitted. - Success:
{"id": 7, "ok": true, "result": {...}}. - Failure:
{"id": 7, "ok": false, "error": {"code": "cannot_found", "message": "...", "alternatives": [...], "suggest": "..."}}.messageis a complete sentence a player can act on.alternatives(optional) lists valid choices (order names, items, techs, or site objects).suggest(optional) is one concrete next call, written as the Python tool call, e.g.unit_order(unit="u3", order="settle", x=17, y=7). - Command line:
CivBridge [--lua-dir <dir>]. The default Lua directory is<bridge dir>/Lua. - One game per process.
new_gamesucceeds once; a second call fails withalready_started. The client restarts the process to start another game.
- Ids. The human player's units and cities get short ids,
u1,u2, … andc1,c2, …, assigned in order of first sight and never reused within a game. Foreign units and cities are not addressable. - Coordinates are the engine's own: integers
x, ywithx + yeven (Civ3 diamond grid). North is(x, y-2), east is(x+2, y), northeast is(x+1, y-1). Wherever a position is reported relative to something, the bridge adds"dist"(tiles,Tile.DistanceTo) and"dir"(one ofN NE E SE S SW W NW, orhere). - Turn limit.
new_gametakesturn_limit. Whenturn >= turn_limitthe game is over:game_overbecomes true andend_turn/unit_orderfail withgame_over. - Victory. After every turn the bridge checks Civ III's victories, for every civilization, an agent's or the
AI's (the engine has none of its own): conquest, when it is the last civilization left (with several seats, also
when its seat is the last one an agent still plays); domination, when it holds two thirds of the world's land
tiles and two thirds of its population; culture, when one of its cities has 20,000 culture points, or it has
100,000 and at least twice as many as any other civilization (Civ III's default thresholds, the BIQ's
OneCityCultureWin and AllCitiesCultureWin, which the ruleset leaves out; a civ's culture is its cities' culture,
as the engine's history counts it, so a city lost takes its culture with it, and a city taken starts at 0 for
its new owner); and, at the turn limit, score, when it has the highest score (a tie on top is no one's victory).
The checks run in that order, so the first one met wins; among several cities over 20,000 the one with the most
culture (the oldest on a tie) decides. A game whose last seat still playing is defeated in its own turn (its last
units disbanded or lost) is checked at once, since no turn ends after that. The game is then over: every seat gets a
victoryevent,game_overturns true, andstate,scoreand the world snapshot carry"victory": {"kind": "conquest"|"domination"|"culture"|"score", "civ", "label", "turn"}(labelis the seat's, null for an AI civ; null until someone wins).autoplayplays on after a victory, as after a defeat, so baselines cover every turn they were asked for. - Score =
10·cities + 3·pop + 1·tiles + 4·techs, wheretilescounts owned tiles that count for score andtechscounts known techs (including starting techs). Every score object carries the components:{"total", "cities", "pop", "tiles", "techs"}. - Observation commands never draw from the engine RNG.
Args (all optional except seed):
| Arg | Default | Values |
|---|---|---|
seed |
required | int ≥ 0 |
civ |
"Rome" |
any playable civ name |
opponents |
3 |
1 – 11 |
size |
"Tiny" |
Tiny, Small, Standard, Large, Huge |
difficulty |
"Regent" |
Chieftain … Sid |
barbarians |
"Sedentary" |
None, Sedentary, Roaming, Restless, Raging |
landform |
"Pangaea" |
Pangaea, Continents, Archipelago |
ocean |
70 |
60, 70, 80 |
turn_limit |
60 |
int ≥ 1 |
seats |
[] |
more civs played by agents, each taking an opponent slot (see Seats) |
labels |
none | {civ: label}, a name per seat for recordings and reports, e.g. the agent's model |
Result: {"turn", "turn_limit", "seed", "civ", "opponents": [civ names], "seats": [{"civ", "label"}], "map": {"width", "height", "wrap_x"}}.
The human player's full situation. Result:
{
"turn": 12, "turn_limit": 60, "game_over": false, "defeated": false, "victory": null, "date": "3400 BC",
"race": {"civs_left": 5, "rank": 2, "domination": 0.667, "culture_goal": 100000, "city_culture_goal": 20000,
"you": {"civ": "Rome", "you": true, "score": 61, "land": 0.04, "pop": 0.05, "culture": 24},
"leader": {"civ": "Greece", "you": false, "score": 66, "land": 0.05, "pop": 0.06, "culture": 40},
"nearest_domination": {"civ": null, "you": false, "score": 58, "land": 0.06, "pop": 0.05, "culture": 31},
"nearest_culture": {"civ": "Greece", "you": false, "score": 66, "land": 0.05, "pop": 0.06, "culture": 40},
"culture_runner_up": {"civ": null, "you": false, "score": 58, "land": 0.06, "pop": 0.05, "culture": 31},
"best_city": {"civ": "Greece", "you": false, "name": "Athens", "culture": 22}},
"civ": "Rome", "era": 0, "government": "Despotism", "anarchy_until": null, "tile_penalty": true,
"governments": [{"name": "Monarchy", "corruption": "problematic", "hurry": "gold", "tile_penalty": false,
"trade_bonus": false, "unit_cost": 1, "free_units_per_city": 3}],
"revolution_target": null, "gold": 34, "gold_per_turn": 3,
"finance": {"income": {"cities": 12, "taxmen": 0, "other_civs": 0, "interest": 0, "total": 12},
"expenses": {"science": 7, "entertainment": 0, "corruption": 1, "maintenance": 1, "unit_costs": 0,
"other_civs": 0, "total": 9}},
"rates": {"tax": 4, "science": 6, "luxury": 0},
"research": {"current": "Bronze Working", "turns_left": 3, "beakers": 8, "cost": 20, "queue": ["Bronze Working"]},
"known_techs": ["Alphabet", "Pottery"],
"score": {"total": 61, "cities": 2, "pop": 5, "tiles": 21, "techs": 3},
"explored_pct": 18.5,
"cities": [{
"id": "c1", "name": "Rome", "x": 12, "y": 10, "size": 3, "capital": true,
"food_stored": 4, "food_needed": 20, "food_per_turn": 2, "turns_to_grow": 8,
"shields_per_turn": 3, "producing": "Settler", "production_stored": 12, "production_cost": 30,
"turns_to_complete": 6, "disorder": false, "buildings": ["Palace"], "queue": ["Warrior", "Granary"],
"food_eaten": 6, "commerce": {"total": 5, "taxes": 2, "science": 3, "luxury": 0, "corrupt": 0, "wealth": 0,
"from_buildings": 0},
"shields": {"total": 3, "useful": 3, "corrupt": 0, "from_buildings": 0},
"bonus": {"science": 0, "tax": 0, "luxury": 0, "shields": 0}, "maintenance": 0
}],
"units": [{
"id": "u3", "type": "Settler", "x": 14, "y": 10, "moves_left": 1.0, "moves_max": 1,
"hp": 3, "hp_max": 3, "status": "idle", "target": null,
"can_found_city": {"ok": false, "reason": "adjacent to Rome (12,10); cities need one empty tile between them"},
"orders": ["settle", "found_city", "goto", "fortify", "hold", "disband"],
"needs_orders": true
}],
"rivals": [{"civ": "Greece", "met": true, "at_war": false, "peace_price": null, "cities_seen": 1}],
"blockers": [{"kind": "idle_unit", "id": "u3", "message": "u3 Settler has moves and no orders"}],
"last_events": [{"turn": 11, "kind": "city_grew", "text": "Rome grew to size 3"}]
}statusis one ofidle,fortified,aboard(carried by a ship, seeboard),exploring,auto_work,goto,settle,working:<job>(e.g.working:build_road),done(no moves left this turn).- A unit aboard a ship has
aboard, the ship's id. A ship that can carry units hascapacity(Galley 2, Caravel 3, Galleon 4) andcargo, the ids of the units aboard. Neither key is there otherwise. targetis{"x", "y", "dist", "dir"}forgoto/settle, else null.orderslists the ordersunit_orderwould accept for this unit right now. A unit with moves next to an enemy also hasattack_targets(seeunit_order).- A unit in one of the seat's cities whose line has a later unit the city can build has
"upgrade": {"to": "Longbowman", "gold": 60, "ok": true}, also when it cannot upgrade now: thenokis false andreasonsays why (no moves left, not enough gold). The later unit is not always stronger: the Chasqui Scout's line ends in the Explorer (0/0), the Samurai's in the Cavalry (defence 3).okis true exactly whenordershasupgrade, andgoldis what the order charges. erais the civ's era (Player.EraIndex()): 0 Ancient Times, 1 Middle Ages, 2 Industrial Age, 3 Modern Era. The client picks its advisors' heads and the science advisor's background by it.governmentslists the governments a revolution can change to now, as inrevolution's result;tile_penaltyis true when the current government takes 1 from any tile yield above 2 (Despotism);revolution_targetis the one a revolution under way ends in.rivals[].peace_priceis the gold that civ asks for peace while at war (null at peace, or while it refuses to talk).rivals[].trade_offeredis the trade that civ offers the seat while it stands and can still be made (as indiplomacy), else null.needs_ordersis true when the unit can move, is not under a standing order, is not fortified and is not aboard a ship.blockerslists what stopsend_turn:no_research(has a city, nothing being researched),no_production(a city producing nothing),idle_unit(one per unit withneeds_orders). Adisorderblocker (a city in civil disorder, or that riots when the turn ends) also hasnow(in disorder already),unhappyandhappy(its citizens, by the engine's riot rule) andluxury, the lowest luxury rate (tenths) that calms it, or null when raising luxury cannot.dateis the turn's year as the client's turn box shows it (TimeOptions.GetRawNumber):"4000 BC","AD 1250"; null for a ruleset that counts months or weeks.raceis how each victory stands:civs_left(undefeated civilizations), the seat'srankby score (1 + the civs with a higher score), andyou, the scoreleader, the civnearest_domination(the highest of its land and population shares' minimum), the civ with the most culture (nearest_culture) and the next one (culture_runner_up, null with one civ left), each with its score, shares andculture(its cities' culture points); a civ the seat has not met hascivnull.best_cityis the city with the most culture for its owner ({"civ", "you", "name", "culture"},civandnamenull for an unmet civ's), andculture_goalandcity_culture_goalare the cultural victory's thresholds (100,000 with twice the next civ's, and 20,000).rankandyouare null once the seat is defeated;raceis null when every civ is.last_eventsare the events produced by the most recentend_turn(empty before the first).financeis the domestic advisor's income and expenses,Player.AggregateFlows()as the client'sDomesticAdvisor.ShowAdvisorshows them. Income:cities"From cities" (CityInflows(): the cities' commerce, corrupt and science and luxury included, less the tax collectors' share, plus Wealth's),taxmen"From taxmen",other_civs"From other civs" (gold-per-turn deals),interest"From interest";total"Income" (Inflows()). Expenses:science,entertainment(the luxury spending),corruption,maintenance(buildings),unit_costs(support beyond the free units),other_civs"To other civs";total"Expenses" (Outflows()). Science, entertainment and corruption are on both sides, as the engine counts them, soincome.total - expenses.totalisgold_per_turn(CalculateGoldPerTurn()isAggregateFlows().Netflows()). All zero without cities.- Each city also has the domestic advisor's row and the city screen's lines:
food_eaten(City.FoodConsumedPerTurn(), two per citizen;food_per_turnis what is left);commerce, fromCity.CurrentCommerceYield(), itstaxes,science(beakers),luxury,corruptandwealth(what building Wealth adds), withtotaltheir sum: the city's tiles' commerce, corrupt part included, plus what its specialists add (taxes counts the tax collectors'), plusfrom_buildings, what its buildings' percentages added to taxes, science and luxury (already in them). The cities'totals add up tofinance.income.cities + taxmen, their science, luxury and corrupt to the expenses' science, entertainment and corruption.shieldsisCity.CurrentProductionYield():useful(=shields_per_turn),corrupt(waste; everything in disorder or anarchy),from_buildings(what its buildings added touseful, already in it) andtotal(useful + corrupt), the city screen's "PRODUCTION: n per turn".bonusis the sum of the percentages the city's buildings (wonder-granted ones included) add, by patch 0016'sCity.Boost:science(Library, University and Research Lab 50 each, Copernicus' Observatory and Newton's University 100),taxandluxury(Marketplace, Bank and Stock Exchange 50 each) andshields(Factory and Manufacturing Plant 25 each). They apply after corruption and the rates, to the specialists' output too, rounded down; Wealth is not raised.maintenanceisCity.MaintenanceCosts(), its buildings' upkeep in gold (the client's column reads 0, a TODO there); they add up tofinance.expenses.maintenance.
Args: x, y (center, required), radius (default 3, max 8). Only tiles the player has
explored are returned. Result:
{"center": {"x": 12, "y": 10}, "radius": 3, "tiles": [{
"x": 13, "y": 9, "dist": 1, "dir": "NE", "visible": true,
"terrain": "Grassland", "overlay": null, "resource": "Wheat", "river": true,
"improvements": ["road"], "owner": "Rome",
"city": {"name": "Rome", "owner": "Rome", "size": 3, "id": "c1"},
"units": [{"owner": "Rome", "type": "Warrior", "count": 1, "id": "u2"}],
"yield": {"food": 3, "shields": 1, "commerce": 1},
"city_site": {"ok": false, "reason": "adjacent to Rome"}
}]}overlay is the overlay terrain (e.g. Forest, Hills, Jungle) or null. units lists only
units on visible tiles; id is present for the player's own units, and aboard (the ship's id) for those of them
aboard a ship. resource is null unless the player knows about it.
Args: none. Everything the seat knows of the world, for drawing it (the play page polls it after every action, so it is one cheap pass over the map: no yields or city-site checks). Result:
{"turn": 12, "width": 60, "height": 60, "wrap_x": true,
"players": [{"index": 0, "civ": "Barbarians", "barbarian": true, "me": false, "color": "#f0f8ff"},
{"index": 1, "civ": "Rome", "barbarian": false, "me": true, "color": "#e6194b"}],
"tiles": [[13, 9, "grassland", null, 6, 1, 1, "Wheat", ["road"], 1]],
"cities": [{"x": 12, "y": 10, "name": "Rome", "owner": 1, "size": 3, "capital": true,
"era": 0, "walls": false, "disorder": false,
"id": "c1", "producing": "Settler", "turns_to_complete": 6, "turns_to_grow": 4, "starving": false}],
"units": [{"x": 12, "y": 10, "owner": 1, "type": "Warrior", "count": 1, "id": "u2",
"hp": 3, "hp_max": 3, "fortified": true, "combat": true},
{"x": 14, "y": 10, "owner": 0, "type": "Horseman", "count": 2,
"hp": 2, "hp_max": 3, "fortified": false, "combat": true}],
"battles": [{"id": 7, "seq": 412, "turn": 12, "kind": "attack",
"attacker": {"owner": 0, "type": "Horseman", "x": 14, "y": 10, "id": null,
"hp_before": 2, "hp_after": 0, "hp_max": 2},
"defender": {"owner": 1, "type": "Warrior", "x": 12, "y": 10, "id": "u2",
"hp_before": 3, "hp_after": 2, "hp_max": 3},
"rounds": ["a", "d", "d"], "winner": "defender",
"city": {"x": 12, "y": 10, "name": "Rome"}, "captured": false, "razed": false}],
"moves": [{"seq": 409, "turn": 12, "owner": 0, "type": "Horseman", "id": null,
"path": [[18, 10], [17, 11], [16, 10]]},
{"seq": 413, "turn": 12, "owner": 1, "type": "Warrior", "id": "u3", "path": [[12, 12], [12, 10]]}]}-
tileshas one row per tile the seat knows, and none for the rest:[x, y, terrain, overlay, river, owner, visible, resource, improvements, bonus].terrainandoverlayare the engine's lower-case keys, as in the world snapshot (overlaynull when it is the base terrain);visibleis 0 or 1;owneris the index of the player whose borders hold the tile, or -1;resourceis null unless the seat knows about it (as inmap);improvementsare as inmap(road,mine, ...), plusbarbarian_campon a tile with a camp (the engine keeps camps as a tile flag, not an improvement). -
riveris a bitmask of the tile's river edges, 0 when no river touches it (so it reads as the old 0/1 flag):NE=1, SE=2, SW=4, NW=8, the four edges rivers run along, thenN=16, E=32, S=64, W=128, the corner flags (only Civ III maps set them; the map generator never does). These are the engine'sTile.river*flags, which the OpenCiv3 client'sRiverLayerreads to pick a river sprite (C7/MapView.cs). An edge is flagged on both of its tiles: a tile's NE bit is its NE neighbour's SW bit, SE pairs with NW, N with S, E with W. The client draws one sprite per vertex, on each known tile's east corner: with N, E, S the tile's NE, E and SE neighbours, its 4x4 cell inmtnRivers.pngis(N&4 || W&1 ? 1 : 0) + (E&8 || N&2 ? 2 : 0) + (W&2 || S&8 ? 4 : 0) + (S&1 || E&4 ? 8 : 0)(nothing for 0). -
bonusis 1 on bonus grassland where the client draws its shield marker (C7/Map/TntLayer.cs) and the tile yields the extra shield: the engine'sisBonusShieldon a grassland overlay (a forest hides it until cleared). -
playerslists every player, barbarians included;ownereverywhere is an index into it, the same index as the world snapshot's, andmemarks the seat.coloris the player's colour in the OpenCiv3 client, as#rrggbb: the client's pick of the civ's primary or secondary colour index, so that players differ where they can (C7/Textures/PlayerTextureUtil.cs), from its standalone colour table (C7/Lua/standalone/textures.lua). It is not the world snapshot'scolor. -
citiesare the cities on known tiles. Each carries what the client draws a city from (C7/Map/CityScene.cs):era, its owner's era (0 Ancient, 1 Middle Ages, 2 Industrial, 3 Modern), the row of the city sprite;walls, whether it has a building that provides walls (the client draws the walled sprite only for towns, size 6 or less); anddisorder, civil disorder (the fire). The seat's own also carry itsid,producing,turns_to_completeandturns_to_grow, as instate, andstarving, true when its food per turn is negative (turns_to_growis null both then and when the city stagnates). -
unitsare on visible tiles only: the seat's own one per unit with itsidandcount1, everyone else's counted per tile, owner and type. Each carries what the client'sUnitLayerdraws beside a unit:hpandhp_max(the hit point bar),fortified(a white frame around the bar) andcombat(attack or defence above 0; the client draws no bar for a unit that cannot fight). A group's values are those of the unit the client would draw of it, its best defender (selectUnitToDisplay): the healthiest not aboard a transport, fortified first. -
battlesare the battles of this turn and the last that the seat saw, oldest first, so the play page can play them the way the client does (each round both units play ATTACK1 facing each other, then the loser DEATH). A battle shows if either unit is the seat's, or if the seat had the attacker's or the defender's tile in sight when it began; that includes battles fought in other players' turns (an AI's attack on the seat's units, AI against AI in sight) and the seat's own. A battle keeps its record andid(a count of the game's battles, from 1, never reused) while it shows; it goes once its turn is two turns old. Each:seq: its place in the order things happened: battles andmovestake their numbers from one count (each as it begins), which an autosave keeps, so a page plays both in the order they were made;turn: the game turn it was fought in (an AI's attacks after the turn advanced carry the new turn);kind:attack(MapUnit.Fight: a unit moving into an enemy's tile) orbombard(MapUnit.Bombardon a unit; a bombardment of a city, walls or an improvement fights no unit and is not listed);attacker,defender:owner(player index),type,x,y(where it stood when the rounds began),id(the seat's id of the unit when it is the seat's, else null; also null for a unit of the seat's killed before the seat ever had it in astate),hp_beforeandhp_after(hit points when the rounds began and ended) andhp_max. A defensive bombard before the rounds is already inhp_before, and a promotion after them (one more hit point) is not inhp_after;rounds: who won each round, in the order the engine drew them:"a"the attacker (the defender loses a hit point),"d"the defender (the attacker loses one). Forbombardeach round is a shot:"a"a hit,"d"a miss (the bombarder never loses hit points);winner:attacker(the defender died),defender(the attacker died; forbombard, the target lived) orretreat(the last round's loser withdrew instead of losing its last hit point: the defender, to the tile behind it, when that round is"a"; the attacker when it is"d"). So for an attack each side'shp_before - hp_afteris the number of rounds the other side won, less the retreat round;city:{"x", "y", "name"}of the city on the defender's tile, or null;captured: the winning attacker moved in and took the city (patch 0011);razed: it moved in and the city, of size 1, was destroyed (barbarians take gold instead and do neither).
The engine fights with animations off, so it sends no animation messages; patch 0010 has
MapUnit.FightandMapUnit.BombardUnitstell the bridge each round as they draw it. Recording a battle changes nothing in it. -
movesare the steps of this turn and the last that the seat saw, oldest first, so the play page can show units walking where they went instead of jumping there. A step shows if the unit is the seat's, or if the seat had the tile it left or the one it entered in sight; that includes other players' turns (AI and barbarian units in sight) and the seat's own moves, orders and standing orders alike. A unit's steps in one turn with nothing else in between (no other step or battle) make one entry, which stays the same while it is listed:seq: its first step's place in the order things happened, as a battle'sseq; its later steps follow on;turn,owner,type: as for a battle's sides;id: the seat's id of the unit, while it lives, when it is the seat's; else null;path:[x, y]where it stood, then each tile it stepped to, each a neighbour of the last. A move into a tile won in an attack is its own step after the battle; a retreat is the loser's step during it. Units aboard a ship move with it and have no steps of their own.
Patch 0012 has
MapUnit.Movetell the bridge of every step; that changes nothing in the game. An engine restored from an autosave has no steps from before, and its count goes on 100000 past the save's: numbers given out after the save, before the engine went down, may already have been played.
Args: unit (a Settler id; default: the first settler, else the capital), top (default 5).
Result: {"origin": {"x", "y"}, "sites": [{"x", "y", "score", "dist", "dir", "turns", "terrain", "river", "coastal", "yield": {"food", "shields", "commerce"}}], "note": "only sites on the unit's continent are scored"}. turns is the estimated travel time for the unit (null without a unit);
yield sums the 3x3 area around the site. For a unit aboard a ship, sites ranks the sites on every landmass
along the ship's body of water instead.
Args: unit (id), order, and x, y where the order needs a target.
| Order | Target | Effect |
|---|---|---|
settle |
x, y | Standing order: walk to (x,y), found a city on arrival. If already there and valid, founds now. |
found_city |
— | Found a city on the unit's tile now. Optional name. |
goto |
x, y | Standing order: walk to (x,y) over as many turns as needed. |
explore |
— | Standing order: auto-explore (scouts, warriors). |
auto_work |
— | Standing order: automated worker. |
fortify |
— | Fortify in place (until woken). |
wake |
— | Cancel fortify or any standing order. |
hold |
— | Skip this unit for this turn. |
disband |
— | Remove the unit. |
build_road, build_mine, irrigate, clear_forest |
— | Worker job on the current tile. |
attack |
x, y | Attack the adjacent tile: the top defender of a civ at war with you (barbarians always are), or move into an undefended enemy city, which is captured: it loses a citizen, its palace, small wonders and what it was building, and one of size 1 is destroyed (patch 0011). |
bombard |
x, y | Bombard a tile in range (bombard units): an enemy unit, city or improvement, at war. |
upgrade |
— | In one of your cities: the unit becomes the furthest unit along its civ's upgrade chain that the city can build now (tech known, a coastal city for ships, strategic resources connected), for max(1, shield difference) × 3 gold (patch 0019). It keeps its id, experience, hit points and fortification, has no moves left this turn, and any other standing order (goto, explore) ends. Refused (invalid_order, with the reason) outside your cities, when nothing in its line can be built there (naming the next unit and what it needs), with no moves left, or without the gold. |
board |
x, y optional | A land unit goes aboard one of your ships with room: on its own tile (in port; free), or on the adjacent water tile (x,y), which it steps onto at the cost of its moves. The ship then carries it, and it is aboard. |
unload |
— | In a city only: a ship's passengers go ashore (or, ordered to a passenger, that one). At sea a passenger lands with goto or settle to a land tile next to its ship, which takes its moves. |
Ships: a Galley (Map Making) carries 2 land units, a Caravel 3, a Galleon 4; a Curragh carries none. goto sails
a ship (it may enter your own cities), and its passengers go with it. A ship lost at sea, sunk or disbanded, takes
its passengers with it (patch 0020); in a city they go ashore. disband's message names them. The engine has no
coast-only rule for the Galley: it sails the open ocean safely.
attack_targets on a unit: [{"x", "y", "dir", "owner", "defender": "Spearman 3/3 hp"|null, "city": name|null, "win_chance": 0.62}]. The chance comes from the engine's own strengths: each combat round the attacker wins
with a/(a+d) and the loser loses a hit point; retreats and defensive bombard are left out. An attack reports the
chance, who died, the attacker's remaining hit points, and any city that fell.
Result: {"message": "<one line>", "unit": {<unit object as in state, or null if gone>}, "city": {<city object as in state>}|null, "path": {"length", "turns"}|null, "battle": {...}|null}. battle is
the battle an attack or bombard fought, as known_map lists it (null for any other order, an undefended
city entered, or a bombardment of no unit).
An upgrade reports "u5 Archer is now a Longbowman (60 gold; 40 left). It has no moves left this turn.".
Error codes: unknown_unit, invalid_order (alternatives = the unit's valid orders),
cannot_found (alternatives = top city sites; suggest = a settle call), bad_target (off map,
x+y odd, or not explored; for attack and bombard, alternatives = the unit's targets; for board, a tile
not next to the unit or land; a land unit's goto to a water tile with your ship on it suggests board), no_path
(a land unit's route across water says to board a ship), no_moves, no_transport (board: no ship of yours with
room there; suggest = boarding one nearby, if any), at_peace (attacking a civ you are at peace with; suggest =
declare war), game_over.
Standing orders are carried out at the start of each human turn (multi-turn goto/settle, explore,
auto_work, worker jobs). When one cannot make progress, end_turn reports an event (e.g.
settle_failed, goto_blocked, explore_done) and the unit becomes idle.
Args: city (id). Result: the city object from state plus "options": [{"name", "kind": "unit"|"building"|"wealth", "cost", "turns"}] (what it can produce now), "tiles_worked", and for the city
screen's map (the client's C7/Map/TileAssignmentLayer.cs):
-
A building option also has
"effects", what it does in short phrases from its engine fields, economic ones first:["+50% science (+2 here)", "+3 culture", "upkeep 1"]. A percentage says what it would add in this city now, at the current rates, tiles and corruption: the engine's yields with the building put in the city for the call ("+50% tax and luxury (+3 gold, +1 luxury here)","+25% shields (+3 here)"). A city in disorder makes nothing, so its figures are those it would have in order (the engine'srespectCivilDisorder: false), as the AI values them. The others:"interest on the treasury (5%, at most 50 gold a turn)","less corruption (4 commerce, 3 shields lost here)"(what the city loses now, in order),"a second centre against corruption","N unhappy made content","more happiness from luxury resources","grows past 6","grows past 12","keeps half its food on growth","+50% defence"(with"up to size 6"for walls),"+1 food on water tiles"(shield, commerce),"veteran land units","veteran sea units","a Granary in every city on the continent"(or"in every city"),"+3 culture","upkeep 1". -
"worked": [[x, y, food, shields, commerce], ...]: the city centre first, then each tile a citizen works, with the yields the client draws on it, the engine'sTile.FoodYield/ProductionYield/CommerceYield(city). They sum to the city's totals before corruption: food minus two per citizen isfood_per_turn, and shields areshields_per_turnplus what corruption (or disorder, or anarchy) takes, less what its buildings add (shields.from_buildings). -
"workable": [[x, y], ...]: the tiles in the city's radius it could work,City.GetWorkableTiles(inside the civ's borders, no city on them), around which the client draws its border; this includes tiles another of the civ's cities works, and not the centre.
And the rest of the client's city screen (C7/UIElements/CityScreen/CityScreen.cs):
"culture": {"per_turn": 1, "total": 7, "next_border": 10}(RenderCulture):City.GetCulturePerTurn()(the buildings' culture, the Palace's included),GetCulture()(gathered so far) and10^GetBorderExpansionLevel(), the culture at which the borders next grow; the client shows "1/turn" and "Total: 7/10"."strategic"and"luxuries":[{"name": "Horses", "icon": 0, "count": 1}],City.GetStrategicResourcesandGetLuxuriesin the engine's order: the resources on the civ's own tiles that the city's road network (the engine'sTradeNetwork, the city tile included) reaches, known to the civ, with how many such tiles.iconisResource.Icon, the resource's index inresources.png(the client draws the strategic ones large with the count under them, the luxuries small after "(count)")."citizens": one per resident,sizein all, in the order the client draws the heads (RenderPopHeads): the laborers happy, then content, then unhappy, then the specialists. A laborer is{"mood": "happy" | "content" | "unhappy", "works": "tile", "tile": [x, y]}(its tile, one oftiles_worked); a specialist is{"mood": null, "works": "specialist", "specialist": "Entertainer"}. Moods are the engine'sCity.RecalculateCitizenMoods, as the client runs it before drawing; the engine leaves a specialist's mood as it was, which thehappy/content/unhappycounts still include, so they can exceed the laborers'."specialists":[{"type": "Entertainer", "index": 1, "count": 1, "taxes": 0, "research": 0, "luxuries": 1, "corruption": 0, "construction": 0}], the residents that are specialists (CitizenType, notIsDefaultCitizen) by type, in the order they first appear, with what each one adds (CitizenType.Taxes,Research,Luxuries, and theCorruptionandConstructionthe client draws as icons and the engine does not use yet).indexisCitizenType.SpecialistIndex, which picks the head: row16 + index - 1ofpopHeads.png, column the era (the client'stextures/popheads.lua). The engine makes a new citizen a specialist when the city has no tile left for it, and an entertainer when working a tile would put the city into disorder (CityTileAssignmentAI).
Args: city, item, then (optional list of up to 10 names). Result: {"message", "city": {...}}. Errors:
unknown_city, unknown_item (alternatives = option names), no_cities, bad_args. A unit the city cannot build
says why: another civ's unique unit, the tech it needs, a city off the coast for a ship, the strategic resources not
connected, or that it is obsolete, with the unit that replaces it (the engine's UnitPrototype.CanProduce, which
leaves out a unit once a unit of its civ's upgrade chain can be built there: exactly when a unit of that type could
upgrade there, patch 0019).
thenis the city's queue (the city object'squeue): each time the city completes something, or the engine changes what it builds, the bridge sets the first queued item it can build now and takes it off the queue; an item it cannot build any more leaves the queue, and thebuiltevent says why ("Archer left the queue: it is obsolete now that Rome can build the Longbowman that replaces it", or a building's reason). Only when the queue is empty does the engine pick, and thechoose_productionblocker asks the agent to keep or change that pick.then: []clears the queue; leavingthenout keeps it. Queues are saved with the game.citymay name several cities: ids or names separated by commas,"all", or"pending"(the cities whose next item the engine picked and nobody has kept or changed, and those building nothing). The result is then{"message": "3 cities now build Warrior, then Granary; 1 could not.", "ok", "failed", "results": [{"city", "ok", "message", "code"?}], "cities": [...]}; when none of them can, the first city's error is raised.no_cities:"pending"names no city now.
Args: orders, 1 to 100 of {"unit", "order", "x"?, "y"?}, each as unit_order takes it, in order. unit is a
unit id or a group: "idle" (every unit waiting for orders), "idle:Worker" (those of a type) or "all:Warrior"
(every unit of a type). One that fails doesn't stop the rest. Result: {"message": "5 orders done, 1 failed.", "ok", "failed", "results": [{"unit", "ok", "message", "code"?}]}, one result per unit (a group gives one per unit it
names, or one no_units failure when it names none now); a failure's code is the error unit_order would give,
or unknown_unit for a type the civ has none of, bad_args for "all" without a type. Errors: bad_args (no
orders, or more than 100), game_over.
Result: {"current", "turns_left", "known": [...], "available": [{"name", "cost", "turns", "era", "unlocks": ["Settler", "Granary", ...]}]}.
Args: tech. Any tech is accepted; missing prerequisites are queued first. Result: {"message", "current", "queue": [...]}. Errors: unknown_tech (alternatives = available names), already_known.
Args: skip_idle (default false), until_attention (default false), max_turns (default 1, max 20).
- If
blockersis non-empty andskip_idleis false, nothing happens: result{"blocked": true, "blockers": [...]}. - Otherwise idle units hold, research is auto-picked if missing, and the turn advances (up to
max_turnswhileuntil_attentionis set and no blocker appears). Result:{"blocked": false, "turns_advanced", "turn", "game_over", "defeated", "events": [...], "auto": [{"kind": "research_picked"|"government_picked", "text"}]}.
Event kinds: city_founded, city_grew, city_starved, built (unit/building completed),
tech_learned, unit_lost, unit_promoted, settle_failed, goto_blocked, explore_done,
job_done, contact (met a civ), war_declared, threat (a foreign or barbarian unit within 3
tiles of a city or a settler), city_destroyed, city_captured (the seat took a city, or a civ it knows
took one in sight), city_lost (one of the seat's cities was taken), civ_destroyed, disorder, trade_offered
(an AI or another seat offers a trade; see accept_trade), trade_signed and trade_declined (another seat answered
the seat's trade offer), unit_upgraded (autoplay's engine_ai upgraded one of the seat's units in its city: "u5
Archer was upgraded to a Longbowman in Rome."). Each event is
{"turn", "kind", "text"}, plus "x", "y" when it has a location.
Args: turns (required), policy: null (end turns holding everything, research auto-picked),
found_capital (found the capital with the first settler on turn 1, automate workers, then like
null), engine_ai (OpenCiv3's own AI plays the human seat each turn), and record (default
false). Result: {"turn", "game_over", "defeated", "score": {...}, "trades_declined", "trajectory": [{"turn", "score": {...}}]} (trajectory only when record). Every policy declines the trades AIs offer the seat
(trades_declined counts them), as the env did before trading, so baselines do not depend on trades; with
engine_ai the engine's AI still trades with other AIs itself during the seat's turn, as it always did.
Result: {"turn", "human": {score}, "players": [{"civ", "is_human", "seat", "defeated", "score": {...}, "share": {"land", "pop"}, "culture"}], "human_share": {"land", "pop"}}: each player's fractions of the world's land tiles and
of its population (Civ III's domination victory needs two thirds of each), and its culture points, its cities' (the
cultural victory needs 100,000 and twice the next civ's). seat is the seat's label (or civ) for a civ an
agent plays, else null; is_human marks the seat the command plays. victory as in state.
Args: government (a name from state.governments). Starts anarchy (the engine's own transition: 2 to 6 turns,
longer for big empires, 2 for religious civs; no taxes and no science), after which the bridge sets the chosen
government at the start of the turn anarchy ends (event government_picked). Called during anarchy, it changes
the target. Result: {"message", "government": {"current", "anarchy_until", "revolution_target", "available": [{"name", "corruption", "hurry": "population"|"gold"|"none", "tile_penalty", "trade_bonus", "unit_cost", "free_units_per_city"}]}}. Errors: unknown_government (alternatives = the choices), same_government.
No args. Result: {"civs": [<civ>], "unmet": n}, where a civ (one you have met and that is alive) is {"civ", "agent" (another seat), "at_war", "talks", "refuses_talks_until", "peace_price", "peace_offered" and "you_offered" (a standing peace offer between seats: {"gold", "until_turn"}), "gold" (its treasury, as the client's deal screen shows it), "techs_for_you" and "techs_for_them", "trade_offered", "you_offered_trade", "score": {...}, "government", "military_vs_yours" (sum of each combat unit's best strength, theirs over yours), "at_war_with": [known civs]}.
techs_for_youare the techs the civ knows and you do not,techs_for_themthe reverse (null at war), each{"name", "you_value", "they_value"}and most valuable to the receiver first. A tech's value to a civ is the engine'sTradeOffer.GoldEquivalentFor: its research cost for that civ (GameData.TechCostFor: lower the more civs it knows have it, and less the beakers already spent when it is the one being researched).trade_offeredis the trade the civ offers you while it stands,you_offered_tradethe one you offered another seat:{"you_get": {"techs", "gold"}, "you_give": {"techs", "gold"}, "you_value_get", "you_value_give", "until_turn"}(the last turn it stands), else null. An offer that can no longer be made as it is (a side lacks the gold, or knows a tech it would get, after another trade or research) is not shown;accept_tradeon it fails withoffer_changedand the reason.
Args: civ. The engine's DeclareWarOn; the civ refuses to talk for 5 to 16 turns (longer after a sneak attack
from inside its borders). Result: {"message", "civ": <civ>}. Errors: unknown_civ (alternatives = the civs you
have met), already_at_war.
Args: civ, gold (default 0, paid to them). Peace is signed through the engine's deal path when gold is at
least the civ's Player.PeacePriceFor (patch 0009): 0 when it is losing or tired of the war, more when it is
winning, and never inside its refuse-contact window or a war younger than its minimum. Result: {"message", "civ": <civ>}. Errors: unknown_civ, not_at_war, no_talks, refused, price (suggest = the call at the
asked price, when you can pay), not_enough_gold.
An AI that wants peace offers it during its turn; the bridge cannot hold the AI's turn open, so it reports a
peace_offered event (which stops end_turn(until_attention)) and the agent accepts with propose_peace. Peace
between any two civs the human knows is reported as peace_signed.
Techs and gold change hands between the seat and a civ it has met and is at peace with, through the engine's own
deal path, as the client's deal screen does it (DealScreen.AttemptDeal): the AI's Player.WouldAcceptDealFrom
judges a trade and Player.ExecuteDeal makes it. An AI takes a trade when what it gets is worth at least what it
gives, both by its own values (its research costs, gold at face value); it has no memory of past trades and no
mood. Gold per turn, maps, luxuries and embassies are not traded (the engine has no tradeable form for them).
quote_tradeandpropose_tradetakeciv,give_techsandget_techs(lists of names; default none),give_goldandget_gold(default 0). Each tech given must be one you know and the civ does not (one of itstechs_for_them), each tech got the reverse; each side must have the gold it gives; a trade with an AI carries at least one tech, as the engine's own trades do. Tech prerequisites are not checked: a tech can be got without the ones it needs, as the engine's AIs trade among themselves and as the client's deal screen lists them. Errors:unknown_civ(alternatives = the civs you have met),not_at_peace,unknown_tech(alternatives = the techs that side can give),not_enough_gold,bad_args.quote_tradechanges nothing. Result:{"civ", "agent", "you_give", "you_get" (each{"techs", "gold"}), "you_value_give", "you_value_get", "they_value_give", "they_value_get", "accepts", "gold_to_balance" (the gold you would add for the AI to accept), "gold_they_would_add" (the most gold you could ask on top and still have it accepted), "suggest" (the balancedpropose_tradecall, when you can pay it: it asks for less of the gold you get first, then gives more gold)}. With another seataccepts,gold_to_balanceandgold_they_would_addare null: the other agent decides.propose_tradewith an AI makes the trade or fails withrefused(the AI's two values; suggest = the balanced call, when you can pay it). Result:{"message", "civ": <civ>, "gold", "research"},researchas instate. With another seat it only offers the trade (see Seats), with the same result.- An AI offers trades itself during its turn (
PlayerAI.AttemptTrading, on about a quarter of its turns). The AI's turn cannot wait for the agent, so the bridge keeps the offer for the seat until the end of its next turn (trade_offeredindiplomacyandstate.rivals, and atrade_offeredevent with what each side is worth to you) and lets the AI's turn go on. Anend_turnthat plays several turns (until_attention) holds every offer made on any of them until the end of the turn it stops on. Atrade_offeredevent is delivered only while its offer stands and can be made, and says the turn it stands until:"<civ> offers <goods> (worth N to you) for <goods> (worth M to you), until the end of turn T. Accept with diplomacy(action=\"accept_trade\", civ=\"<civ>\")."; one answered, replaced by a newer offer, lapsed or made impossible before it is delivered is dropped. The AI values its offers at least even for itself; by your own values they are often bad, so compareyou_value_getwithyou_value_give. A newer offer from the same AI replaces the older one. accept_trade {civ}makes the standing offer, checked again first: each side must still have what it gives and an AI must still accept it by its values (both drift a little in a turn). Result aspropose_trade's. Errors:no_offer(none stands: it lapsed, was answered, or a war ended it),offer_changed(with why it can no longer be made).decline_trade {civ}drops it (no_offerlikewise), also one not shown because it can no longer be made (its message then says why); letting it lapse does the same.- A tech got in a trade is known at once. If it was the one being researched, its beakers go with it and the research
moves on as when a tech is learned (the seat's queue goes on, else the engine picks and
choose_researchasks); any other research keeps its progress (patch 0017).declare_wardrops the trades standing between the two.
new_game with seats makes a game several agents play, one civ each. The first civ (civ) stays the engine's UI
controller; each seat civ takes an opponent slot and is a human player, so the engine gives it no AI turn and the
human's costs. Opponent slots beyond the seats are the engine's AI. Every command but new_game and load takes
seat (a seat's civ; default the first) and plays that seat: its ids (u1, c1, … per seat), standing orders,
events, decisions and plan state are its own. An unknown seat fails with unknown_seat.
- The turn.
end_turnmarks the seat ready; until every live seat is, it answers{"blocked": false, "turns_advanced": 0, "turn", "waiting_for": [civs]}and the game does not move. The seat that completes the set advances the game one turn (until_attentionandmax_turnsdo not apply) and gets{"turn", "seats": {civ: <end_turn result>}}, every seat's own events. The seats play the turn at the same time; standing orders run at the start of the next turn, seat by seat in seat order. A seat can keep giving orders after it ends the turn. - What another seat did. Attacks and bombards reach the target seat as
unit_lost,attackedorbombardedevents; war declarations aswar_declared(to every seat that knows both); contacts ascontactthe turn after, whoever's move made them. - Peace between seats has no price:
propose_peacefrom one seat stands until the end of the next turn and reaches the other as apeace_offeredevent; apropose_peacefrom the other meanwhile signs it, each side paying the gold it offered. Talks are never refused between seats. - Trades between seats need both:
propose_tradefrom one seat changes nothing yet; the offer stands until the end of the next turn, shows in the other'sdiplomacy(trade_offered) and reaches it as atrade_offeredevent when the turn ends, in its own values, unless it was answered or replaced meanwhile. The other makes it withaccept_trade, or by proposing the same trade back, once (both seats' offers between the two go); the proposer then gets atrade_signedevent, ortrade_declinedafterdecline_trade. Gold alone may change hands between seats. - Victory (see Conventions): conquest also when one seat's civilization is the last an agent still plays (the other seats are defeated), whatever AI civs are left. The game is then over for every seat. A victory on score at the turn limit may be an AI civ's, as may a conquest, domination or cultural victory; the env's match then counts it as that AI player slot's win and a loss for every seat (tools.md, Several agents in one game).
autoplayfails withmulti_seat. The autosave (format 2) keeps every seat, the victory, the battlesknown_maplists and the standing peace and trade offers (trade_offers; a save without them loads with none);loadrestores them.
patches/*.patch apply to the pinned OpenCiv3 sources at build time (scripts/prepare-engine.sh
copies C7Engine, QueryCiv3, Blast and C7/Lua into build/engine/ and applies them; the
submodule itself stays untouched):
0001-defeated-controller-ends-turn-loop.patch:TurnHandling.PlayPlayerTurnshands control back (sendsMsgStartTurn, returns true) when the UI controller is defeated, instead of looping forever.0002-declare-war-uses-game-rng.patch:Player.DeclareWarOndrawsrefuseContactUntilTurnfromGameData.rng, so games stay deterministic.0003-budget-never-throws.patch: when the budget cannot be balanced, gold stays at 0 instead of throwing mid-turn.0004-ai-turn-exceptions-are-contained.patch: an exception in one AI or barbarian turn ends that player's turn instead of aborting or stalling the game.0005-building-prerequisites-check-the-city.patch: a building that needs another (Bank, University, Cathedral and 12 more) becomes buildable once the city has the prerequisite; the check compared the building with itself, so none could ever be built.0006-small-wonders-are-buildable.patch: small wonders can be built, once per civ and by one city at a time (Heroic Epic and The Pentagon stay blocked: they need armies, which the engine lacks). The AI values a Forbidden Palace by the corruption it removes.0007-ai-keeps-its-science-funded.patch: the AI runs a deficit only while its treasury covers it, builds no units it cannot support, gives up science before units when broke and then disbands its least useful unit, and leaves Despotism for the best government it knows (at most once per 50 turns). The human seat's budget is unchanged.0008-research-cost-follows-the-difficulty.patch: the difficulty's AI cost factor scales the AI's research the way it scales production (the AI pays 200% at Chieftain and 40% at Sid; it paid 50% and 250%). Regent and the human's costs are unchanged.0009-ai-makes-peace.patch:Player.PeacePriceFor(gameData, other), the gold an AI asks for peace (propose_peaceabove);WouldAcceptDealFromcharges it for a peace offer; AIs sign peace with each other (the loser paying the winner's price) and offer a human peace every 5 turns when they want nothing; a war declared within 50 turns of a treaty counts as breaking it, which makes the victim refuse peace for longer. The new war records are saved with the game.0010-combat-is-observable.patch:MapUnit.combatObserver(anICombatObserver) is told when a battle's rounds begin (inMapUnit.Fight, after any defensive bombard, and inMapUnit.BombardUnits), who won each round or shot as the engine draws it, and when the rounds end. The bridge recordsknown_map'sbattleswith it; the observer draws nothing fromGameData.rngand changes nothing, so games replay as before.0011-cities-are-captured.patch: a combat unit that enters an enemy city takes it (CityInteractions.CaptureCity) instead of destroying it, as in Civ III. The city loses a citizen (one of size 1 is destroyed), its palace, small wonders, stored food and shields; great wonders and other buildings stay, the loser's units in it are lost, its citizens keep their nationality and are put back to work, its borders follow the new owner's culture, and the new owner's AI picks what it builds.MsgCityCapturedtells the UI. The loser gets a new capital from patch 0013. Barbarians still take gold.0012-moves-are-observable.patch:MapUnit.moveObserver(anIMoveObserver) is told of every step a unit takes inMapUnit.Move(a move, the last step of a won attack, or a retreat), once the unit is on its new tile and before it enters it (OnEnterTile). The bridge recordsknown_map'smovesand the world snapshot'smoveswith it; like 0010's observer it draws nothing fromGameData.rngand changes nothing.0013-the-palace-moves-when-the-capital-falls.patch: a civ whose capital is taken (CaptureCity) or destroyed (DestroyCity) gets a new one at once and free, as in Civ III (CityInteractions.RelocatePalace): its largest city (the oldest of those), preferring one without a Forbidden Palace; a Forbidden Palace where the palace lands is lost. Corruption is measured again from there. A civ that held no city when it took one gets its palace in that city, as one founding its first city does.0014-units-disbanded-in-a-city-add-their-shields.patch: the ruleset's disband script (C7/Lua/civ3/behaviors/gameplay.lua) failed for every unit disbanded inside its civ's borders (HasCityread as a method, thenGetType().Nameon a MoonSharp static userdata), so none gave shields. A unit disbanded in one of its civ's cities now addsShieldRateForDisbanding(a quarter) of its cost to what the city builds, unless that is a great wonder or wealth (City.TakesDisbandShields);unit_order'sdisbandmessage says how many.0015-a-tech-got-out-of-order-leaves-the-research-queue.patch:Player.CompleteResearchingTechdropped the research queue's head whatever tech was completed, so a tech got in a trade dropped the one being researched, and a traded tech further down the queue stayed in it; at the head,PlayerAI.MaybePickTechToResearchpicked it again forever and the turn hung (a seat with aset_researchqueue, played byautoplay'sengine_ai). The completed tech now leaves the queue wherever it is, and a known tech at the head is skipped.0016-buildings-multiply-science-gold-and-shields.patch: buildings have their Civ III economic effects; a Library, Marketplace, University, Bank or Factory cost upkeep and returned nothing.SaveBuildingandBuildinggetsciencePercent,taxPercent,luxuryPercentandproductionPercent, andCity.Boostraises the beakers, taxes and luxury left after corruption and the rates (the specialists' included, Wealth's not) inCurrentCommerceYieldRaw, and the useful shields left after waste inCurrentProductionYield, by the sum of the city's buildings' percentages, rounded down;CommerceBreakdown.fromBuildingsandCorruptableValue.fromBuildingsrecord what was added (the city'sfrom_buildings). The ruleset gives Library, University and Research Lab +50% science, Copernicus' Observatory and Newton's University +100%, Marketplace, Bank and Stock Exchange +50% tax and luxury, Factory and Manufacturing Plant +25% shields, and Wall Street thetreasuryEarnsInterestflag the engine already paid on (5% of the treasury, at most 50 gold a turn).ImportCiv3maps the BIQ's three +50% flags. The AI (ChooseProducible.ScoreBuilding) values such a building by what it would add in the city in order:CurrentProductionYield, likeCurrentCommerceYieldRaw, takesrespectCivilDisorder, so a Marketplace's luxury counts in a city that riots. Everything that reads the city's yields (finances, research, the AI's budget and government choice, moods, the client's city screen) follows; nothing draws fromGameData.rng. A save keeps its own building definitions, so a save made before the patch has no multipliers.0017-a-traded-tech-keeps-research-progress.patch:Player.CompleteResearchingTechcleared the current research and its beakers for every tech it completed, so a civ that got a tech in a trade (Player.ExecuteDeal) lost all its progress on whatever else it was researching (AIs lost it about three times per 200-turn game, and an agent buying a tech would at every purchase). The research now ends only when the completed tech is the one being researched; a traded tech that is the current research still takes its beakers with it.0018-the-standalone-ruleset-keeps-every-unit-it-can-play.patch: the standalone ruleset (C7/Lua/standalone/ruleset.lua) kept only the 27 units with community art, and dropped every unit's upgrades (it looked theupgradesTolist up as a key), so no unit ever went obsolete and 25 civs had lost a unit class to their missing unique units. It now keeps 76 units: those 27, the 12 later land units (Rifleman to Modern Armor, Artillery, Radar Artillery), the 7 later sea units (Ironclad to AEGIS Cruiser, Transport) and the 30 unique units, each new one drawn with the art of the unit it replaces or descends from, and their upgrades to units kept. It leaves out air units, missiles, nukes, the Carrier, Nuclear Submarine, Flak and Mobile SAM (the engine has no air movement, air missions or nuclear attack) and the leaders and armies (no rule behind them).UnitPrototype.GetUnitUpgradetakes the next step where several targets fit a civ (the Spearman goes to the Pikeman, or to the Musketman for the Dutch), instead of the alphabetically first with a warning.0019-units-upgrade-in-a-city-for-gold.patch:MapUnit.Upgrade(MapUnit_Upgrade.cs) upgrades a unit in one of its owner's cities to the furthest unit of its civ's chain the city can build (UnitPrototype.UpgradeTargetIn), for the shield difference (at least 1) timesRules.UpgradeCostPerShieldgold (3; Conquests' value is not imported). It keeps its id, experience, hit points and fortification and uses up its moves. The AI (PlayerAI.MaybeUpgradeUnits) spends the gold above a reserve of a turn's upkeep (at least 50) on its combat units in its cities, the largest defence gain first, skipping an upgrade to a unit that does not fight (the Chasqui Scout to the Explorer) or that defends worse while no other unit in the city defends as well (the Samurai to the Cavalry); while a city's best defender could upgrade, it keeps one tenth of its commerce from science for it. A unit is obsolete in a city exactly when it could upgrade there:UnitPrototype.IsUnitObsoletefollows the civ's own chain, without the "siblings" that made the Archer obsolete at Feudalism (through the Medieval Infantry) before it could upgrade at Invention. The bridge'supgradeorder and the units'upgradefield use it.0020-cargo-goes-down-with-its-ship.patch:GameData.RemoveUnitleft a lost ship's passengers on the water, still aboard a ship that no longer existed. A ship lost at sea (sunk, disbanded, or disbanded by patch 0007's broke AI) now takes its passengers with it, as in Civ III; one lost in a city puts them ashore, awake. As one removal can now take several units, a destroyed civ's units are removed from a copy of its list (by index, the engine threw once a passenger listed before its ship had gone with it, and a captured last city stayed on its tile), a broke human seat stops disbanding when it has no units left, and the broke AI spares a loaded ship at sea as it spares settlers and workers.0021-the-ai-explores-by-sea.patch: the AI builds a few ships to explore the ocean. A coastal city builds one while unknown tiles border what its civ knows of the ocean next to it (ExplorerAI.HasOceanToExplore, which, unlike the check it replaces, changes nothing), the civ is at peace and its ships, built or being built, number fewer than min(4, 1 + cities / 6); the ship gets +15, which the cheap Curragh wins.ExplorerAIskips unreachable targets (no route is an empty path, length 0, not null) and a ship explores only its own body of water, so a boat on a lake refusesexplore.0022-the-ai-ferries-settlers-overseas.patch: the AI settles other landmasses. A settler weighs the best known site overseas (reachable by sea from one of its civ's ports on its landmass, outside other civs' borders) against the best one at home and takes it when it scores more than twice as much; it waits in that port, a free ship is sent or the port builds one, and a newFerryAIcarries it and its escort to a landing next to the site. A ship found with cargo and no plan (after a load) finishes the crossing or puts its cargo ashore in port (with no port it can reach, on the nearest shore it can reach), and passengers stay aboard, fortified, until landed (a settler of a civ with no city left founds its city ashore, not where it stood on the water). A unit told to hold where it stood that is carried off its post before it fortifies (an escort whose ship sails, or a seat unit moved bygotobeforeautoplay) plans again; it used to throw on its missing path and end its player's turn. Wars across water, amphibious attack and naval combat are not done (docs/full-game.md).
Measured over full 540-turn Standard games with 7 AIs at Regent (seeds 1-3), patches 0005-0009 take:
- mean AI techs at T540 from 32 to 43-45, and civs with an Industrial-era tech from 0 to 3-7;
- Banks, Universities and Cathedrals from 0 to 6-8, 25-45 and 81-110;
- wars ending in peace from 0 to 6-15 per game;
- AI governments at T540 from all Despotism to mostly Republic and Democracy.
Wall time per game is unchanged at 74-78 s, and a seed replays byte-identically.
Patch 0016 then takes, over the same games (seeds 1-3, with the patches through 0015 as the baseline):
- mean techs of the AIs left at T540 from 37-42 to 58-65, the best from 43-49 to 60-66;
- Universities from 32-43 to 70-104, Banks from 1-6 to 58-78, Stock Exchanges from 0 to 19-36, Factories from 0 to 3-10;
- cities captured from 39-85 to 100-117 per game, and civs destroyed from 0-1 to 2-3.
Wall time per game stays within the machine's noise (167-246 s before, 197-227 s after, on a shared machine).
Patch 0017 re-measured on the same games (autoplay engine_ai, which declines the AIs' offers to the seat as
before): seed 3 plays out identically; on seeds 1 and 2 a traded tech that kept the receiver's progress sends the
game another way from then on, so the mean AI techs at T540 go from 42.4 to 36.9 and from 36.7 to 43.0 (39.7 to
40.0 over the three seeds). In 200-turn games (Small with 6 civs on seed 1, Standard with 8 on seed 2) the mean
AI techs at T200 are 24.0 and 23.3 before, 24.0 and 23.4 after. Wall time is unchanged.
Implemented. These extend the sections above; folding them in is still to do.
set_rates {science, luxury}:- tax is
10 - science - luxury; - each rate is checked against the government's maximum; bad values give
bad_rates, listing the allowed range; - result:
{"message", "rates", "gold_per_turn", "turns_left_research"}.
- tax is
hurry {city}: buys the current production with gold (or population, where the government uses forced labour).- Before acting, it uses
GetHurryProductionDetails; refusals givecannot_hurrywith the engine's reason and the cost. - Result:
{"message", "gold_cost", "pop_cost", "city"}.
- Before acting, it uses
- Trades were auto-declined; they are now made with
propose_tradeandaccept_trade(see Trades above). - Defenders keep order. Under Despotism, each military unit in a city suppresses one unhappy citizen (up to 2).
statereports it and the disorder hints say so.
- Engine picks are reported. When the engine picks production after a completion, or the next research after a tech, the bridge reports it rather than hiding it:
state.cities[].producing_sourceandstate.research.sourceareagentorengine;- a new blocker
choose_production(per city) orchoose_researchasks the agent to confirm or change the pick; end_turn(skip_idle=true)accepts the engine's picks.
state.decisions:{"production": {"agent": n, "engine": n}, "research": {"agent": n, "engine": n}}. It counts completed items and learned techs by who chose them.
- New city fields in
state:happy,content,unhappy,defenders(military units in the city),riot_risk(true when one more citizen would put the city in disorder at the current luxury rate and garrison);capped(true when production is full but waiting for population, e.g. a Settler at size 1 or 2),shields_lost_last_turn.
- New blocker
disorder(per city in disorder). Its message names the fixes: raise luxury (set_rates), move a military unit into the city, or let it shrink. - New events:
disorder_started,disorder_ended,riot_risk(the turn the risk first appears);production_capped(once per item);gold_stolen(barbarians or capture took gold; amount);defenseless(a city with no defender while a hostile unit is within 3 tiles).
threatis narrower. It only covers hostile units (at war, or barbarian), and only once per unit until it leaves and returns. Peaceful passers-by are never threats.until_attentionstops on more. It stops on any blocker, and also ondisorder_started,riot_risk,unit_lost,city_destroyed,city_lost,city_captured,gold_stolen,war_declared,defenselessandthreat.
city_sitesalso returnsnearby: every legal site within 4 tiles of the unit, with its score, so agents never have to guess coordinates.- Settle suggestions only for settlers. For a non-settler,
suggestis never asettlecall. - Precise movement errors. A goto or settle to a tile occupied by a foreign unit or city fails with
occupied, naming the occupant, notno_path. For a settler it also lists the best free sites and suggests settling the first.
autoplaygainssettler_bot. It follows the env's own suggestions, with no LLM:- found the capital on turn 0;
- every settler:
settleatcity_sitestop-1 from its position; - workers:
auto_work. The first warrior explores; the others fortify in the nearest city without a defender. - each city builds a Warrior if it has no defender, else a Settler if size ≥ 2 and fewer than 8 cities, else a Worker if workers < cities, else Warrior;
- research: the cheapest available tech.
- It is the reference agents must beat. It plays through the same standing orders and rules as the agent.
- Engine patches:
0003-budget-never-throws.patch: when the budget can't be balanced, clamp instead of throwing.0004-ai-turn-exceptions-are-contained.patch: one AI player's exception is logged, and that player's turn ends, instead of aborting or stalling the turn.- The exact sites are in the robustness audit.
- Autosave and
load: every human turn start writes<autosave dir>/autosave.json. The--autosave <dir>flag sets the directory; it defaults to a temp dir. Aload {path}command restores a save (a new process plusload), so the env can recover from a bridge crash. - Recording:
--record <dir>and theworldcommand, as specified indocs/recording.md. - Per-turn saves:
--saves <dir>also keeps every turn's autosave as<dir>/turn-NNNN.json.gz, for renderers that load engine saves (the client's view indocs/recording.md). Each one loads withloadonce decompressed.