Compare commits

...

2 commits

Author SHA1 Message Date
Pagwin
ae7fef5d7b Optional conversion and cancellation
Added in optional conversions and cancelling searches without just
closing the tab
2026-06-18 21:43:21 -04:00
Pagwin
742d6cce23 share links added 2026-06-17 23:33:12 -04:00
5 changed files with 302 additions and 17 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
__pycache__ __pycache__
.venv .venv
*.pdf *.pdf
*.db

View file

@ -6,4 +6,11 @@ services:
environment: environment:
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=8000 - PORT=8000
# Stored solves live on the mounted volume so they survive restarts.
- DB_PATH=/data/solves.db
volumes:
- solves:/data
restart: unless-stopped restart: unless-stopped
volumes:
solves:

View file

@ -372,8 +372,21 @@
<button class="mini" type="button" onclick="addConversion()">+ conversion</button> <button class="mini" type="button" onclick="addConversion()">+ conversion</button>
</fieldset> </fieldset>
<fieldset>
<legend>Optional conversions</legend>
<p class="help">An optional conversion <b>is</b> a choice for the optimizer &mdash; it may
apply the conversion (spending <b>from amount</b> of one resource to yield <b>to amount</b>
of another on the given turn) or refrain entirely. <b>Max count</b> lets it apply the
conversion up to that many times on the turn. The chosen counts appear in the solution and
the CSV download.
</p>
<div id="optional_conversions" class="cards"></div>
<button class="mini" type="button" onclick="addOptionalConversion()">+ optional conversion</button>
</fieldset>
<p> <p>
<button id="solveBtn" class="primary" type="button" onclick="run()">Solve</button> <button id="solveBtn" class="primary" type="button" onclick="run()">Solve</button>
<button id="cancelBtn" type="button" onclick="cancelSolve()" style="display:none;margin-left:.4rem">Cancel</button>
<span id="busy" class="help" style="margin-left:.6rem"></span> <span id="busy" class="help" style="margin-left:.6rem"></span>
</p> </p>
@ -764,6 +777,37 @@
document.getElementById("conversions").append(card); document.getElementById("conversions").append(card);
} }
// --- optional conversions ---
// Unlike a forced conversion, the optimizer chooses how many times (0..max
// count) to apply this on its turn, or skips it entirely.
function addOptionalConversion(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 maxCount = num(c.max_count ?? 1, {min: 1});
const turn = turnSelect(c.turn);
card._get = () => {
const o = {
from: from.value, to: to.value,
from_amount: +fromAmt.value, to_amount: +toAmt.value,
max_count: +maxCount.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("Max count", maxCount),
field("Turn (blank=final)", turn),
el("div", {class: "card-actions"}, removeBtn(card)));
document.getElementById("optional_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);
@ -803,6 +847,7 @@
objective: {terms: collect("terms")}, objective: {terms: collect("terms")},
resource_constraints: collect("constraints"), resource_constraints: collect("constraints"),
conversions: collect("conversions"), conversions: collect("conversions"),
optional_conversions: collect("optional_conversions"),
}; };
} }
@ -831,6 +876,25 @@
// so we don't cancel a solve the user backgrounds and comes back to). // so we don't cancel a solve the user backgrounds and comes back to).
window.addEventListener("pagehide", cancelActiveSolve); window.addEventListener("pagehide", cancelActiveSolve);
// Cancel this tab's in-flight solve without closing the tab: POST /cancel
// so the server stops the search. The running /solve then returns with the
// best plan found so far (or an empty/infeasible one), which run() renders.
function cancelSolve() {
if (!activeToken) return;
const btn = document.getElementById("cancelBtn");
btn.disabled = true;
btn.textContent = "Cancelling…";
fetch("/cancel?token=" + encodeURIComponent(activeToken), {method: "POST"})
.catch(() => {/* the solve still returns; nothing to do here */});
}
// Toggle the Cancel button's visibility, resetting its label/disabled state.
function showCancel(visible) {
const btn = document.getElementById("cancelBtn");
btn.style.display = visible ? "" : "none";
btn.disabled = false;
btn.textContent = "Cancel";
}
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");
@ -849,6 +913,7 @@
const token = (crypto.randomUUID ? crypto.randomUUID() const token = (crypto.randomUUID ? crypto.randomUUID()
: String(Date.now()) + Math.random()); : String(Date.now()) + Math.random());
activeToken = token; activeToken = token;
showCancel(true);
try { try {
const resp = await fetch("/solve", { const resp = await fetch("/solve", {
method: "POST", method: "POST",
@ -862,9 +927,9 @@
} }
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, token);
} catch (e) {pending.remove(); errBox.textContent = e.message;} } catch (e) {pending.remove(); errBox.textContent = e.message;}
finally {solvingHere = false; activeToken = null; setSolveDisabled(false);} finally {solvingHere = false; activeToken = null; showCancel(false); setSolveDisabled(false);}
} }
// Enable/disable the Solve button with an optional note beside it. `kind` // Enable/disable the Solve button with an optional note beside it. `kind`
@ -891,7 +956,7 @@
setInterval(pollStatus, 1000); setInterval(pollStatus, 1000);
pollStatus(); pollStatus();
function renderSolution(s, placeholder) { function renderSolution(s, placeholder, token) {
// 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;
@ -900,6 +965,19 @@
details.append(el("summary", {}, details.append(el("summary", {},
`Solution ${n} — ${s.status}, objective ${s.objective_value ?? "—"}`)); `Solution ${n} — ${s.status}, objective ${s.objective_value ?? "—"}`));
const out = details; const out = details;
// A shareable permalink to this stored solve, looked up by its UUID.
if (token) {
const url = location.origin + location.pathname + "?solve=" + encodeURIComponent(token);
out.append(el("p", {}, el("button", {
class: "mini", type: "button",
onclick: (ev) => {
navigator.clipboard?.writeText(url);
ev.target.textContent = "Link copied!";
setTimeout(() => {ev.target.textContent = "Copy share link";}, 1500);
},
}, "Copy share link")));
}
out.append(el("p", { out.append(el("p", {
html: html:
`<b>Status:</b> ${s.status} &nbsp; <b>Objective:</b> ${s.objective_value ?? "—"} &nbsp; ` + `<b>Status:</b> ${s.status} &nbsp; <b>Objective:</b> ${s.objective_value ?? "—"} &nbsp; ` +
@ -951,6 +1029,15 @@
[c.turn, c.from, fmtNum(c.from_amount), c.to, fmtNum(c.to_amount)]))); [c.turn, c.from, fmtNum(c.from_amount), c.to, fmtNum(c.to_amount)])));
} }
if (s.optional_conversions && s.optional_conversions.length) {
out.append(el("h3", {}, "Optional conversions (chosen)"));
out.append(gridTable(
["Turn", "Count", "From", "From amount", "To", "To amount"],
s.optional_conversions.map(c =>
[c.turn, c.count, c.from, fmtNum(c.from_amount * c.count),
c.to, fmtNum(c.to_amount * c.count)])));
}
// 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);
@ -1038,6 +1125,19 @@
`${fmtNum(c.to_amount)} ${c.to} (turn ${c.turn})` `${fmtNum(c.to_amount)} ${c.to} (turn ${c.turn})`
}); });
} }
// Optional conversions the optimizer chose: one row each, totalling the
// chosen count of applications spending `from` and yielding `to`.
for (const c of (s.optional_conversions || [])) {
const fromTotal = c.from_amount * c.count, toTotal = c.to_amount * c.count;
const d = {};
d[c.from] = (d[c.from] || 0) - fromTotal;
d[c.to] = (d[c.to] || 0) + toTotal;
push({
turn: c.turn, deltas: d,
summary: `convert ${fmtNum(fromTotal)} ${c.from} → ` +
`${fmtNum(toTotal)} ${c.to} (turn ${c.turn})`
});
}
// The solver only guarantees resources are non-negative at the END of // The solver only guarantees resources are non-negative at the END of
// each Turn, so the raw (turn, city) order can momentarily overdraw a // each Turn, so the raw (turn, city) order can momentarily overdraw a
@ -1096,6 +1196,27 @@
"brass": 1, "brass": 1,
"electrum": 2 "electrum": 2
}) })
// --- deep link: /?solve=<uuid> loads a previously stored solve ---
async function loadSharedSolve(token) {
const out = document.getElementById("output");
const errBox = document.getElementById("error");
const pending = el("p", {class: "pending"}, "Loading shared solve…");
out.prepend(pending);
try {
const resp = await fetch("/solve/" + encodeURIComponent(token), {cache: "no-store"});
if (resp.status === 404) {
pending.remove();
errBox.textContent = "No stored solve found for that link (it may have been evicted).";
return;
}
const rec = await resp.json();
if (!resp.ok) {pending.remove(); errBox.textContent = rec.error || "Server error"; return;}
renderSolution(rec.solution, pending, rec.token);
} catch (e) {pending.remove(); errBox.textContent = e.message;}
}
const sharedToken = new URLSearchParams(location.search).get("solve");
if (sharedToken) loadSharedSolve(sharedToken);
</script> </script>
</body> </body>

