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