From ae7fef5d7bf13f24c1831aaf6f0543ea4b793d36 Mon Sep 17 00:00:00 2001 From: Pagwin Date: Thu, 18 Jun 2026 21:42:49 -0400 Subject: [PATCH] Optional conversion and cancellation Added in optional conversions and cancelling searches without just closing the tab --- index.html | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++- main.py | 20 ++++++------ solve.py | 72 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 10 deletions(-) diff --git a/index.html b/index.html index bcd47da..afa9d8e 100644 --- a/index.html +++ b/index.html @@ -372,8 +372,21 @@ +
+ Optional conversions +

An optional conversion is a choice for the optimizer — it may + apply the conversion (spending from amount of one resource to yield to amount + of another on the given turn) or refrain entirely. Max count lets it apply the + conversion up to that many times on the turn. The chosen counts appear in the solution and + the CSV download. +

+
+ +
+

+

@@ -764,6 +777,37 @@ 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 --- function parseInts(s) { const out = s.split(",").map(x => x.trim()).filter(Boolean).map(Number); @@ -803,6 +847,7 @@ objective: {terms: collect("terms")}, resource_constraints: collect("constraints"), 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). 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() { const errBox = document.getElementById("error"); const out = document.getElementById("output"); @@ -849,6 +913,7 @@ const token = (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + Math.random()); activeToken = token; + showCancel(true); try { const resp = await fetch("/solve", { method: "POST", @@ -864,7 +929,7 @@ if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;} renderSolution(data, pending, token); } 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` @@ -964,6 +1029,15 @@ [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. if (placeholder) placeholder.replaceWith(details); else document.getElementById("output").prepend(details); @@ -1051,6 +1125,19 @@ `${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 // each Turn, so the raw (turn, city) order can momentarily overdraw a diff --git a/main.py b/main.py index 2d0987b..a20c93e 100644 --- a/main.py +++ b/main.py @@ -100,12 +100,13 @@ def fetch_solve(token): # the busy state by polling /status). _solve_lock = threading.Lock() -# Tracks the in-flight solve so /cancel can stop it. When a tab closes, the -# browser fires navigator.sendBeacon("/cancel?token=...") — a normal small -# request that survives the close and passes cleanly through reverse proxies, -# unlike a dropped upstream socket (which the proxy keeps alive). Guarded by -# _active_lock; only one solve runs at a time, but the lock keeps the token / -# solver handoff race-free against a concurrent /cancel. +# Tracks the in-flight solve so /cancel can stop it. /cancel is hit two ways: +# the user clicking Cancel (a normal fetch, tab stays open) and a closing tab +# firing navigator.sendBeacon("/cancel?token=...") — both small requests that +# pass cleanly through reverse proxies, unlike a dropped upstream socket (which +# the proxy keeps alive). Guarded by _active_lock; only one solve runs at a +# time, but the lock keeps the token / solver handoff race-free against a +# concurrent /cancel. _active_lock = threading.Lock() _active = {"token": None, "solver": None} @@ -236,9 +237,10 @@ class Handler(BaseHTTPRequestHandler): _solve_lock.release() def _handle_cancel(self, query): - # Stop the in-flight solve iff the beacon's token matches it, so a closing - # tab can't cancel a *different* viewer's solve. The running /solve handler - # then returns normally and releases the lock. + # Stop the in-flight solve iff the request's token matches it, so a Cancel + # click (or closing tab) can't cancel a *different* viewer's solve. The + # running /solve handler then returns normally — with the best plan found + # so far — and releases the lock. token = (query.get("token") or [""])[0] stopped = False with _active_lock: diff --git a/solve.py b/solve.py index f58a67b..2c081bc 100644 --- a/solve.py +++ b/solve.py @@ -384,6 +384,14 @@ class Problem: # 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) + # 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: # list of {turn, from, to, from_amount, to_amount}. 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, : amount}. resources_by_turn: list[dict] = field(default_factory=list) @@ -485,6 +496,7 @@ class _Builder: self._build_city_dynamics() self._build_trade_conversion() self._build_conversions() + self._build_optional_conversions() self._build_onetime_governor_bonuses() self._build_resource_balance() self._build_provisioner_electrum() @@ -1026,10 +1038,18 @@ class _Builder: found_active = type_active[CityType.FOUNDRY][t] found_collect = self._mul_bool(collect, found_active, 1) # 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 = {} for r in vat_res: pick[r] = m.NewBoolVar(f"vatpick_{ci}_{r}_t{t}") 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) self._vat_choice[(ci, t)] = pick @@ -1149,6 +1169,48 @@ class _Builder: "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): """Courier: the first Turn the Agent governs any City, grant a one-time ``{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) if 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 = [] for t in range(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)), trade_conversions=conversions, forced_conversions=list(b.conversions), + optional_conversions=optional_conversions, resources_by_turn=resources_by_turn, )