added adjacency for industrialist

This commit is contained in:
Pagwin 2026-06-17 14:53:37 -04:00
parent 1a27e9048b
commit e350036b1b
2 changed files with 66 additions and 11 deletions

19
main.py
View file

@ -100,7 +100,9 @@ INDEX_HTML = r"""<!DOCTYPE html>
<legend>Cities</legend>
<p class="help">The <b>name</b> is the city's unique identifier &mdash; it labels the
output plan and is what an agent's "forced city" refers to. <b>Upgrades</b> are the
ones already installed at the start; the available choices follow the city's type.</p>
ones already installed at the start; the available choices follow the city's type.
<b>Adjacent cities</b> lists neighbours by name (adjacency is symmetric); an
Industrialist Governor grants free Infrastructure to its city and every adjacent one.</p>
<div id="cities" class="cards"></div>
<button class="mini" type="button" onclick="addCity()">+ city</button>
</fieldset>
@ -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],

View file

@ -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.<attr>`` over Agents governing City ``ci`` on Turn
``t`` (each such Agent's per-Collection bonus). None if no contributor."""