112
main.py
View file

@ -16,10 +16,12 @@ import json
import os import os
import select import select
import socket import socket
import sqlite3
import threading import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, urlsplit from urllib.parse import parse_qs, unquote, urlsplit
from solve import problem_from_dict, solve, solution_to_dict from solve import problem_from_dict, solve, solution_to_dict
@ -27,17 +29,84 @@ 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")
# Completed solves are persisted to SQLite so they can be looked up later by
# their UUID (GET /solve/<uuid>) and shared via /?solve=<uuid> deep links. Point
# DB_PATH at a mounted volume so history survives container restarts/redeploys.
# Old rows are evicted so the DB stays bounded (keep the newest MAX_SOLVES).
DB_PATH = os.environ.get(
"DB_PATH", str(Path(__file__).resolve().parent / "data" / "solves.db"))
MAX_SOLVES = int(os.environ.get("MAX_SOLVES", "500"))
def _db():
conn = sqlite3.connect(DB_PATH, timeout=5.0)
# WAL lets the concurrent /solve/<uuid> readers run alongside a writer.
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
return conn
def init_db():
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
conn = _db()
try:
with conn:
conn.execute(
"CREATE TABLE IF NOT EXISTS solves ("
"token TEXT PRIMARY KEY, ts REAL NOT NULL, status TEXT, "
"problem TEXT NOT NULL, solution TEXT NOT NULL)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_solves_ts ON solves(ts)")
finally:
conn.close()
def store_solve(token, problem, solution):
# token is client-generated; skip blanks. INSERT OR REPLACE means a repeated
# token overwrites (a client could clobber its own/another's row — acceptable
# for this app; switch to a server-issued id if that ever matters).
if not token:
return
conn = _db()
try:
with conn:
conn.execute(
"INSERT OR REPLACE INTO solves (token, ts, status, problem, solution) "
"VALUES (?, ?, ?, ?, ?)",
(token, time.time(), solution.get("status"),
json.dumps(problem), json.dumps(solution)))
conn.execute(
"DELETE FROM solves WHERE token NOT IN "
"(SELECT token FROM solves ORDER BY ts DESC LIMIT ?)",
(MAX_SOLVES,))
finally:
conn.close()
def fetch_solve(token):
conn = _db()
try:
row = conn.execute(
"SELECT token, ts, status, problem, solution FROM solves WHERE token = ?",
(token,)).fetchone()
finally:
conn.close()
if row is None:
return None
return {"token": row[0], "ts": row[1], "status": row[2],
"problem": json.loads(row[3]), "solution": json.loads(row[4])}
# Only one solve may run at a time across all viewers. While it's held, other # 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 # clients get an immediate 429 from /solve and disable their button (they learn
# the busy state by polling /status). # the busy state by polling /status).
_solve_lock = threading.Lock() _solve_lock = threading.Lock()
# Tracks the in-flight solve so /cancel can stop it. When a tab closes, the # Tracks the in-flight solve so /cancel can stop it. /cancel is hit two ways:
# browser fires navigator.sendBeacon("/cancel?token=...") — a normal small # the user clicking Cancel (a normal fetch, tab stays open) and a closing tab
# request that survives the close and passes cleanly through reverse proxies, # firing navigator.sendBeacon("/cancel?token=...") — both small requests that
# unlike a dropped upstream socket (which the proxy keeps alive). Guarded by # pass cleanly through reverse proxies, unlike a dropped upstream socket (which
# _active_lock; only one solve runs at a time, but the lock keeps the token / # the proxy keeps alive). Guarded by _active_lock; only one solve runs at a
# solver handoff race-free against a concurrent /cancel. # time, but the lock keeps the token / solver handoff race-free against a
# concurrent /cancel.
_active_lock = threading.Lock() _active_lock = threading.Lock()
_active = {"token": None, "solver": None} _active = {"token": None, "solver": None}
@ -63,10 +132,18 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(body) self.wfile.write(body)
def do_GET(self): def do_GET(self):
if self.path in ("/", "/index.html"): path = urlsplit(self.path).path
if 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": elif path == "/status":
self._send(200, json.dumps({"solving": _solve_lock.locked()}), no_cache=True) self._send(200, json.dumps({"solving": _solve_lock.locked()}), no_cache=True)
elif path.startswith("/solve/"):
token = unquote(path[len("/solve/"):])
rec = fetch_solve(token)
if rec is None:
self._send(404, json.dumps({"error": "not found"}), no_cache=True)
else:
self._send(200, json.dumps(rec), no_cache=True)
else: else:
self._send(404, json.dumps({"error": "not found"})) self._send(404, json.dumps({"error": "not found"}))
@ -99,7 +176,8 @@ class Handler(BaseHTTPRequestHandler):
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"{}")
problem = problem_from_dict(payload.get("problem", {})) raw_problem = payload.get("problem", {})
problem = problem_from_dict(raw_problem)
max_time = float(payload.get("max_time_seconds", 30.0)) max_time = float(payload.get("max_time_seconds", 30.0))
token = str(payload.get("token") or "") token = str(payload.get("token") or "")
@ -143,7 +221,11 @@ class Handler(BaseHTTPRequestHandler):
if "err" in result: if "err" in result:
raise result["err"] raise result["err"]
self._send(200, json.dumps(solution_to_dict(result["sol"]))) sol_dict = solution_to_dict(result["sol"])
# Persist the completed solve so it can be looked up by its UUID.
# (Cancelled solves take the early return above and aren't stored.)
store_solve(token, raw_problem, sol_dict)
self._send(200, json.dumps(sol_dict))
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: finally:
@ -155,9 +237,10 @@ class Handler(BaseHTTPRequestHandler):
_solve_lock.release() _solve_lock.release()
def _handle_cancel(self, query): def _handle_cancel(self, query):
# Stop the in-flight solve iff the beacon's token matches it, so a closing # Stop the in-flight solve iff the request's token matches it, so a Cancel
# tab can't cancel a *different* viewer's solve. The running /solve handler # click (or closing tab) can't cancel a *different* viewer's solve. The
# then returns normally and releases the lock. # running /solve handler then returns normally — with the best plan found
# so far — and releases the lock.
token = (query.get("token") or [""])[0] token = (query.get("token") or [""])[0]
stopped = False stopped = False
with _active_lock: with _active_lock:
@ -171,6 +254,7 @@ class Handler(BaseHTTPRequestHandler):
def main(): def main():
init_db()
host = os.environ.get("HOST", "127.0.0.1") host = os.environ.get("HOST", "127.0.0.1")
port = int(os.environ.get("PORT", "8000")) port = int(os.environ.get("PORT", "8000"))
server = ThreadingHTTPServer((host, port), Handler) server = ThreadingHTTPServer((host, port), Handler)

