Optional conversion and cancellation

Added in optional conversions and cancelling searches without just
closing the tab
This commit is contained in:
Pagwin 2026-06-18 21:42:49 -04:00
parent 742d6cce23
commit ae7fef5d7b
3 changed files with 171 additions and 10 deletions

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",
@ -864,7 +929,7 @@
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, token); 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`
@ -964,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);
@ -1051,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

20
main.py
View file

@ -100,12 +100,13 @@ def fetch_solve(token):
# 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}
@ -236,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:

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,
) )