Conversions and mutually exclusive solving

Conversions now are a thing to model trading

exclusive solving is in so my teammates can't kill the $5 VPS
This commit is contained in:
Pagwin 2026-06-17 20:54:00 -04:00
parent 9e71a64969
commit 727cd03f65
3 changed files with 360 additions and 3 deletions

View file

@ -70,6 +70,26 @@
font-weight: 600; font-weight: 600;
} }
#solveBtn:disabled {
opacity: 50%;
background: #888;
}
#busy {
font-size: 1rem;
font-weight: 600;
}
/* This tab is the one solving. */
#busy.busy-self {
color: #16a34a;
}
/* Another viewer holds the solve lock. */
#busy.busy-other {
color: #d97706;
}
.mini { .mini {
padding: .1rem .45rem; padding: .1rem .45rem;
font-size: .85rem; font-size: .85rem;
@ -341,8 +361,20 @@
<button class="mini" type="button" onclick="addConstraint()">+ constraint</button> <button class="mini" type="button" onclick="addConstraint()">+ constraint</button>
</fieldset> </fieldset>
<fieldset>
<legend>Forced conversions</legend>
<p class="help">A conversion is <b>not</b> a choice for the optimizer &mdash; it always
happens on the given turn, spending <b>from amount</b> of one resource and yielding
<b>to amount</b> of another. The optimizer is forced to have the spent resource
available at that turn. These appear in the solution and the CSV download.
</p>
<div id="conversions" class="cards"></div>
<button class="mini" type="button" onclick="addConversion()">+ conversion</button>
</fieldset>
<p> <p>
<button class="primary" type="button" onclick="run()">Solve</button> <button id="solveBtn" class="primary" type="button" onclick="run()">Solve</button>
<span id="busy" class="help" style="margin-left:.6rem"></span>
</p> </p>
<div id="error" class="err"></div> <div id="error" class="err"></div>
@ -704,6 +736,34 @@
document.getElementById("constraints").append(card); document.getElementById("constraints").append(card);
} }
// --- forced conversions ---
// A forced conversion always happens on its turn: spend from_amount of
// `from` and gain to_amount of `to`. It is not an optimizer choice.
function addConversion(c = {}) {
const card = el("div", {class: "card"});
const from = selectEl(RESOURCES, c.from || "trade_goods");
const fromAmt = num(c.from_amount ?? c.amount ?? 1, {min: 0});
const to = selectEl(RESOURCES, c.to || "capital");
const toAmt = num(c.to_amount ?? c.amount ?? 1, {min: 0});
const turn = turnSelect(c.turn);
card._get = () => {
const o = {
from: from.value, to: to.value,
from_amount: +fromAmt.value, to_amount: +toAmt.value,
};
if (turn.value !== "") o.turn = +turn.value;
return o;
};
card.append(
field("From", from),
field("From amount", fromAmt),
field("To", to),
field("To amount", toAmt),
field("Turn (blank=final)", turn),
el("div", {class: "card-actions"}, removeBtn(card)));
document.getElementById("conversions").append(card);
}
// --- parsing helpers --- // --- parsing helpers ---
function parseInts(s) { 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);
@ -742,6 +802,7 @@
agents: collect("agents"), agents: collect("agents"),
objective: {terms: collect("terms")}, objective: {terms: collect("terms")},
resource_constraints: collect("constraints"), resource_constraints: collect("constraints"),
conversions: collect("conversions"),
}; };
} }
@ -750,6 +811,11 @@
// them in request order. // them in request order.
let solutionCount = 0; let solutionCount = 0;
// Whether *this* tab is the one currently solving. The /status poll only
// disables the button for OTHER viewers, so the solver keeps its button
// state under run()'s own control.
let solvingHere = false;
async function run() { async function run() {
const errBox = document.getElementById("error"); const errBox = document.getElementById("error");
const out = document.getElementById("output"); const out = document.getElementById("output");
@ -763,18 +829,50 @@
problem = buildProblem(); problem = buildProblem();
time = +document.getElementById("time").value; time = +document.getElementById("time").value;
} catch (e) {errBox.textContent = e.message; pending.remove(); return;} } catch (e) {errBox.textContent = e.message; pending.remove(); return;}
solvingHere = true;
setSolveDisabled(true, "Solving…");
try { try {
const resp = await fetch("/solve", { const resp = await fetch("/solve", {
method: "POST", method: "POST",
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
body: JSON.stringify({problem, max_time_seconds: time}), body: JSON.stringify({problem, max_time_seconds: time}),
}); });
if (resp.status === 429) {
pending.remove();
errBox.textContent = "Another viewer is already solving — try again once they finish.";
return;
}
const data = await resp.json(); const data = await resp.json();
if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;} if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;}
renderSolution(data, pending); renderSolution(data, pending);
} catch (e) {pending.remove(); errBox.textContent = e.message;} } catch (e) {pending.remove(); errBox.textContent = e.message;}
finally {solvingHere = false; setSolveDisabled(false);}
} }
// Enable/disable the Solve button with an optional note beside it. `kind`
// ("self" or "other") picks the note's color so this tab's "Solving…" and
// another viewer's "Another viewer is solving…" stand out distinctly.
function setSolveDisabled(disabled, note = "", kind = "self") {
document.getElementById("solveBtn").disabled = disabled;
const busy = document.getElementById("busy");
busy.textContent = note;
busy.classList.toggle("busy-self", kind === "self");
busy.classList.toggle("busy-other", kind === "other");
}
// Poll the server's solve state so that while one viewer is solving the
// others see their Solve button disabled (and re-enabled when it frees up).
async function pollStatus() {
try {
const resp = await fetch("/status", {cache: "no-store"});
const {solving} = await resp.json();
if (!solvingHere) setSolveDisabled(solving,
solving ? "Another viewer is solving…" : "", "other");
} catch (e) {/* leave button as-is on a transient error */}
}
setInterval(pollStatus, 1000);
pollStatus();
function renderSolution(s, placeholder) { function renderSolution(s, placeholder) {
// Collapse any previously-shown solutions so the new one is the focus. // Collapse any previously-shown solutions so the new one is the focus.
for (const d of document.querySelectorAll("#output details.solution")) d.open = false; for (const d of document.querySelectorAll("#output details.solution")) d.open = false;
@ -798,6 +896,10 @@
if (s.plan && s.plan.length) { if (s.plan && s.plan.length) {
out.append(el("h3", {}, "Plan")); out.append(el("h3", {}, "Plan"));
out.append(el("p", {}, el("button", {
class: "mini", type: "button",
onclick: () => downloadPlanCsv(s, n),
}, "Download CSV")));
out.append(gridTable( out.append(gridTable(
["Turn", "City", "Action", "Detail", "Governor", "Overwork"], ["Turn", "City", "Action", "Detail", "Governor", "Overwork"],
s.plan.map(p => [p.turn, p.city, p.action, p.detail, p.governor, s.plan.map(p => [p.turn, p.city, p.action, p.detail, p.governor,
@ -823,11 +925,139 @@
s.trade_conversions.map(c => [c.turn, c.resource, fmtNum(c.amount)]))); s.trade_conversions.map(c => [c.turn, c.resource, fmtNum(c.amount)])));
} }
if (s.forced_conversions && s.forced_conversions.length) {
out.append(el("h3", {}, "Forced conversions"));
out.append(gridTable(
["Turn", "From", "From amount", "To", "To amount"],
s.forced_conversions.map(c =>
[c.turn, c.from, fmtNum(c.from_amount), c.to, fmtNum(c.to_amount)])));
}
// Swap the finished card in for this request's placeholder. // Swap the finished card in for this request's placeholder.
if (placeholder) placeholder.replaceWith(details); if (placeholder) placeholder.replaceWith(details);
else document.getElementById("output").prepend(details); else document.getElementById("output").prepend(details);
} }
// Reorder one Turn's moves so that, starting from ``open`` balances, the
// running total of every resource in ``keys`` stays >= 0 after each move.
// Backtracking DFS keyed on the used-move bitmask (the set of used moves
// fully determines the balance), memoizing dead-ends so the search is at
// worst O(2^n) for a Turn's n moves (n is small: one per City + one
// conversion). Returns a valid ordering, or the moves unchanged if none
// exists (which means the solved plan isn't strictly orderable).
function orderMovesNoNegative(moves, open, keys) {
const n = moves.length;
if (n <= 1 || n > 22) return moves.slice();
const full = (1 << n) - 1;
const failed = new Set();
const pick = [];
function dfs(mask, bal) {
if (mask === full) return true;
if (failed.has(mask)) return false;
for (let i = 0; i < n; i++) {
if (mask & (1 << i)) continue;
const nb = {...bal};
let ok = true;
for (const r of keys) {
nb[r] = (nb[r] || 0) + (moves[i].deltas[r] || 0);
if (nb[r] < -1e-9) {ok = false; break;}
}
if (!ok) continue;
pick.push(i);
if (dfs(mask | (1 << i), nb)) return true;
pick.pop();
}
failed.add(mask);
return false;
}
const base = {};
for (const r of keys) base[r] = open[r] || 0;
return dfs(0, base) ? pick.map(i => moves[i]) : moves.slice();
}
// Build and download the plan's move history as a CSV. One row per move:
// each City's Industry Action, plus one row per Turn for that Turn's Trade
// Goods conversions. Ordering id is a unique increasing integer giving the
// order; Summary is "<action> <city> (turn N)" (or "convert trade goods
// (turn N)"). Datetime, Details and Scratch are left blank; the Δ columns
// are that move's net resource change.
function downloadPlanCsv(s, n) {
const headers = ["Ordering id", "Datetime", "Summary", "ΔElectrum",
"ΔBrass", "ΔSteel", "ΔLuxuries", "ΔCapital", "ΔTrade Goods",
"ΔExpress Tickets", "Details", "Scratch"];
const cell = v => {
const str = v == null ? "" : String(v);
return /[",\n]/.test(str) ? '"' + str.replace(/"/g, '""') + '"' : str;
};
// Collect every move and group by turn. A move is a City's Industry
// Action or a Turn's Trade Goods conversion (aggregated into one row).
const byTurn = {};
const push = m => (byTurn[m.turn] || (byTurn[m.turn] = [])).push(m);
for (const p of (s.plan || []))
push({
turn: p.turn, deltas: p.deltas || {},
summary: `${p.action} ${p.city} (turn ${p.turn})`
});
const convByTurn = {};
for (const c of (s.trade_conversions || [])) {
const d = convByTurn[c.turn] || (convByTurn[c.turn] = {});
d[c.resource] = (d[c.resource] || 0) + c.amount;
d.trade_goods = (d.trade_goods || 0) - c.amount;
}
for (const t of Object.keys(convByTurn))
push({
turn: +t, deltas: convByTurn[t],
summary: `convert trade goods (turn ${t})`
});
// Forced conversions: one row each, spending `from` and yielding `to`.
for (const c of (s.forced_conversions || [])) {
const d = {};
d[c.from] = (d[c.from] || 0) - c.from_amount;
d[c.to] = (d[c.to] || 0) + c.to_amount;
push({
turn: c.turn, deltas: d,
summary: `convert ${fmtNum(c.from_amount)} ${c.from} → ` +
`${fmtNum(c.to_amount)} ${c.to} (turn ${c.turn})`
});
}
// The solver only guarantees resources are non-negative at the END of
// each Turn, so the raw (turn, city) order can momentarily overdraw a
// resource (e.g. two Upgrades spend Steel before a conversion refills
// it). Within each Turn we reorder moves so the running balance never
// goes negative, carrying the balance across Turns. Electrum is
// included so forced conversions that spend it are ordered correctly;
// the Provisioner's off-pool +0.5/turn only ever inflates the running
// electrum balance, so it never triggers a false overdraw.
const ORDER_RES = ["capital", "luxuries", "steel", "brass",
"electrum", "trade_goods", "express"];
const moves = [];
let bal = {...(s.start_resources || {})};
const turns = Object.keys(byTurn).map(Number).sort((a, b) => a - b);
for (const t of turns) {
const ordered = orderMovesNoNegative(byTurn[t], bal, ORDER_RES);
for (const m of ordered) {
for (const r of ORDER_RES) bal[r] = (bal[r] || 0) + (m.deltas[r] || 0);
moves.push(m);
}
}
const delta = (m, r) => fmtNum(m.deltas[r] || 0);
const rows = moves.map((m, i) =>
[i + 1, "", m.summary,
delta(m, "electrum"), delta(m, "brass"), delta(m, "steel"),
delta(m, "luxuries"), delta(m, "capital"), delta(m, "trade_goods"),
delta(m, "express"), "", ""].map(cell).join(","));
const csv = headers.map(cell).join(",") + "\n" + rows.join("\n") + "\n";
const blob = new Blob([csv], {type: "text/csv;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = el("a", {href: url, download: `solution-${n}-moves.csv`});
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
// Trim trailing ".0" so whole numbers read cleanly. // Trim trailing ".0" so whole numbers read cleanly.
function fmtNum(v) { function fmtNum(v) {
if (typeof v !== "number") return String(v); if (typeof v !== "number") return String(v);

18
main.py
View file

@ -14,6 +14,7 @@ from __future__ import annotations
import json import json
import os import os
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
@ -23,20 +24,29 @@ from solve import problem_from_dict, solve, solution_to_dict
# The UI is a single static page served from index.html next to this module. # The UI is a single static page served from index.html next to this module.
INDEX_HTML = (Path(__file__).resolve().parent / "index.html").read_text(encoding="utf-8") INDEX_HTML = (Path(__file__).resolve().parent / "index.html").read_text(encoding="utf-8")
# Only one solve may run at a time across all viewers. While it's held, other
# clients get an immediate 429 from /solve and disable their button (they learn
# the busy state by polling /status).
_solve_lock = threading.Lock()
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
def _send(self, code, body, content_type="application/json"): def _send(self, code, body, content_type="application/json", no_cache=False):
if isinstance(body, str): if isinstance(body, str):
body = body.encode("utf-8") body = body.encode("utf-8")
self.send_response(code) self.send_response(code)
self.send_header("Content-Type", content_type) self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
if no_cache:
self.send_header("Cache-Control", "no-store")
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
def do_GET(self): def do_GET(self):
if self.path in ("/", "/index.html"): if self.path in ("/", "/index.html"):
self._send(200, INDEX_HTML, "text/html; charset=utf-8") self._send(200, INDEX_HTML, "text/html; charset=utf-8")
elif self.path == "/status":
self._send(200, json.dumps({"solving": _solve_lock.locked()}), no_cache=True)
else: else:
self._send(404, json.dumps({"error": "not found"})) self._send(404, json.dumps({"error": "not found"}))
@ -44,6 +54,10 @@ class Handler(BaseHTTPRequestHandler):
if self.path != "/solve": if self.path != "/solve":
self._send(404, json.dumps({"error": "not found"})) self._send(404, json.dumps({"error": "not found"}))
return return
# Reject immediately if another viewer is already solving.
if not _solve_lock.acquire(blocking=False):
self._send(429, json.dumps({"error": "Another solve is already in progress"}))
return
try: try:
length = int(self.headers.get("Content-Length", 0)) length = int(self.headers.get("Content-Length", 0))
payload = json.loads(self.rfile.read(length) or b"{}") payload = json.loads(self.rfile.read(length) or b"{}")
@ -53,6 +67,8 @@ class Handler(BaseHTTPRequestHandler):
self._send(200, json.dumps(solution_to_dict(sol))) self._send(200, json.dumps(solution_to_dict(sol)))
except Exception as exc: # surface errors to the browser except Exception as exc: # surface errors to the browser
self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"})) self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"}))
finally:
_solve_lock.release()
def log_message(self, fmt, *args): # quieter console def log_message(self, fmt, *args): # quieter console
pass pass

113
solve.py
View file

@ -375,6 +375,15 @@ class Problem:
# Each entry: {"turn": int, "resource": str, "op": one of >=/<=/==, "value": int}. # Each entry: {"turn": int, "resource": str, "op": one of >=/<=/==, "value": int}.
# Turns are 0-indexed (None or omitted => final Turn). # Turns are 0-indexed (None or omitted => final Turn).
resource_constraints: list[dict] = field(default_factory=list) resource_constraints: list[dict] = field(default_factory=list)
# Forced resource conversions on specific Turns. These are NOT decisions the
# optimizer makes - each one deterministically spends ``from_amount`` of one
# resource and yields ``to_amount`` of another on the given Turn. Because the
# resource balance is kept non-negative, the model is forced to have the
# spent resource available at that Turn. Each entry:
# {"turn": int, "from": str, "to": str, "from_amount": number, "to_amount": number}.
# Turns are 0-indexed (None or omitted => final Turn); ``amount`` may be given
# as a shorthand for an equal ``from_amount``/``to_amount`` (1-for-1).
conversions: list[dict] = field(default_factory=list)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@ -389,6 +398,9 @@ class CityTurnPlan:
detail: str = "" # e.g. collect choice, upgrade name, renovate target detail: str = "" # e.g. collect choice, upgrade name, renovate target
governor: str = "" # agent appointed governor (if any) governor: str = "" # agent appointed governor (if any)
overwork: bool = False overwork: 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)
@dataclass @dataclass
@ -397,9 +409,15 @@ class Solution:
objective_value: float | None objective_value: float | None
final_resources: dict[str, float] final_resources: dict[str, float]
final_renown_total: int final_renown_total: int
# Resolved starting resource amounts (after rounding), so the UI can
# reconstruct each Turn's opening balance for ordering moves.
start_resources: dict[str, float] = field(default_factory=dict)
plan: list[CityTurnPlan] = field(default_factory=list) plan: list[CityTurnPlan] = field(default_factory=list)
# Mid-game Trade Goods conversions: list of {turn, resource, amount}. # Mid-game Trade Goods conversions: list of {turn, resource, amount}.
trade_conversions: list[dict] = field(default_factory=list) trade_conversions: list[dict] = field(default_factory=list)
# Forced (caller-specified, non-optimizer) conversions that were applied:
# list of {turn, from, to, from_amount, to_amount}.
forced_conversions: list[dict] = field(default_factory=list)
# Resource amounts at the END of each turn: list of {turn, <resource>: amount}. # Resource amounts at the END of each turn: list of {turn, <resource>: amount}.
resources_by_turn: list[dict] = field(default_factory=list) resources_by_turn: list[dict] = field(default_factory=list)
@ -420,6 +438,12 @@ class _Builder:
self.res: dict[str, list[cp_model.IntVar]] = {} self.res: dict[str, list[cp_model.IntVar]] = {}
# resource production delta per (resource, turn). # resource production delta per (resource, turn).
self.delta: dict[tuple[str, int], list] = {} self.delta: dict[tuple[str, int], list] = {}
# same deltas, but attributed to the City whose Action produced them, so
# each plan row (city+turn) can report its own per-resource change. Set
# while a City is being built (see _cur_ci); global deltas (trade
# conversion, governor one-time bonuses) are left unattributed.
self.city_delta: dict[tuple[int, str, int], list] = {}
self._cur_ci: Optional[int] = None
# per (city_index, turn) action booleans # per (city_index, turn) action booleans
self.act: dict[tuple[int, int, Action], cp_model.IntVar] = {} self.act: dict[tuple[int, int, Action], cp_model.IntVar] = {}
# governor assignment: gov[(agent_idx, city_idx, turn)] bool # governor assignment: gov[(agent_idx, city_idx, turn)] bool
@ -450,6 +474,8 @@ class _Builder:
def _add_delta(self, resource: str, t: int, expr): def _add_delta(self, resource: str, t: int, expr):
self.delta.setdefault((resource, t), []).append(expr) self.delta.setdefault((resource, t), []).append(expr)
if self._cur_ci is not None:
self.city_delta.setdefault((self._cur_ci, resource, t), []).append(expr)
# -- main build -------------------------------------------------------- # # -- main build -------------------------------------------------------- #
@ -458,6 +484,7 @@ class _Builder:
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()
self._build_conversions()
self._build_onetime_governor_bonuses() self._build_onetime_governor_bonuses()
self._build_resource_balance() self._build_resource_balance()
self._build_provisioner_electrum() self._build_provisioner_electrum()
@ -575,7 +602,10 @@ class _Builder:
def _build_city_dynamics(self): def _build_city_dynamics(self):
for ci, city in enumerate(self.p.cities): for ci, city in enumerate(self.p.cities):
# Attribute every delta added while building this City to it.
self._cur_ci = ci
self._build_one_city(ci, city) self._build_one_city(ci, city)
self._cur_ci = None
def _build_one_city(self, ci: int, city: City): def _build_one_city(self, ci: int, city: City):
m = self.m m = self.m
@ -1087,6 +1117,38 @@ class _Builder:
self._add_delta(r, t, c) # +1 target resource self._add_delta(r, t, c) # +1 target resource
self._add_delta("trade_goods", t, -c) # -1 Trade Good self._add_delta("trade_goods", t, -c) # -1 Trade Good
def _build_conversions(self):
"""Forced (non-optimizer) resource conversions on specific Turns.
Each conversion deterministically spends ``from_amount`` of one resource
and yields ``to_amount`` of another at the END of its Turn. They are
plain constant deltas (not decisions), added before the resource balance
is built, so the non-negative balance forces the model to have the spent
resource available at that Turn."""
self.conversions: list[dict] = []
for c in self.p.conversions:
t_in = c.get("turn")
t = self.T - 1 if t_in is None else int(t_in)
if not (0 <= t < self.T):
raise ValueError(
f"conversion turn {t_in} out of range 0..{self.T - 1}")
src, dst = c["from"], c["to"]
if src not in RESOURCES:
raise ValueError(f"conversion 'from' unknown resource: {src!r}")
if dst not in RESOURCES:
raise ValueError(f"conversion 'to' unknown resource: {dst!r}")
amount = c.get("amount")
from_amt = int(round(c.get("from_amount", amount if amount is not None else 0)))
to_amt = int(round(c.get("to_amount", amount if amount is not None else from_amt)))
if from_amt < 0 or to_amt < 0:
raise ValueError("conversion amounts must be non-negative")
self._add_delta(src, t, -from_amt)
self._add_delta(dst, t, to_amt)
self.conversions.append({
"turn": t, "from": src, "to": dst,
"from_amount": from_amt, "to_amount": to_amt,
})
def _build_onetime_governor_bonuses(self): def _build_onetime_governor_bonuses(self):
"""Courier: the first Turn the Agent governs any City, grant a one-time """Courier: the first Turn the Agent governs any City, grant a one-time
``{resource: amount}`` bonus. Added to the resource deltas before the ``{resource: amount}`` bonus. Added to the resource deltas before the
@ -1286,6 +1348,37 @@ def solve(problem: Problem, max_time_seconds: float = 30.0,
def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solution: def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solution:
T = b.T T = b.T
# Resources an Agent grants as a side-effect of *governing* (not produced by
# the City's own Industry Action, so not in city_delta): the Provisioner's
# +1.5 Electrum per governing Turn and the Courier's one-time bonus. These
# are attributed to the governed City's row for that Turn.
extra: dict[tuple[int, int], dict[str, float]] = {}
def _add_extra(ci, t, res, amt):
if amt:
d = extra.setdefault((ci, t), {})
d[res] = d.get(res, 0.0) + amt
for ai, agent in enumerate(problem.agents):
if agent.governor_electrum_half:
for ci in range(len(problem.cities)):
for t in range(T):
g = b.gov.get((ai, ci, t))
if g is not None and solver.Value(g) == 1:
_add_extra(ci, t, "electrum", agent.governor_electrum_half / 2.0)
if agent.onetime_governor_bonus:
# Only the first Turn this Agent governs any City pays the bonus.
first = next(
((ci, t) for t in range(T) for ci in range(len(problem.cities))
if (g := b.gov.get((ai, ci, t))) is not None and solver.Value(g) == 1),
None)
if first is not None:
ci, t = first
for res, amt in agent.onetime_governor_bonus.items():
if res in RESOURCES:
_add_extra(ci, t, res, float(amt))
plan: list[CityTurnPlan] = [] plan: list[CityTurnPlan] = []
for ci, city in enumerate(problem.cities): for ci, city in enumerate(problem.cities):
for t in range(T): for t in range(T):
@ -1300,7 +1393,8 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio
(ci, t) in b._foreman_renov (ci, t) in b._foreman_renov
and bool(solver.Value(b._foreman_renov[(ci, t)])) and bool(solver.Value(b._foreman_renov[(ci, t)]))
) )
if (chosen is None or chosen == Action.IDLE) and not foreman_renov: if (chosen is None or chosen == Action.IDLE) and not foreman_renov \
and (ci, t) not in extra:
continue continue
detail = "" detail = ""
governor = "" governor = ""
@ -1342,9 +1436,24 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio
detail = f"{detail} + {rdetail}" if detail else rdetail detail = f"{detail} + {rdetail}" if detail else rdetail
if chosen is None or chosen == Action.IDLE: if chosen is None or chosen == Action.IDLE:
action_label = Action.RENOVATE.value action_label = Action.RENOVATE.value
# Net per-resource change attributed to this City this Turn: the
# change produced by its Industry Action, plus any resources granted
# to it by a governing Agent (Provisioner/Courier).
deltas = {}
for r in RESOURCES:
total = 0
for expr in b.city_delta.get((ci, r, t), []):
total += expr if isinstance(expr, int) else solver.Value(expr)
if total:
deltas[r] = float(total)
for r, amt in extra.get((ci, t), {}).items():
deltas[r] = deltas.get(r, 0.0) + amt
if deltas[r] == 0:
del deltas[r]
plan.append(CityTurnPlan( plan.append(CityTurnPlan(
turn=t, city=city.name, action=action_label, turn=t, city=city.name, action=action_label,
detail=detail, governor=governor, overwork=overwork, detail=detail, governor=governor, overwork=overwork,
deltas=deltas,
)) ))
final_resources = { final_resources = {
@ -1366,8 +1475,10 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio
objective_value=solver.ObjectiveValue() / OBJ_SCALE, objective_value=solver.ObjectiveValue() / OBJ_SCALE,
final_resources=final_resources, final_resources=final_resources,
final_renown_total=int(solver.Value(b.renown_total)), final_renown_total=int(solver.Value(b.renown_total)),
start_resources={r: float(int(round(problem.start.get(r, 0)))) for r in RESOURCES},
plan=sorted(plan, key=lambda p: (p.turn, p.city)), plan=sorted(plan, key=lambda p: (p.turn, p.city)),
trade_conversions=conversions, trade_conversions=conversions,
forced_conversions=list(b.conversions),
resources_by_turn=resources_by_turn, resources_by_turn=resources_by_turn,
) )