View file

@ -384,6 +384,14 @@ class Problem:
# Turns are 0-indexed (None or omitted => final Turn); ``amount`` may be given # 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). # as a shorthand for an equal ``from_amount``/``to_amount`` (1-for-1).
conversions: list[dict] = field(default_factory=list) conversions: list[dict] = field(default_factory=list)
# Optional resource conversions the optimizer MAY apply on a given Turn (or
# refrain from), unlike the forced ``conversions`` above. Each one is offered
# up to ``max_count`` times (default 1): the model chooses an integer
# 0..max_count of applications, each spending ``from_amount`` of one resource
# and yielding ``to_amount`` of another on that Turn. Same entry shape as
# ``conversions`` plus an optional ``max_count``; Turns are 0-indexed
# (None/omitted => final Turn).
optional_conversions: list[dict] = field(default_factory=list)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@ -418,6 +426,9 @@ class Solution:
# Forced (caller-specified, non-optimizer) conversions that were applied: # Forced (caller-specified, non-optimizer) conversions that were applied:
# list of {turn, from, to, from_amount, to_amount}. # list of {turn, from, to, from_amount, to_amount}.
forced_conversions: list[dict] = field(default_factory=list) forced_conversions: list[dict] = field(default_factory=list)
# Optional conversions the optimizer chose to apply (count > 0): list of
# {turn, from, to, from_amount, to_amount, count}.
optional_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)
@ -485,6 +496,7 @@ class _Builder:
self._build_city_dynamics() self._build_city_dynamics()
self._build_trade_conversion() self._build_trade_conversion()
self._build_conversions() self._build_conversions()
self._build_optional_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()
@ -1026,10 +1038,18 @@ class _Builder:
found_active = type_active[CityType.FOUNDRY][t] found_active = type_active[CityType.FOUNDRY][t]
found_collect = self._mul_bool(collect, found_active, 1) found_collect = self._mul_bool(collect, found_active, 1)
# choose which vat to collect (at most one, only if found_collect) # choose which vat to collect (at most one, only if found_collect)
# A vat may only be collected if it is non-empty: collecting an
# empty vat (level 0) is disallowed. When every vat is empty the
# Foundry therefore cannot Collect this Turn (found_collect == 0).
pick = {} pick = {}
for r in vat_res: for r in vat_res:
pick[r] = m.NewBoolVar(f"vatpick_{ci}_{r}_t{t}") pick[r] = m.NewBoolVar(f"vatpick_{ci}_{r}_t{t}")
m.Add(pick[r] <= found_collect) m.Add(pick[r] <= found_collect)
# nonempty[r] == 1 iff vat[r][t] >= 1.
nonempty = m.NewBoolVar(f"vatnonempty_{ci}_{r}_t{t}")
m.Add(vat[r][t] >= 1).OnlyEnforceIf(nonempty)
m.Add(vat[r][t] == 0).OnlyEnforceIf(nonempty.Not())
m.Add(pick[r] <= nonempty)
m.Add(sum(pick[r] for r in vat_res) == found_collect) m.Add(sum(pick[r] for r in vat_res) == found_collect)
self._vat_choice[(ci, t)] = pick self._vat_choice[(ci, t)] = pick
@ -1149,6 +1169,48 @@ class _Builder:
"from_amount": from_amt, "to_amount": to_amt, "from_amount": from_amt, "to_amount": to_amt,
}) })
def _build_optional_conversions(self):
"""Optional (optimizer-chosen) resource conversions on specific Turns.
Unlike the forced ``conversions``, each of these is a *decision*: the
model picks an integer count in ``0..max_count`` of how many times to
apply it on its Turn, spending ``from_amount`` per application of one
resource and yielding ``to_amount`` of another. A count of 0 means the
conversion is simply skipped. The non-negative resource balance still
forces the spent resource to be available whenever count > 0."""
m = self.m
# Parallel to self.conversions: meta dict + the chosen-count IntVar.
self.optional_conversions: list[dict] = []
self._opt_conv_vars: list[tuple[dict, cp_model.IntVar]] = []
for i, c in enumerate(self.p.optional_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"optional 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"optional conversion 'from' unknown resource: {src!r}")
if dst not in RESOURCES:
raise ValueError(f"optional 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("optional conversion amounts must be non-negative")
max_count = int(c.get("max_count", 1))
if max_count < 1:
raise ValueError("optional conversion max_count must be >= 1")
n = m.NewIntVar(0, max_count, f"optconv{i}_t{t}")
self._add_delta(src, t, -from_amt * n)
self._add_delta(dst, t, to_amt * n)
meta = {
"turn": t, "from": src, "to": dst,
"from_amount": from_amt, "to_amount": to_amt, "max_count": max_count,
}
self.optional_conversions.append(meta)
self._opt_conv_vars.append((meta, n))
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
@ -1468,6 +1530,15 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio
amt = solver.Value(var) amt = solver.Value(var)
if amt: if amt:
conversions.append({"turn": t, "resource": r, "amount": amt}) conversions.append({"turn": t, "resource": r, "amount": amt})
optional_conversions = []
for meta, n in b._opt_conv_vars:
count = int(solver.Value(n))
if count:
optional_conversions.append({
"turn": meta["turn"], "from": meta["from"], "to": meta["to"],
"from_amount": meta["from_amount"], "to_amount": meta["to_amount"],
"count": count,
})
resources_by_turn = [] resources_by_turn = []
for t in range(T): for t in range(T):
row = {"turn": t} row = {"turn": t}
@ -1483,6 +1554,7 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio
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), forced_conversions=list(b.conversions),
optional_conversions=optional_conversions,
resources_by_turn=resources_by_turn, resources_by_turn=resources_by_turn,
) )