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> <legend>Cities</legend>
<p class="help">The <b>name</b> is the city's unique identifier &mdash; it labels the <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 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> <div id="cities" class="cards"></div>
<button class="mini" type="button" onclick="addCity()">+ city</button> <button class="mini" type="button" onclick="addCity()">+ city</button>
</fieldset> </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}), 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}); ve = num(c.vat_electrum||0, {min:0});
const reno = el("input", {type:"checkbox"}); reno.checked = c.can_renovate !== false; 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 forced = el("input", {value:"", placeholder:"0:upgrade"});
const avail = el("input", {value:"", placeholder:""}); const avail = el("input", {value:"", placeholder:""});
@ -315,6 +319,8 @@ function addCity(c={}) {
const u = allowedUpgrades().filter(name => upBoxes[name] && upBoxes[name].checked); const u = allowedUpgrades().filter(name => upBoxes[name] && upBoxes[name].checked);
if (u.length) o.upgrades = u; if (u.length) o.upgrades = u;
if (!reno.checked) o.can_renovate = false; if (!reno.checked) o.can_renovate = false;
const adj = parseStrs(adjacent.value);
if (adj) o.adjacent = adj;
const fa = parsePairs(forced.value); const fa = parsePairs(forced.value);
if (Object.keys(fa).length) o.forced_action = fa; if (Object.keys(fa).length) o.forced_action = fa;
const at = parseInts(avail.value); const at = parseInts(avail.value);
@ -330,6 +336,7 @@ function addCity(c={}) {
field("Vat electrum", ve), field("Vat electrum", ve),
field("Upgrades (already installed)", upWrap), field("Upgrades (already installed)", upWrap),
checkField("Can renovate", reno), checkField("Can renovate", reno),
field("Adjacent cities (csv of names)", adjacent),
field("Forced actions (turn:action, csv)", forced), field("Forced actions (turn:action, csv)", forced),
field("Avail turns (csv, blank=all)", avail), field("Avail turns (csv, blank=all)", avail),
el("div", {class:"card-actions"}, removeBtn(card))); 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); const out = s.split(",").map(x=>x.trim()).filter(Boolean).map(Number);
return out.length ? out : null; 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) { function parsePairs(s, valueIsString=false) {
const o = {}; const o = {};
for (const part of s.split(",").map(x=>x.trim()).filter(Boolean)) { for (const part of s.split(",").map(x=>x.trim()).filter(Boolean)) {
@ -563,9 +574,9 @@ function fmtNum(v) {
} }
// --- seed with the example problem --- // --- seed with the example problem ---
addCity({name:"Aridias", type:"hub", renown:2}); 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}); 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}); addCity({name:"Kingsland", type:"metropolis", renown:4, can_renovate:false, adjacent:["Roseward"]});
addCity({name:"Roseward", type:"monument", renown:2}); addCity({name:"Roseward", type:"monument", renown:2});
addAgent({kind:"Planner"}); addAgent({kind:"Planner"});
[["renown",5],["capital",1],["luxuries",1],["steel",1],["brass",1], [["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), Baron (+Trade Goods/Bastion), Builder (free type-specific Upgrade),
Capitalist (+2 Capital), Vinter (+2 Luxuries), Artificer (+1 Trade Good), Capitalist (+2 Capital), Vinter (+2 Luxuries), Artificer (+1 Trade Good),
Metallurgist (Overflow Vats on a Foundry), Industrialist (free 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 (refund <=2 Steel on Upgrade/Launch), Provisioner (+1.5 Electrum per
governing Turn), Courier (one-time +3 Capital/Steel/Brass). governing Turn), Courier (one-time +3 Capital/Steel/Brass).
@ -165,6 +166,11 @@ class City:
vat_brass: int = 0 vat_brass: int = 0
vat_electrum: int = 0 vat_electrum: int = 0
upgrades: list[str] = field(default_factory=list) # already-installed 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 available_turns: Optional[list[int]] = None # None => all turns
can_renovate: bool = True # Metropolis cannot renovate can_renovate: bool = True # Metropolis cannot renovate
# Hard constraint: force a specific action on a given turn (turn -> Action). # Hard constraint: force a specific action on a given turn (turn -> Action).
@ -252,9 +258,9 @@ class Agent:
@classmethod @classmethod
def industrialist(cls, name: str = "Industrialist", **kw) -> "Agent": def industrialist(cls, name: str = "Industrialist", **kw) -> "Agent":
"""Industrialist: when appointed Governor, their City gains the """Industrialist: when appointed Governor, their City and every City
Infrastructure Upgrade for free (the adjacent-City grant is not adjacent to it (see ``City.adjacent``) gain the Infrastructure Upgrade
modeled, as the map layout is outside this resource model).""" for free."""
return cls(name=name, grants_infrastructure=True, **kw) return cls(name=name, grants_infrastructure=True, **kw)
@classmethod @classmethod
@ -448,6 +454,7 @@ class _Builder:
# -- main build -------------------------------------------------------- # # -- main build -------------------------------------------------------- #
def build(self): def build(self):
self._build_adjacency()
self._build_actions_and_governors() self._build_actions_and_governors()
self._build_city_dynamics() self._build_city_dynamics()
self._build_trade_conversion() self._build_trade_conversion()
@ -543,6 +550,23 @@ class _Builder:
# Only one overworking placement at a time is already implied by # Only one overworking placement at a time is already implied by
# the agent's "<=1 city" constraint. # 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: def _city_index(self, name: str) -> int:
for i, c in enumerate(self.p.cities): for i, c in enumerate(self.p.cities):
if c.name == name: if c.name == name:
@ -694,9 +718,9 @@ class _Builder:
ni = ai + bf ni = ai + bf
if u == "infrastructure": if u == "infrastructure":
# Industrialist Governor: installs Infrastructure for free # Industrialist Governor: installs Infrastructure for free
# this turn, regardless of the City's Action. # this turn, regardless of the City's Action. The grant
inf_src = self._gov_attr_bool( # spreads from the governed City to every adjacent City.
ci, t, lambda a: a.grants_infrastructure, "indinfra") inf_src = self._industrialist_infra_bool(ci, t)
if inf_src is not None: if inf_src is not None:
bf = m.NewBoolVar(f"indinfrafree_{ci}_t{t}") bf = m.NewBoolVar(f"indinfrafree_{ci}_t{t}")
# bf = inf_src AND not already installed # bf = inf_src AND not already installed
@ -811,6 +835,26 @@ class _Builder:
def _free_upgrade_bool(self, ci: int, t: int): def _free_upgrade_bool(self, ci: int, t: int):
return self._gov_attr_bool(ci, t, lambda a: a.free_upgrade, "freeup") 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): def _bonus_collect_expr(self, ci: int, t: int, attr: str):
"""Sum of ``agent.<attr>`` over Agents governing City ``ci`` on Turn """Sum of ``agent.<attr>`` over Agents governing City ``ci`` on Turn
``t`` (each such Agent's per-Collection bonus). None if no contributor.""" ``t`` (each such Agent's per-Collection bonus). None if no contributor."""