1409 lines
61 KiB
HTML
1409 lines
61 KiB
HTML
<!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 — 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 — 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 — 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) => 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 — 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 — 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"];
|
||
// Electrum is the one resource the solver tracks in tenths (it takes
|
||
// fractional amounts); its log tables are therefore sampled per tenth.
|
||
// Must match ELECTRUM_SCALE in solve.py.
|
||
const ELECTRUM_SCALE = 10;
|
||
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;
|
||
}
|
||
// Plain turn-number selects (no "final" entry): a blank option (value "")
|
||
// labeled however the caller likes, then turn 0..count-1. Used for the
|
||
// cities' Arrival/Departure turns where blank means "unbounded" rather
|
||
// than "final".
|
||
const plainTurnSelects = new Set();
|
||
function fillPlainTurnOptions(sel) {
|
||
const prev = sel.value;
|
||
sel.innerHTML = "";
|
||
sel.append(el("option", {value: ""}, sel._blankLabel || ""));
|
||
for (let t = 0; t < turnsCount(); t++)
|
||
sel.append(el("option", {value: String(t)}, "turn " + t));
|
||
sel.value = [...sel.options].some(o => o.value === prev) ? prev : "";
|
||
}
|
||
function plainTurnSelect(value, blankLabel) {
|
||
const sel = el("select");
|
||
sel._blankLabel = blankLabel;
|
||
plainTurnSelects.add(sel);
|
||
fillPlainTurnOptions(sel);
|
||
if (value !== undefined && value !== null && value !== "") sel.value = String(value);
|
||
return sel;
|
||
}
|
||
|
||
// Multi-turn picker: a row of checkboxes ("final" + turn 0..count-1).
|
||
// _selected() returns the checked values as strings ("" = final). Registers
|
||
// for refresh so its options track the Turns count, preserving selections.
|
||
const multiTurnGroups = new Set();
|
||
function fillMultiTurnOptions(group) {
|
||
const prev = new Set((group._boxes || []).filter(b => b.checked).map(b => b.value));
|
||
group._boxes = [];
|
||
group.innerHTML = "";
|
||
const mk = (val, label) => {
|
||
const cb = el("input", {type: "checkbox"});
|
||
cb.value = val;
|
||
if (prev.has(val)) cb.checked = true;
|
||
group._boxes.push(cb);
|
||
return el("label", {style: "display:inline-block;margin-right:.6rem;font-size:.85rem"},
|
||
[cb, " " + label]);
|
||
};
|
||
group.append(mk("", "final"));
|
||
for (let t = 0; t < turnsCount(); t++) group.append(mk(String(t), "turn " + t));
|
||
}
|
||
function multiTurnSelect(values) {
|
||
const group = el("div", {class: "multi-turn"});
|
||
group._boxes = [];
|
||
group._selected = () => group._boxes.filter(b => b.checked).map(b => b.value);
|
||
multiTurnGroups.add(group);
|
||
fillMultiTurnOptions(group);
|
||
if (values && values.length) {
|
||
const want = new Set(values.map(v => (v === "" || v == null) ? "" : String(v)));
|
||
for (const b of group._boxes) if (want.has(b.value)) b.checked = true;
|
||
}
|
||
return group;
|
||
}
|
||
|
||
function refreshTurnSelects() {
|
||
for (const sel of turnSelects) {
|
||
if (!sel.isConnected) {turnSelects.delete(sel); continue;}
|
||
fillTurnOptions(sel);
|
||
}
|
||
for (const sel of plainTurnSelects) {
|
||
if (!sel.isConnected) {plainTurnSelects.delete(sel); continue;}
|
||
fillPlainTurnOptions(sel);
|
||
}
|
||
for (const group of multiTurnGroups) {
|
||
if (!group.isConnected) {multiTurnGroups.delete(group); continue;}
|
||
fillMultiTurnOptions(group);
|
||
}
|
||
}
|
||
|
||
// --- 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"});
|
||
// 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
|
||
// means available on every turn.
|
||
const arrival = plainTurnSelect(undefined, "start");
|
||
const departure = plainTurnSelect(undefined, "end");
|
||
if (c.available_turns && c.available_turns.length) {
|
||
arrival.value = String(Math.min(...c.available_turns));
|
||
departure.value = String(Math.max(...c.available_turns));
|
||
}
|
||
|
||
// 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 a = arrival.value, d = departure.value;
|
||
if (a !== "" || d !== "") {
|
||
const lo = a !== "" ? +a : 0;
|
||
const hi = d !== "" ? +d : Math.max(0, turnsCount() - 1);
|
||
const at = [];
|
||
for (let t = lo; t <= hi; t++) at.push(t);
|
||
if (at.length) 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("Arrival turn (blank=start)", arrival),
|
||
field("Departure turn (blank=end)", departure),
|
||
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, res.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 sample it over the
|
||
// amounts the lookup table needs (0..max_resource). The solver indexes the
|
||
// table by the resource's internal amount, so Electrum (tracked in tenths)
|
||
// is sampled per tenth -- entry k is the score at k/ELECTRUM_SCALE -- while
|
||
// every other resource gets one entry per whole unit.
|
||
function buildLogTable(exprStr, resource) {
|
||
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 scale = resource === "electrum" ? ELECTRUM_SCALE : 1;
|
||
const table = [];
|
||
for (let i = 0; i <= max * scale; i++) {
|
||
const v = Number(fn(i / scale));
|
||
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});
|
||
// Multiple turns may be selected; each chosen turn yields its own copy of
|
||
// this conversion in the problem JSON (one entry per turn).
|
||
const turns = multiTurnSelect(c.turn != null ? [c.turn] : []);
|
||
card._get = () => {
|
||
const base = {
|
||
from: from.value, to: to.value,
|
||
from_amount: +fromAmt.value, to_amount: +toAmt.value,
|
||
max_count: +maxCount.value,
|
||
};
|
||
const sel = turns._selected();
|
||
return (sel.length ? sel : [""]).map(tv => {
|
||
const o = {...base};
|
||
if (tv !== "") o.turn = +tv;
|
||
return o;
|
||
});
|
||
};
|
||
card.append(
|
||
field("From", from),
|
||
field("From amount", fromAmt),
|
||
field("To", to),
|
||
field("To amount", toAmt),
|
||
field("Max count", maxCount),
|
||
field("Turns (none=final)", turns),
|
||
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").flat(),
|
||
};
|
||
}
|
||
|
||
// 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} <b>Objective:</b> ${s.objective_value ?? "—"} ` +
|
||
`<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>
|