Compare commits
No commits in common. "new-main" and "f36ba39e" have entirely different histories.
3 changed files with 19 additions and 218 deletions
7
LICENSE
7
LICENSE
|
|
@ -1,7 +0,0 @@
|
|||
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.
|
||||
156
index.html
156
index.html
|
|
@ -410,8 +410,6 @@
|
|||
solution</button>
|
||||
<input id="importFile" type="file" accept="application/json,.json" style="display:none"
|
||||
onchange="onImportFile(event)">
|
||||
<label id="notifyWrap" style="display:none;margin-left:.6rem">
|
||||
<input id="notifyDone" type="checkbox"> Notify when queued items complete</label>
|
||||
</p>
|
||||
|
||||
<div id="error" class="err"></div>
|
||||
|
|
@ -650,9 +648,6 @@
|
|||
placeholder: "Bearhearth, Kingsland"
|
||||
});
|
||||
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
|
||||
// expanded into the explicit list the solver expects. Blank arrival means
|
||||
// "from turn 0", blank departure means "through the last turn"; both blank
|
||||
|
|
@ -716,8 +711,6 @@
|
|||
if (adj) o.adjacent = adj;
|
||||
const fa = parsePairs(forced.value);
|
||||
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;
|
||||
if (a !== "" || d !== "") {
|
||||
const lo = a !== "" ? +a : 0;
|
||||
|
|
@ -738,7 +731,6 @@
|
|||
field("Upgrades (already installed)", upWrap),
|
||||
field("Adjacent cities (csv of names)", adjacent),
|
||||
field("Forced actions (turn:action, csv)", forced),
|
||||
field("Hint actions (turn:action, csv)", hinted),
|
||||
field("Arrival turn (blank=start)", arrival),
|
||||
field("Departure turn (blank=end)", departure),
|
||||
vatGroup,
|
||||
|
|
@ -760,9 +752,6 @@
|
|||
const desc = el("span", {class: "help"});
|
||||
const bastions = num(a.bonus_trade_goods || 3, {min: 0});
|
||||
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 bastionsField = field("Bastions (Baron only)", bastions);
|
||||
|
|
@ -782,8 +771,6 @@
|
|||
if (type.value === "Baron") o.bonus_trade_goods = +bastions.value;
|
||||
const fc = parsePairs(forced.value, true);
|
||||
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);
|
||||
if (at) o.available_turns = at;
|
||||
return o;
|
||||
|
|
@ -792,7 +779,6 @@
|
|||
field("Agent", type),
|
||||
field("Effect", desc),
|
||||
field("Forced city (turn:city, csv)", forced),
|
||||
field("Hint city (turn:city, csv)", hintCity),
|
||||
field("Avail turns (csv, blank=all)", avail),
|
||||
bastionsField,
|
||||
el("div", {class: "card-actions"}, removeBtn(card)));
|
||||
|
|
@ -1020,34 +1006,6 @@
|
|||
// queued -> running -> done (rendered) / error / cancelled.
|
||||
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() {
|
||||
return crypto.randomUUID ? crypto.randomUUID()
|
||||
: String(Date.now()) + Math.random();
|
||||
|
|
@ -1232,11 +1190,7 @@
|
|||
if (!entry) return;
|
||||
pending.delete(token);
|
||||
action(entry);
|
||||
if (pending.size === 0) {
|
||||
if (stream) {stream.close(); stream = null;}
|
||||
notifyQueueEmpty();
|
||||
}
|
||||
updateNotifyUi();
|
||||
if (pending.size === 0 && stream) {stream.close(); stream = null;}
|
||||
}
|
||||
|
||||
// Put a pending card into a terminal state: show the message, drop the
|
||||
|
|
@ -1329,7 +1283,6 @@
|
|||
// 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});
|
||||
updateNotifyUi();
|
||||
fetch("/solve", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
|
|
@ -1377,10 +1330,8 @@
|
|||
}
|
||||
actions.append(el("button", {
|
||||
class: "mini", type: "button",
|
||||
onclick: () => exportSolve({
|
||||
token, n, solution: s, problem, maxTime,
|
||||
name: solveLabel(token, n)
|
||||
}),
|
||||
onclick: () => exportSolve({token, n, solution: s, problem, maxTime,
|
||||
name: solveLabel(token, n)}),
|
||||
}, "Export"));
|
||||
// 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
|
||||
|
|
@ -1755,9 +1706,6 @@
|
|||
}
|
||||
|
||||
// --- 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: "1", type: "foundry", renown: 2, vat_steel: 1, vat_brass: 1, vat_electrum: 1});
|
||||
addCity({name: "2", type: "hub", renown: 2});
|
||||
|
|
@ -1770,8 +1718,7 @@
|
|||
"steel": 2,
|
||||
"brass": 1,
|
||||
"electrum": 2
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
// --- deep links ---
|
||||
// /?solve=<id> loads one solve; /?solves=<id1>,<id2>,… loads an arbitrary
|
||||
|
|
@ -1789,103 +1736,12 @@
|
|||
document.getElementById("output").prepend(card);
|
||||
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 sharedTokens = (params.get("solves") || params.get("solve") || "")
|
||||
.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).
|
||||
for (const token of newShared.slice().reverse()) loadSharedSolve(token);
|
||||
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();
|
||||
for (const token of sharedTokens.slice().reverse()) loadSharedSolve(token);
|
||||
if (sharedTokens.length) syncStream();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
|
|
|||
48
solve.py
48
solve.py
|
|
@ -183,10 +183,6 @@ class City:
|
|||
can_renovate: bool = True # Metropolis cannot renovate
|
||||
# Hard constraint: force a specific action on a given turn (turn -> Action).
|
||||
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):
|
||||
if self.renown is None:
|
||||
|
|
@ -222,10 +218,6 @@ class Agent:
|
|||
available_turns: Optional[list[int]] = None
|
||||
# Hard constraint: must govern this city on this turn (turn -> city name).
|
||||
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:
|
||||
return self.available_turns is None or t in self.available_turns
|
||||
|
|
@ -551,7 +543,6 @@ class _Builder:
|
|||
self._build_renown_total()
|
||||
self._build_resource_constraints()
|
||||
self._build_objective()
|
||||
self._build_hints()
|
||||
return self.m
|
||||
|
||||
def _build_actions_and_governors(self):
|
||||
|
|
@ -638,41 +629,6 @@ class _Builder:
|
|||
# Only one overworking placement at a time is already implied by
|
||||
# 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):
|
||||
"""Build the symmetric City adjacency map (``self.adj[ci]`` -> set of
|
||||
adjacent City indices). Declaring A adjacent to B also makes B adjacent
|
||||
|
|
@ -1734,16 +1690,12 @@ def problem_from_dict(d: dict) -> Problem:
|
|||
c["type"] = CityType(c["type"])
|
||||
if "forced_action" in c:
|
||||
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))
|
||||
agents = []
|
||||
for a in d.get("agents", []):
|
||||
a = dict(a)
|
||||
if "forced_city" in a:
|
||||
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))
|
||||
obj_d = dict(d.get("objective", {}))
|
||||
if "terms" in obj_d:
|
||||
|
|
|
|||
Loading…
Reference in a new issue