Compare commits

..

No commits in common. "new-planner-meta" and "new-main" have entirely different histories.

3 changed files with 39 additions and 144 deletions

View file

@ -78,7 +78,6 @@ 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)
}
],
@ -112,15 +111,8 @@ 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): 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.
- **Overwork** (Planner Governor): doubles a city's collection that turn, waives
the Capital cost, and **locks** that city out of collecting the next turn.
- **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
@ -154,8 +146,7 @@ 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,
"overwork_armed": false} // armed = the Planner charged this city here
"detail": "hub: +2 luxuries", "governor": "", "overwork": false}
]
}
```

View file

@ -645,10 +645,6 @@
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"
@ -716,7 +712,6 @@
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);
@ -738,7 +733,6 @@
el("div", {class: "stack"}, [
field("Type", type),
checkField("Can renovate", reno),
checkField("Overwork pending", owPend),
]),
field("Renown", renown),
field("Upgrades (already installed)", upWrap),
@ -1428,16 +1422,10 @@
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,
owCell(p)])));
p.overwork ? "yes" : ""])));
} else {
out.append(el("p", {}, "(no actions / no feasible plan)"));
}

154
solve.py
View file

@ -37,30 +37,11 @@ Upgrades (Steel cost in brackets, each grants +1 City Renown unless noted):
Governors / Overwork:
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.
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.
Other modeled industrial Governor Agents (see Agent named constructors):
Baron (+Trade Goods/Bastion), Builder (free type-specific Upgrade),
@ -200,10 +181,6 @@ 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
@ -227,7 +204,7 @@ class Agent:
agents can be expressed with the generic effect flags.
"""
name: str
overwork: bool = False # Planner: arms Overwork on the governed City
overwork: bool = False # Planner: double + waive cost
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) ---
@ -257,9 +234,8 @@ class Agent:
@classmethod
def planner(cls, name: str = "Planner", **kw) -> "Agent":
"""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."""
"""Faction Planner Leader: Overwork (double collection, waive Capital
cost, lock next-Turn collect on the governed City)."""
return cls(name=name, overwork=True, **kw)
@classmethod
@ -454,13 +430,7 @@ 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)
@ -530,16 +500,8 @@ 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: this City's Collection on this Turn
# is the Overworked one (doubled yield, Capital cost waived).
# overwork[(city_idx, turn)] bool (planner governs & overworks this city)
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)
@ -657,73 +619,24 @@ class _Builder:
if govs:
m.Add(sum(govs) <= 1)
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. Charges never stack: the Planner
is not placed on a City that is already holding one (``arm + pending
<= 1``), even on a Turn whose Collection would spend that charge - the
Collection is doubled once either way, so the placement would buy
nothing while quietly banking a second charge.
"""
m = self.m
# Overwork bool: planner (overwork agent) governs this city.
overwork_agents = [ai for ai, a in enumerate(agents) if a.overwork]
for ci, city in enumerate(cities):
for ci in range(len(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
]
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))
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
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)
# No stacking: don't arm a City that already holds a charge.
# Without this the Planner could arm a City on the very Turn
# its held charge fires, spending one and banking another.
m.Add(arm + pend <= 1)
# Charge flow.
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)
# Only one overworking placement at a time is already implied by
# the agent's "<=1 city" constraint.
def _build_hints(self):
"""Apply soft solution hints via ``model.AddHint``. Unlike the forced_*
@ -1014,8 +927,17 @@ class _Builder:
refund = self._mul_bool(capped, prodigy, cap)
self._add_delta("steel", t, refund)
# --- Overwork: arming, spending and the "no collect next turn" lock
# all live in _build_overwork, which runs before this.
# --- 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)])
# --- Launches need available steel handled by balance; renown -------
# --- Final renown of the city --------------------------------------
self._build_city_renown(ci, city, installed, type_active)
@ -1139,11 +1061,8 @@ class _Builder:
# ---- per-Collection bonuses from governor agents ---------------
# Each is applied whenever the City Collects (any type) while
# 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.)
# governed. A bonus Governor occupies the City's only Governor slot,
# so these never co-occur with the Planner's Overwork doubling.
# Baron -> +N Trade Goods (N = bonus per Bastion)
# Artificer -> +1 Trade Good
# Capitalist -> +2 Capital (only on a Hub collecting Capital)
@ -1691,15 +1610,12 @@ 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 not armed and (ci, t) not in extra:
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:
@ -1760,7 +1676,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,
overwork_armed=armed, deltas=deltas,
deltas=deltas,
))
def _unscale(r, raw):