dws-city-res-solve/main.py
2026-06-17 14:35:29 -04:00

626 lines
24 KiB
Python
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.

"""Web UI for the Days Without Strife planner (see solve.py).
A dependency-free (stdlib only) HTTP server that exposes every input the
solver accepts: turns, starting resources, cities, agents, scoring terms
(linear or log), resource constraints and the misc Problem knobs.
For log-scored terms the user supplies a JavaScript expression that evals into
a single-argument function (e.g. ``(x) => Math.log2(x)``). The browser evals it
once, then calls it over the amounts it needs (0..max_resource) to build the
``log_mapping`` lookup table, which is sent to the server as a plain array.
"""
from __future__ import annotations
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from solve import problem_from_dict, solve, solution_to_dict
INDEX_HTML = r"""<!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 &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.</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" ? 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 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 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("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 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} &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)])));
}
}
// 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});
addCity({name:"Bearhearth", type:"foundry", renown:2, vat_steel:3, vat_brass:2, vat_electrum:1});
addCity({name:"Kingsland", type:"metropolis", renown:4, can_renovate:false});
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):
def _send(self, code, body, content_type="application/json"):
if isinstance(body, str):
body = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path in ("/", "/index.html"):
self._send(200, INDEX_HTML, "text/html; charset=utf-8")
else:
self._send(404, json.dumps({"error": "not found"}))
def do_POST(self):
if self.path != "/solve":
self._send(404, json.dumps({"error": "not found"}))
return
try:
length = int(self.headers.get("Content-Length", 0))
payload = json.loads(self.rfile.read(length) or b"{}")
problem = problem_from_dict(payload.get("problem", {}))
max_time = float(payload.get("max_time_seconds", 30.0))
sol = solve(problem, max_time_seconds=max_time)
self._send(200, json.dumps(solution_to_dict(sol)))
except Exception as exc: # surface errors to the browser
self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"}))
def log_message(self, fmt, *args): # quieter console
pass
def main():
host, port = "127.0.0.1", 8000
server = ThreadingHTTPServer((host, port), Handler)
print(f"Days Without Strife planner UI: http://{host}:{port}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nshutting down")
server.shutdown()
if __name__ == "__main__":
main()