From 727cd03f651b85a524f098b07c24a7fab9261129 Mon Sep 17 00:00:00 2001 From: Pagwin Date: Wed, 17 Jun 2026 20:54:00 -0400 Subject: [PATCH] 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 --- index.html | 232 ++++++++++++++++++++++++++++++++++++++++++++++++++++- main.py | 18 ++++- solve.py | 113 +++++++++++++++++++++++++- 3 files changed, 360 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index 85f733c..35d7748 100644 --- a/index.html +++ b/index.html @@ -70,6 +70,26 @@ 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 { padding: .1rem .45rem; font-size: .85rem; @@ -341,8 +361,20 @@ +
+ Forced conversions +

A conversion is not a choice for the optimizer — it always + happens on the given turn, spending from amount of one resource and yielding + to amount of another. The optimizer is forced to have the spent resource + available at that turn. These appear in the solution and the CSV download. +

+
+ +
+

- + +

@@ -704,6 +736,34 @@ 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 --- function parseInts(s) { const out = s.split(",").map(x => x.trim()).filter(Boolean).map(Number); @@ -742,6 +802,7 @@ agents: collect("agents"), objective: {terms: collect("terms")}, resource_constraints: collect("constraints"), + conversions: collect("conversions"), }; } @@ -750,6 +811,11 @@ // them in request order. 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() { const errBox = document.getElementById("error"); const out = document.getElementById("output"); @@ -763,18 +829,50 @@ problem = buildProblem(); time = +document.getElementById("time").value; } catch (e) {errBox.textContent = e.message; pending.remove(); return;} + solvingHere = true; + setSolveDisabled(true, "Solving…"); try { const resp = await fetch("/solve", { method: "POST", headers: {"Content-Type": "application/json"}, 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(); if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;} renderSolution(data, pending); } 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) { // Collapse any previously-shown solutions so the new one is the focus. for (const d of document.querySelectorAll("#output details.solution")) d.open = false; @@ -798,6 +896,10 @@ if (s.plan && s.plan.length) { out.append(el("h3", {}, "Plan")); + out.append(el("p", {}, el("button", { + class: "mini", type: "button", + onclick: () => downloadPlanCsv(s, n), + }, "Download CSV"))); out.append(gridTable( ["Turn", "City", "Action", "Detail", "Governor", "Overwork"], 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)]))); } + 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. if (placeholder) placeholder.replaceWith(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 " (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. function fmtNum(v) { if (typeof v !== "number") return String(v); diff --git a/main.py b/main.py index 3ce30e5..9e19851 100644 --- a/main.py +++ b/main.py @@ -14,6 +14,7 @@ from __future__ import annotations import json import os +import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 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. 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): - 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): body = body.encode("utf-8") self.send_response(code) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(body))) + if no_cache: + self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) def do_GET(self): if self.path in ("/", "/index.html"): 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: self._send(404, json.dumps({"error": "not found"})) @@ -44,6 +54,10 @@ class Handler(BaseHTTPRequestHandler): if self.path != "/solve": self._send(404, json.dumps({"error": "not found"})) 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: length = int(self.headers.get("Content-Length", 0)) 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))) except Exception as exc: # surface errors to the browser self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"})) + finally: + _solve_lock.release() def log_message(self, fmt, *args): # quieter console pass diff --git a/solve.py b/solve.py index 5eda9d9..80fb71e 100644 --- a/solve.py +++ b/solve.py @@ -375,6 +375,15 @@ class Problem: # Each entry: {"turn": int, "resource": str, "op": one of >=/<=/==, "value": int}. # Turns are 0-indexed (None or omitted => final Turn). 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 governor: str = "" # agent appointed governor (if any) 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 @@ -397,9 +409,15 @@ class Solution: objective_value: float | None final_resources: dict[str, float] 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) # Mid-game Trade Goods conversions: list of {turn, resource, amount}. 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, : amount}. resources_by_turn: list[dict] = field(default_factory=list) @@ -420,6 +438,12 @@ class _Builder: self.res: dict[str, list[cp_model.IntVar]] = {} # resource production delta per (resource, turn). 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 self.act: dict[tuple[int, int, Action], cp_model.IntVar] = {} # governor assignment: gov[(agent_idx, city_idx, turn)] bool @@ -450,6 +474,8 @@ class _Builder: def _add_delta(self, resource: str, t: int, 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 -------------------------------------------------------- # @@ -458,6 +484,7 @@ class _Builder: self._build_actions_and_governors() self._build_city_dynamics() self._build_trade_conversion() + self._build_conversions() self._build_onetime_governor_bonuses() self._build_resource_balance() self._build_provisioner_electrum() @@ -575,7 +602,10 @@ class _Builder: def _build_city_dynamics(self): 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._cur_ci = None def _build_one_city(self, ci: int, city: City): m = self.m @@ -1087,6 +1117,38 @@ class _Builder: self._add_delta(r, t, c) # +1 target resource 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): """Courier: the first Turn the Agent governs any City, grant a one-time ``{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: 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] = [] for ci, city in enumerate(problem.cities): 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 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 detail = "" governor = "" @@ -1342,9 +1436,24 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio detail = f"{detail} + {rdetail}" if detail else rdetail if chosen is None or chosen == Action.IDLE: 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( turn=t, city=city.name, action=action_label, detail=detail, governor=governor, overwork=overwork, + deltas=deltas, )) final_resources = { @@ -1366,8 +1475,10 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio objective_value=solver.ObjectiveValue() / OBJ_SCALE, final_resources=final_resources, 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)), trade_conversions=conversions, + forced_conversions=list(b.conversions), resources_by_turn=resources_by_turn, )