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;
|
||||
}
|
||||
|
||||
#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 {
|
||||
padding: .1rem .45rem;
|
||||
font-size: .85rem;
|
||||
|
|
@ -341,8 +361,20 @@
|
|||
<button class="mini" type="button" onclick="addConstraint()">+ constraint</button>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Forced conversions</legend>
|
||||
<p class="help">A conversion is <b>not</b> a choice for the optimizer — it always
|
||||
happens on the given turn, spending <b>from amount</b> of one resource and yielding
|
||||
<b>to amount</b> of another. The optimizer is forced to have the spent resource
|
||||
available at that turn. These appear in the solution and the CSV download.
|
||||
</p>
|
||||
<div id="conversions" class="cards"></div>
|
||||
<button class="mini" type="button" onclick="addConversion()">+ conversion</button>
|
||||
</fieldset>
|
||||
|
||||
<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>
|
||||
|
||||
<div id="error" class="err"></div>
|
||||
|
|
@ -704,6 +736,34 @@
|
|||
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 ---
|
||||
function parseInts(s) {
|
||||
const out = s.split(",").map(x => x.trim()).filter(Boolean).map(Number);
|
||||
|
|
@ -742,6 +802,7 @@
|
|||
agents: collect("agents"),
|
||||
objective: {terms: collect("terms")},
|
||||
resource_constraints: collect("constraints"),
|
||||
conversions: collect("conversions"),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -750,6 +811,11 @@
|
|||
// them in request order.
|
||||
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() {
|
||||
const errBox = document.getElementById("error");
|
||||
const out = document.getElementById("output");
|
||||
|
|
@ -763,18 +829,50 @@
|
|||
problem = buildProblem();
|
||||
time = +document.getElementById("time").value;
|
||||
} catch (e) {errBox.textContent = e.message; pending.remove(); return;}
|
||||
solvingHere = true;
|
||||
setSolveDisabled(true, "Solving…");
|
||||
try {
|
||||
const resp = await fetch("/solve", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
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();
|
||||
if (!resp.ok) {pending.remove(); errBox.textContent = data.error || "Server error"; return;}
|
||||
renderSolution(data, pending);
|
||||
} 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) {
|
||||
// Collapse any previously-shown solutions so the new one is the focus.
|
||||
for (const d of document.querySelectorAll("#output details.solution")) d.open = false;
|
||||
|
|
@ -798,6 +896,10 @@
|
|||
|
||||
if (s.plan && s.plan.length) {
|
||||
out.append(el("h3", {}, "Plan"));
|
||||
out.append(el("p", {}, el("button", {
|
||||
class: "mini", type: "button",
|
||||
onclick: () => downloadPlanCsv(s, n),
|
||||
}, "Download CSV")));
|
||||
out.append(gridTable(
|
||||
["Turn", "City", "Action", "Detail", "Governor", "Overwork"],
|
||||
s.plan.map(p => [p.turn, p.city, p.action, p.detail, p.governor,
|
||||
|
|
@ -823,11 +925,139 @@
|
|||
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.
|
||||
if (placeholder) placeholder.replaceWith(details);
|
||||
else document.getElementById("output").prepend(details);
|
||||
}
|
||||
|
||||
// Reorder one Turn's moves so that, starting from ``open`` balances, the
|
||||
// running total of every resource in ``keys`` stays >= 0 after each move.
|
||||
// Backtracking DFS keyed on the used-move bitmask (the set of used moves
|
||||
// fully determines the balance), memoizing dead-ends so the search is at
|
||||
// worst O(2^n) for a Turn's n moves (n is small: one per City + one
|
||||
// conversion). Returns a valid ordering, or the moves unchanged if none
|
||||
// exists (which means the solved plan isn't strictly orderable).
|
||||
function orderMovesNoNegative(moves, open, keys) {
|
||||
const n = moves.length;
|
||||
if (n <= 1 || n > 22) return moves.slice();
|
||||
const full = (1 << n) - 1;
|
||||
const failed = new Set();
|
||||
const pick = [];
|
||||
function dfs(mask, bal) {
|
||||
if (mask === full) return true;
|
||||
if (failed.has(mask)) return false;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (mask & (1 << i)) continue;
|
||||
const nb = {...bal};
|
||||
let ok = true;
|
||||
for (const r of keys) {
|
||||
nb[r] = (nb[r] || 0) + (moves[i].deltas[r] || 0);
|
||||
if (nb[r] < -1e-9) {ok = false; break;}
|
||||
}
|
||||
if (!ok) continue;
|
||||
pick.push(i);
|
||||
if (dfs(mask | (1 << i), nb)) return true;
|
||||
pick.pop();
|
||||
}
|
||||
failed.add(mask);
|
||||
return false;
|
||||
}
|
||||
const base = {};
|
||||
for (const r of keys) base[r] = open[r] || 0;
|
||||
return dfs(0, base) ? pick.map(i => moves[i]) : moves.slice();
|
||||
}
|
||||
|
||||
// Build and download the plan's move history as a CSV. One row per move:
|
||||
// each City's Industry Action, plus one row per Turn for that Turn's Trade
|
||||
// Goods conversions. Ordering id is a unique increasing integer giving the
|
||||
// order; Summary is "<action> <city> (turn N)" (or "convert trade goods
|
||||
// (turn N)"). Datetime, Details and Scratch are left blank; the Δ columns
|
||||
// are that move's net resource change.
|
||||
function downloadPlanCsv(s, n) {
|
||||
const headers = ["Ordering id", "Datetime", "Summary", "ΔElectrum",
|
||||
"ΔBrass", "ΔSteel", "ΔLuxuries", "ΔCapital", "ΔTrade Goods",
|
||||
"ΔExpress Tickets", "Details", "Scratch"];
|
||||
const cell = v => {
|
||||
const str = v == null ? "" : String(v);
|
||||
return /[",\n]/.test(str) ? '"' + str.replace(/"/g, '""') + '"' : str;
|
||||
};
|
||||
// Collect every move and group by turn. A move is a City's Industry
|
||||
// Action or a Turn's Trade Goods conversion (aggregated into one row).
|
||||
const byTurn = {};
|
||||
const push = m => (byTurn[m.turn] || (byTurn[m.turn] = [])).push(m);
|
||||
for (const p of (s.plan || []))
|
||||
push({
|
||||
turn: p.turn, deltas: p.deltas || {},
|
||||
summary: `${p.action} ${p.city} (turn ${p.turn})`
|
||||
});
|
||||
const convByTurn = {};
|
||||
for (const c of (s.trade_conversions || [])) {
|
||||
const d = convByTurn[c.turn] || (convByTurn[c.turn] = {});
|
||||
d[c.resource] = (d[c.resource] || 0) + c.amount;
|
||||
d.trade_goods = (d.trade_goods || 0) - c.amount;
|
||||
}
|
||||
for (const t of Object.keys(convByTurn))
|
||||
push({
|
||||
turn: +t, deltas: convByTurn[t],
|
||||
summary: `convert trade goods (turn ${t})`
|
||||
});
|
||||
// Forced conversions: one row each, spending `from` and yielding `to`.
|
||||
for (const c of (s.forced_conversions || [])) {
|
||||
const d = {};
|
||||
d[c.from] = (d[c.from] || 0) - c.from_amount;
|
||||
d[c.to] = (d[c.to] || 0) + c.to_amount;
|
||||
push({
|
||||
turn: c.turn, deltas: d,
|
||||
summary: `convert ${fmtNum(c.from_amount)} ${c.from} → ` +
|
||||
`${fmtNum(c.to_amount)} ${c.to} (turn ${c.turn})`
|
||||
});
|
||||
}
|
||||
|
||||
// The solver only guarantees resources are non-negative at the END of
|
||||
// each Turn, so the raw (turn, city) order can momentarily overdraw a
|
||||
// resource (e.g. two Upgrades spend Steel before a conversion refills
|
||||
// it). Within each Turn we reorder moves so the running balance never
|
||||
// goes negative, carrying the balance across Turns. Electrum is
|
||||
// included so forced conversions that spend it are ordered correctly;
|
||||
// the Provisioner's off-pool +0.5/turn only ever inflates the running
|
||||
// electrum balance, so it never triggers a false overdraw.
|
||||
const ORDER_RES = ["capital", "luxuries", "steel", "brass",
|
||||
"electrum", "trade_goods", "express"];
|
||||
const moves = [];
|
||||
let bal = {...(s.start_resources || {})};
|
||||
const turns = Object.keys(byTurn).map(Number).sort((a, b) => a - b);
|
||||
for (const t of turns) {
|
||||
const ordered = orderMovesNoNegative(byTurn[t], bal, ORDER_RES);
|
||||
for (const m of ordered) {
|
||||
for (const r of ORDER_RES) bal[r] = (bal[r] || 0) + (m.deltas[r] || 0);
|
||||
moves.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
const delta = (m, r) => fmtNum(m.deltas[r] || 0);
|
||||
const rows = moves.map((m, i) =>
|
||||
[i + 1, "", m.summary,
|
||||
delta(m, "electrum"), delta(m, "brass"), delta(m, "steel"),
|
||||
delta(m, "luxuries"), delta(m, "capital"), delta(m, "trade_goods"),
|
||||
delta(m, "express"), "", ""].map(cell).join(","));
|
||||
const csv = headers.map(cell).join(",") + "\n" + rows.join("\n") + "\n";
|
||||
const blob = new Blob([csv], {type: "text/csv;charset=utf-8"});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = el("a", {href: url, download: `solution-${n}-moves.csv`});
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Trim trailing ".0" so whole numbers read cleanly.
|
||||
function fmtNum(v) {
|
||||
if (typeof v !== "number") return String(v);
|
||||
|
|
|
|||
18
main.py
18
main.py
|
|
@ -14,6 +14,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
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.
|
||||
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):
|
||||
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):
|
||||
body = body.encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
if no_cache:
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
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")
|
||||
elif self.path == "/status":
|
||||
self._send(200, json.dumps({"solving": _solve_lock.locked()}), no_cache=True)
|
||||
else:
|
||||
self._send(404, json.dumps({"error": "not found"}))
|
||||
|
||||
|
|
@ -44,6 +54,10 @@ class Handler(BaseHTTPRequestHandler):
|
|||
if self.path != "/solve":
|
||||
self._send(404, json.dumps({"error": "not found"}))
|
||||
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:
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
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)))
|
||||
except Exception as exc: # surface errors to the browser
|
||||
self._send(400, json.dumps({"error": f"{type(exc).__name__}: {exc}"}))
|
||||
finally:
|
||||
_solve_lock.release()
|
||||
|
||||
def log_message(self, fmt, *args): # quieter console
|
||||
pass
|
||||
|
|
|
|||
113
solve.py
113
solve.py
|
|
@ -375,6 +375,15 @@ class Problem:
|
|||
# Each entry: {"turn": int, "resource": str, "op": one of >=/<=/==, "value": int}.
|
||||
# Turns are 0-indexed (None or omitted => final Turn).
|
||||
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
|
||||
governor: str = "" # agent appointed governor (if any)
|
||||
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
|
||||
|
|
@ -397,9 +409,15 @@ class Solution:
|
|||
objective_value: float | None
|
||||
final_resources: dict[str, float]
|
||||
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)
|
||||
# Mid-game Trade Goods conversions: list of {turn, resource, amount}.
|
||||
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}.
|
||||
resources_by_turn: list[dict] = field(default_factory=list)
|
||||
|
||||
|
|
@ -420,6 +438,12 @@ class _Builder:
|
|||
self.res: dict[str, list[cp_model.IntVar]] = {}
|
||||
# resource production delta per (resource, turn).
|
||||
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
|
||||
self.act: dict[tuple[int, int, Action], cp_model.IntVar] = {}
|
||||
# governor assignment: gov[(agent_idx, city_idx, turn)] bool
|
||||
|
|
@ -450,6 +474,8 @@ class _Builder:
|
|||
|
||||
def _add_delta(self, resource: str, t: int, 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 -------------------------------------------------------- #
|
||||
|
||||
|
|
@ -458,6 +484,7 @@ class _Builder:
|
|||
self._build_actions_and_governors()
|
||||
self._build_city_dynamics()
|
||||
self._build_trade_conversion()
|
||||
self._build_conversions()
|
||||
self._build_onetime_governor_bonuses()
|
||||
self._build_resource_balance()
|
||||
self._build_provisioner_electrum()
|
||||
|
|
@ -575,7 +602,10 @@ class _Builder:
|
|||
|
||||
def _build_city_dynamics(self):
|
||||
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._cur_ci = None
|
||||
|
||||
def _build_one_city(self, ci: int, city: City):
|
||||
m = self.m
|
||||
|
|
@ -1087,6 +1117,38 @@ class _Builder:
|
|||
self._add_delta(r, t, c) # +1 target resource
|
||||
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):
|
||||
"""Courier: the first Turn the Agent governs any City, grant a one-time
|
||||
``{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:
|
||||
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] = []
|
||||
for ci, city in enumerate(problem.cities):
|
||||
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
|
||||
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
|
||||
detail = ""
|
||||
governor = ""
|
||||
|
|
@ -1342,9 +1436,24 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio
|
|||
detail = f"{detail} + {rdetail}" if detail else rdetail
|
||||
if chosen is None or chosen == Action.IDLE:
|
||||
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(
|
||||
turn=t, city=city.name, action=action_label,
|
||||
detail=detail, governor=governor, overwork=overwork,
|
||||
deltas=deltas,
|
||||
))
|
||||
|
||||
final_resources = {
|
||||
|
|
@ -1366,8 +1475,10 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio
|
|||
objective_value=solver.ObjectiveValue() / OBJ_SCALE,
|
||||
final_resources=final_resources,
|
||||
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)),
|
||||
trade_conversions=conversions,
|
||||
forced_conversions=list(b.conversions),
|
||||
resources_by_turn=resources_by_turn,
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue