Compare commits
No commits in common. "db9eb00a6f589cead68caf32d66e89c79c65478f" and "b27a73bc1ff6134e9dec9e34d5653c84208ecbdf" have entirely different histories.
db9eb00a6f
...
b27a73bc1f
3 changed files with 16 additions and 252 deletions
192
index.html
192
index.html
|
|
@ -406,10 +406,6 @@
|
||||||
<button id="solveBtn" class="primary" type="button" onclick="run()">Solve</button>
|
<button id="solveBtn" class="primary" type="button" onclick="run()">Solve</button>
|
||||||
<button id="shareAllBtn" type="button" onclick="shareVisible()" style="margin-left:.4rem">Share
|
<button id="shareAllBtn" type="button" onclick="shareVisible()" style="margin-left:.4rem">Share
|
||||||
visible solutions</button>
|
visible solutions</button>
|
||||||
<button id="importBtn" type="button" onclick="importSolveFile()" style="margin-left:.4rem">Import
|
|
||||||
solution</button>
|
|
||||||
<input id="importFile" type="file" accept="application/json,.json" style="display:none"
|
|
||||||
onchange="onImportFile(event)">
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div id="error" class="err"></div>
|
<div id="error" class="err"></div>
|
||||||
|
|
@ -422,10 +418,6 @@
|
||||||
// Must match ELECTRUM_SCALE in solve.py.
|
// Must match ELECTRUM_SCALE in solve.py.
|
||||||
const ELECTRUM_SCALE = 10;
|
const ELECTRUM_SCALE = 10;
|
||||||
const SCORE_KEYS = RESOURCES.concat(["renown"]);
|
const SCORE_KEYS = RESOURCES.concat(["renown"]);
|
||||||
// Constraints may additionally pin "airships": the cumulative count of
|
|
||||||
// Airships produced via Launch by the end of the chosen Turn. Not a
|
|
||||||
// scorable resource, so it's only offered for constraints.
|
|
||||||
const CONSTRAINT_KEYS = SCORE_KEYS.concat(["airships"]);
|
|
||||||
const CITY_TYPES = ["hub", "foundry", "monument", "metropolis"];
|
const CITY_TYPES = ["hub", "foundry", "monument", "metropolis"];
|
||||||
const ACTIONS = ["idle", "collect", "renovate", "upgrade", "launch"];
|
const ACTIONS = ["idle", "collect", "renovate", "upgrade", "launch"];
|
||||||
// Upgrades that apply to any city type, plus the type-specific "3rd" upgrade.
|
// Upgrades that apply to any city type, plus the type-specific "3rd" upgrade.
|
||||||
|
|
@ -647,7 +639,7 @@
|
||||||
value: (c.adjacent || []).join(", "),
|
value: (c.adjacent || []).join(", "),
|
||||||
placeholder: "Bearhearth, Kingsland"
|
placeholder: "Bearhearth, Kingsland"
|
||||||
});
|
});
|
||||||
const forced = el("input", {value: pairsToStr(c.forced_action), placeholder: "0:upgrade"});
|
const forced = el("input", {value: "", placeholder: "0:upgrade"});
|
||||||
// Available turns are given as an inclusive arrival..departure range and
|
// Available turns are given as an inclusive arrival..departure range and
|
||||||
// expanded into the explicit list the solver expects. Blank arrival means
|
// expanded into the explicit list the solver expects. Blank arrival means
|
||||||
// "from turn 0", blank departure means "through the last turn"; both blank
|
// "from turn 0", blank departure means "through the last turn"; both blank
|
||||||
|
|
@ -751,8 +743,8 @@
|
||||||
const name = el("input", {value: a.name || kind});
|
const name = el("input", {value: a.name || kind});
|
||||||
const desc = el("span", {class: "help"});
|
const desc = el("span", {class: "help"});
|
||||||
const bastions = num(a.bonus_trade_goods || 3, {min: 0});
|
const bastions = num(a.bonus_trade_goods || 3, {min: 0});
|
||||||
const forced = el("input", {value: pairsToStr(a.forced_city), placeholder: "0:Aridias"});
|
const forced = el("input", {value: "", placeholder: "0:Aridias"});
|
||||||
const avail = el("input", {value: (a.available_turns || []).join(", ")});
|
const avail = el("input", {value: ""});
|
||||||
|
|
||||||
const bastionsField = field("Bastions (Baron only)", bastions);
|
const bastionsField = field("Bastions (Baron only)", bastions);
|
||||||
|
|
||||||
|
|
@ -801,14 +793,7 @@
|
||||||
card._get = () => {
|
card._get = () => {
|
||||||
const o = {resource: res.value, scalar: +scalar.value};
|
const o = {resource: res.value, scalar: +scalar.value};
|
||||||
if (turn.value !== "") o.turn = +turn.value;
|
if (turn.value !== "") o.turn = +turn.value;
|
||||||
if (isLog.checked) {
|
if (isLog.checked) o.log_mapping = buildLogTable(expr.value, res.value);
|
||||||
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;
|
return o;
|
||||||
};
|
};
|
||||||
const resF = field("Resource", res); resF.classList.add("linear-only");
|
const resF = field("Resource", res); resF.classList.add("linear-only");
|
||||||
|
|
@ -855,7 +840,7 @@
|
||||||
// --- resource constraints ---
|
// --- resource constraints ---
|
||||||
function addConstraint(c = {}) {
|
function addConstraint(c = {}) {
|
||||||
const card = el("div", {class: "card"});
|
const card = el("div", {class: "card"});
|
||||||
const res = selectEl(CONSTRAINT_KEYS, c.resource || "capital");
|
const res = selectEl(SCORE_KEYS, c.resource || "capital");
|
||||||
const op = selectEl([">=", "<=", "=="], c.op || ">=");
|
const op = selectEl([">=", "<=", "=="], c.op || ">=");
|
||||||
const value = num(c.value ?? 0, {step: resStep(res.value)});
|
const value = num(c.value ?? 0, {step: resStep(res.value)});
|
||||||
res.onchange = () => {value.step = resStep(res.value);};
|
res.onchange = () => {value.step = resStep(res.value);};
|
||||||
|
|
@ -963,12 +948,6 @@
|
||||||
}
|
}
|
||||||
return o;
|
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) {
|
function collect(id) {
|
||||||
return [...document.getElementById(id).children].map(r => r._get());
|
return [...document.getElementById(id).children].map(r => r._get());
|
||||||
|
|
@ -1243,7 +1222,7 @@
|
||||||
break;
|
break;
|
||||||
case "done":
|
case "done":
|
||||||
finishPending(j.token, (e) =>
|
finishPending(j.token, (e) =>
|
||||||
renderSolution(j.solution, e.card, j.token, e.n, j, e.problem, e.maxTime));
|
renderSolution(j.solution, e.card, j.token, e.n, j));
|
||||||
break;
|
break;
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
// Leave a visible "cancelled" card (whether this tab cancelled
|
// Leave a visible "cancelled" card (whether this tab cancelled
|
||||||
|
|
@ -1280,9 +1259,7 @@
|
||||||
const n = ++solutionCount;
|
const n = ++solutionCount;
|
||||||
const card = makePendingCard(token, n);
|
const card = makePendingCard(token, n);
|
||||||
document.getElementById("output").prepend(card);
|
document.getElementById("output").prepend(card);
|
||||||
// Stash the inputs alongside the pending solve so, once it finishes,
|
pending.set(token, {token, card, n, confirmed: false});
|
||||||
// its card can export a self-contained {problem, solution} bundle.
|
|
||||||
pending.set(token, {token, card, n, confirmed: false, problem, maxTime: time});
|
|
||||||
fetch("/solve", {
|
fetch("/solve", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {"Content-Type": "application/json"},
|
||||||
|
|
@ -1299,7 +1276,7 @@
|
||||||
}).catch((e) => pendingError(token, e.message));
|
}).catch((e) => pendingError(token, e.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSolution(s, placeholder, token, n, timing, problem, maxTime) {
|
function renderSolution(s, placeholder, token, n, timing) {
|
||||||
// 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;
|
||||||
|
|
||||||
|
|
@ -1316,30 +1293,14 @@
|
||||||
details.append(summary);
|
details.append(summary);
|
||||||
const out = details;
|
const out = details;
|
||||||
|
|
||||||
// Card actions. A shareable permalink + Rename are only meaningful for
|
// A shareable permalink to this stored solve, looked up by its UUID,
|
||||||
// a stored/shareable solve (one with a token). Export is always offered:
|
// plus a Rename button (only meaningful for a stored/shareable solve).
|
||||||
// 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) {
|
if (token) {
|
||||||
actions.append(
|
out.append(el("p", {}, [
|
||||||
copyLinkButton("Copy share link", () => shareUrl([token])), " ",
|
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", {
|
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 ?? "—"} ` +
|
||||||
|
|
@ -1556,133 +1517,6 @@
|
||||||
return Number.isInteger(v) ? String(v) : String(+v.toFixed(2));
|
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 ---
|
// --- seed with the example problem ---
|
||||||
addCity({name: "0", type: "hub", renown: 2});
|
addCity({name: "0", type: "hub", renown: 2});
|
||||||
addCity({name: "1", type: "foundry", renown: 2, vat_steel: 1, vat_brass: 1, vat_electrum: 1});
|
addCity({name: "1", type: "foundry", renown: 2, vat_steel: 1, vat_brass: 1, vat_electrum: 1});
|
||||||
|
|
|
||||||
35
main.py
35
main.py
|
|
@ -352,9 +352,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
if path.path == "/rename":
|
if path.path == "/rename":
|
||||||
self._handle_rename()
|
self._handle_rename()
|
||||||
return
|
return
|
||||||
if path.path == "/import":
|
|
||||||
self._handle_import()
|
|
||||||
return
|
|
||||||
if path.path != "/solve":
|
if path.path != "/solve":
|
||||||
self._send(404, json.dumps({"error": "not found"}))
|
self._send(404, json.dumps({"error": "not found"}))
|
||||||
return
|
return
|
||||||
|
|
@ -385,38 +382,6 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
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}"}))
|
||||||
|
|
||||||
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):
|
def _handle_rename(self):
|
||||||
# Set (or clear) a solve's custom display name. Works whether the solve
|
# 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
|
# is still queued/running (name kept in memory, applied when stored) or
|
||||||
|
|
|
||||||
41
solve.py
41
solve.py
|
|
@ -387,10 +387,7 @@ class Problem:
|
||||||
max_vat: int = DEFAULT_MAX_VAT
|
max_vat: int = DEFAULT_MAX_VAT
|
||||||
# Hard constraints on a resource's amount at the END of a specific Turn.
|
# Hard constraints on a resource's amount at the END of a specific Turn.
|
||||||
# Each entry: {"turn": int, "resource": str, "op": one of >=/<=/==, "value": int}.
|
# Each entry: {"turn": int, "resource": str, "op": one of >=/<=/==, "value": int}.
|
||||||
# ``resource`` is a stockpiled resource, "renown" (final Turn only), or
|
# Turns are 0-indexed (None or omitted => final Turn).
|
||||||
# "airships" (cumulative count of Airships produced by end of that Turn,
|
|
||||||
# incl. any ``airships_launched`` before the horizon). Turns are 0-indexed
|
|
||||||
# (None or omitted => final Turn).
|
|
||||||
resource_constraints: list[dict] = field(default_factory=list)
|
resource_constraints: list[dict] = field(default_factory=list)
|
||||||
# Forced resource conversions on specific Turns. These are NOT decisions the
|
# Forced resource conversions on specific Turns. These are NOT decisions the
|
||||||
# optimizer makes - each one deterministically spends ``from_amount`` of one
|
# optimizer makes - each one deterministically spends ``from_amount`` of one
|
||||||
|
|
@ -498,12 +495,6 @@ class _Builder:
|
||||||
self.city_final_renown: list[cp_model.IntVar] = []
|
self.city_final_renown: list[cp_model.IntVar] = []
|
||||||
# airship launch booleans (each adds AIRSHIP_RENOWN to asset renown)
|
# airship launch booleans (each adds AIRSHIP_RENOWN to asset renown)
|
||||||
self.launches: list[cp_model.IntVar] = []
|
self.launches: list[cp_model.IntVar] = []
|
||||||
# launch booleans indexed by the Turn they occur on, so a cumulative
|
|
||||||
# "airships produced by end of Turn t" count can be built for constraints.
|
|
||||||
self.launch_by_turn: dict[int, list] = {}
|
|
||||||
# cumulative airships produced by END of each Turn (incl. any launched
|
|
||||||
# before the horizon). Populated by _build_airship_counts.
|
|
||||||
self.airships_cum: list[cp_model.IntVar] = []
|
|
||||||
# bookkeeping for solution extraction
|
# bookkeeping for solution extraction
|
||||||
self._collect_detail: dict[tuple[int, int], dict] = {}
|
self._collect_detail: dict[tuple[int, int], dict] = {}
|
||||||
self._upgrade_choice: dict[tuple[int, int], dict] = {}
|
self._upgrade_choice: dict[tuple[int, int], dict] = {}
|
||||||
|
|
@ -533,7 +524,6 @@ class _Builder:
|
||||||
self._build_adjacency()
|
self._build_adjacency()
|
||||||
self._build_actions_and_governors()
|
self._build_actions_and_governors()
|
||||||
self._build_city_dynamics()
|
self._build_city_dynamics()
|
||||||
self._build_airship_counts()
|
|
||||||
self._build_trade_conversion()
|
self._build_trade_conversion()
|
||||||
self._build_conversions()
|
self._build_conversions()
|
||||||
self._build_optional_conversions()
|
self._build_optional_conversions()
|
||||||
|
|
@ -866,7 +856,6 @@ class _Builder:
|
||||||
launch = self.act[(ci, t, Action.LAUNCH)]
|
launch = self.act[(ci, t, Action.LAUNCH)]
|
||||||
self._add_delta("steel", t, -AIRSHIP_COST_STEEL * launch)
|
self._add_delta("steel", t, -AIRSHIP_COST_STEEL * launch)
|
||||||
self.launches.append(launch)
|
self.launches.append(launch)
|
||||||
self.launch_by_turn.setdefault(t, []).append(launch)
|
|
||||||
steel_spent = steel_spent + AIRSHIP_COST_STEEL * launch
|
steel_spent = steel_spent + AIRSHIP_COST_STEEL * launch
|
||||||
|
|
||||||
# Prodigy Governor: when Upgrading or Launching an Airship, refund
|
# Prodigy Governor: when Upgrading or Launching an Airship, refund
|
||||||
|
|
@ -1369,8 +1358,7 @@ class _Builder:
|
||||||
"""Resource amount var at END of ``turn`` (None => final Turn).
|
"""Resource amount var at END of ``turn`` (None => final Turn).
|
||||||
|
|
||||||
``resource`` may be "renown" only for the final Turn, since per-Turn
|
``resource`` may be "renown" only for the final Turn, since per-Turn
|
||||||
Renown is not tracked. It may also be "airships" (the cumulative count
|
Renown is not tracked."""
|
||||||
of Airships produced by the end of ``turn``), valid on any Turn."""
|
|
||||||
T = self.T
|
T = self.T
|
||||||
t = T - 1 if turn is None else turn
|
t = T - 1 if turn is None else turn
|
||||||
if not (0 <= t < T):
|
if not (0 <= t < T):
|
||||||
|
|
@ -1379,9 +1367,6 @@ class _Builder:
|
||||||
if turn is not None and turn != T - 1:
|
if turn is not None and turn != T - 1:
|
||||||
raise ValueError("'renown' is only available on the final Turn")
|
raise ValueError("'renown' is only available on the final Turn")
|
||||||
return self.renown_total
|
return self.renown_total
|
||||||
if resource == "airships":
|
|
||||||
# Cumulative Airships produced by END of this Turn (whole units).
|
|
||||||
return self.airships_cum[t]
|
|
||||||
if resource not in RESOURCES:
|
if resource not in RESOURCES:
|
||||||
raise ValueError(f"Unknown resource: {resource!r}")
|
raise ValueError(f"Unknown resource: {resource!r}")
|
||||||
if resource == "electrum":
|
if resource == "electrum":
|
||||||
|
|
@ -1389,21 +1374,6 @@ class _Builder:
|
||||||
return self.electrum_eff[t]
|
return self.electrum_eff[t]
|
||||||
return self.res[resource][t]
|
return self.res[resource][t]
|
||||||
|
|
||||||
def _build_airship_counts(self):
|
|
||||||
"""Cumulative count of Airships produced by the END of each Turn.
|
|
||||||
|
|
||||||
``airships_cum[t]`` = ``airships_launched`` (produced before the
|
|
||||||
horizon) + every LAUNCH taken on Turns 0..t across all Cities. This is
|
|
||||||
the quantity a caller constrains via a ``resource_constraints`` entry
|
|
||||||
whose resource is ``"airships"`` (see ``_resource_at``)."""
|
|
||||||
m = self.m
|
|
||||||
cum_prev = self.p.airships_launched
|
|
||||||
for t in range(self.T):
|
|
||||||
cum = m.NewIntVar(0, AIRSHIP_MAX, f"airships_cum_t{t}")
|
|
||||||
m.Add(cum == cum_prev + sum(self.launch_by_turn.get(t, [])))
|
|
||||||
cum_prev = cum
|
|
||||||
self.airships_cum.append(cum)
|
|
||||||
|
|
||||||
def _build_renown_total(self):
|
def _build_renown_total(self):
|
||||||
m = self.m
|
m = self.m
|
||||||
# Each Faction can Launch at most 3 Airships total (incl. any already
|
# Each Faction can Launch at most 3 Airships total (incl. any already
|
||||||
|
|
@ -1687,13 +1657,8 @@ def problem_from_dict(d: dict) -> Problem:
|
||||||
agents.append(Agent(**a))
|
agents.append(Agent(**a))
|
||||||
obj_d = dict(d.get("objective", {}))
|
obj_d = dict(d.get("objective", {}))
|
||||||
if "terms" in obj_d:
|
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"] = [
|
obj_d["terms"] = [
|
||||||
t if isinstance(t, ScoreTerm)
|
t if isinstance(t, ScoreTerm) else ScoreTerm(**t) for t in obj_d["terms"]
|
||||||
else ScoreTerm(**{k: v for k, v in t.items() if not k.startswith("_")})
|
|
||||||
for t in obj_d["terms"]
|
|
||||||
]
|
]
|
||||||
obj = Objective(**obj_d)
|
obj = Objective(**obj_d)
|
||||||
kwargs = {k: v for k, v in d.items()
|
kwargs = {k: v for k, v in d.items()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue