Compare commits

..

4 commits

Author SHA1 Message Date
Pagwin
4f01b3f1af session restore added 2026-07-05 02:59:30 -04:00
Pagwin
71a428ee48 Google OR tools hints 2026-07-02 00:04:50 -04:00
Pagwin
3f9ee57208 initial notification checkbox 2026-07-01 20:01:27 -04:00
Pagwin
6ba7d08e97 license added 2026-06-25 16:21:28 -04:00
3 changed files with 218 additions and 19 deletions

7
LICENSE Normal file
View file

@ -0,0 +1,7 @@
Copyright 2026 Spencer Powell aka Pagwin
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -410,6 +410,8 @@
solution</button> solution</button>
<input id="importFile" type="file" accept="application/json,.json" style="display:none" <input id="importFile" type="file" accept="application/json,.json" style="display:none"
onchange="onImportFile(event)"> onchange="onImportFile(event)">
<label id="notifyWrap" style="display:none;margin-left:.6rem">
<input id="notifyDone" type="checkbox"> Notify when queued items complete</label>
</p> </p>
<div id="error" class="err"></div> <div id="error" class="err"></div>
@ -648,6 +650,9 @@
placeholder: "Bearhearth, Kingsland" placeholder: "Bearhearth, Kingsland"
}); });
const forced = el("input", {value: pairsToStr(c.forced_action), placeholder: "0:upgrade"}); const forced = el("input", {value: pairsToStr(c.forced_action), placeholder: "0:upgrade"});
// Soft hint (not a constraint): a suggested action per turn that
// warm-starts the search but can be overridden by the solver.
const hinted = el("input", {value: pairsToStr(c.hint_action), placeholder: "0:collect"});
// 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
@ -711,6 +716,8 @@
if (adj) o.adjacent = adj; if (adj) o.adjacent = adj;
const fa = parsePairs(forced.value); const fa = parsePairs(forced.value);
if (Object.keys(fa).length) o.forced_action = fa; if (Object.keys(fa).length) o.forced_action = fa;
const ha = parsePairs(hinted.value);
if (Object.keys(ha).length) o.hint_action = ha;
const a = arrival.value, d = departure.value; const a = arrival.value, d = departure.value;
if (a !== "" || d !== "") { if (a !== "" || d !== "") {
const lo = a !== "" ? +a : 0; const lo = a !== "" ? +a : 0;
@ -731,6 +738,7 @@
field("Upgrades (already installed)", upWrap), field("Upgrades (already installed)", upWrap),
field("Adjacent cities (csv of names)", adjacent), field("Adjacent cities (csv of names)", adjacent),
field("Forced actions (turn:action, csv)", forced), field("Forced actions (turn:action, csv)", forced),
field("Hint actions (turn:action, csv)", hinted),
field("Arrival turn (blank=start)", arrival), field("Arrival turn (blank=start)", arrival),
field("Departure turn (blank=end)", departure), field("Departure turn (blank=end)", departure),
vatGroup, vatGroup,
@ -752,6 +760,9 @@
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: pairsToStr(a.forced_city), placeholder: "0:Aridias"});
// Soft hint (not a constraint): a suggested city to govern per turn
// that biases the search but can be overridden by the solver.
const hintCity = el("input", {value: pairsToStr(a.hint_city), placeholder: "0:Aridias"});
const avail = el("input", {value: (a.available_turns || []).join(", ")}); const avail = el("input", {value: (a.available_turns || []).join(", ")});
const bastionsField = field("Bastions (Baron only)", bastions); const bastionsField = field("Bastions (Baron only)", bastions);
@ -771,6 +782,8 @@
if (type.value === "Baron") o.bonus_trade_goods = +bastions.value; if (type.value === "Baron") o.bonus_trade_goods = +bastions.value;
const fc = parsePairs(forced.value, true); const fc = parsePairs(forced.value, true);
if (Object.keys(fc).length) o.forced_city = fc; if (Object.keys(fc).length) o.forced_city = fc;
const hc = parsePairs(hintCity.value, true);
if (Object.keys(hc).length) o.hint_city = hc;
const at = parseInts(avail.value); const at = parseInts(avail.value);
if (at) o.available_turns = at; if (at) o.available_turns = at;
return o; return o;
@ -779,6 +792,7 @@
field("Agent", type), field("Agent", type),
field("Effect", desc), field("Effect", desc),
field("Forced city (turn:city, csv)", forced), field("Forced city (turn:city, csv)", forced),
field("Hint city (turn:city, csv)", hintCity),
field("Avail turns (csv, blank=all)", avail), field("Avail turns (csv, blank=all)", avail),
bastionsField, bastionsField,
el("div", {class: "card-actions"}, removeBtn(card))); el("div", {class: "card-actions"}, removeBtn(card)));
@ -1006,6 +1020,34 @@
// queued -> running -> done (rendered) / error / cancelled. // queued -> running -> done (rendered) / error / cancelled.
const pending = new Map(); const pending = new Map();
// --- "notify when queue empty" checkbox ---
// The checkbox only appears while solves are queued/running. Permission is
// requested the first time it's checked (not on page load). When the queue
// drains to empty it fires a notification (if still checked), then unchecks
// itself and hides until there's work again.
const notifyWrap = document.getElementById("notifyWrap");
const notifyDone = document.getElementById("notifyDone");
notifyDone.onchange = () => {
if (notifyDone.checked && "Notification" in window
&& Notification.permission === "default") {
Notification.requestPermission();
}
};
// Show/hide the checkbox to match the current queue state, and uncheck it
// once the queue is empty. Call after any change to `pending`.
function updateNotifyUi() {
notifyWrap.style.display = pending.size ? "" : "none";
if (!pending.size) notifyDone.checked = false;
}
// Fire the "queue empty" notification if the user opted in and granted it.
function notifyQueueEmpty() {
if (notifyDone.checked && "Notification" in window
&& Notification.permission === "granted") {
new Notification("DWS Solver",
{body: "All queued solves have finished."});
}
}
function uuid() { function uuid() {
return crypto.randomUUID ? crypto.randomUUID() return crypto.randomUUID ? crypto.randomUUID()
: String(Date.now()) + Math.random(); : String(Date.now()) + Math.random();
@ -1190,7 +1232,11 @@
if (!entry) return; if (!entry) return;
pending.delete(token); pending.delete(token);
action(entry); action(entry);
if (pending.size === 0 && stream) {stream.close(); stream = null;} if (pending.size === 0) {
if (stream) {stream.close(); stream = null;}
notifyQueueEmpty();
}
updateNotifyUi();
} }
// Put a pending card into a terminal state: show the message, drop the // Put a pending card into a terminal state: show the message, drop the
@ -1283,6 +1329,7 @@
// Stash the inputs alongside the pending solve so, once it finishes, // Stash the inputs alongside the pending solve so, once it finishes,
// its card can export a self-contained {problem, solution} bundle. // its card can export a self-contained {problem, solution} bundle.
pending.set(token, {token, card, n, confirmed: false, problem, maxTime: time}); pending.set(token, {token, card, n, confirmed: false, problem, maxTime: time});
updateNotifyUi();
fetch("/solve", { fetch("/solve", {
method: "POST", method: "POST",
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
@ -1330,8 +1377,10 @@
} }
actions.append(el("button", { actions.append(el("button", {
class: "mini", type: "button", class: "mini", type: "button",
onclick: () => exportSolve({token, n, solution: s, problem, maxTime, onclick: () => exportSolve({
name: solveLabel(token, n)}), token, n, solution: s, problem, maxTime,
name: solveLabel(token, n)
}),
}, "Export")); }, "Export"));
// The problem may be on hand (a solve queued from this tab) or, for a // The problem may be on hand (a solve queued from this tab) or, for a
// solve opened via a share link, fetched from the server by token on // solve opened via a share link, fetched from the server by token on
@ -1706,6 +1755,9 @@
} }
// --- seed with the example problem --- // --- seed with the example problem ---
// Only used for a fresh tab; a tab reload/restore refills the form from
// sessionStorage instead (see restoreSession below).
function seedDefault() {
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});
addCity({name: "2", type: "hub", renown: 2}); addCity({name: "2", type: "hub", renown: 2});
@ -1718,7 +1770,8 @@
"steel": 2, "steel": 2,
"brass": 1, "brass": 1,
"electrum": 2 "electrum": 2
}) });
}
// --- deep links --- // --- deep links ---
// /?solve=<id> loads one solve; /?solves=<id1>,<id2>,… loads an arbitrary // /?solve=<id> loads one solve; /?solves=<id1>,<id2>,… loads an arbitrary
@ -1736,12 +1789,103 @@
document.getElementById("output").prepend(card); document.getElementById("output").prepend(card);
pending.set(token, {token, card, n, confirmed: true}); pending.set(token, {token, card, n, confirmed: true});
} }
// --- session persistence -------------------------------------------
// The browser natively restores plain form controls (the Game inputs,
// start-resource numbers, tradeable checkboxes) on a reload / "reopen
// closed tab", but not the SPA-built cards (cities, agents, terms, …) nor
// the queue of solve cards under #output — those are recreated by script
// each load. We mirror both into sessionStorage (per-tab, and preserved
// across tab restore) so the whole page comes back as it was left.
const SESSION_KEY = "dws_session";
// Tokens of every solve card currently on the page, top-to-bottom.
function currentTokens() {
return [...document.querySelectorAll("#output [data-token]")]
.map(e => e.dataset.token);
}
function saveSession() {
// buildProblem() samples log terms and can throw on a half-typed
// expression; if so, keep whatever problem we last stored so a
// transiently-invalid form doesn't wipe the saved inputs.
let problem = null, maxTime;
try {
problem = buildProblem();
maxTime = +document.getElementById("time").value;
} catch (e) {/* keep previously-saved problem */}
let prev = {};
try {prev = JSON.parse(sessionStorage.getItem(SESSION_KEY) || "{}");}
catch (e) {/* ignore corrupt/absent state */}
const data = {
problem: problem || prev.problem || null,
maxTime: problem ? maxTime : prev.maxTime,
tokens: currentTokens(),
solutionCount,
};
try {sessionStorage.setItem(SESSION_KEY, JSON.stringify(data));}
catch (e) {/* storage may be full/unavailable; state just won't persist */}
}
// Debounced: coalesce bursts of edits (and card add/remove mutations)
// into a single write.
let saveTimer = null;
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(saveSession, 300);
}
// Refill the form and re-open the solve queue from a prior session.
// Returns true if there was a session to restore.
function restoreSession() {
let data;
try {data = JSON.parse(sessionStorage.getItem(SESSION_KEY) || "null");}
catch (e) {return false;}
if (!data) return false;
if (data.problem) applyProblem(data.problem, data.maxTime);
const tokens = data.tokens || [];
// Reserve a card per token and let the shared /job_status stream
// resolve each (rendering a finished solve fetched from the server,
// or tracking one still queued/running). Load in reverse so the
// first token ends up on top, mirroring how they were prepended.
for (const token of tokens.slice().reverse()) loadSharedSolve(token);
if (tokens.length) syncStream();
// Keep future solve numbering above anything from the old session.
if (typeof data.solutionCount === "number")
solutionCount = Math.max(solutionCount, data.solutionCount);
return true;
}
// --- startup ---
const restored = restoreSession();
if (!restored) seedDefault();
// Deep links still work on top of a restored session; skip any token the
// restored queue already holds so it isn't duplicated.
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const sharedTokens = (params.get("solves") || params.get("solve") || "") const sharedTokens = (params.get("solves") || params.get("solve") || "")
.split(",").map(s => s.trim()).filter(Boolean); .split(",").map(s => s.trim()).filter(Boolean);
const present = new Set(currentTokens());
const newShared = sharedTokens.filter(t => !present.has(t));
// Load in reverse so the first id ends up on top (each load prepends). // Load in reverse so the first id ends up on top (each load prepends).
for (const token of sharedTokens.slice().reverse()) loadSharedSolve(token); for (const token of newShared.slice().reverse()) loadSharedSolve(token);
if (sharedTokens.length) syncStream(); if (newShared.length) syncStream();
updateNotifyUi();
// Wire autosave only after the initial load has settled, so the seed /
// restore / deep-link work above doesn't churn through it. Edits to any
// control save; card add/remove and queue changes save via observers.
document.addEventListener("input", scheduleSave);
document.addEventListener("change", scheduleSave);
const cardObserver = new MutationObserver(scheduleSave);
for (const id of ["cities", "agents", "terms", "constraints",
"conversions", "optional_conversions"])
cardObserver.observe(document.getElementById(id), {childList: true});
// The solve queue: card add/remove plus the data-token that appears when a
// pending card is replaced by its rendered solution.
new MutationObserver(scheduleSave).observe(document.getElementById("output"),
{childList: true, subtree: true, attributes: true, attributeFilter: ["data-token"]});
saveSession();
</script> </script>
</body> </body>

View file

@ -183,6 +183,10 @@ class City:
can_renovate: bool = True # Metropolis cannot renovate can_renovate: bool = True # Metropolis cannot renovate
# Hard constraint: force a specific action on a given turn (turn -> Action). # Hard constraint: force a specific action on a given turn (turn -> Action).
forced_action: dict[int, str] = field(default_factory=dict) forced_action: dict[int, str] = field(default_factory=dict)
# Soft hint (NOT a constraint): a suggested action on a given turn
# (turn -> Action). Warm-starts/biases the search toward this plan, but the
# solver may override it if a better plan exists (cf. forced_action).
hint_action: dict[int, str] = field(default_factory=dict)
def __post_init__(self): def __post_init__(self):
if self.renown is None: if self.renown is None:
@ -218,6 +222,10 @@ class Agent:
available_turns: Optional[list[int]] = None available_turns: Optional[list[int]] = None
# Hard constraint: must govern this city on this turn (turn -> city name). # Hard constraint: must govern this city on this turn (turn -> city name).
forced_city: dict[int, str] = field(default_factory=dict) forced_city: dict[int, str] = field(default_factory=dict)
# Soft hint (NOT a constraint): a suggested city to govern on this turn
# (turn -> city name). Warm-starts/biases the search, but the solver may
# override it (cf. forced_city).
hint_city: dict[int, str] = field(default_factory=dict)
def is_available(self, t: int) -> bool: def is_available(self, t: int) -> bool:
return self.available_turns is None or t in self.available_turns return self.available_turns is None or t in self.available_turns
@ -543,6 +551,7 @@ class _Builder:
self._build_renown_total() self._build_renown_total()
self._build_resource_constraints() self._build_resource_constraints()
self._build_objective() self._build_objective()
self._build_hints()
return self.m return self.m
def _build_actions_and_governors(self): def _build_actions_and_governors(self):
@ -629,6 +638,41 @@ class _Builder:
# Only one overworking placement at a time is already implied by # Only one overworking placement at a time is already implied by
# the agent's "<=1 city" constraint. # the agent's "<=1 city" constraint.
def _build_hints(self):
"""Apply soft solution hints via ``model.AddHint``. Unlike the forced_*
fields these add no constraints: they only warm-start / bias the search
toward a suggested plan, and the solver may override any of them.
* City.hint_action : suggest a City takes a given Action on a Turn.
* Agent.hint_city : suggest an Agent governs a given City on a Turn.
Hints that can't apply are skipped: out-of-range or unavailable Turns,
an Action the City can never take (Renovate on a Metropolis / a City
that can't renovate), or an unknown target City."""
m = self.m
for ci, city in enumerate(self.p.cities):
for t, action in city.hint_action.items():
if not (0 <= t < self.T) or not city.is_available(t):
continue
a = Action(action)
# Renovate is a fixed 0-constant for these Cities (see
# _build_actions_and_governors); it isn't a real decision var.
if a == Action.RENOVATE and (
not city.can_renovate or city.type == CityType.METROPOLIS
):
continue
var = self.act.get((ci, t, a))
if var is not None:
m.AddHint(var, 1)
for ai, agent in enumerate(self.p.agents):
for t, city_name in agent.hint_city.items():
if not (0 <= t < self.T) or not agent.is_available(t):
continue
ci = self._city_index(city_name)
g = self.gov.get((ai, ci, t))
if g is not None:
m.AddHint(g, 1)
def _build_adjacency(self): def _build_adjacency(self):
"""Build the symmetric City adjacency map (``self.adj[ci]`` -> set of """Build the symmetric City adjacency map (``self.adj[ci]`` -> set of
adjacent City indices). Declaring A adjacent to B also makes B adjacent adjacent City indices). Declaring A adjacent to B also makes B adjacent
@ -1690,12 +1734,16 @@ def problem_from_dict(d: dict) -> Problem:
c["type"] = CityType(c["type"]) c["type"] = CityType(c["type"])
if "forced_action" in c: if "forced_action" in c:
c["forced_action"] = {int(k): v for k, v in c["forced_action"].items()} c["forced_action"] = {int(k): v for k, v in c["forced_action"].items()}
if "hint_action" in c:
c["hint_action"] = {int(k): v for k, v in c["hint_action"].items()}
cities.append(City(**c)) cities.append(City(**c))
agents = [] agents = []
for a in d.get("agents", []): for a in d.get("agents", []):
a = dict(a) a = dict(a)
if "forced_city" in a: if "forced_city" in a:
a["forced_city"] = {int(k): v for k, v in a["forced_city"].items()} a["forced_city"] = {int(k): v for k, v in a["forced_city"].items()}
if "hint_city" in a:
a["hint_city"] = {int(k): v for k, v in a["hint_city"].items()}
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: