dws-city-res-solve/index.html
Pagwin ae7fef5d7b Optional conversion and cancellation
Added in optional conversions and cancelling searches without just
closing the tab
2026-06-18 21:43:21 -04:00

1223 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Days Without Strife - Planner</title>
<style>
* {
box-sizing: border-box;
}
:root {
color-scheme: light dark;
}
body {
font-family: system-ui, sans-serif;
margin: 0;
padding: 1.5rem;
max-width: 100vw;
margin-inline: auto;
line-height: 1.4;
}
h1 {
margin-top: 0;
}
fieldset {
margin: 1rem 0;
border: 1px solid #8884;
border-radius: 8px;
}
legend {
font-weight: 600;
padding: 0 .4rem;
}
label {
display: inline-block;
}
input,
select,
textarea {
font: inherit;
padding: .2rem .35rem;
}
input[type=number] {
width: 6rem;
}
button {
font: inherit;
padding: .3rem .7rem;
border-radius: 6px;
border: 1px solid #8886;
cursor: pointer;
background: #8881;
}
button.primary {
background: #2563eb;
color: #fff;
border-color: #2563eb;
padding: .5rem 1.2rem;
font-weight: 600;
}
#solveBtn:disabled {
opacity: 50%;
background: #888;
}
#busy {
font-size: 1rem;
font-weight: 600;
}
/* This tab is the one solving. */
#busy.busy-self {
color: #16a34a;
}
/* Another viewer holds the solve lock. */
#busy.busy-other {
color: #d97706;
}
.mini {
padding: .1rem .45rem;
font-size: .85rem;
}
textarea {
width: 100%;
box-sizing: border-box;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: .6rem 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: .15rem;
min-width: 0;
}
.field>span {
font-size: .8rem;
opacity: .8;
}
pre {
background: #8881;
padding: 1rem;
border-radius: 8px;
overflow: auto;
}
.err {
color: #dc2626;
white-space: pre-wrap;
}
.help {
font-size: .8rem;
opacity: .75;
margin: .2rem 0 0;
}
/* Each input "row" is a card whose labeled fields flow in an auto-fit grid,
so wide rows wrap onto multiple lines instead of overflowing the page. */
.cards {
display: grid;
gap: .6rem;
}
.card {
border: 1px solid #8884;
border-radius: 8px;
padding: .55rem .7rem;
display: grid;
gap: .45rem .8rem;
align-items: start;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
/* Top-align cells but keep every field's input on a common baseline by
reserving a uniform (two-line) label height — so a wrapping label no
longer drops its input below the others, and a stacked cell (Type +
"Can renovate") can hang its extra control underneath while its primary
input still lines up with the rest. */
.card .field:not(.check):not(.vat-row)>span {
min-height: 2.8em;
display: flex;
align-items: flex-end;
}
.card input,
.card select,
.card textarea {
width: 100%;
}
.card input[type=checkbox] {
width: auto;
}
.card .check {
flex-direction: row;
align-items: center;
gap: .35rem;
}
/* Stack two controls vertically inside a single grid cell (e.g. the city
Type field with its "Can renovate" checkbox tucked underneath). Top-align
so the Type input stays on the same row as the card's other inputs while
the checkbox hangs below. */
.card .stack {
display: flex;
flex-direction: column;
gap: .45rem;
min-width: 0;
align-self: start;
}
/* Vat amounts: stacked rows, each with its label to the left of the input. */
.card .vat-group {
display: flex;
flex-direction: column;
gap: .3rem;
min-width: 0;
}
.card .vat-head {
font-size: .8rem;
opacity: .8;
}
.card .vat-row {
flex-direction: row;
align-items: center;
gap: .4rem;
}
.card .vat-row>span {
flex: 0 0 4rem;
}
.card .vat-row input {
width: auto;
flex: 1;
min-width: 0;
}
.card-actions {
align-self: start;
justify-self: end;
}
/* Keep the "Log" checkbox and its JS-function field together in one cell
and reserve the space up front, so enabling the field only fills the
already-allotted room instead of reflowing the whole card. */
.card .log-group {
grid-column: span 2;
display: flex;
align-items: start;
gap: .5rem;
min-width: 0;
}
.log-cell {
display: none;
}
.card.log-on .log-cell {
display: flex;
flex: 1;
}
/* Output tables: a CSS grid kept inside a scroll container so it never
stretches the page wider than it should. */
.gtable-wrap {
overflow-x: auto;
margin-top: .3rem;
}
.gtable {
display: grid;
width: max-content;
min-width: 100%;
}
.gtable>div {
padding: .25rem .5rem;
border-bottom: 1px solid #8883;
}
.gtable>.gh {
font-size: .85rem;
opacity: .8;
border-bottom: 1px solid #8886;
}
/* Solution cards: each solve gets its own collapsible block so older
solutions stay viewable, newest on top. */
details.solution {
border: 1px solid #8884;
border-radius: 8px;
padding: .3rem .8rem;
margin-top: .8rem;
}
details.solution>summary {
font-weight: 600;
cursor: pointer;
padding: .3rem 0;
}
.pending {
opacity: .75;
margin-top: .8rem;
}
</style>
</head>
<body>
<h1>Days Without Strife &mdash; Planner Optimizer</h1>
<fieldset>
<legend>Game</legend>
<div class="grid">
<label class="field"><span>Turns</span><input id="turns" type="number" min="1" value="5"
oninput="refreshTurnSelects()"></label>
<label class="field"><span>Extra renown (constant)</span><input id="extra_renown" type="number"
value="0"></label>
<label class="field"><span>Airships already launched</span><input id="airships_launched" type="number"
min="0" value="0"></label>
<label class="field"><span>Max resource</span><input id="max_resource" type="number" min="1"
value="300"></label>
<label class="field"><span>Max vat</span><input id="max_vat" type="number" min="1" value="12"></label>
<label class="field"><span>Solver time limit (s)</span><input id="time" type="number" min="1"
value="30"></label>
</div>
</fieldset>
<fieldset>
<legend>Starting resources</legend>
<div class="grid" id="start"></div>
</fieldset>
<fieldset>
<legend>Tradeable into (Trade Goods convert 1-for-1 for scoring)</legend>
<div id="tradeable"></div>
</fieldset>
<fieldset>
<legend>Cities</legend>
<p class="help">The <b>name</b> is the city's unique identifier &mdash; it labels the
output plan and is what an agent's "forced city" refers to. <b>Upgrades</b> are the
ones already installed at the start; the available choices follow the city's type.
<b>Adjacent cities</b> lists neighbours by name (adjacency is symmetric); an
Industrialist Governor grants free Infrastructure to its city and every adjacent one.
</p>
<div id="cities" class="cards"></div>
<button class="mini" type="button" onclick="addCity()">+ city</button>
</fieldset>
<fieldset>
<legend>Agents</legend>
<p class="help">Pick a known agent; its governor behaviour is implicit and
shown in the <b>Effect</b> column. The <b>name</b> identifies it and is what a
forced city refers back to. All effects apply while the agent is Governor of a
city (at most one city per turn).</p>
<div id="agents" class="cards"></div>
<button class="mini" type="button" onclick="addAgent()">+ agent</button>
</fieldset>
<fieldset>
<legend>Objective &mdash; scoring terms</legend>
<p class="help">Each term scores a resource (or <code>renown</code>) at the end of a
turn (blank turn = final turn). For a <b>log</b> term, write a JS expression that
evals to a one-argument function, e.g. <code>(x) =&gt; Math.log2(x + 1)</code>.
It is called over amounts <code>0..max_resource</code> in the browser to build the
lookup table.</p>
<div id="terms" class="cards"></div>
<button class="mini" type="button" onclick="addTerm()">+ term</button>
</fieldset>
<fieldset>
<legend>Resource constraints</legend>
<div id="constraints" class="cards"></div>
<button class="mini" type="button" onclick="addConstraint()">+ constraint</button>
</fieldset>
<fieldset>
<legend>Forced conversions</legend>
<p class="help">A conversion is <b>not</b> a choice for the optimizer &mdash; it always
happens on the given turn, spending <b>from amount</b> of one resource and yielding
<b>to amount</b> of another. The optimizer is forced to have the spent resource
available at that turn. These appear in the solution and the CSV download.
</p>
<div id="conversions" class="cards"></div>
<button class="mini" type="button" onclick="addConversion()">+ conversion</button>
</fieldset>
<fieldset>
<legend>Optional conversions</legend>
<p class="help">An optional conversion <b>is</b> a choice for the optimizer &mdash; it may
apply the conversion (spending <b>from amount</b> of one resource to yield <b>to amount</b>
of another on the given turn) or refrain entirely. <b>Max count</b> lets it apply the
conversion up to that many times on the turn. The chosen counts appear in the solution and
the CSV download.
</p>
<div id="optional_conversions" class="cards"></div>
<button class="mini" type="button" onclick="addOptionalConversion()">+ optional conversion</button>
</fieldset>
<p>
<button id="solveBtn" class="primary" type="button" onclick="run()">Solve</button>
<button id="cancelBtn" type="button" onclick="cancelSolve()" style="display:none;margin-left:.4rem">Cancel</button>
<span id="busy" class="help" style="margin-left:.6rem"></span>
</p>
<div id="error" class="err"></div>
<div id="output"></div>
<script>
const RESOURCES = ["capital", "luxuries", "steel", "brass", "electrum", "trade_goods", "express"];
const SCORE_KEYS = RESOURCES.concat(["renown"]);
const CITY_TYPES = ["hub", "foundry", "monument", "metropolis"];
const ACTIONS = ["idle", "collect", "renovate", "upgrade", "launch"];
// Upgrades that apply to any city type, plus the type-specific "3rd" upgrade.
const UNIVERSAL_UPGRADES = ["infrastructure", "harvester", "fortification"];
const TYPE_UPGRADE = {
hub: "fine_dining", foundry: "overflow_vats",
metropolis: "transit_authority", monument: "propaganda"
};
// Known agents and the implicit logic each one carries.
const AGENT_PRESETS = {
Planner: {overwork: true},
Baron: {bonus_trade_goods: 3}, // +N Trade Goods per Bastion on collect
Builder: {free_upgrade: true},
Capitalist: {bonus_capital: 2}, // +2 Capital on collect
Vinter: {bonus_luxuries: 2}, // +2 Luxuries on collect
Artificer: {bonus_trade_goods: 1}, // +1 Trade Good on collect
Metallurgist: {grants_overflow_vats: true}, // Overflow Vats on a Foundry
Industrialist: {grants_infrastructure: true}, // free Infrastructure
Foreman: {free_renovate: true}, // Renovate without spending the Action
Prodigy: {steel_refund: 2}, // refund <=2 Steel on Upgrade/Launch
Provisioner: {governor_electrum_half: 3}, // +1.5 Electrum / governing turn
Courier: {onetime_governor_bonus: {capital: 3, steel: 3, brass: 3}},
};
// Short human-readable effect, shown inline next to each agent's dropdown.
const AGENT_DESC = {
Planner: "Overwork: double Collection & waive Capital cost; locks next-turn Collect",
Baron: "+N Trade Goods per Bastion on Collect",
Builder: "free type-specific Upgrade",
Capitalist: "+2 Capital on Collect",
Vinter: "+2 Luxuries on Collect",
Artificer: "+1 Trade Good on Collect",
Metallurgist: "Overflow Vats effect on a Foundry",
Industrialist: "free Infrastructure Upgrade",
Foreman: "Renovate without spending the Action",
Prodigy: "refund ≤2 Steel on Upgrade/Launch",
Provisioner: "+1.5 Electrum per governing Turn",
Courier: "one-time +3 Capital/Steel/Brass on first govern",
};
function el(tag, attrs = {}, children = []) {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") e.className = v;
else if (k === "html") e.innerHTML = v;
else if (k.startsWith("on")) e[k] = v;
else e.setAttribute(k, v);
}
for (const c of [].concat(children)) e.append(c);
return e;
}
function num(value, attrs = {}) {return el("input", {type: "number", value, ...attrs});}
function selectEl(opts, value) {
const s = el("select");
for (const o of opts) {
const opt = el("option", {value: o}, o);
if (o === value) opt.selected = true;
s.append(opt);
}
return s;
}
function removeBtn(row) {
return el("button", {
class: "mini", type: "button",
onclick: () => row.remove()
}, "×");
}
// A labeled field inside a card (label stacked above its control).
function field(label, control) {
return el("label", {class: "field"}, [el("span", {}, label), control]);
}
// A checkbox field (checkbox beside its label, laid out horizontally).
function checkField(label, cb) {
return el("label", {class: "field check"}, [cb, el("span", {}, label)]);
}
// Build an output grid "table": a CSS grid (one column per header) wrapped in
// a horizontally-scrollable container so it never widens the page.
function gridTable(headers, rows) {
const wrap = el("div", {class: "gtable-wrap"});
const g = el("div", {
class: "gtable",
style: `grid-template-columns: repeat(${headers.length}, auto)`
});
for (const h of headers) g.append(el("div", {class: "gh"}, String(h)));
for (const r of rows)
for (const c of r) g.append(el("div", {}, String(c)));
wrap.append(g);
return wrap;
}
// --- turn dropdowns -------------------------------------------------------
// Turns are chosen from the current "Turns" count rather than typed freehand.
// Every turn <select> registers here so its options can be rebuilt (preserving
// the current selection) whenever the Turns count changes. value "" = final.
const turnSelects = new Set();
function turnsCount() {return +document.getElementById("turns").value || 0;}
function fillTurnOptions(sel) {
const prev = sel.value;
sel.innerHTML = "";
sel.append(el("option", {value: ""}, "final"));
for (let t = 0; t < turnsCount(); t++)
sel.append(el("option", {value: String(t)}, "turn " + t));
// Keep the prior choice if it still exists, else fall back to "final".
sel.value = [...sel.options].some(o => o.value === prev) ? prev : "";
}
function turnSelect(value) {
const sel = el("select");
turnSelects.add(sel);
fillTurnOptions(sel);
if (value !== undefined && value !== null && value !== "") sel.value = String(value);
return sel;
}
function refreshTurnSelects() {
for (const sel of turnSelects) {
if (!sel.isConnected) {turnSelects.delete(sel); continue;}
fillTurnOptions(sel);
}
}
// --- starting resources & tradeable ---
const startInputs = {};
const tradeInputs = {};
for (const r of RESOURCES) {
const inp = num(r === "express" || r === "trade_goods" ? 0 : 3, {min: 0});
startInputs[r] = inp;
document.getElementById("start").append(
el("label", {class: "field"}, [el("span", {}, r), inp]));
const cb = el("input", {type: "checkbox"});
if (r !== "express") cb.checked = true;
tradeInputs[r] = cb;
document.getElementById("tradeable").append(
el("label", {style: "margin-right:1rem"}, [cb, " " + r]));
}
// --- cities ---
// Pick a name not already used by an existing city: the lowest non-negative
// integer (as a string) that's free, matching the seeded "0", "1", … names.
function uniqueCityName() {
const taken = new Set(
[...document.getElementById("cities").children].map(r => r._get().name));
for (let n = 0; ; n++) if (!taken.has(String(n))) return String(n);
}
function addCity(c = {}) {
const card = el("div", {class: "card"});
const name = el("input", {value: c.name || uniqueCityName()});
const type = selectEl(CITY_TYPES, c.type || "hub");
const renown = num(c.renown ?? "", {min: 1, placeholder: "auto"});
const vs = num(c.vat_steel || 1, {min: 0}), vb = num(c.vat_brass || 1, {min: 0}),
ve = num(c.vat_electrum || 1, {min: 0});
const reno = el("input", {type: "checkbox"}); reno.checked = c.can_renovate !== false;
const adjacent = el("input", {
value: (c.adjacent || []).join(", "),
placeholder: "Bearhearth, Kingsland"
});
const forced = el("input", {value: "", placeholder: "0:upgrade"});
const avail = el("input", {value: "", placeholder: ""});
// Upgrade checkboxes: the 3 universal upgrades plus the current type's
// type-specific upgrade. State persists across type changes (hidden boxes
// are simply ignored), and only the currently-allowed checked ones count.
const preset = new Set(c.upgrades || []);
const upBoxes = {};
const upWrap = el("div");
function allowedUpgrades() {
return UNIVERSAL_UPGRADES.concat([TYPE_UPGRADE[type.value]]);
}
function renderUpgrades() {
upWrap.innerHTML = "";
for (const u of allowedUpgrades()) {
if (!upBoxes[u]) {
const cb = el("input", {type: "checkbox"});
if (preset.has(u)) cb.checked = true;
upBoxes[u] = cb;
}
upWrap.append(el("label", {style: "display:block;font-size:.85rem"},
[upBoxes[u], " " + u]));
}
}
// Vat amounts only matter for Foundries; hide them otherwise and keep
// them as the card's last field (before the actions). They stack in a
// single cell, each input labeled on its left.
const vatRow = (label, input) =>
el("label", {class: "field vat-row"}, [el("span", {}, label), input]);
const vatGroup = el("div", {class: "vat-group"}, [
el("span", {class: "vat-head"}, "Vat amounts"),
vatRow("steel", vs),
vatRow("brass", vb),
vatRow("electrum", ve),
]);
function renderVats() {
vatGroup.style.display = type.value === "foundry" ? "" : "none";
}
type.onchange = () => {renderUpgrades(); renderVats();};
renderUpgrades();
renderVats();
card._get = () => {
const o = {name: name.value, type: type.value};
if (renown.value !== "") o.renown = +renown.value;
if (+vs.value) o.vat_steel = +vs.value;
if (+vb.value) o.vat_brass = +vb.value;
if (+ve.value) o.vat_electrum = +ve.value;
const u = allowedUpgrades().filter(name => upBoxes[name] && upBoxes[name].checked);
if (u.length) o.upgrades = u;
if (!reno.checked) o.can_renovate = false;
const adj = parseStrs(adjacent.value);
if (adj) o.adjacent = adj;
const fa = parsePairs(forced.value);
if (Object.keys(fa).length) o.forced_action = fa;
const at = parseInts(avail.value);
if (at) o.available_turns = at;
return o;
};
card.append(
field("Name", name),
el("div", {class: "stack"}, [
field("Type", type),
checkField("Can renovate", reno),
]),
field("Renown", renown),
field("Upgrades (already installed)", upWrap),
field("Adjacent cities (csv of names)", adjacent),
field("Forced actions (turn:action, csv)", forced),
field("Avail turns (csv, blank=all)", avail),
vatGroup,
el("div", {class: "card-actions"}, removeBtn(card)));
document.getElementById("cities").append(card);
}
// --- agents ---
function addAgent(a = {}) {
const card = el("div", {class: "card"});
// Identify the agent by its preset flags; the logic is implicit to the choice.
let kind = a.kind;
if (!kind) {
kind = Object.keys(AGENT_PRESETS).find(
k => Object.keys(AGENT_PRESETS[k]).some(f => a[f] !== undefined)) || "Planner";
}
const type = selectEl(Object.keys(AGENT_PRESETS), kind);
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 bastionsField = field("Bastions (Baron only)", bastions);
let nameEdited = !!a.name;
name.oninput = () => {nameEdited = true;};
function syncType() {
if (!nameEdited) name.value = type.value;
bastionsField.style.display = (type.value === "Baron") ? "" : "none";
desc.textContent = AGENT_DESC[type.value] || "";
}
type.onchange = syncType;
syncType();
card._get = () => {
const o = {name: name.value, ...AGENT_PRESETS[type.value]};
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 at = parseInts(avail.value);
if (at) o.available_turns = at;
return o;
};
card.append(
field("Agent", type),
field("Effect", desc),
field("Forced city (turn:city, csv)", forced),
field("Avail turns (csv, blank=all)", avail),
bastionsField,
el("div", {class: "card-actions"}, removeBtn(card)));
document.getElementById("agents").append(card);
}
// --- objective terms ---
function addTerm(t = {}) {
const card = el("div", {class: "card"});
const res = selectEl(SCORE_KEYS, t.resource || "renown");
const scalar = num(t.scalar ?? 1, {step: "any"});
const turn = turnSelect(t.turn);
const isLog = el("input", {type: "checkbox"});
isLog.checked = !!t.log_mapping;
const toggle = () => card.classList.toggle("log-on", isLog.checked);
isLog.onchange = toggle;
const expr = el("textarea", {rows: 1, placeholder: "(x) => Math.log2(x + 1)"});
expr.innerText = "(x) => Math.log2(x + 1)";
if (t._expr) expr.value = t._expr;
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);
return o;
};
const resF = field("Resource", res); resF.classList.add("linear-only");
const turnF = field("Turn", turn); turnF.classList.add("linear-only");
const exprF = field("JS function of amount x", expr); exprF.classList.add("log-cell");
card.append(
resF,
field("Scalar", scalar),
turnF,
el("div", {class: "log-group"}, [field("Log", isLog), exprF]),
el("div", {class: "card-actions"}, removeBtn(card)));
document.getElementById("terms").append(card);
toggle();
}
function addTerms(terms = {}) {
Object.entries(terms).forEach(([resource, scalar]) =>
addTerm({resource, scalar}));
}
// Eval the expression ONCE into a function, then call it over the amounts
// the lookup table needs (0..max_resource).
function buildLogTable(exprStr) {
let fn;
try {
fn = eval(exprStr);
} catch (e) {
throw new Error("Could not eval log expression: " + e.message);
}
if (typeof fn !== "function")
throw new Error("Log expression must eval to a function, got " + typeof fn);
const max = +document.getElementById("max_resource").value || 0;
const table = [];
for (let x = 0; x <= max; x++) {
const v = Number(fn(x));
table.push(Number.isFinite(v) ? v : 0);
}
return table;
}
// --- resource constraints ---
function addConstraint(c = {}) {
const card = el("div", {class: "card"});
const res = selectEl(SCORE_KEYS, c.resource || "capital");
const op = selectEl([">=", "<=", "=="], c.op || ">=");
const value = num(c.value ?? 0);
const turn = turnSelect(c.turn);
card._get = () => {
const o = {resource: res.value, op: op.value, value: +value.value};
if (turn.value !== "") o.turn = +turn.value;
return o;
};
card.append(
field("Resource", res),
field("Op", op),
field("Value", value),
field("Turn (blank=final)", turn),
el("div", {class: "card-actions"}, removeBtn(card)));
document.getElementById("constraints").append(card);
}
// --- forced conversions ---
// A forced conversion always happens on its turn: spend from_amount of
// `from` and gain to_amount of `to`. It is not an optimizer choice.
function addConversion(c = {}) {
const card = el("div", {class: "card"});
const from = selectEl(RESOURCES, c.from || "trade_goods");
const fromAmt = num(c.from_amount ?? c.amount ?? 1, {min: 0});
const to = selectEl(RESOURCES, c.to || "capital");
const toAmt = num(c.to_amount ?? c.amount ?? 1, {min: 0});
const turn = turnSelect(c.turn);
card._get = () => {
const o = {
from: from.value, to: to.value,
from_amount: +fromAmt.value, to_amount: +toAmt.value,
};
if (turn.value !== "") o.turn = +turn.value;
return o;
};
card.append(
field("From", from),
field("From amount", fromAmt),
field("To", to),
field("To amount", toAmt),
field("Turn (blank=final)", turn),
el("div", {class: "card-actions"}, removeBtn(card)));
document.getElementById("conversions").append(card);
}
// --- optional conversions ---
// Unlike a forced conversion, the optimizer chooses how many times (0..max
// count) to apply this on its turn, or skips it entirely.
function addOptionalConversion(c = {}) {
const card = el("div", {class: "card"});
const from = selectEl(RESOURCES, c.from || "trade_goods");
const fromAmt = num(c.from_amount ?? c.amount ?? 1, {min: 0});
const to = selectEl(RESOURCES, c.to || "capital");
const toAmt = num(c.to_amount ?? c.amount ?? 1, {min: 0});
const maxCount = num(c.max_count ?? 1, {min: 1});
const turn = turnSelect(c.turn);
card._get = () => {
const o = {
from: from.value, to: to.value,
from_amount: +fromAmt.value, to_amount: +toAmt.value,
max_count: +maxCount.value,
};
if (turn.value !== "") o.turn = +turn.value;
return o;
};
card.append(
field("From", from),
field("From amount", fromAmt),
field("To", to),
field("To amount", toAmt),
field("Max count", maxCount),
field("Turn (blank=final)", turn),
el("div", {class: "card-actions"}, removeBtn(card)));
document.getElementById("optional_conversions").append(card);
}
// --- parsing helpers ---
function parseInts(s) {
const out = s.split(",").map(x => x.trim()).filter(Boolean).map(Number);
return out.length ? out : null;
}
function parseStrs(s) {
const out = s.split(",").map(x => x.trim()).filter(Boolean);
return out.length ? out : null;
}
function parsePairs(s, valueIsString = false) {
const o = {};
for (const part of s.split(",").map(x => x.trim()).filter(Boolean)) {
const [k, v] = part.split(":").map(x => x.trim());
o[+k] = valueIsString ? v : v;
}
return o;
}
function collect(id) {
return [...document.getElementById(id).children].map(r => r._get());
}
function buildProblem() {
const start = {};
for (const r of RESOURCES) {const v = +startInputs[r].value; if (v) start[r] = v;}
const tradeable_into = RESOURCES.filter(r => tradeInputs[r].checked);
return {
turns: +document.getElementById("turns").value,
extra_renown: +document.getElementById("extra_renown").value,
airships_launched: +document.getElementById("airships_launched").value,
max_resource: +document.getElementById("max_resource").value,
max_vat: +document.getElementById("max_vat").value,
start,
tradeable_into,
cities: collect("cities"),
agents: collect("agents"),
objective: {terms: collect("terms")},
resource_constraints: collect("constraints"),
conversions: collect("conversions"),
optional_conversions: collect("optional_conversions"),
};
}
// Each solve produces its own collapsible <details> card; older ones are
// kept (collapsed) so previous solutions stay viewable. solutionCount labels
// them in request order.
let solutionCount = 0;
// Whether *this* tab is the one currently solving. The /status poll only
// disables the button for OTHER viewers, so the solver keeps its button
// state under run()'s own control.
let solvingHere = false;
// Token identifying this tab's in-flight solve. When the tab closes mid
// solve we beacon /cancel?token=... so the server stops it and frees the
// lock immediately — reliable even behind a reverse proxy, which keeps
// the upstream socket open and so hides the disconnect from the server.
let activeToken = null;
function cancelActiveSolve() {
if (activeToken && navigator.sendBeacon) {
navigator.sendBeacon("/cancel?token=" + encodeURIComponent(activeToken));
}
}
// pagehide fires on tab close / navigation away (not on mere tab switch,
// so we don't cancel a solve the user backgrounds and comes back to).
window.addEventListener("pagehide", cancelActiveSolve);
// Cancel this tab's in-flight solve without closing the tab: POST /cancel
// so the server stops the search. The running /solve then returns with the
// best plan found so far (or an empty/infeasible one), which run() renders.
function cancelSolve() {
if (!activeToken) return;
const btn = document.getElementById("cancelBtn");
btn.disabled = true;
btn.textContent = "Cancelling…";
fetch("/cancel?token=" + encodeURIComponent(activeToken), {method: "POST"})
.catch(() => {/* the solve still returns; nothing to do here */});
}
// Toggle the Cancel button's visibility, resetting its label/disabled state.
function showCancel(visible) {
const btn = document.getElementById("cancelBtn");
btn.style.display = visible ? "" : "none";
btn.disabled = false;
btn.textContent = "Cancel";
}
async function run() {
const errBox = document.getElementById("error");
const out = document.getElementById("output");
errBox.textContent = "";
// A placeholder card for this request, prepended so the newest is on top.
// It's replaced by the solution on success, or removed on error.
const pending = el("p", {class: "pending"}, "Solving…");
out.prepend(pending);
let problem, time;
try {
problem = buildProblem();
time = +document.getElementById("time").value;
} catch (e) {errBox.textContent = e.message; pending.remove(); return;}
solvingHere = true;
setSolveDisabled(true, "Solving…");
const token = (crypto.randomUUID ? crypto.randomUUID()
: String(Date.now()) + Math.random());
activeToken = token;
showCancel(true);
try {
const resp = await fetch("/solve", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({problem, max_time_seconds: time, token}),
});
if (resp.status === 429) {
pending.remove();
errBox.textContent = "Another viewer is already solving — try again once they finish.";
return;
}
const data = await resp.json();
if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;}
renderSolution(data, pending, token);
} catch (e) {pending.remove(); errBox.textContent = e.message;}
finally {solvingHere = false; activeToken = null; showCancel(false); setSolveDisabled(false);}
}
// Enable/disable the Solve button with an optional note beside it. `kind`
// ("self" or "other") picks the note's color so this tab's "Solving…" and
// another viewer's "Another viewer is solving…" stand out distinctly.
function setSolveDisabled(disabled, note = "", kind = "self") {
document.getElementById("solveBtn").disabled = disabled;
const busy = document.getElementById("busy");
busy.textContent = note;
busy.classList.toggle("busy-self", kind === "self");
busy.classList.toggle("busy-other", kind === "other");
}
// Poll the server's solve state so that while one viewer is solving the
// others see their Solve button disabled (and re-enabled when it frees up).
async function pollStatus() {
try {
const resp = await fetch("/status", {cache: "no-store"});
const {solving} = await resp.json();
if (!solvingHere) setSolveDisabled(solving,
solving ? "Another viewer is solving…" : "", "other");
} catch (e) {/* leave button as-is on a transient error */}
}
setInterval(pollStatus, 1000);
pollStatus();
function renderSolution(s, placeholder, token) {
// Collapse any previously-shown solutions so the new one is the focus.
for (const d of document.querySelectorAll("#output details.solution")) d.open = false;
const n = ++solutionCount;
const details = el("details", {class: "solution", open: ""});
details.append(el("summary", {},
`Solution ${n}${s.status}, objective ${s.objective_value ?? "—"}`));
const out = details;
// A shareable permalink to this stored solve, looked up by its UUID.
if (token) {
const url = location.origin + location.pathname + "?solve=" + encodeURIComponent(token);
out.append(el("p", {}, el("button", {
class: "mini", type: "button",
onclick: (ev) => {
navigator.clipboard?.writeText(url);
ev.target.textContent = "Link copied!";
setTimeout(() => {ev.target.textContent = "Copy share link";}, 1500);
},
}, "Copy share link")));
}
out.append(el("p", {
html:
`<b>Status:</b> ${s.status} &nbsp; <b>Objective:</b> ${s.objective_value ?? "—"} &nbsp; ` +
`<b>Final renown total:</b> ${s.final_renown_total}`
}));
const fr = el("p", {});
fr.append(el("b", {}, "Final resources: "));
fr.append(document.createTextNode(
Object.entries(s.final_resources).map(([k, v]) => `${k}=${fmtNum(v)}`).join(", ") || "—"));
out.append(fr);
if (s.plan && s.plan.length) {
out.append(el("h3", {}, "Plan"));
out.append(el("p", {}, el("button", {
class: "mini", type: "button",
onclick: () => downloadPlanCsv(s, n),
}, "Download CSV")));
out.append(gridTable(
["Turn", "City", "Action", "Detail", "Governor", "Overwork"],
s.plan.map(p => [p.turn, p.city, p.action, p.detail, p.governor,
p.overwork ? "yes" : ""])));
} else {
out.append(el("p", {}, "(no actions / no feasible plan)"));
}
// Resource amounts at the end of each turn.
if (s.resources_by_turn && s.resources_by_turn.length) {
const cols = RESOURCES.filter(r => s.resources_by_turn.some(row => row[r] !== undefined));
out.append(el("h3", {}, "Resources by turn"));
out.append(gridTable(
["Turn"].concat(cols),
s.resources_by_turn.map(row =>
[row.turn].concat(cols.map(r => fmtNum(row[r]))))));
}
if (s.trade_conversions && s.trade_conversions.length) {
out.append(el("h3", {}, "Trade Goods conversions"));
out.append(gridTable(
["Turn", "Converted into", "Amount"],
s.trade_conversions.map(c => [c.turn, c.resource, fmtNum(c.amount)])));
}
if (s.forced_conversions && s.forced_conversions.length) {
out.append(el("h3", {}, "Forced conversions"));
out.append(gridTable(
["Turn", "From", "From amount", "To", "To amount"],
s.forced_conversions.map(c =>
[c.turn, c.from, fmtNum(c.from_amount), c.to, fmtNum(c.to_amount)])));
}
if (s.optional_conversions && s.optional_conversions.length) {
out.append(el("h3", {}, "Optional conversions (chosen)"));
out.append(gridTable(
["Turn", "Count", "From", "From amount", "To", "To amount"],
s.optional_conversions.map(c =>
[c.turn, c.count, c.from, fmtNum(c.from_amount * c.count),
c.to, fmtNum(c.to_amount * c.count)])));
}
// Swap the finished card in for this request's placeholder.
if (placeholder) placeholder.replaceWith(details);
else document.getElementById("output").prepend(details);
}
// Reorder one Turn's moves so that, starting from ``open`` balances, the
// running total of every resource in ``keys`` stays >= 0 after each move.
// Backtracking DFS keyed on the used-move bitmask (the set of used moves
// fully determines the balance), memoizing dead-ends so the search is at
// worst O(2^n) for a Turn's n moves (n is small: one per City + one
// conversion). Returns a valid ordering, or the moves unchanged if none
// exists (which means the solved plan isn't strictly orderable).
function orderMovesNoNegative(moves, open, keys) {
const n = moves.length;
if (n <= 1 || n > 22) return moves.slice();
const full = (1 << n) - 1;
const failed = new Set();
const pick = [];
function dfs(mask, bal) {
if (mask === full) return true;
if (failed.has(mask)) return false;
for (let i = 0; i < n; i++) {
if (mask & (1 << i)) continue;
const nb = {...bal};
let ok = true;
for (const r of keys) {
nb[r] = (nb[r] || 0) + (moves[i].deltas[r] || 0);
if (nb[r] < -1e-9) {ok = false; break;}
}
if (!ok) continue;
pick.push(i);
if (dfs(mask | (1 << i), nb)) return true;
pick.pop();
}
failed.add(mask);
return false;
}
const base = {};
for (const r of keys) base[r] = open[r] || 0;
return dfs(0, base) ? pick.map(i => moves[i]) : moves.slice();
}
// Build and download the plan's move history as a CSV. One row per move:
// each City's Industry Action, plus one row per Turn for that Turn's Trade
// Goods conversions. Ordering id is a unique increasing integer giving the
// order; Summary is "<action> <city> (turn N)" (or "convert trade goods
// (turn N)"). Datetime, Details and Scratch are left blank; the Δ columns
// are that move's net resource change.
function downloadPlanCsv(s, n) {
const headers = ["Ordering id", "Datetime", "Summary", "ΔElectrum",
"ΔBrass", "ΔSteel", "ΔLuxuries", "ΔCapital", "ΔTrade Goods",
"ΔExpress Tickets", "Details", "Scratch"];
const cell = v => {
const str = v == null ? "" : String(v);
return /[",\n]/.test(str) ? '"' + str.replace(/"/g, '""') + '"' : str;
};
// Collect every move and group by turn. A move is a City's Industry
// Action or a Turn's Trade Goods conversion (aggregated into one row).
const byTurn = {};
const push = m => (byTurn[m.turn] || (byTurn[m.turn] = [])).push(m);
for (const p of (s.plan || []))
push({
turn: p.turn, deltas: p.deltas || {},
summary: `${p.action} ${p.city} (turn ${p.turn})`
});
const convByTurn = {};
for (const c of (s.trade_conversions || [])) {
const d = convByTurn[c.turn] || (convByTurn[c.turn] = {});
d[c.resource] = (d[c.resource] || 0) + c.amount;
d.trade_goods = (d.trade_goods || 0) - c.amount;
}
for (const t of Object.keys(convByTurn))
push({
turn: +t, deltas: convByTurn[t],
summary: `convert trade goods (turn ${t})`
});
// Forced conversions: one row each, spending `from` and yielding `to`.
for (const c of (s.forced_conversions || [])) {
const d = {};
d[c.from] = (d[c.from] || 0) - c.from_amount;
d[c.to] = (d[c.to] || 0) + c.to_amount;
push({
turn: c.turn, deltas: d,
summary: `convert ${fmtNum(c.from_amount)} ${c.from}` +
`${fmtNum(c.to_amount)} ${c.to} (turn ${c.turn})`
});
}
// Optional conversions the optimizer chose: one row each, totalling the
// chosen count of applications spending `from` and yielding `to`.
for (const c of (s.optional_conversions || [])) {
const fromTotal = c.from_amount * c.count, toTotal = c.to_amount * c.count;
const d = {};
d[c.from] = (d[c.from] || 0) - fromTotal;
d[c.to] = (d[c.to] || 0) + toTotal;
push({
turn: c.turn, deltas: d,
summary: `convert ${fmtNum(fromTotal)} ${c.from}` +
`${fmtNum(toTotal)} ${c.to} (turn ${c.turn})`
});
}
// The solver only guarantees resources are non-negative at the END of
// each Turn, so the raw (turn, city) order can momentarily overdraw a
// resource (e.g. two Upgrades spend Steel before a conversion refills
// it). Within each Turn we reorder moves so the running balance never
// goes negative, carrying the balance across Turns. Electrum is
// included so forced conversions that spend it are ordered correctly;
// the Provisioner's off-pool +0.5/turn only ever inflates the running
// electrum balance, so it never triggers a false overdraw.
const ORDER_RES = ["capital", "luxuries", "steel", "brass",
"electrum", "trade_goods", "express"];
const moves = [];
let bal = {...(s.start_resources || {})};
const turns = Object.keys(byTurn).map(Number).sort((a, b) => a - b);
for (const t of turns) {
const ordered = orderMovesNoNegative(byTurn[t], bal, ORDER_RES);
for (const m of ordered) {
for (const r of ORDER_RES) bal[r] = (bal[r] || 0) + (m.deltas[r] || 0);
moves.push(m);
}
}
const delta = (m, r) => fmtNum(m.deltas[r] || 0);
const rows = moves.map((m, i) =>
[i + 1, "", m.summary,
delta(m, "electrum"), delta(m, "brass"), delta(m, "steel"),
delta(m, "luxuries"), delta(m, "capital"), delta(m, "trade_goods"),
delta(m, "express"), "", ""].map(cell).join(","));
const csv = headers.map(cell).join(",") + "\n" + rows.join("\n") + "\n";
const blob = new Blob([csv], {type: "text/csv;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = el("a", {href: url, download: `solution-${n}-moves.csv`});
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
// Trim trailing ".0" so whole numbers read cleanly.
function fmtNum(v) {
if (typeof v !== "number") return String(v);
return Number.isInteger(v) ? String(v) : String(+v.toFixed(2));
}
// --- 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});
addCity({name: "2", type: "hub", renown: 2});
addCity({name: "3", type: "foundry", renown: 2});
addCity({name: "4", type: "monument", renown: 2});
addAgent({kind: "Planner"});
addTerms({
"renown": 0,
"luxuries": 1,
"steel": 2,
"brass": 1,
"electrum": 2
})
// --- deep link: /?solve=<uuid> loads a previously stored solve ---
async function loadSharedSolve(token) {
const out = document.getElementById("output");
const errBox = document.getElementById("error");
const pending = el("p", {class: "pending"}, "Loading shared solve…");
out.prepend(pending);
try {
const resp = await fetch("/solve/" + encodeURIComponent(token), {cache: "no-store"});
if (resp.status === 404) {
pending.remove();
errBox.textContent = "No stored solve found for that link (it may have been evicted).";
return;
}
const rec = await resp.json();
if (!resp.ok) {pending.remove(); errBox.textContent = rec.error || "Server error"; return;}
renderSolution(rec.solution, pending, rec.token);
} catch (e) {pending.remove(); errBox.textContent = e.message;}
}
const sharedToken = new URLSearchParams(location.search).get("solve");
if (sharedToken) loadSharedSolve(sharedToken);
</script>
</body>
</html>