moved to template and changed defaults to be more reasonable
This commit is contained in:
parent
e350036b1b
commit
dd99ed8a23
2 changed files with 733 additions and 568 deletions
730
index.html
Normal file
730
index.html
Normal file
|
|
@ -0,0 +1,730 @@
|
||||||
|
<!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: end;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card input,
|
||||||
|
.card select,
|
||||||
|
.card textarea {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card input[type=checkbox] {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card .check {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
align-self: start;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-cell {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.log-on .log-cell {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.log-on .linear-only {
|
||||||
|
opacity: .4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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 ---
|
||||||
|
function addCity(c = {}) {
|
||||||
|
const card = el("div", {class: "card"});
|
||||||
|
const name = el("input", {value: c.name || ""});
|
||||||
|
const type = selectEl(CITY_TYPES, c.type || "hub");
|
||||||
|
const renown = num(c.renown ?? "", {min: 1, placeholder: "auto"});
|
||||||
|
const vs = num(c.vat_steel || 0, {min: 0}), vb = num(c.vat_brass || 0, {min: 0}),
|
||||||
|
ve = num(c.vat_electrum || 0, {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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type.onchange = renderUpgrades;
|
||||||
|
renderUpgrades();
|
||||||
|
|
||||||
|
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),
|
||||||
|
field("Type", type),
|
||||||
|
field("Renown", renown),
|
||||||
|
field("Vat steel", vs),
|
||||||
|
field("Vat brass", vb),
|
||||||
|
field("Vat electrum", ve),
|
||||||
|
field("Upgrades (already installed)", upWrap),
|
||||||
|
checkField("Can renovate", reno),
|
||||||
|
field("Adjacent cities (csv of names)", adjacent),
|
||||||
|
field("Forced actions (turn:action, csv)", forced),
|
||||||
|
field("Avail turns (csv, blank=all)", avail),
|
||||||
|
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: ""});
|
||||||
|
|
||||||
|
let nameEdited = !!a.name;
|
||||||
|
name.oninput = () => {nameEdited = true;};
|
||||||
|
function syncType() {
|
||||||
|
if (!nameEdited) name.value = type.value;
|
||||||
|
bastions.disabled = (type.value !== "Baron");
|
||||||
|
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("Name", name),
|
||||||
|
field("Effect", desc),
|
||||||
|
field("Bastions (Baron only)", bastions),
|
||||||
|
field("Forced city (turn:city, csv)", forced),
|
||||||
|
field("Avail turns (csv, blank=all)", avail),
|
||||||
|
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)"});
|
||||||
|
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,
|
||||||
|
checkField("Log?", isLog),
|
||||||
|
exprF,
|
||||||
|
el("div", {class: "card-actions"}, removeBtn(card)));
|
||||||
|
document.getElementById("terms").append(card);
|
||||||
|
toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const errBox = document.getElementById("error");
|
||||||
|
const out = document.getElementById("output");
|
||||||
|
errBox.textContent = ""; out.innerHTML = "";
|
||||||
|
let problem, time;
|
||||||
|
try {
|
||||||
|
problem = buildProblem();
|
||||||
|
time = +document.getElementById("time").value;
|
||||||
|
} catch (e) {errBox.textContent = e.message; return;}
|
||||||
|
out.textContent = "Solving…";
|
||||||
|
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) {out.innerHTML = ""; errBox.textContent = data.error || "Server error"; return;}
|
||||||
|
renderSolution(data);
|
||||||
|
} catch (e) {out.innerHTML = ""; errBox.textContent = e.message;}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSolution(s) {
|
||||||
|
const out = document.getElementById("output");
|
||||||
|
out.innerHTML = "";
|
||||||
|
out.append(el("h2", {}, "Solution"));
|
||||||
|
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(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)])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: "Aridias", type: "hub", renown: 2, adjacent: ["Bearhearth"]});
|
||||||
|
addCity({name: "Bearhearth", type: "foundry", renown: 2, vat_steel: 3, vat_brass: 2, vat_electrum: 1, adjacent: ["Kingsland"]});
|
||||||
|
addCity({name: "Kingsland", type: "metropolis", renown: 4, can_renovate: false, adjacent: ["Roseward"]});
|
||||||
|
addCity({name: "Roseward", type: "monument", renown: 2});
|
||||||
|
addAgent({kind: "Planner"});
|
||||||
|
Object.entries({
|
||||||
|
"renown": 0,
|
||||||
|
"luxuries": 1,
|
||||||
|
"steel": 2,
|
||||||
|
"brass": 1,
|
||||||
|
"electrum": 2
|
||||||
|
}).forEach(([resource, scalar]) =>
|
||||||
|
addTerm({resource, scalar}));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
571
main.py
571
main.py
|
|
@ -14,578 +14,13 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from solve import problem_from_dict, solve, solution_to_dict
|
from solve import problem_from_dict, solve, solution_to_dict
|
||||||
|
|
||||||
|
|
||||||
INDEX_HTML = r"""<!DOCTYPE html>
|
# The UI is a single static page served from index.html next to this module.
|
||||||
<html lang="en">
|
INDEX_HTML = (Path(__file__).resolve().parent / "index.html").read_text(encoding="utf-8")
|
||||||
<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: end;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
|
|
||||||
.card input, .card select, .card textarea { width: 100%; }
|
|
||||||
.card input[type=checkbox] { width: auto; }
|
|
||||||
.card .check { flex-direction: row; align-items: center; gap: .35rem; }
|
|
||||||
.card-actions { align-self: start; justify-self: end; }
|
|
||||||
.log-cell { display: none; }
|
|
||||||
.card.log-on .log-cell { display: flex; }
|
|
||||||
.card.log-on .linear-only { opacity: .4; }
|
|
||||||
|
|
||||||
/* 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; }
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<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" ? 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 ---
|
|
||||||
function addCity(c={}) {
|
|
||||||
const card = el("div", {class:"card"});
|
|
||||||
const name = el("input", {value:c.name||""});
|
|
||||||
const type = selectEl(CITY_TYPES, c.type||"hub");
|
|
||||||
const renown = num(c.renown ?? "", {min:1, placeholder:"auto"});
|
|
||||||
const vs = num(c.vat_steel||0, {min:0}), vb = num(c.vat_brass||0, {min:0}),
|
|
||||||
ve = num(c.vat_electrum||0, {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]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
type.onchange = renderUpgrades;
|
|
||||||
renderUpgrades();
|
|
||||||
|
|
||||||
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),
|
|
||||||
field("Type", type),
|
|
||||||
field("Renown", renown),
|
|
||||||
field("Vat steel", vs),
|
|
||||||
field("Vat brass", vb),
|
|
||||||
field("Vat electrum", ve),
|
|
||||||
field("Upgrades (already installed)", upWrap),
|
|
||||||
checkField("Can renovate", reno),
|
|
||||||
field("Adjacent cities (csv of names)", adjacent),
|
|
||||||
field("Forced actions (turn:action, csv)", forced),
|
|
||||||
field("Avail turns (csv, blank=all)", avail),
|
|
||||||
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:""});
|
|
||||||
|
|
||||||
let nameEdited = !!a.name;
|
|
||||||
name.oninput = () => { nameEdited = true; };
|
|
||||||
function syncType() {
|
|
||||||
if (!nameEdited) name.value = type.value;
|
|
||||||
bastions.disabled = (type.value !== "Baron");
|
|
||||||
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("Name", name),
|
|
||||||
field("Effect", desc),
|
|
||||||
field("Bastions (Baron only)", bastions),
|
|
||||||
field("Forced city (turn:city, csv)", forced),
|
|
||||||
field("Avail turns (csv, blank=all)", avail),
|
|
||||||
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)"});
|
|
||||||
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,
|
|
||||||
checkField("Log?", isLog),
|
|
||||||
exprF,
|
|
||||||
el("div", {class:"card-actions"}, removeBtn(card)));
|
|
||||||
document.getElementById("terms").append(card);
|
|
||||||
toggle();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const errBox = document.getElementById("error");
|
|
||||||
const out = document.getElementById("output");
|
|
||||||
errBox.textContent = ""; out.innerHTML = "";
|
|
||||||
let problem, time;
|
|
||||||
try {
|
|
||||||
problem = buildProblem();
|
|
||||||
time = +document.getElementById("time").value;
|
|
||||||
} catch (e) { errBox.textContent = e.message; return; }
|
|
||||||
out.textContent = "Solving…";
|
|
||||||
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) { out.innerHTML = ""; errBox.textContent = data.error || "Server error"; return; }
|
|
||||||
renderSolution(data);
|
|
||||||
} catch (e) { out.innerHTML = ""; errBox.textContent = e.message; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSolution(s) {
|
|
||||||
const out = document.getElementById("output");
|
|
||||||
out.innerHTML = "";
|
|
||||||
out.append(el("h2", {}, "Solution"));
|
|
||||||
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(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)])));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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:"Aridias", type:"hub", renown:2, adjacent:["Bearhearth"]});
|
|
||||||
addCity({name:"Bearhearth", type:"foundry", renown:2, vat_steel:3, vat_brass:2, vat_electrum:1, adjacent:["Kingsland"]});
|
|
||||||
addCity({name:"Kingsland", type:"metropolis", renown:4, can_renovate:false, adjacent:["Roseward"]});
|
|
||||||
addCity({name:"Roseward", type:"monument", renown:2});
|
|
||||||
addAgent({kind:"Planner"});
|
|
||||||
[["renown",5],["capital",1],["luxuries",1],["steel",1],["brass",1],
|
|
||||||
["electrum",2],["trade_goods",1],["express",3]].forEach(([r,s]) =>
|
|
||||||
addTerm({resource:r, scalar:s}));
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue