dws-city-res-solve/index.html
Pagwin f52871318e Solves are Queued
This has some nice improvements in the case I want to batch a bunch of
stuff overnight.
2026-06-18 23:16:18 -04:00

1317 lines
56 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;
}
.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;
}
/* A reserved slot for a queued/running solve: dashed to read as "not done
yet", holding a spinner, its share link, and a Cancel button. */
.solution-pending {
border: 1px dashed #8886;
border-radius: 8px;
padding: .4rem .8rem;
margin-top: .8rem;
}
.solution-pending.err {
border-color: #dc2626;
}
.pending-head {
font-weight: 600;
padding: .3rem 0;
}
.spinner {
display: inline-block;
width: .9em;
height: .9em;
border: 2px solid #8886;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin .8s linear infinite;
vertical-align: -.1em;
margin-right: .45rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</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="shareAllBtn" type="button" onclick="shareVisible()" style="margin-left:.4rem">Share
visible solutions</button>
</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;
// Live tracking of this tab's queued/running solves.
// token -> {token, card, n, confirmed}. Each gets a reserved "pending" card
// on the page right away; a shared /job_status event stream drives it through
// queued -> running -> done (rendered) / error / cancelled.
const pending = new Map();
function uuid() {
return crypto.randomUUID ? crypto.randomUUID()
: String(Date.now()) + Math.random();
}
// A share URL for a set of solve tokens. The schema is
// ?solves=<id1>,<id2>,… so anyone who knows the ids can compose an
// arbitrary set; a single ?solve=<id> link is also still understood.
function shareUrl(tokens) {
return location.origin + location.pathname +
"?solves=" + tokens.map(encodeURIComponent).join(",");
}
// A "copy link" button that briefly confirms after writing to the clipboard.
function copyLinkButton(label, getUrl) {
return el("button", {
class: "mini", type: "button",
onclick: (ev) => {
navigator.clipboard?.writeText(getUrl());
const orig = ev.target.textContent;
ev.target.textContent = "Copied!";
setTimeout(() => {ev.target.textContent = orig;}, 1500);
},
}, label);
}
// Copy a single link sharing every solution currently on the page (pending
// ones included — their links resolve once the solve finishes).
function shareVisible() {
const tokens = [...document.querySelectorAll("#output [data-token]")]
.map(e => e.dataset.token);
const btn = document.getElementById("shareAllBtn");
const reset = () => setTimeout(() => {btn.textContent = "Share visible solutions";}, 1500);
if (!tokens.length) {btn.textContent = "Nothing to share"; reset(); return;}
navigator.clipboard?.writeText(shareUrl(tokens));
btn.textContent = "Link copied!";
reset();
}
// Build the reserved card for a queued/running solve: a spinner + status,
// the (already valid) share link, and a Cancel button.
function makePendingCard(token, n) {
const card = el("div", {class: "solution-pending"});
card.dataset.token = token;
const status = el("span", {}, "Queued…");
card._status = status;
const actions = el("p", {}, [
copyLinkButton("Copy share link", () => shareUrl([token])), " ",
el("button", {
class: "mini", type: "button",
onclick: () => cancelPending(token),
}, "Cancel"),
]);
card._actions = actions;
card.append(
el("div", {class: "pending-head"},
[el("span", {class: "spinner"}), `Solution ${n}`, status]),
actions);
return card;
}
// Cancel a pending solve: drop it from the queue or stop the running search.
// The stream then reports "cancelled" (card removed) or, for a stopped
// running solve, "done" with the best plan found so far (card rendered).
function cancelPending(token) {
const entry = pending.get(token);
if (entry) entry.card._status.textContent = "Cancelling…";
fetch("/cancel?token=" + encodeURIComponent(token), {method: "POST"})
.catch(() => {/* the stream reflects the outcome regardless */});
}
// Stop tracking a pending solve and run a final action on its card. Once
// nothing is pending, close the event stream so it doesn't reconnect.
function finishPending(token, action) {
const entry = pending.get(token);
if (!entry) return;
pending.delete(token);
action(entry);
if (pending.size === 0 && stream) {stream.close(); stream = null;}
}
// Put a pending card into a terminal state: show the message, drop the
// spinner & cancel, and offer a Dismiss button. `isError` tints it red.
function finalizeCard(token, msg, isError = false) {
finishPending(token, (entry) => {
entry.card.classList.toggle("err", isError);
entry.card.querySelector(".spinner")?.remove(); // it's no longer loading
entry.card._status.textContent = msg;
entry.card._actions.replaceChildren(el("button", {
class: "mini", type: "button",
onclick: () => entry.card.remove(),
}, "Dismiss"));
});
}
function pendingError(token, msg) {finalizeCard(token, msg, true);}
// A single Server-Sent Events stream carries progress for every pending
// solve at once: /job_status?tokens=t1,t2,… It's rebuilt whenever the set
// of confirmed (enqueued) solves changes, and closed when none remain.
let stream = null;
function syncStream() {
if (stream) {stream.close(); stream = null;}
const tokens = [...pending.values()].filter(e => e.confirmed).map(e => e.token);
if (!tokens.length) return;
stream = new EventSource(
"/job_status?tokens=" + tokens.map(encodeURIComponent).join(","));
stream.onmessage = (ev) => handleJobEvent(JSON.parse(ev.data));
// On a transient network error EventSource auto-reconnects to the same
// URL; we explicitly close it (in finishPending) once all solves finish.
stream.onerror = () => {/* let the browser retry */};
}
// Advance one pending solve's card from a streamed state update.
function handleJobEvent(j) {
const entry = pending.get(j.token);
if (!entry) return; // already finished/dismissed
switch (j.status) {
case "queued":
entry.card._status.textContent =
j.position > 0 ? `Queued (${j.position} ahead)…` : "Queued (next up)…";
break;
case "running":
entry.card._status.textContent = "Solving…";
break;
case "done":
finishPending(j.token, (e) =>
renderSolution(j.solution, e.card, j.token, e.n));
break;
case "cancelled":
// Leave a visible "cancelled" card (whether this tab cancelled
// it or it was cancelled elsewhere) rather than silently vanishing.
finalizeCard(j.token, "This solve was cancelled.");
break;
case "error":
pendingError(j.token, "Error: " + (j.error || "solve failed"));
break;
default: // "unknown": no such job/solve (evicted, or server restarted)
pendingError(j.token,
`Solve ${j.token} not found (it may have been evicted or the server restarted).`);
}
}
// Closing or leaving a tab no longer cancels its solves: with the queue,
// a left-behind solve just runs (or waits its turn) to completion and is
// stored, so its share link still resolves — and a tab that only *views* a
// shared solve can't cancel the author's work just by being closed. Cancel
// is now an explicit per-card button only (see cancelPending).
// Queue a solve: reserve its card immediately, POST it, then subscribe to
// the progress stream once it's enqueued (confirmed). Multiple solves can
// be queued at once; the server runs them one at a time.
function run() {
const errBox = document.getElementById("error");
errBox.textContent = "";
let problem, time;
try {
problem = buildProblem();
time = +document.getElementById("time").value;
} catch (e) {errBox.textContent = e.message; return;}
const token = uuid();
const n = ++solutionCount;
const card = makePendingCard(token, n);
document.getElementById("output").prepend(card);
pending.set(token, {token, card, n, confirmed: false});
fetch("/solve", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({problem, max_time_seconds: time, token}),
}).then(async (resp) => {
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
pendingError(token, data.error || "Server error");
return;
}
// Now the server knows the token; (re)open the stream to include it.
const entry = pending.get(token);
if (entry) {entry.confirmed = true; syncStream();}
}).catch((e) => pendingError(token, e.message));
}
function renderSolution(s, placeholder, token, n) {
// Collapse any previously-shown solutions so the new one is the focus.
for (const d of document.querySelectorAll("#output details.solution")) d.open = false;
n = n ?? ++solutionCount;
const details = el("details", {class: "solution", open: ""});
// Tag the card with its token so "Share visible solutions" can collect it.
if (token) details.dataset.token = token;
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) {
out.append(el("p", {}, copyLinkButton("Copy share link",
() => shareUrl([token]))));
}
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 links ---
// /?solve=<id> loads one solve; /?solves=<id1>,<id2>,… loads an arbitrary
// set (the schema "Share visible solutions" produces). Each id is attached
// to its live job state exactly like a locally-queued solve: a card is
// reserved synchronously (so the set keeps its given order) and the shared
// /job_status stream resolves it — rendering a finished solve, or tracking
// one that's still queued/running until it completes (jobs are server-wide).
// Only truly-unknown ids (evicted / never existed) fall to the "not found"
// message, instead of every in-flight solve 404ing as "not stored yet".
function loadSharedSolve(token) {
const n = ++solutionCount;
const card = makePendingCard(token, n);
card._status.textContent = "Loading…";
document.getElementById("output").prepend(card);
pending.set(token, {token, card, n, confirmed: true});
}
const params = new URLSearchParams(location.search);
const sharedTokens = (params.get("solves") || params.get("solve") || "")
.split(",").map(s => s.trim()).filter(Boolean);
// Load in reverse so the first id ends up on top (each load prepends).
for (const token of sharedTokens.slice().reverse()) loadSharedSolve(token);
if (sharedTokens.length) syncStream();
</script>
</body>
</html>