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;
}
#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 @@
<button class="mini" type="button" onclick="addConstraint()">+ constraint</button>
</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>
<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>
<div id="error" class="err"></div>
@ -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 "<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.
function fmtNum(v) {
if (typeof v !== "number") return String(v);

18
main.py
View file

@ -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

113
solve.py
View file

@ -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, <resource>: 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,
)