New planner meta incorporated into the model

This commit is contained in:
Pagwin 2026-08-02 14:31:24 -04:00
parent 4f01b3f1af
commit 6ebfeddf81
No known key found for this signature in database
GPG key ID: 81137023740CA260
3 changed files with 139 additions and 39 deletions

View file

@ -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
]
}
```

View file

@ -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)"));
}

149
solve.py
View file

@ -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):