diff --git a/index.html b/index.html index 963ed5b..e89e7e6 100644 --- a/index.html +++ b/index.html @@ -406,6 +406,10 @@ + +

@@ -639,7 +643,7 @@ value: (c.adjacent || []).join(", "), placeholder: "Bearhearth, Kingsland" }); - const forced = el("input", {value: "", placeholder: "0:upgrade"}); + const forced = el("input", {value: pairsToStr(c.forced_action), placeholder: "0:upgrade"}); // Available turns are given as an inclusive arrival..departure range and // expanded into the explicit list the solver expects. Blank arrival means // "from turn 0", blank departure means "through the last turn"; both blank @@ -743,8 +747,8 @@ const name = el("input", {value: a.name || kind}); const desc = el("span", {class: "help"}); const bastions = num(a.bonus_trade_goods || 3, {min: 0}); - const forced = el("input", {value: "", placeholder: "0:Aridias"}); - const avail = el("input", {value: ""}); + const forced = el("input", {value: pairsToStr(a.forced_city), placeholder: "0:Aridias"}); + const avail = el("input", {value: (a.available_turns || []).join(", ")}); const bastionsField = field("Bastions (Baron only)", bastions); @@ -793,7 +797,14 @@ card._get = () => { const o = {resource: res.value, scalar: +scalar.value}; if (turn.value !== "") o.turn = +turn.value; - if (isLog.checked) o.log_mapping = buildLogTable(expr.value, res.value); + if (isLog.checked) { + o.log_mapping = buildLogTable(expr.value, res.value); + // The source expression rides along under an underscore key: + // the solver ignores it (it only wants the sampled table) but + // the server stores it, so it round-trips through both share + // links and export bundles back into this field on re-import. + o._expr = expr.value; + } return o; }; const resF = field("Resource", res); resF.classList.add("linear-only"); @@ -948,6 +959,12 @@ } return o; } + // Inverse of parsePairs: serialise a {turn: value} map back to the + // "turn:value, …" text its input expects (so an imported problem can + // refill the field). Returns "" for an empty/absent map. + function pairsToStr(obj) { + return Object.entries(obj || {}).map(([k, v]) => `${k}:${v}`).join(", "); + } function collect(id) { return [...document.getElementById(id).children].map(r => r._get()); @@ -1222,7 +1239,7 @@ break; case "done": finishPending(j.token, (e) => - renderSolution(j.solution, e.card, j.token, e.n, j)); + renderSolution(j.solution, e.card, j.token, e.n, j, e.problem, e.maxTime)); break; case "cancelled": // Leave a visible "cancelled" card (whether this tab cancelled @@ -1259,7 +1276,9 @@ const n = ++solutionCount; const card = makePendingCard(token, n); document.getElementById("output").prepend(card); - pending.set(token, {token, card, n, confirmed: false}); + // Stash the inputs alongside the pending solve so, once it finishes, + // its card can export a self-contained {problem, solution} bundle. + pending.set(token, {token, card, n, confirmed: false, problem, maxTime: time}); fetch("/solve", { method: "POST", headers: {"Content-Type": "application/json"}, @@ -1276,7 +1295,7 @@ }).catch((e) => pendingError(token, e.message)); } - function renderSolution(s, placeholder, token, n, timing) { + function renderSolution(s, placeholder, token, n, timing, problem, maxTime) { // Collapse any previously-shown solutions so the new one is the focus. for (const d of document.querySelectorAll("#output details.solution")) d.open = false; @@ -1293,14 +1312,30 @@ details.append(summary); const out = details; - // A shareable permalink to this stored solve, looked up by its UUID, - // plus a Rename button (only meaningful for a stored/shareable solve). + // Card actions. A shareable permalink + Rename are only meaningful for + // a stored/shareable solve (one with a token). Export is always offered: + // it downloads a self-contained {problem, solution} bundle that can be + // re-imported later even after the solve leaves the server's database. + // When the solve's inputs are on hand, "Load parameters into form" + // recreates them in the form so they can be tweaked and re-solved. + const actions = el("p", {}); if (token) { - out.append(el("p", {}, [ + actions.append( copyLinkButton("Copy share link", () => shareUrl([token])), " ", - renameButton(token, n, (label) => {nameSpan.textContent = label;}), - ])); + renameButton(token, n, (label) => {nameSpan.textContent = label;}), " "); } + actions.append(el("button", { + class: "mini", type: "button", + onclick: () => exportSolve({token, n, solution: s, problem, maxTime, + name: solveLabel(token, n)}), + }, "Export")); + if (problem) { + actions.append(" ", el("button", { + class: "mini", type: "button", + onclick: () => applyProblem(problem, maxTime), + }, "Load parameters into form")); + } + out.append(actions); out.append(el("p", { html: `Status: ${s.status}   Objective: ${s.objective_value ?? "—"}   ` + @@ -1517,6 +1552,133 @@ return Number.isInteger(v) ? String(v) : String(+v.toFixed(2)); } + // --- export / import a solve ---------------------------------------- + // A solve bundle is self-contained JSON: the exact problem sent to the + // solver (every input parameter) plus its solution result. It can be + // re-imported later — rendered, and its inputs reloaded into the form — + // even after the solve has been evicted from the server's database. + const SOLVE_FILE_FORMAT = "dws-solve"; + + function downloadJson(filename, obj) { + const blob = new Blob([JSON.stringify(obj, null, 2)], + {type: "application/json;charset=utf-8"}); + const url = URL.createObjectURL(blob); + const a = el("a", {href: url, download: filename}); + document.body.append(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } + + // Export one solve to a JSON file. The problem may be known locally (a + // solve queued from this tab) or, failing that, fetched from the server + // by token while the solve still exists there. If neither yields it the + // solution is still exported on its own. + async function exportSolve({token, n, solution, problem, maxTime, name}) { + let prob = problem, mt = maxTime; + if (!prob && token) { + try { + const r = await fetch("/solve/" + encodeURIComponent(token)); + if (r.ok) prob = (await r.json()).problem; + } catch (e) {/* fall through: export the solution alone */} + } + const bundle = { + format: SOLVE_FILE_FORMAT, version: 1, + exported_at: new Date().toISOString(), + name: name || `Solution ${n}`, + max_time_seconds: mt ?? null, + problem: prob || null, + solution, + }; + const safe = (bundle.name || "solution").replace(/[^\w.-]+/g, "_"); + downloadJson(`${safe}.dws.json`, bundle); + } + + function importSolveFile() {document.getElementById("importFile").click();} + + function onImportFile(ev) { + const file = ev.target.files && ev.target.files[0]; + ev.target.value = ""; // let the same file be re-imported later + if (!file) return; + const errBox = document.getElementById("error"); + const reader = new FileReader(); + reader.onload = () => { + errBox.textContent = ""; + let bundle; + try {bundle = JSON.parse(reader.result);} + catch (e) {errBox.textContent = "Import failed: not valid JSON."; return;} + if (!bundle || bundle.format !== SOLVE_FILE_FORMAT || !bundle.solution) { + errBox.textContent = + "Import failed: not a Days Without Strife solve file."; + return; + } + const n = ++solutionCount; + // Rebuild the whole input form to match the imported problem, so + // the UI describes exactly the solve that was exported. Log terms + // round-trip their source expression too (the bundle carries it). + if (bundle.problem) applyProblem(bundle.problem, bundle.max_time_seconds); + // Give the imported solve a fresh token and register it with the + // server (POST /import) so it's stored like any other solve — its + // share link then resolves and the share buttons work even though + // the server never computed it. A real custom name (anything but + // the default "Solution n") is adopted as this token's label. + const token = uuid(); + const name = (bundle.name && !/^Solution \d+$/.test(bundle.name)) + ? bundle.name : null; + if (name) {solveNames.set(token, name); saveSolveNames();} + fetch("/import", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + token, name, problem: bundle.problem || null, + solution: bundle.solution, + }), + }).catch(() => {/* offline: the card still renders, links just 404 */}); + const placeholder = el("div", {style: "display:none"}); + document.getElementById("output").prepend(placeholder); + renderSolution(bundle.solution, placeholder, token, n, null, + bundle.problem || null, bundle.max_time_seconds); + }; + reader.onerror = () => {errBox.textContent = "Import failed: could not read file.";}; + reader.readAsText(file); + } + + // Load a problem's input parameters back into the form, replacing the + // current inputs. Backs the "Load parameters into form" button. + function applyProblem(problem, maxTime) { + if (!problem) return; + const setVal = (id, v) => { + if (v !== undefined && v !== null) document.getElementById(id).value = v; + }; + // Turns first so the cards rebuilt below get turn dropdowns spanning + // the right range. + setVal("turns", problem.turns); + refreshTurnSelects(); + setVal("extra_renown", problem.extra_renown); + setVal("airships_launched", problem.airships_launched); + setVal("max_resource", problem.max_resource); + setVal("max_vat", problem.max_vat); + if (maxTime !== undefined && maxTime !== null) setVal("time", maxTime); + + const start = problem.start || {}; + for (const r of RESOURCES) startInputs[r].value = start[r] || 0; + const trade = new Set(problem.tradeable_into || []); + for (const r of RESOURCES) tradeInputs[r].checked = trade.has(r); + + const reset = id => {document.getElementById(id).innerHTML = "";}; + reset("cities"); (problem.cities || []).forEach(c => addCity(c)); + reset("agents"); (problem.agents || []).forEach(a => addAgent(a)); + reset("terms"); + (((problem.objective || {}).terms) || []).forEach(t => addTerm(t)); + reset("constraints"); (problem.resource_constraints || []).forEach(c => addConstraint(c)); + reset("conversions"); (problem.conversions || []).forEach(c => addConversion(c)); + reset("optional_conversions"); + (problem.optional_conversions || []).forEach(c => addOptionalConversion(c)); + + document.getElementById("error").textContent = ""; + window.scrollTo({top: 0, behavior: "smooth"}); + } + // --- seed with the example problem --- addCity({name: "0", type: "hub", renown: 2}); addCity({name: "1", type: "foundry", renown: 2, vat_steel: 1, vat_brass: 1, vat_electrum: 1}); diff --git a/main.py b/main.py index f24a5f6..d9cef22 100644 --- a/main.py +++ b/main.py @@ -352,6 +352,9 @@ class Handler(BaseHTTPRequestHandler): if path.path == "/rename": self._handle_rename() return + if path.path == "/import": + self._handle_import() + return if path.path != "/solve": self._send(404, json.dumps({"error": "not found"})) return @@ -382,6 +385,38 @@ class Handler(BaseHTTPRequestHandler): except Exception as exc: # surface errors to the browser self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"})) + def _handle_import(self): + # Register a solve that was exported and re-imported (POST /import with + # {token, problem, solution, name?}). It's stored exactly like a solve + # this server ran, so its share link resolves and the share buttons work + # even though the server never computed it. A blank/"Solution n" name is + # left unset so the card keeps its default label. + try: + length = int(self.headers.get("Content-Length", 0)) + payload = json.loads(self.rfile.read(length) or b"{}") + except Exception as exc: + self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"})) + return + token = str(payload.get("token") or "") + solution = payload.get("solution") + problem = payload.get("problem") + if not token: + self._send(400, json.dumps({"error": "missing token"})) + return + if not isinstance(solution, dict): + self._send(400, json.dumps({"error": "missing solution"})) + return + raw = payload.get("name") + name = (str(raw).strip() or None) if raw is not None else None + if name is not None: + with _names_lock: + _names[token] = name + # problem column is NOT NULL; store an empty object if none was bundled. + store_solve(token, problem if problem is not None else {}, solution) + with _cond: + _cond.notify_all() # wake any /job_status stream already watching it + self._send(200, json.dumps({"ok": True, "token": token}), no_cache=True) + def _handle_rename(self): # Set (or clear) a solve's custom display name. Works whether the solve # is still queued/running (name kept in memory, applied when stored) or diff --git a/solve.py b/solve.py index 220fe73..1009b0c 100644 --- a/solve.py +++ b/solve.py @@ -1657,8 +1657,13 @@ def problem_from_dict(d: dict) -> Problem: agents.append(Agent(**a)) obj_d = dict(d.get("objective", {})) if "terms" in obj_d: + # Underscore-prefixed keys (e.g. "_expr", the log term's source text the + # UI round-trips) are metadata the solver ignores; drop them so ScoreTerm + # only sees its own fields while the stored raw problem keeps them. obj_d["terms"] = [ - t if isinstance(t, ScoreTerm) else ScoreTerm(**t) for t in obj_d["terms"] + t if isinstance(t, ScoreTerm) + else ScoreTerm(**{k: v for k, v in t.items() if not k.startswith("_")}) + for t in obj_d["terms"] ] obj = Objective(**obj_d) kwargs = {k: v for k, v in d.items()