The name is the city's unique identifier — it labels the
output plan and is what an agent's "forced city" refers to. Upgrades are the
- ones already installed at the start; the available choices follow the city's type.
+ ones already installed at the start; the available choices follow the city's type.
+ Adjacent cities lists neighbours by name (adjacency is symmetric); an
+ Industrialist Governor grants free Infrastructure to its city and every adjacent one.
@@ -279,6 +281,8 @@ function addCity(c={}) {
const vs = num(c.vat_steel||0, {min:0}), vb = num(c.vat_brass||0, {min:0}),
ve = num(c.vat_electrum||0, {min:0});
const reno = el("input", {type:"checkbox"}); reno.checked = c.can_renovate !== false;
+ const adjacent = el("input", {value:(c.adjacent||[]).join(", "),
+ placeholder:"Bearhearth, Kingsland"});
const forced = el("input", {value:"", placeholder:"0:upgrade"});
const avail = el("input", {value:"", placeholder:""});
@@ -315,6 +319,8 @@ function addCity(c={}) {
const u = allowedUpgrades().filter(name => upBoxes[name] && upBoxes[name].checked);
if (u.length) o.upgrades = u;
if (!reno.checked) o.can_renovate = false;
+ const adj = parseStrs(adjacent.value);
+ if (adj) o.adjacent = adj;
const fa = parsePairs(forced.value);
if (Object.keys(fa).length) o.forced_action = fa;
const at = parseInts(avail.value);
@@ -330,6 +336,7 @@ function addCity(c={}) {
field("Vat electrum", ve),
field("Upgrades (already installed)", upWrap),
checkField("Can renovate", reno),
+ field("Adjacent cities (csv of names)", adjacent),
field("Forced actions (turn:action, csv)", forced),
field("Avail turns (csv, blank=all)", avail),
el("div", {class:"card-actions"}, removeBtn(card)));
@@ -460,6 +467,10 @@ function parseInts(s) {
const out = s.split(",").map(x=>x.trim()).filter(Boolean).map(Number);
return out.length ? out : null;
}
+function parseStrs(s) {
+ const out = s.split(",").map(x=>x.trim()).filter(Boolean);
+ return out.length ? out : null;
+}
function parsePairs(s, valueIsString=false) {
const o = {};
for (const part of s.split(",").map(x=>x.trim()).filter(Boolean)) {
@@ -563,9 +574,9 @@ function fmtNum(v) {
}
// --- seed with the example problem ---
-addCity({name:"Aridias", type:"hub", renown:2});
-addCity({name:"Bearhearth", type:"foundry", renown:2, vat_steel:3, vat_brass:2, vat_electrum:1});
-addCity({name:"Kingsland", type:"metropolis", renown:4, can_renovate:false});
+addCity({name:"Aridias", type:"hub", renown:2, adjacent:["Bearhearth"]});
+addCity({name:"Bearhearth", type:"foundry", renown:2, vat_steel:3, vat_brass:2, vat_electrum:1, adjacent:["Kingsland"]});
+addCity({name:"Kingsland", type:"metropolis", renown:4, can_renovate:false, adjacent:["Roseward"]});
addCity({name:"Roseward", type:"monument", renown:2});
addAgent({kind:"Planner"});
[["renown",5],["capital",1],["luxuries",1],["steel",1],["brass",1],
diff --git a/solve.py b/solve.py
index 64cd88c..011754e 100644
--- a/solve.py
+++ b/solve.py
@@ -47,7 +47,8 @@ Governors / Overwork:
Baron (+Trade Goods/Bastion), Builder (free type-specific Upgrade),
Capitalist (+2 Capital), Vinter (+2 Luxuries), Artificer (+1 Trade Good),
Metallurgist (Overflow Vats on a Foundry), Industrialist (free
- Infrastructure), Foreman (Renovate without spending the Action), Prodigy
+ Infrastructure for the governed City *and every adjacent City*), Foreman
+ (Renovate without spending the Action), Prodigy
(refund <=2 Steel on Upgrade/Launch), Provisioner (+1.5 Electrum per
governing Turn), Courier (one-time +3 Capital/Steel/Brass).
@@ -165,6 +166,11 @@ class City:
vat_brass: int = 0
vat_electrum: int = 0
upgrades: list[str] = field(default_factory=list) # already-installed
+ # Names of Cities adjacent to this one on the map. Adjacency is treated as
+ # symmetric (declaring A adjacent to B implies B adjacent to A) and is used
+ # by the Industrialist Governor, whose free Infrastructure Upgrade spreads
+ # to every City adjacent to the one being governed.
+ adjacent: list[str] = field(default_factory=list)
available_turns: Optional[list[int]] = None # None => all turns
can_renovate: bool = True # Metropolis cannot renovate
# Hard constraint: force a specific action on a given turn (turn -> Action).
@@ -252,9 +258,9 @@ class Agent:
@classmethod
def industrialist(cls, name: str = "Industrialist", **kw) -> "Agent":
- """Industrialist: when appointed Governor, their City gains the
- Infrastructure Upgrade for free (the adjacent-City grant is not
- modeled, as the map layout is outside this resource model)."""
+ """Industrialist: when appointed Governor, their City and every City
+ adjacent to it (see ``City.adjacent``) gain the Infrastructure Upgrade
+ for free."""
return cls(name=name, grants_infrastructure=True, **kw)
@classmethod
@@ -448,6 +454,7 @@ class _Builder:
# -- main build -------------------------------------------------------- #
def build(self):
+ self._build_adjacency()
self._build_actions_and_governors()
self._build_city_dynamics()
self._build_trade_conversion()
@@ -543,6 +550,23 @@ class _Builder:
# Only one overworking placement at a time is already implied by
# the agent's "<=1 city" constraint.
+ def _build_adjacency(self):
+ """Build the symmetric City adjacency map (``self.adj[ci]`` -> set of
+ adjacent City indices). Declaring A adjacent to B also makes B adjacent
+ to A, so callers need only list each edge once."""
+ name_to_idx = {c.name: i for i, c in enumerate(self.p.cities)}
+ self.adj: dict[int, set[int]] = {i: set() for i in range(len(self.p.cities))}
+ for i, c in enumerate(self.p.cities):
+ for nm in c.adjacent or []:
+ if nm not in name_to_idx:
+ raise KeyError(
+ f"City {c.name!r} is adjacent to unknown City {nm!r}")
+ j = name_to_idx[nm]
+ if j == i:
+ continue
+ self.adj[i].add(j)
+ self.adj[j].add(i)
+
def _city_index(self, name: str) -> int:
for i, c in enumerate(self.p.cities):
if c.name == name:
@@ -694,9 +718,9 @@ class _Builder:
ni = ai + bf
if u == "infrastructure":
# Industrialist Governor: installs Infrastructure for free
- # this turn, regardless of the City's Action.
- inf_src = self._gov_attr_bool(
- ci, t, lambda a: a.grants_infrastructure, "indinfra")
+ # this turn, regardless of the City's Action. The grant
+ # spreads from the governed City to every adjacent City.
+ inf_src = self._industrialist_infra_bool(ci, t)
if inf_src is not None:
bf = m.NewBoolVar(f"indinfrafree_{ci}_t{t}")
# bf = inf_src AND not already installed
@@ -811,6 +835,26 @@ class _Builder:
def _free_upgrade_bool(self, ci: int, t: int):
return self._gov_attr_bool(ci, t, lambda a: a.free_upgrade, "freeup")
+ def _industrialist_infra_bool(self, ci: int, t: int):
+ """Bool that is 1 iff an Industrialist Governs City ``ci`` *or* any City
+ adjacent to it on Turn ``t`` (each grants ``ci`` a free Infrastructure
+ Upgrade). Returns None when no Industrialist could reach this City.
+
+ Multiple adjacent Cities may each be Governed by an Industrialist, so
+ the contributions are OR'd (any one is enough) rather than summed."""
+ sources = [ci] + sorted(self.adj[ci])
+ contrib = [
+ self.gov[(ai, cj, t)]
+ for cj in sources
+ for ai, a in enumerate(self.p.agents)
+ if a.grants_infrastructure and (ai, cj, t) in self.gov
+ ]
+ if not contrib:
+ return None
+ b = self.m.NewBoolVar(f"indinfra_c{ci}_t{t}")
+ self.m.AddMaxEquality(b, contrib)
+ return b
+
def _bonus_collect_expr(self, ci: int, t: int, attr: str):
"""Sum of ``agent.`` over Agents governing City ``ci`` on Turn
``t`` (each such Agent's per-Collection bonus). None if no contributor."""