diff --git a/main.py b/main.py index 3b1d52c..b2d39a4 100644 --- a/main.py +++ b/main.py @@ -100,7 +100,9 @@ INDEX_HTML = r"""
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.