diff --git a/README.md b/README.md index 990f826..a42d327 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ for step in solution.plan: "upgrades": [], // already-installed upgrade keys "available_turns": null, // null = all turns, or e.g. [0,2,4] "can_renovate": true, // metropolis must be false + "overwork_pending": false, // starts holding an Overwork charge "forced_action": {"1": "renovate"} // turn -> action (hard constraint) } ], @@ -111,8 +112,15 @@ Upgrade keys: `infrastructure`, `harvester`, `fine_dining`, `overflow_vats`, Monument/Metropolis renown + trade goods), with the **1 Capital** cost. - **Foundry vats**: collecting a vat yields its level, empties it, and adds +1 to the other two (full stateful per-turn model; *Overflow Vats* adds +1 more). -- **Overwork** (Planner Governor): doubles a city's collection that turn, waives - the Capital cost, and **locks** that city out of collecting the next turn. +- **Overwork** (Planner Governor): an Overworked collection is doubled, has its + Capital cost waived, and **locks** that city out of collecting the next turn. + Appointing the Planner *arms* the city rather than necessarily firing that + turn: the charge may be spent by that same turn's collection (the classic + play) or held while the city collects normally, acts, or idles — in which case + the city's **next** collection is the Overworked one, whether or not the + Planner is still there. A held charge is not optional (the next collection + spends it) and charges do not stack. `City.overwork_pending` seeds a city that + entered the horizon already holding one. - **Upgrades** with Steel costs (Infrastructure 0 / Harvester 2 / type-specific 2 / Fortification 4), Infrastructure's −1 future-cost discount, and the +1/+2 Renown each grants. *Harvester*, *Fine Dining*, *Transit Authority* yield @@ -146,7 +154,8 @@ Upgrade keys: `infrastructure`, `harvester`, `fine_dining`, `overflow_vats`, "final_renown_total": 35, "plan": [ {"turn": 0, "city": "Aridias", "action": "collect", - "detail": "hub: +2 luxuries", "governor": "", "overwork": false} + "detail": "hub: +2 luxuries", "governor": "", "overwork": false, + "overwork_armed": false} // armed = the Planner charged this city here ] } ``` diff --git a/index.html b/index.html index 74d53b5..f977877 100644 --- a/index.html +++ b/index.html @@ -645,6 +645,10 @@ const vs = num(c.vat_steel || 1, {min: 0}), vb = num(c.vat_brass || 1, {min: 0}), ve = num(c.vat_electrum || 1, {min: 0}); const reno = el("input", {type: "checkbox"}); reno.checked = c.can_renovate !== false; + // Mid-game restore: the Planner already governed this city and it has + // not collected since, so its first collection here is overworked. + const owPend = el("input", {type: "checkbox"}); + owPend.checked = !!c.overwork_pending; const adjacent = el("input", { value: (c.adjacent || []).join(", "), placeholder: "Bearhearth, Kingsland" @@ -712,6 +716,7 @@ const u = allowedUpgrades().filter(name => upBoxes[name] && upBoxes[name].checked); if (u.length) o.upgrades = u; if (!reno.checked) o.can_renovate = false; + if (owPend.checked) o.overwork_pending = true; const adj = parseStrs(adjacent.value); if (adj) o.adjacent = adj; const fa = parsePairs(forced.value); @@ -733,6 +738,7 @@ el("div", {class: "stack"}, [ field("Type", type), checkField("Can renovate", reno), + checkField("Overwork pending", owPend), ]), field("Renown", renown), field("Upgrades (already installed)", upWrap), @@ -1422,10 +1428,16 @@ class: "mini", type: "button", onclick: () => downloadPlanCsv(s, n), }, "Download CSV"))); + // Overwork column: the Planner *arms* a city, and the charge is + // spent by that city's next Collection - which may be a later + // turn, with the Planner long gone. + const owCell = p => p.overwork + ? (p.overwork_armed ? "yes (armed here)" : "yes (armed earlier)") + : (p.overwork_armed ? "armed - fires on next collect" : ""); out.append(gridTable( ["Turn", "City", "Action", "Detail", "Governor", "Overwork"], s.plan.map(p => [p.turn, p.city, p.action, p.detail, p.governor, - p.overwork ? "yes" : ""]))); + owCell(p)]))); } else { out.append(el("p", {}, "(no actions / no feasible plan)")); } diff --git a/solve.py b/solve.py index bff89b4..4827466 100644 --- a/solve.py +++ b/solve.py @@ -37,11 +37,30 @@ Upgrades (Steel cost in brackets, each grants +1 City Renown unless noted): Governors / Overwork: - The Planner Leader has *Overwork*: when appointed Governor of a City it - doubles that City's Collection that Turn and waives the Capital cost, but - the City cannot Collect the following Turn. The Planner can govern at most - one City per Turn. Generic governor Agents may also grant a free Upgrade - or bonus Trade Goods on Collect. + The Planner Leader has *Overwork*: an Overworked Collection is doubled and + its Capital cost waived, but the City cannot Collect the following Turn. + The Planner can govern at most one City per Turn. + + Appointing the Planner Governor of a City *arms* that City rather than + necessarily firing that Turn, which lets the model cover every legal + timing: + + * arm and spend the same Turn - the classic case: the Planner governs a + Collecting City and that Collection is the doubled one; + * arm and hold - the Planner governs a City that Collects normally (or + takes some other Action, or is idle) and the charge carries forward; + the City's *next* Collection is the Overworked one, whether or not the + Planner is still there. + + A held charge is not optional: once armed, the very next Collection that + City makes is Overworked (and is followed by the no-Collect lock). Charges + never stack, so the model does not offer arming a City that is already + holding one (re-arming would buy nothing anyway). + ``City.overwork_pending`` seeds a City that entered the horizon already + holding a charge. + + Generic governor Agents may also grant a free Upgrade or bonus Trade Goods + on Collect. Other modeled industrial Governor Agents (see Agent named constructors): Baron (+Trade Goods/Bastion), Builder (free type-specific Upgrade), @@ -181,6 +200,10 @@ class City: adjacent: list[str] = field(default_factory=list) available_turns: Optional[list[int]] = None # None => all turns can_renovate: bool = True # Metropolis cannot renovate + # Start state: the City is already holding an unspent Overwork charge (the + # Planner governed it before the horizon began and it has not Collected + # since). Its first Collection in the horizon is then Overworked. + overwork_pending: bool = False # Hard constraint: force a specific action on a given turn (turn -> Action). forced_action: dict[int, str] = field(default_factory=dict) # Soft hint (NOT a constraint): a suggested action on a given turn @@ -204,7 +227,7 @@ class Agent: agents can be expressed with the generic effect flags. """ name: str - overwork: bool = False # Planner: double + waive cost + overwork: bool = False # Planner: arms Overwork on the governed City free_upgrade: bool = False # e.g. Brotherhood Builder bonus_trade_goods: int = 0 # e.g. Baron: +N Trade Goods on collect # --- additional industrial Governor effects (see named constructors) --- @@ -234,8 +257,9 @@ class Agent: @classmethod def planner(cls, name: str = "Planner", **kw) -> "Agent": - """Faction Planner Leader: Overwork (double collection, waive Capital - cost, lock next-Turn collect on the governed City).""" + """Faction Planner Leader: arms Overwork on the governed City. The + armed Collection (this Turn's, or the City's next one) is doubled with + its Capital cost waived, and locks the following Turn's Collect.""" return cls(name=name, overwork=True, **kw) @classmethod @@ -430,7 +454,13 @@ class CityTurnPlan: action: str detail: str = "" # e.g. collect choice, upgrade name, renovate target governor: str = "" # agent appointed governor (if any) + # This Turn's Collection is the Overworked one (doubled, Capital waived). overwork: bool = False + # The Planner armed an Overwork charge here this Turn. Together with + # ``overwork``: armed+overwork = armed and spent now; armed alone = the + # charge is held for this City's next Collection; overwork alone = it is + # spending a charge armed on an earlier Turn. + overwork_armed: bool = False # Net resource change produced by this Action (resource -> amount). Only the # stockpiled resources this City's Action affects appear here. deltas: dict[str, float] = field(default_factory=dict) @@ -500,8 +530,16 @@ class _Builder: self.act: dict[tuple[int, int, Action], cp_model.IntVar] = {} # governor assignment: gov[(agent_idx, city_idx, turn)] bool self.gov: dict[tuple[int, int, int], cp_model.IntVar] = {} - # overwork[(city_idx, turn)] bool (planner governs & overworks this city) + # overwork[(city_idx, turn)] bool: this City's Collection on this Turn + # is the Overworked one (doubled yield, Capital cost waived). self.overwork: dict[tuple[int, int], cp_model.IntVar] = {} + # ow_arm[(city_idx, turn)] bool: the Planner governs this City this Turn + # and so arms an Overwork charge on it. + self.ow_arm: dict[tuple[int, int], cp_model.IntVar] = {} + # ow_pending[(city_idx, turn)] bool: an armed-but-unspent charge is + # carried *into* this Turn. Defined for turns 0..T (T = after the last + # Turn); ow_pending[(ci, 0)] comes from City.overwork_pending. + self.ow_pending: dict[tuple[int, int], cp_model.IntVar] = {} # final renown per city self.city_final_renown: list[cp_model.IntVar] = [] # airship launch booleans (each adds AIRSHIP_RENOWN to asset renown) @@ -619,24 +657,68 @@ class _Builder: if govs: m.Add(sum(govs) <= 1) - # Overwork bool: planner (overwork agent) governs this city. + self._build_overwork(cities, agents) + + def _build_overwork(self, cities: list[City], agents: list[Agent]): + """Overwork as an armed-charge state machine, one per City. + + Appointing the Planner (an ``overwork`` Agent) Governor *arms* the City + (``ow_arm``). A charge sits on the City (``ow_pending``) until a + Collection spends it, and the Collection that spends it is the + Overworked one (``overwork``): doubled, Capital cost waived, and the + following Turn's Collect is locked out. + + The charge may be spent by the Collection on the very Turn it is armed + (the classic Planner play) or held for a later one - the Planner does + not have to still be there, and the City is free to Collect normally, + act, or idle on the arming Turn. What it cannot do is *skip* a charge: + a carried charge is spent by the next Collection, whichever Turn that + falls on. + + The flow equation ``pending' = pending + arm - overwork`` over 0/1 + variables is what ties this together; because ``pending`` is a Bool it + also rules out arming a City that is already holding a charge (unless + that Turn's Collection spends it), which would be wasted anyway. + """ + m = self.m overwork_agents = [ai for ai, a in enumerate(agents) if a.overwork] - for ci in range(len(cities)): + for ci, city in enumerate(cities): for t in range(self.T): contrib = [ self.gov[(ai, ci, t)] for ai in overwork_agents if (ai, ci, t) in self.gov ] - ow = m.NewBoolVar(f"overwork_c{ci}_t{t}") - if contrib: - m.Add(ow == sum(contrib)) # at most one planner total - else: - m.Add(ow == 0) - self.overwork[(ci, t)] = ow + arm = m.NewBoolVar(f"owarm_c{ci}_t{t}") + # At most one Planner can be here: each governs <=1 City and + # each City has <=1 Governor, so the sum is already 0/1. + m.Add(arm == sum(contrib) if contrib else arm == 0) + self.ow_arm[(ci, t)] = arm + self.overwork[(ci, t)] = m.NewBoolVar(f"overwork_c{ci}_t{t}") + for t in range(self.T + 1): + self.ow_pending[(ci, t)] = m.NewBoolVar(f"owpend_c{ci}_t{t}") + # Charge carried in from before the horizon (session restore). + m.Add(self.ow_pending[(ci, 0)] == int(city.overwork_pending)) - # Only one overworking placement at a time is already implied by - # the agent's "<=1 city" constraint. + for t in range(self.T): + arm = self.ow_arm[(ci, t)] + ow = self.overwork[(ci, t)] + pend = self.ow_pending[(ci, t)] + collect = self.act[(ci, t, Action.COLLECT)] + # Only a Collection can be Overworked, and only with a charge + # (armed this Turn or carried in). + m.Add(ow <= collect) + m.Add(ow <= pend + arm) + # A carried charge is not optional: Collecting spends it. + m.Add(ow >= pend + collect - 1) + # Charge flow. ``ow_pending`` being a Bool caps this at one + # charge per City at a time. + m.Add(self.ow_pending[(ci, t + 1)] == pend + arm - ow) + # An Overworked Collection locks the next Turn's Collect. + if t + 1 < self.T: + m.Add( + self.act[(ci, t + 1, Action.COLLECT)] == 0 + ).OnlyEnforceIf(ow) def _build_hints(self): """Apply soft solution hints via ``model.AddHint``. Unlike the forced_* @@ -927,17 +1009,8 @@ class _Builder: refund = self._mul_bool(capped, prodigy, cap) self._add_delta("steel", t, refund) - # --- Overwork "no collect next turn" lock -------------------------- - for t in range(T - 1): - # if overworked at t, cannot collect at t+1 - m.Add(self.act[(ci, t + 1, Action.COLLECT)] == 0).OnlyEnforceIf( - self.overwork[(ci, t)] - ) - # Overwork requires a collection this turn (otherwise pointless, and a - # governor that overworks implies the city collects). - for t in range(T): - m.Add(self.act[(ci, t, Action.COLLECT)] >= self.overwork[(ci, t)]) - + # --- Overwork: arming, spending and the "no collect next turn" lock + # all live in _build_overwork, which runs before this. # --- Launches need available steel handled by balance; renown ------- # --- Final renown of the city -------------------------------------- self._build_city_renown(ci, city, installed, type_active) @@ -1061,8 +1134,11 @@ class _Builder: # ---- per-Collection bonuses from governor agents --------------- # Each is applied whenever the City Collects (any type) while - # governed. A bonus Governor occupies the City's only Governor slot, - # so these never co-occur with the Planner's Overwork doubling. + # governed. These are *not* doubled by Overwork: the Governor's + # bonus is the Agent's own grant, not part of the City's Collection + # yield. (Overwork can now co-occur with one of these, since a + # charge armed on an earlier Turn is spent by a Collection that a + # different Agent may be governing.) # Baron -> +N Trade Goods (N = bonus per Bastion) # Artificer -> +1 Trade Good # Capitalist -> +2 Capital (only on a Hub collecting Capital) @@ -1610,12 +1686,15 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio (ci, t) in b._foreman_renov and bool(solver.Value(b._foreman_renov[(ci, t)])) ) + overwork = bool(solver.Value(b.overwork[(ci, t)])) + # The Planner can arm a City that is idling this Turn, so an + # otherwise-empty row still has to be reported. + armed = bool(solver.Value(b.ow_arm[(ci, t)])) if (chosen is None or chosen == Action.IDLE) and not foreman_renov \ - and (ci, t) not in extra: + and not armed and (ci, t) not in extra: continue detail = "" governor = "" - overwork = bool(solver.Value(b.overwork[(ci, t)])) # governor name for ai, agent in enumerate(problem.agents): if (ai, ci, t) in b.gov and solver.Value(b.gov[(ai, ci, t)]) == 1: @@ -1676,7 +1755,7 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio plan.append(CityTurnPlan( turn=t, city=city.name, action=action_label, detail=detail, governor=governor, overwork=overwork, - deltas=deltas, + overwork_armed=armed, deltas=deltas, )) def _unscale(r, raw):