dws-city-res-solve/index.html
2026-06-17 16:23:20 -04:00

854 lines
33 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;
}
.pending {
opacity: .75;
margin-top: .8rem;
}
</style>
</head>
<body>
<h1>Days Without Strife &mdash; Planner Optimizer</h1>
<fieldset>
<legend>Game</legend>
<div class="grid">
<label class="field"><span>Turns</span><input id="turns" type="number" min="1" value="5"
oninput="refreshTurnSelects()"></label>
<label class="field"><span>Extra renown (constant)</span><input id="extra_renown" type="number"
value="0"></label>
<label class="field"><span>Airships already launched</span><input id="airships_launched" type="number"
min="0" value="0"></label>
<label class="field"><span>Max resource</span><input id="max_resource" type="number" min="1"
value="300"></label>
<label class="field"><span>Max vat</span><input id="max_vat" type="number" min="1" value="12"></label>
<label class="field"><span>Solver time limit (s)</span><input id="time" type="number" min="1"
value="30"></label>
</div>
</fieldset>
<fieldset>
<legend>Starting resources</legend>
<div class="grid" id="start"></div>
</fieldset>
<fieldset>
<legend>Tradeable into (Trade Goods convert 1-for-1 for scoring)</legend>
<div id="tradeable"></div>
</fieldset>
<fieldset>
<legend>Cities</legend>
<p class="help">The <b>name</b> is the city's unique identifier &mdash; it labels the
output plan and is what an agent's "forced city" refers to. <b>Upgrades</b> are the
ones already installed at the start; the available choices follow the city's type.
<b>Adjacent cities</b> lists neighbours by name (adjacency is symmetric); an
Industrialist Governor grants free Infrastructure to its city and every adjacent one.
</p>
<div id="cities" class="cards"></div>
<button class="mini" type="button" onclick="addCity()">+ city</button>
</fieldset>
<fieldset>
<legend>Agents</legend>
<p class="help">Pick a known agent; its governor behaviour is implicit and
shown in the <b>Effect</b> column. The <b>name</b> identifies it and is what a
forced city refers back to. All effects apply while the agent is Governor of a
city (at most one city per turn).</p>
<div id="agents" class="cards"></div>
<button class="mini" type="button" onclick="addAgent()">+ agent</button>
</fieldset>
<fieldset>
<legend>Objective &mdash; scoring terms</legend>
<p class="help">Each term scores a resource (or <code>renown</code>) at the end of a
turn (blank turn = final turn). For a <b>log</b> term, write a JS expression that
evals to a one-argument function, e.g. <code>(x) =&gt; Math.log2(x + 1)</code>.
It is called over amounts <code>0..max_resource</code> in the browser to build the
lookup table.</p>
<div id="terms" class="cards"></div>
<button class="mini" type="button" onclick="addTerm()">+ term</button>
</fieldset>
<fieldset>
<legend>Resource constraints</legend>
<div id="constraints" class="cards"></div>
<button class="mini" type="button" onclick="addConstraint()">+ constraint</button>
</fieldset>
<p>
<button class="primary" type="button" onclick="run()">Solve</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);
}
// --- 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"),
};
}
// 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;
async function run() {
const errBox = document.getElementById("error");
const out = document.getElementById("output");
errBox.textContent = "";
// A placeholder card for this request, prepended so the newest is on top.
// It's replaced by the solution on success, or removed on error.
const pending = el("p", {class: "pending"}, "Solving…");
out.prepend(pending);
let problem, time;
try {
problem = buildProblem();
time = +document.getElementById("time").value;
} catch (e) {errBox.textContent = e.message; pending.remove(); return;}
try {
const resp = await fetch("/solve", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({problem, max_time_seconds: time}),
});
const data = await resp.json();
if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;}
renderSolution(data, pending);
} catch (e) {pending.remove(); errBox.textContent = e.message;}
}
function renderSolution(s, placeholder) {
// Collapse any previously-shown solutions so the new one is the focus.
for (const d of document.querySelectorAll("#output details.solution")) d.open = false;
const n = ++solutionCount;
const details = el("details", {class: "solution", open: ""});
details.append(el("summary", {},
`Solution ${n}${s.status}, objective ${s.objective_value ?? "—"}`));
const out = details;
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(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)])));
}
// Swap the finished card in for this request's placeholder.
if (placeholder) placeholder.replaceWith(details);
else document.getElementById("output").prepend(details);
}
// 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
})
</script>
</body>
</html>