Compare commits
2 commits
9fc9057dfe
...
ae7fef5d7b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae7fef5d7b | ||
|
|
742d6cce23 |
5 changed files with 302 additions and 17 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
__pycache__
|
__pycache__
|
||||||
.venv
|
.venv
|
||||||
*.pdf
|
*.pdf
|
||||||
|
*.db
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
127
index.html
127
index.html
|
|
@ -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 — 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} <b>Objective:</b> ${s.objective_value ?? "—"} ` +
|
`<b>Status:</b> ${s.status} <b>Objective:</b> ${s.objective_value ?? "—"} ` +
|
||||||
|
|
@ -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
112
main.py
|
|
@ -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)
|
||||||
|
|
|
||||||
72
solve.py
72
solve.py
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue