Conversions and mutually exclusive solving
Conversions now are a thing to model trading exclusive solving is in so my teammates can't kill the $5 VPS
This commit is contained in:
parent
9e71a64969
commit
727cd03f65
3 changed files with 360 additions and 3 deletions
232
index.html
232
index.html
|
|
@ -70,6 +70,26 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#solveBtn:disabled {
|
||||||
|
opacity: 50%;
|
||||||
|
background: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
#busy {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* This tab is the one solving. */
|
||||||
|
#busy.busy-self {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Another viewer holds the solve lock. */
|
||||||
|
#busy.busy-other {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
.mini {
|
.mini {
|
||||||
padding: .1rem .45rem;
|
padding: .1rem .45rem;
|
||||||
font-size: .85rem;
|
font-size: .85rem;
|
||||||
|
|
@ -341,8 +361,20 @@
|
||||||
<button class="mini" type="button" onclick="addConstraint()">+ constraint</button>
|
<button class="mini" type="button" onclick="addConstraint()">+ constraint</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Forced conversions</legend>
|
||||||
|
<p class="help">A conversion is <b>not</b> a choice for the optimizer — it always
|
||||||
|
happens on the given turn, spending <b>from amount</b> of one resource and yielding
|
||||||
|
<b>to amount</b> of another. The optimizer is forced to have the spent resource
|
||||||
|
available at that turn. These appear in the solution and the CSV download.
|
||||||
|
</p>
|
||||||
|
<div id="conversions" class="cards"></div>
|
||||||
|
<button class="mini" type="button" onclick="addConversion()">+ conversion</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<button class="primary" type="button" onclick="run()">Solve</button>
|
<button id="solveBtn" class="primary" type="button" onclick="run()">Solve</button>
|
||||||
|
<span id="busy" class="help" style="margin-left:.6rem"></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div id="error" class="err"></div>
|
<div id="error" class="err"></div>
|
||||||
|
|
@ -704,6 +736,34 @@
|
||||||
document.getElementById("constraints").append(card);
|
document.getElementById("constraints").append(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- forced conversions ---
|
||||||
|
// A forced conversion always happens on its turn: spend from_amount of
|
||||||
|
// `from` and gain to_amount of `to`. It is not an optimizer choice.
|
||||||
|
function addConversion(c = {}) {
|
||||||
|
const card = el("div", {class: "card"});
|
||||||
|
const from = selectEl(RESOURCES, c.from || "trade_goods");
|
||||||
|
const fromAmt = num(c.from_amount ?? c.amount ?? 1, {min: 0});
|
||||||
|
const to = selectEl(RESOURCES, c.to || "capital");
|
||||||
|
const toAmt = num(c.to_amount ?? c.amount ?? 1, {min: 0});
|
||||||
|
const turn = turnSelect(c.turn);
|
||||||
|
card._get = () => {
|
||||||
|
const o = {
|
||||||
|
from: from.value, to: to.value,
|
||||||
|
from_amount: +fromAmt.value, to_amount: +toAmt.value,
|
||||||
|
};
|
||||||
|
if (turn.value !== "") o.turn = +turn.value;
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
card.append(
|
||||||
|
field("From", from),
|
||||||
|
field("From amount", fromAmt),
|
||||||
|
field("To", to),
|
||||||
|
field("To amount", toAmt),
|
||||||
|
field("Turn (blank=final)", turn),
|
||||||
|
el("div", {class: "card-actions"}, removeBtn(card)));
|
||||||
|
document.getElementById("conversions").append(card);
|
||||||
|
}
|
||||||
|
|
||||||
// --- parsing helpers ---
|
// --- parsing helpers ---
|
||||||
function parseInts(s) {
|
function parseInts(s) {
|
||||||
const out = s.split(",").map(x => x.trim()).filter(Boolean).map(Number);
|
const out = s.split(",").map(x => x.trim()).filter(Boolean).map(Number);
|
||||||
|
|
@ -742,6 +802,7 @@
|
||||||
agents: collect("agents"),
|
agents: collect("agents"),
|
||||||
objective: {terms: collect("terms")},
|
objective: {terms: collect("terms")},
|
||||||
resource_constraints: collect("constraints"),
|
resource_constraints: collect("constraints"),
|
||||||
|
conversions: collect("conversions"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -750,6 +811,11 @@
|
||||||
// them in request order.
|
// them in request order.
|
||||||
let solutionCount = 0;
|
let solutionCount = 0;
|
||||||
|
|
||||||
|
// Whether *this* tab is the one currently solving. The /status poll only
|
||||||
|
// disables the button for OTHER viewers, so the solver keeps its button
|
||||||
|
// state under run()'s own control.
|
||||||
|
let solvingHere = false;
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
const errBox = document.getElementById("error");
|
const errBox = document.getElementById("error");
|
||||||
const out = document.getElementById("output");
|
const out = document.getElementById("output");
|
||||||
|
|
@ -763,18 +829,50 @@
|
||||||
problem = buildProblem();
|
problem = buildProblem();
|
||||||
time = +document.getElementById("time").value;
|
time = +document.getElementById("time").value;
|
||||||
} catch (e) {errBox.textContent = e.message; pending.remove(); return;}
|
} catch (e) {errBox.textContent = e.message; pending.remove(); return;}
|
||||||
|
solvingHere = true;
|
||||||
|
setSolveDisabled(true, "Solving…");
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/solve", {
|
const resp = await fetch("/solve", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {"Content-Type": "application/json"},
|
||||||
body: JSON.stringify({problem, max_time_seconds: time}),
|
body: JSON.stringify({problem, max_time_seconds: time}),
|
||||||
});
|
});
|
||||||
|
if (resp.status === 429) {
|
||||||
|
pending.remove();
|
||||||
|
errBox.textContent = "Another viewer is already solving — try again once they finish.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;}
|
if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;}
|
||||||
renderSolution(data, pending);
|
renderSolution(data, pending);
|
||||||
} catch (e) {pending.remove(); errBox.textContent = e.message;}
|
} catch (e) {pending.remove(); errBox.textContent = e.message;}
|
||||||
|
finally {solvingHere = false; setSolveDisabled(false);}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enable/disable the Solve button with an optional note beside it. `kind`
|
||||||
|
// ("self" or "other") picks the note's color so this tab's "Solving…" and
|
||||||
|
// another viewer's "Another viewer is solving…" stand out distinctly.
|
||||||
|
function setSolveDisabled(disabled, note = "", kind = "self") {
|
||||||
|
document.getElementById("solveBtn").disabled = disabled;
|
||||||
|
const busy = document.getElementById("busy");
|
||||||
|
busy.textContent = note;
|
||||||
|
busy.classList.toggle("busy-self", kind === "self");
|
||||||
|
busy.classList.toggle("busy-other", kind === "other");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll the server's solve state so that while one viewer is solving the
|
||||||
|
// others see their Solve button disabled (and re-enabled when it frees up).
|
||||||
|
async function pollStatus() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/status", {cache: "no-store"});
|
||||||
|
const {solving} = await resp.json();
|
||||||
|
if (!solvingHere) setSolveDisabled(solving,
|
||||||
|
solving ? "Another viewer is solving…" : "", "other");
|
||||||
|
} catch (e) {/* leave button as-is on a transient error */}
|
||||||
|
}
|
||||||
|
setInterval(pollStatus, 1000);
|
||||||
|
pollStatus();
|
||||||
|
|
||||||
function renderSolution(s, placeholder) {
|
function renderSolution(s, placeholder) {
|
||||||
// Collapse any previously-shown solutions so the new one is the focus.
|
// Collapse any previously-shown solutions so the new one is the focus.
|
||||||
for (const d of document.querySelectorAll("#output details.solution")) d.open = false;
|
for (const d of document.querySelectorAll("#output details.solution")) d.open = false;
|
||||||
|
|
@ -798,6 +896,10 @@
|
||||||
|
|
||||||
if (s.plan && s.plan.length) {
|
if (s.plan && s.plan.length) {
|
||||||
out.append(el("h3", {}, "Plan"));
|
out.append(el("h3", {}, "Plan"));
|
||||||
|
out.append(el("p", {}, el("button", {
|
||||||
|
class: "mini", type: "button",
|
||||||
|
onclick: () => downloadPlanCsv(s, n),
|
||||||
|
}, "Download CSV")));
|
||||||
out.append(gridTable(
|
out.append(gridTable(
|
||||||
["Turn", "City", "Action", "Detail", "Governor", "Overwork"],
|
["Turn", "City", "Action", "Detail", "Governor", "Overwork"],
|
||||||
s.plan.map(p => [p.turn, p.city, p.action, p.detail, p.governor,
|
s.plan.map(p => [p.turn, p.city, p.action, p.detail, p.governor,
|
||||||
|
|
@ -823,11 +925,139 @@
|
||||||
s.trade_conversions.map(c => [c.turn, c.resource, fmtNum(c.amount)])));
|
s.trade_conversions.map(c => [c.turn, c.resource, fmtNum(c.amount)])));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (s.forced_conversions && s.forced_conversions.length) {
|
||||||
|
out.append(el("h3", {}, "Forced conversions"));
|
||||||
|
out.append(gridTable(
|
||||||
|
["Turn", "From", "From amount", "To", "To amount"],
|
||||||
|
s.forced_conversions.map(c =>
|
||||||
|
[c.turn, c.from, fmtNum(c.from_amount), c.to, fmtNum(c.to_amount)])));
|
||||||
|
}
|
||||||
|
|
||||||
// Swap the finished card in for this request's placeholder.
|
// Swap the finished card in for this request's placeholder.
|
||||||
if (placeholder) placeholder.replaceWith(details);
|
if (placeholder) placeholder.replaceWith(details);
|
||||||
else document.getElementById("output").prepend(details);
|
else document.getElementById("output").prepend(details);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reorder one Turn's moves so that, starting from ``open`` balances, the
|
||||||
|
// running total of every resource in ``keys`` stays >= 0 after each move.
|
||||||
|
// Backtracking DFS keyed on the used-move bitmask (the set of used moves
|
||||||
|
// fully determines the balance), memoizing dead-ends so the search is at
|
||||||
|
// worst O(2^n) for a Turn's n moves (n is small: one per City + one
|
||||||
|
// conversion). Returns a valid ordering, or the moves unchanged if none
|
||||||
|
// exists (which means the solved plan isn't strictly orderable).
|
||||||
|
function orderMovesNoNegative(moves, open, keys) {
|
||||||
|
const n = moves.length;
|
||||||
|
if (n <= 1 || n > 22) return moves.slice();
|
||||||
|
const full = (1 << n) - 1;
|
||||||
|
const failed = new Set();
|
||||||
|
const pick = [];
|
||||||
|
function dfs(mask, bal) {
|
||||||
|
if (mask === full) return true;
|
||||||
|
if (failed.has(mask)) return false;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (mask & (1 << i)) continue;
|
||||||
|
const nb = {...bal};
|
||||||
|
let ok = true;
|
||||||
|
for (const r of keys) {
|
||||||
|
nb[r] = (nb[r] || 0) + (moves[i].deltas[r] || 0);
|
||||||
|
if (nb[r] < -1e-9) {ok = false; break;}
|
||||||
|
}
|
||||||
|
if (!ok) continue;
|
||||||
|
pick.push(i);
|
||||||
|
if (dfs(mask | (1 << i), nb)) return true;
|
||||||
|
pick.pop();
|
||||||
|
}
|
||||||
|
failed.add(mask);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const base = {};
|
||||||
|
for (const r of keys) base[r] = open[r] || 0;
|
||||||
|
return dfs(0, base) ? pick.map(i => moves[i]) : moves.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and download the plan's move history as a CSV. One row per move:
|
||||||
|
// each City's Industry Action, plus one row per Turn for that Turn's Trade
|
||||||
|
// Goods conversions. Ordering id is a unique increasing integer giving the
|
||||||
|
// order; Summary is "<action> <city> (turn N)" (or "convert trade goods
|
||||||
|
// (turn N)"). Datetime, Details and Scratch are left blank; the Δ columns
|
||||||
|
// are that move's net resource change.
|
||||||
|
function downloadPlanCsv(s, n) {
|
||||||
|
const headers = ["Ordering id", "Datetime", "Summary", "ΔElectrum",
|
||||||
|
"ΔBrass", "ΔSteel", "ΔLuxuries", "ΔCapital", "ΔTrade Goods",
|
||||||
|
"ΔExpress Tickets", "Details", "Scratch"];
|
||||||
|
const cell = v => {
|
||||||
|
const str = v == null ? "" : String(v);
|
||||||
|
return /[",\n]/.test(str) ? '"' + str.replace(/"/g, '""') + '"' : str;
|
||||||
|
};
|
||||||
|
// Collect every move and group by turn. A move is a City's Industry
|
||||||
|
// Action or a Turn's Trade Goods conversion (aggregated into one row).
|
||||||
|
const byTurn = {};
|
||||||
|
const push = m => (byTurn[m.turn] || (byTurn[m.turn] = [])).push(m);
|
||||||
|
for (const p of (s.plan || []))
|
||||||
|
push({
|
||||||
|
turn: p.turn, deltas: p.deltas || {},
|
||||||
|
summary: `${p.action} ${p.city} (turn ${p.turn})`
|
||||||
|
});
|
||||||
|
const convByTurn = {};
|
||||||
|
for (const c of (s.trade_conversions || [])) {
|
||||||
|
const d = convByTurn[c.turn] || (convByTurn[c.turn] = {});
|
||||||
|
d[c.resource] = (d[c.resource] || 0) + c.amount;
|
||||||
|
d.trade_goods = (d.trade_goods || 0) - c.amount;
|
||||||
|
}
|
||||||
|
for (const t of Object.keys(convByTurn))
|
||||||
|
push({
|
||||||
|
turn: +t, deltas: convByTurn[t],
|
||||||
|
summary: `convert trade goods (turn ${t})`
|
||||||
|
});
|
||||||
|
// Forced conversions: one row each, spending `from` and yielding `to`.
|
||||||
|
for (const c of (s.forced_conversions || [])) {
|
||||||
|
const d = {};
|
||||||
|
d[c.from] = (d[c.from] || 0) - c.from_amount;
|
||||||
|
d[c.to] = (d[c.to] || 0) + c.to_amount;
|
||||||
|
push({
|
||||||
|
turn: c.turn, deltas: d,
|
||||||
|
summary: `convert ${fmtNum(c.from_amount)} ${c.from} → ` +
|
||||||
|
`${fmtNum(c.to_amount)} ${c.to} (turn ${c.turn})`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The solver only guarantees resources are non-negative at the END of
|
||||||
|
// each Turn, so the raw (turn, city) order can momentarily overdraw a
|
||||||
|
// resource (e.g. two Upgrades spend Steel before a conversion refills
|
||||||
|
// it). Within each Turn we reorder moves so the running balance never
|
||||||
|
// goes negative, carrying the balance across Turns. Electrum is
|
||||||
|
// included so forced conversions that spend it are ordered correctly;
|
||||||
|
// the Provisioner's off-pool +0.5/turn only ever inflates the running
|
||||||
|
// electrum balance, so it never triggers a false overdraw.
|
||||||
|
const ORDER_RES = ["capital", "luxuries", "steel", "brass",
|
||||||
|
"electrum", "trade_goods", "express"];
|
||||||
|
const moves = [];
|
||||||
|
let bal = {...(s.start_resources || {})};
|
||||||
|
const turns = Object.keys(byTurn).map(Number).sort((a, b) => a - b);
|
||||||
|
for (const t of turns) {
|
||||||
|
const ordered = orderMovesNoNegative(byTurn[t], bal, ORDER_RES);
|
||||||
|
for (const m of ordered) {
|
||||||
|
for (const r of ORDER_RES) bal[r] = (bal[r] || 0) + (m.deltas[r] || 0);
|
||||||
|
moves.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = (m, r) => fmtNum(m.deltas[r] || 0);
|
||||||
|
const rows = moves.map((m, i) =>
|
||||||
|
[i + 1, "", m.summary,
|
||||||
|
delta(m, "electrum"), delta(m, "brass"), delta(m, "steel"),
|
||||||
|
delta(m, "luxuries"), delta(m, "capital"), delta(m, "trade_goods"),
|
||||||
|
delta(m, "express"), "", ""].map(cell).join(","));
|
||||||
|
const csv = headers.map(cell).join(",") + "\n" + rows.join("\n") + "\n";
|
||||||
|
const blob = new Blob([csv], {type: "text/csv;charset=utf-8"});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = el("a", {href: url, download: `solution-${n}-moves.csv`});
|
||||||
|
document.body.append(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
// Trim trailing ".0" so whole numbers read cleanly.
|
// Trim trailing ".0" so whole numbers read cleanly.
|
||||||
function fmtNum(v) {
|
function fmtNum(v) {
|
||||||
if (typeof v !== "number") return String(v);
|
if (typeof v !== "number") return String(v);
|
||||||
|
|
|
||||||
18
main.py
18
main.py
|
|
@ -14,6 +14,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -23,20 +24,29 @@ from solve import problem_from_dict, solve, solution_to_dict
|
||||||
# The UI is a single static page served from index.html next to this module.
|
# The UI is a single static page served from index.html next to this module.
|
||||||
INDEX_HTML = (Path(__file__).resolve().parent / "index.html").read_text(encoding="utf-8")
|
INDEX_HTML = (Path(__file__).resolve().parent / "index.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Only one solve may run at a time across all viewers. While it's held, other
|
||||||
|
# clients get an immediate 429 from /solve and disable their button (they learn
|
||||||
|
# the busy state by polling /status).
|
||||||
|
_solve_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
def _send(self, code, body, content_type="application/json"):
|
def _send(self, code, body, content_type="application/json", no_cache=False):
|
||||||
if isinstance(body, str):
|
if isinstance(body, str):
|
||||||
body = body.encode("utf-8")
|
body = body.encode("utf-8")
|
||||||
self.send_response(code)
|
self.send_response(code)
|
||||||
self.send_header("Content-Type", content_type)
|
self.send_header("Content-Type", content_type)
|
||||||
self.send_header("Content-Length", str(len(body)))
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
if no_cache:
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
if self.path in ("/", "/index.html"):
|
if self.path in ("/", "/index.html"):
|
||||||
self._send(200, INDEX_HTML, "text/html; charset=utf-8")
|
self._send(200, INDEX_HTML, "text/html; charset=utf-8")
|
||||||
|
elif self.path == "/status":
|
||||||
|
self._send(200, json.dumps({"solving": _solve_lock.locked()}), no_cache=True)
|
||||||
else:
|
else:
|
||||||
self._send(404, json.dumps({"error": "not found"}))
|
self._send(404, json.dumps({"error": "not found"}))
|
||||||
|
|
||||||
|
|
@ -44,6 +54,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
if self.path != "/solve":
|
if self.path != "/solve":
|
||||||
self._send(404, json.dumps({"error": "not found"}))
|
self._send(404, json.dumps({"error": "not found"}))
|
||||||
return
|
return
|
||||||
|
# Reject immediately if another viewer is already solving.
|
||||||
|
if not _solve_lock.acquire(blocking=False):
|
||||||
|
self._send(429, json.dumps({"error": "Another solve is already in progress"}))
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
length = int(self.headers.get("Content-Length", 0))
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
payload = json.loads(self.rfile.read(length) or b"{}")
|
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||||
|
|
@ -53,6 +67,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self._send(200, json.dumps(solution_to_dict(sol)))
|
self._send(200, json.dumps(solution_to_dict(sol)))
|
||||||
except Exception as exc: # surface errors to the browser
|
except Exception as exc: # surface errors to the browser
|
||||||
self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"}))
|
self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"}))
|
||||||
|
finally:
|
||||||
|
_solve_lock.release()
|
||||||
|
|
||||||
def log_message(self, fmt, *args): # quieter console
|
def log_message(self, fmt, *args): # quieter console
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
113
solve.py
113
solve.py
|
|
@ -375,6 +375,15 @@ class Problem:
|
||||||
# Each entry: {"turn": int, "resource": str, "op": one of >=/<=/==, "value": int}.
|
# Each entry: {"turn": int, "resource": str, "op": one of >=/<=/==, "value": int}.
|
||||||
# Turns are 0-indexed (None or omitted => final Turn).
|
# Turns are 0-indexed (None or omitted => final Turn).
|
||||||
resource_constraints: list[dict] = field(default_factory=list)
|
resource_constraints: list[dict] = field(default_factory=list)
|
||||||
|
# Forced resource conversions on specific Turns. These are NOT decisions the
|
||||||
|
# optimizer makes - each one deterministically spends ``from_amount`` of one
|
||||||
|
# resource and yields ``to_amount`` of another on the given Turn. Because the
|
||||||
|
# resource balance is kept non-negative, the model is forced to have the
|
||||||
|
# spent resource available at that Turn. Each entry:
|
||||||
|
# {"turn": int, "from": str, "to": str, "from_amount": number, "to_amount": number}.
|
||||||
|
# Turns are 0-indexed (None or omitted => final Turn); ``amount`` may be given
|
||||||
|
# as a shorthand for an equal ``from_amount``/``to_amount`` (1-for-1).
|
||||||
|
conversions: list[dict] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
@ -389,6 +398,9 @@ class CityTurnPlan:
|
||||||
detail: str = "" # e.g. collect choice, upgrade name, renovate target
|
detail: str = "" # e.g. collect choice, upgrade name, renovate target
|
||||||
governor: str = "" # agent appointed governor (if any)
|
governor: str = "" # agent appointed governor (if any)
|
||||||
overwork: bool = False
|
overwork: bool = False
|
||||||
|
# Net resource change produced by this Action (resource -> amount). Only the
|
||||||
|
# stockpiled resources this City's Action affects appear here.
|
||||||
|
deltas: dict[str, float] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -397,9 +409,15 @@ class Solution:
|
||||||
objective_value: float | None
|
objective_value: float | None
|
||||||
final_resources: dict[str, float]
|
final_resources: dict[str, float]
|
||||||
final_renown_total: int
|
final_renown_total: int
|
||||||
|
# Resolved starting resource amounts (after rounding), so the UI can
|
||||||
|
# reconstruct each Turn's opening balance for ordering moves.
|
||||||
|
start_resources: dict[str, float] = field(default_factory=dict)
|
||||||
plan: list[CityTurnPlan] = field(default_factory=list)
|
plan: list[CityTurnPlan] = field(default_factory=list)
|
||||||
# Mid-game Trade Goods conversions: list of {turn, resource, amount}.
|
# Mid-game Trade Goods conversions: list of {turn, resource, amount}.
|
||||||
trade_conversions: list[dict] = field(default_factory=list)
|
trade_conversions: list[dict] = field(default_factory=list)
|
||||||
|
# Forced (caller-specified, non-optimizer) conversions that were applied:
|
||||||
|
# list of {turn, from, to, from_amount, to_amount}.
|
||||||
|
forced_conversions: list[dict] = field(default_factory=list)
|
||||||
# Resource amounts at the END of each turn: list of {turn, <resource>: amount}.
|
# Resource amounts at the END of each turn: list of {turn, <resource>: amount}.
|
||||||
resources_by_turn: list[dict] = field(default_factory=list)
|
resources_by_turn: list[dict] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
@ -420,6 +438,12 @@ class _Builder:
|
||||||
self.res: dict[str, list[cp_model.IntVar]] = {}
|
self.res: dict[str, list[cp_model.IntVar]] = {}
|
||||||
# resource production delta per (resource, turn).
|
# resource production delta per (resource, turn).
|
||||||
self.delta: dict[tuple[str, int], list] = {}
|
self.delta: dict[tuple[str, int], list] = {}
|
||||||
|
# same deltas, but attributed to the City whose Action produced them, so
|
||||||
|
# each plan row (city+turn) can report its own per-resource change. Set
|
||||||
|
# while a City is being built (see _cur_ci); global deltas (trade
|
||||||
|
# conversion, governor one-time bonuses) are left unattributed.
|
||||||
|
self.city_delta: dict[tuple[int, str, int], list] = {}
|
||||||
|
self._cur_ci: Optional[int] = None
|
||||||
# per (city_index, turn) action booleans
|
# per (city_index, turn) action booleans
|
||||||
self.act: dict[tuple[int, int, Action], cp_model.IntVar] = {}
|
self.act: dict[tuple[int, int, Action], cp_model.IntVar] = {}
|
||||||
# governor assignment: gov[(agent_idx, city_idx, turn)] bool
|
# governor assignment: gov[(agent_idx, city_idx, turn)] bool
|
||||||
|
|
@ -450,6 +474,8 @@ class _Builder:
|
||||||
|
|
||||||
def _add_delta(self, resource: str, t: int, expr):
|
def _add_delta(self, resource: str, t: int, expr):
|
||||||
self.delta.setdefault((resource, t), []).append(expr)
|
self.delta.setdefault((resource, t), []).append(expr)
|
||||||
|
if self._cur_ci is not None:
|
||||||
|
self.city_delta.setdefault((self._cur_ci, resource, t), []).append(expr)
|
||||||
|
|
||||||
# -- main build -------------------------------------------------------- #
|
# -- main build -------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
@ -458,6 +484,7 @@ class _Builder:
|
||||||
self._build_actions_and_governors()
|
self._build_actions_and_governors()
|
||||||
self._build_city_dynamics()
|
self._build_city_dynamics()
|
||||||
self._build_trade_conversion()
|
self._build_trade_conversion()
|
||||||
|
self._build_conversions()
|
||||||
self._build_onetime_governor_bonuses()
|
self._build_onetime_governor_bonuses()
|
||||||
self._build_resource_balance()
|
self._build_resource_balance()
|
||||||
self._build_provisioner_electrum()
|
self._build_provisioner_electrum()
|
||||||
|
|
@ -575,7 +602,10 @@ class _Builder:
|
||||||
|
|
||||||
def _build_city_dynamics(self):
|
def _build_city_dynamics(self):
|
||||||
for ci, city in enumerate(self.p.cities):
|
for ci, city in enumerate(self.p.cities):
|
||||||
|
# Attribute every delta added while building this City to it.
|
||||||
|
self._cur_ci = ci
|
||||||
self._build_one_city(ci, city)
|
self._build_one_city(ci, city)
|
||||||
|
self._cur_ci = None
|
||||||
|
|
||||||
def _build_one_city(self, ci: int, city: City):
|
def _build_one_city(self, ci: int, city: City):
|
||||||
m = self.m
|
m = self.m
|
||||||
|
|
@ -1087,6 +1117,38 @@ class _Builder:
|
||||||
self._add_delta(r, t, c) # +1 target resource
|
self._add_delta(r, t, c) # +1 target resource
|
||||||
self._add_delta("trade_goods", t, -c) # -1 Trade Good
|
self._add_delta("trade_goods", t, -c) # -1 Trade Good
|
||||||
|
|
||||||
|
def _build_conversions(self):
|
||||||
|
"""Forced (non-optimizer) resource conversions on specific Turns.
|
||||||
|
|
||||||
|
Each conversion deterministically spends ``from_amount`` of one resource
|
||||||
|
and yields ``to_amount`` of another at the END of its Turn. They are
|
||||||
|
plain constant deltas (not decisions), added before the resource balance
|
||||||
|
is built, so the non-negative balance forces the model to have the spent
|
||||||
|
resource available at that Turn."""
|
||||||
|
self.conversions: list[dict] = []
|
||||||
|
for c in self.p.conversions:
|
||||||
|
t_in = c.get("turn")
|
||||||
|
t = self.T - 1 if t_in is None else int(t_in)
|
||||||
|
if not (0 <= t < self.T):
|
||||||
|
raise ValueError(
|
||||||
|
f"conversion turn {t_in} out of range 0..{self.T - 1}")
|
||||||
|
src, dst = c["from"], c["to"]
|
||||||
|
if src not in RESOURCES:
|
||||||
|
raise ValueError(f"conversion 'from' unknown resource: {src!r}")
|
||||||
|
if dst not in RESOURCES:
|
||||||
|
raise ValueError(f"conversion 'to' unknown resource: {dst!r}")
|
||||||
|
amount = c.get("amount")
|
||||||
|
from_amt = int(round(c.get("from_amount", amount if amount is not None else 0)))
|
||||||
|
to_amt = int(round(c.get("to_amount", amount if amount is not None else from_amt)))
|
||||||
|
if from_amt < 0 or to_amt < 0:
|
||||||
|
raise ValueError("conversion amounts must be non-negative")
|
||||||
|
self._add_delta(src, t, -from_amt)
|
||||||
|
self._add_delta(dst, t, to_amt)
|
||||||
|
self.conversions.append({
|
||||||
|
"turn": t, "from": src, "to": dst,
|
||||||
|
"from_amount": from_amt, "to_amount": to_amt,
|
||||||
|
})
|
||||||
|
|
||||||
def _build_onetime_governor_bonuses(self):
|
def _build_onetime_governor_bonuses(self):
|
||||||
"""Courier: the first Turn the Agent governs any City, grant a one-time
|
"""Courier: the first Turn the Agent governs any City, grant a one-time
|
||||||
``{resource: amount}`` bonus. Added to the resource deltas before the
|
``{resource: amount}`` bonus. Added to the resource deltas before the
|
||||||
|
|
@ -1286,6 +1348,37 @@ def solve(problem: Problem, max_time_seconds: float = 30.0,
|
||||||
|
|
||||||
def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solution:
|
def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solution:
|
||||||
T = b.T
|
T = b.T
|
||||||
|
|
||||||
|
# Resources an Agent grants as a side-effect of *governing* (not produced by
|
||||||
|
# the City's own Industry Action, so not in city_delta): the Provisioner's
|
||||||
|
# +1.5 Electrum per governing Turn and the Courier's one-time bonus. These
|
||||||
|
# are attributed to the governed City's row for that Turn.
|
||||||
|
extra: dict[tuple[int, int], dict[str, float]] = {}
|
||||||
|
|
||||||
|
def _add_extra(ci, t, res, amt):
|
||||||
|
if amt:
|
||||||
|
d = extra.setdefault((ci, t), {})
|
||||||
|
d[res] = d.get(res, 0.0) + amt
|
||||||
|
|
||||||
|
for ai, agent in enumerate(problem.agents):
|
||||||
|
if agent.governor_electrum_half:
|
||||||
|
for ci in range(len(problem.cities)):
|
||||||
|
for t in range(T):
|
||||||
|
g = b.gov.get((ai, ci, t))
|
||||||
|
if g is not None and solver.Value(g) == 1:
|
||||||
|
_add_extra(ci, t, "electrum", agent.governor_electrum_half / 2.0)
|
||||||
|
if agent.onetime_governor_bonus:
|
||||||
|
# Only the first Turn this Agent governs any City pays the bonus.
|
||||||
|
first = next(
|
||||||
|
((ci, t) for t in range(T) for ci in range(len(problem.cities))
|
||||||
|
if (g := b.gov.get((ai, ci, t))) is not None and solver.Value(g) == 1),
|
||||||
|
None)
|
||||||
|
if first is not None:
|
||||||
|
ci, t = first
|
||||||
|
for res, amt in agent.onetime_governor_bonus.items():
|
||||||
|
if res in RESOURCES:
|
||||||
|
_add_extra(ci, t, res, float(amt))
|
||||||
|
|
||||||
plan: list[CityTurnPlan] = []
|
plan: list[CityTurnPlan] = []
|
||||||
for ci, city in enumerate(problem.cities):
|
for ci, city in enumerate(problem.cities):
|
||||||
for t in range(T):
|
for t in range(T):
|
||||||
|
|
@ -1300,7 +1393,8 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio
|
||||||
(ci, t) in b._foreman_renov
|
(ci, t) in b._foreman_renov
|
||||||
and bool(solver.Value(b._foreman_renov[(ci, t)]))
|
and bool(solver.Value(b._foreman_renov[(ci, t)]))
|
||||||
)
|
)
|
||||||
if (chosen is None or chosen == Action.IDLE) and not foreman_renov:
|
if (chosen is None or chosen == Action.IDLE) and not foreman_renov \
|
||||||
|
and (ci, t) not in extra:
|
||||||
continue
|
continue
|
||||||
detail = ""
|
detail = ""
|
||||||
governor = ""
|
governor = ""
|
||||||
|
|
@ -1342,9 +1436,24 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio
|
||||||
detail = f"{detail} + {rdetail}" if detail else rdetail
|
detail = f"{detail} + {rdetail}" if detail else rdetail
|
||||||
if chosen is None or chosen == Action.IDLE:
|
if chosen is None or chosen == Action.IDLE:
|
||||||
action_label = Action.RENOVATE.value
|
action_label = Action.RENOVATE.value
|
||||||
|
# Net per-resource change attributed to this City this Turn: the
|
||||||
|
# change produced by its Industry Action, plus any resources granted
|
||||||
|
# to it by a governing Agent (Provisioner/Courier).
|
||||||
|
deltas = {}
|
||||||
|
for r in RESOURCES:
|
||||||
|
total = 0
|
||||||
|
for expr in b.city_delta.get((ci, r, t), []):
|
||||||
|
total += expr if isinstance(expr, int) else solver.Value(expr)
|
||||||
|
if total:
|
||||||
|
deltas[r] = float(total)
|
||||||
|
for r, amt in extra.get((ci, t), {}).items():
|
||||||
|
deltas[r] = deltas.get(r, 0.0) + amt
|
||||||
|
if deltas[r] == 0:
|
||||||
|
del deltas[r]
|
||||||
plan.append(CityTurnPlan(
|
plan.append(CityTurnPlan(
|
||||||
turn=t, city=city.name, action=action_label,
|
turn=t, city=city.name, action=action_label,
|
||||||
detail=detail, governor=governor, overwork=overwork,
|
detail=detail, governor=governor, overwork=overwork,
|
||||||
|
deltas=deltas,
|
||||||
))
|
))
|
||||||
|
|
||||||
final_resources = {
|
final_resources = {
|
||||||
|
|
@ -1366,8 +1475,10 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio
|
||||||
objective_value=solver.ObjectiveValue() / OBJ_SCALE,
|
objective_value=solver.ObjectiveValue() / OBJ_SCALE,
|
||||||
final_resources=final_resources,
|
final_resources=final_resources,
|
||||||
final_renown_total=int(solver.Value(b.renown_total)),
|
final_renown_total=int(solver.Value(b.renown_total)),
|
||||||
|
start_resources={r: float(int(round(problem.start.get(r, 0)))) for r in RESOURCES},
|
||||||
plan=sorted(plan, key=lambda p: (p.turn, p.city)),
|
plan=sorted(plan, key=lambda p: (p.turn, p.city)),
|
||||||
trade_conversions=conversions,
|
trade_conversions=conversions,
|
||||||
|
forced_conversions=list(b.conversions),
|
||||||
resources_by_turn=resources_by_turn,
|
resources_by_turn=resources_by_turn,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue