Compare commits

..

2 commits

Author SHA1 Message Date
Pagwin
0c94d94936 illegal amounts disallowed 2026-06-19 14:55:59 -04:00
Pagwin
dff7f995ef electrum handled correctly 2026-06-19 14:49:55 -04:00
2 changed files with 151 additions and 48 deletions

View file

@ -53,6 +53,13 @@
width: 6rem; width: 6rem;
} }
/* Flag an amount that's too precise for the resource (e.g. a fractional
non-Electrum amount, or finer than a tenth of Electrum): the step
attribute makes it :invalid and the server rejects it too. */
input:invalid {
outline: 2px solid #dc2626;
}
button { button {
font: inherit; font: inherit;
padding: .3rem .7rem; padding: .3rem .7rem;
@ -406,6 +413,10 @@
<script> <script>
const RESOURCES = ["capital", "luxuries", "steel", "brass", "electrum", "trade_goods", "express"]; const RESOURCES = ["capital", "luxuries", "steel", "brass", "electrum", "trade_goods", "express"];
// Electrum is the one resource the solver tracks in tenths (it takes
// fractional amounts); its log tables are therefore sampled per tenth.
// Must match ELECTRUM_SCALE in solve.py.
const ELECTRUM_SCALE = 10;
const SCORE_KEYS = RESOURCES.concat(["renown"]); const SCORE_KEYS = RESOURCES.concat(["renown"]);
const CITY_TYPES = ["hub", "foundry", "monument", "metropolis"]; const CITY_TYPES = ["hub", "foundry", "monument", "metropolis"];
const ACTIONS = ["idle", "collect", "renovate", "upgrade", "launch"]; const ACTIONS = ["idle", "collect", "renovate", "upgrade", "launch"];
@ -458,6 +469,11 @@
return e; return e;
} }
function num(value, attrs = {}) {return el("input", {type: "number", value, ...attrs});} function num(value, attrs = {}) {return el("input", {type: "number", value, ...attrs});}
// Smallest amount a resource can be specified in: Electrum is tracked in
// tenths (0.1 steps), every other resource is whole-unit (integers). Used
// to set the <input step> so over-precise amounts read as :invalid; the
// server enforces the same rule. "renown" (constraints) is whole-unit too.
function resStep(resource) {return resource === "electrum" ? "0.1" : "1";}
function selectEl(opts, value) { function selectEl(opts, value) {
const s = el("select"); const s = el("select");
for (const o of opts) { for (const o of opts) {
@ -591,7 +607,7 @@
const startInputs = {}; const startInputs = {};
const tradeInputs = {}; const tradeInputs = {};
for (const r of RESOURCES) { for (const r of RESOURCES) {
const inp = num(r === "express" || r === "trade_goods" ? 0 : 3, {min: 0}); const inp = num(r === "express" || r === "trade_goods" ? 0 : 3, {min: 0, step: resStep(r)});
startInputs[r] = inp; startInputs[r] = inp;
document.getElementById("start").append( document.getElementById("start").append(
el("label", {class: "field"}, [el("span", {}, r), inp])); el("label", {class: "field"}, [el("span", {}, r), inp]));
@ -777,7 +793,7 @@
card._get = () => { card._get = () => {
const o = {resource: res.value, scalar: +scalar.value}; const o = {resource: res.value, scalar: +scalar.value};
if (turn.value !== "") o.turn = +turn.value; if (turn.value !== "") o.turn = +turn.value;
if (isLog.checked) o.log_mapping = buildLogTable(expr.value); if (isLog.checked) o.log_mapping = buildLogTable(expr.value, res.value);
return o; return o;
}; };
const resF = field("Resource", res); resF.classList.add("linear-only"); const resF = field("Resource", res); resF.classList.add("linear-only");
@ -797,9 +813,12 @@
addTerm({resource, scalar})); addTerm({resource, scalar}));
} }
// Eval the expression ONCE into a function, then call it over the amounts // Eval the expression ONCE into a function, then sample it over the
// the lookup table needs (0..max_resource). // amounts the lookup table needs (0..max_resource). The solver indexes the
function buildLogTable(exprStr) { // table by the resource's internal amount, so Electrum (tracked in tenths)
// is sampled per tenth -- entry k is the score at k/ELECTRUM_SCALE -- while
// every other resource gets one entry per whole unit.
function buildLogTable(exprStr, resource) {
let fn; let fn;
try { try {
fn = eval(exprStr); fn = eval(exprStr);
@ -809,9 +828,10 @@
if (typeof fn !== "function") if (typeof fn !== "function")
throw new Error("Log expression must eval to a function, got " + typeof fn); throw new Error("Log expression must eval to a function, got " + typeof fn);
const max = +document.getElementById("max_resource").value || 0; const max = +document.getElementById("max_resource").value || 0;
const scale = resource === "electrum" ? ELECTRUM_SCALE : 1;
const table = []; const table = [];
for (let x = 0; x <= max; x++) { for (let i = 0; i <= max * scale; i++) {
const v = Number(fn(x)); const v = Number(fn(i / scale));
table.push(Number.isFinite(v) ? v : 0); table.push(Number.isFinite(v) ? v : 0);
} }
return table; return table;
@ -822,7 +842,8 @@
const card = el("div", {class: "card"}); const card = el("div", {class: "card"});
const res = selectEl(SCORE_KEYS, c.resource || "capital"); const res = selectEl(SCORE_KEYS, c.resource || "capital");
const op = selectEl([">=", "<=", "=="], c.op || ">="); const op = selectEl([">=", "<=", "=="], c.op || ">=");
const value = num(c.value ?? 0); const value = num(c.value ?? 0, {step: resStep(res.value)});
res.onchange = () => {value.step = resStep(res.value);};
const turn = turnSelect(c.turn); const turn = turnSelect(c.turn);
card._get = () => { card._get = () => {
const o = {resource: res.value, op: op.value, value: +value.value}; const o = {resource: res.value, op: op.value, value: +value.value};
@ -844,9 +865,11 @@
function addConversion(c = {}) { function addConversion(c = {}) {
const card = el("div", {class: "card"}); const card = el("div", {class: "card"});
const from = selectEl(RESOURCES, c.from || "trade_goods"); const from = selectEl(RESOURCES, c.from || "trade_goods");
const fromAmt = num(c.from_amount ?? c.amount ?? 1, {min: 0}); const fromAmt = num(c.from_amount ?? c.amount ?? 1, {min: 0, step: resStep(from.value)});
const to = selectEl(RESOURCES, c.to || "capital"); const to = selectEl(RESOURCES, c.to || "capital");
const toAmt = num(c.to_amount ?? c.amount ?? 1, {min: 0}); const toAmt = num(c.to_amount ?? c.amount ?? 1, {min: 0, step: resStep(to.value)});
from.onchange = () => {fromAmt.step = resStep(from.value);};
to.onchange = () => {toAmt.step = resStep(to.value);};
const turn = turnSelect(c.turn); const turn = turnSelect(c.turn);
card._get = () => { card._get = () => {
const o = { const o = {
@ -872,10 +895,12 @@
function addOptionalConversion(c = {}) { function addOptionalConversion(c = {}) {
const card = el("div", {class: "card"}); const card = el("div", {class: "card"});
const from = selectEl(RESOURCES, c.from || "trade_goods"); const from = selectEl(RESOURCES, c.from || "trade_goods");
const fromAmt = num(c.from_amount ?? c.amount ?? 1, {min: 0}); const fromAmt = num(c.from_amount ?? c.amount ?? 1, {min: 0, step: resStep(from.value)});
const to = selectEl(RESOURCES, c.to || "capital"); const to = selectEl(RESOURCES, c.to || "capital");
const toAmt = num(c.to_amount ?? c.amount ?? 1, {min: 0}); const toAmt = num(c.to_amount ?? c.amount ?? 1, {min: 0, step: resStep(to.value)});
const maxCount = num(c.max_count ?? 1, {min: 1}); from.onchange = () => {fromAmt.step = resStep(from.value);};
to.onchange = () => {toAmt.step = resStep(to.value);};
const maxCount = num(c.max_count ?? 1, {min: 1, step: "1"});
// Multiple turns may be selected; each chosen turn yields its own copy of // Multiple turns may be selected; each chosen turn yields its own copy of
// this conversion in the problem JSON (one entry per turn). // this conversion in the problem JSON (one entry per turn).
const turns = multiTurnSelect(c.turn != null ? [c.turn] : []); const turns = multiTurnSelect(c.turn != null ? [c.turn] : []);

148
solve.py
View file

@ -96,6 +96,14 @@ RESOURCES = [
# values so the CP-SAT objective stays integral. # values so the CP-SAT objective stays integral.
OBJ_SCALE = 1000 OBJ_SCALE = 1000
# Electrum is the one stockpiled resource that takes fractional amounts (the
# Provisioner's +1.5/Turn, "awful" 2.5-each Trade Good buys, etc.). CP-SAT only
# holds integers, so the Electrum pool is tracked internally in *tenths*: a
# stored value of 25 means 2.5 Electrum. Every Electrum amount entering the
# model is multiplied by this scale, and every Electrum amount read back out is
# divided by it. All other resources stay 1:1 integers.
ELECTRUM_SCALE = 10
# Upper bound used to bound resource accumulators (and AddElement domains in # Upper bound used to bound resource accumulators (and AddElement domains in
# log mode). Override via Problem.max_resource if your game runs hotter. # log mode). Override via Problem.max_resource if your game runs hotter.
DEFAULT_MAX_RESOURCE = 300 DEFAULT_MAX_RESOURCE = 300
@ -311,6 +319,12 @@ class ScoreTerm:
``resource`` is a stockpiled resource name or "renown". Because per-Turn ``resource`` is a stockpiled resource name or "renown". Because per-Turn
Renown is not tracked, a "renown" term must target the final Turn Renown is not tracked, a "renown" term must target the final Turn
(``turn is None``). (``turn is None``).
For a log term, ``log_mapping`` is indexed by the resource amount in its own
internal units. That is one entry per whole unit for every resource except
Electrum, which is tracked in tenths (see ELECTRUM_SCALE): an Electrum table
must be sampled per tenth, so ``log_mapping[k]`` is the score at
``k / ELECTRUM_SCALE`` Electrum (the web UI builds it this way).
""" """
resource: str resource: str
scalar: float = 1.0 scalar: float = 1.0
@ -437,6 +451,22 @@ class Solution:
# Model construction # Model construction
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _scaled_int(value: float, scale: int, what: str) -> int:
"""Represent ``value`` as the integer the model stores for it, where ``scale``
is the resource's internal sub-unit count (1 for whole-unit resources,
ELECTRUM_SCALE for Electrum's tenths).
Rather than silently rounding, reject any amount too precise to represent
exactly: a non-integer for a whole-unit resource, or finer than a tenth for
Electrum. ``what`` names the field for the error message."""
scaled = value * scale
nearest = round(scaled)
if abs(scaled - nearest) > 1e-6:
unit = "a whole number" if scale == 1 else f"a multiple of {1 / scale:g}"
raise ValueError(f"{what} must be {unit}; got {value}")
return int(nearest)
class _Builder: class _Builder:
def __init__(self, problem: Problem): def __init__(self, problem: Problem):
self.p = problem self.p = problem
@ -1062,6 +1092,9 @@ class _Builder:
picked_level = self._gate_expr(vat[r][t], pick[r]) picked_level = self._gate_expr(vat[r][t], pick[r])
gain_base = picked_level + self._mul_bool(harv, pick[r], 1) gain_base = picked_level + self._mul_bool(harv, pick[r], 1)
gain_total = gain_base + self._mul_bool(gain_base, ow, self.MAXR) gain_total = gain_base + self._mul_bool(gain_base, ow, self.MAXR)
# Vats hold whole units; the Electrum pool is tracked in tenths.
if r == "electrum":
gain_total = ELECTRUM_SCALE * gain_total
self._add_delta(r, t, gain_total) self._add_delta(r, t, gain_total)
# transition to next turn's vat levels # transition to next turn's vat levels
@ -1134,7 +1167,9 @@ class _Builder:
for r in targets: for r in targets:
c = m.NewIntVar(0, self.MAXR, f"tgconv_{r}_t{t}") c = m.NewIntVar(0, self.MAXR, f"tgconv_{r}_t{t}")
self.trade_conv[(r, t)] = c self.trade_conv[(r, t)] = c
self._add_delta(r, t, c) # +1 target resource # 1 Trade Good -> 1 target unit; Electrum is stored in tenths.
gain = ELECTRUM_SCALE * c if r == "electrum" else c
self._add_delta(r, t, gain) # +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): def _build_conversions(self):
@ -1158,15 +1193,22 @@ class _Builder:
if dst not in RESOURCES: if dst not in RESOURCES:
raise ValueError(f"conversion 'to' unknown resource: {dst!r}") raise ValueError(f"conversion 'to' unknown resource: {dst!r}")
amount = c.get("amount") amount = c.get("amount")
from_amt = int(round(c.get("from_amount", amount if amount is not None else 0))) raw_from = 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))) raw_to = c.get("to_amount", amount if amount is not None else raw_from)
# Electrum is tracked in tenths, so its amounts keep one decimal of
# precision; every other resource stays an integer.
from_scale = ELECTRUM_SCALE if src == "electrum" else 1
to_scale = ELECTRUM_SCALE if dst == "electrum" else 1
from_amt = _scaled_int(raw_from, from_scale, f"conversion from_amount ({src})")
to_amt = _scaled_int(raw_to, to_scale, f"conversion to_amount ({dst})")
if from_amt < 0 or to_amt < 0: if from_amt < 0 or to_amt < 0:
raise ValueError("conversion amounts must be non-negative") raise ValueError("conversion amounts must be non-negative")
self._add_delta(src, t, -from_amt) self._add_delta(src, t, -from_amt)
self._add_delta(dst, t, to_amt) self._add_delta(dst, t, to_amt)
self.conversions.append({ self.conversions.append({
"turn": t, "from": src, "to": dst, "turn": t, "from": src, "to": dst,
"from_amount": from_amt, "to_amount": to_amt, "from_amount": from_amt / from_scale if from_scale != 1 else from_amt,
"to_amount": to_amt / to_scale if to_scale != 1 else to_amt,
}) })
def _build_optional_conversions(self): def _build_optional_conversions(self):
@ -1194,8 +1236,16 @@ class _Builder:
if dst not in RESOURCES: if dst not in RESOURCES:
raise ValueError(f"optional conversion 'to' unknown resource: {dst!r}") raise ValueError(f"optional conversion 'to' unknown resource: {dst!r}")
amount = c.get("amount") amount = c.get("amount")
from_amt = int(round(c.get("from_amount", amount if amount is not None else 0))) raw_from = 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))) raw_to = c.get("to_amount", amount if amount is not None else raw_from)
# Electrum is tracked in tenths, so its amounts keep one decimal of
# precision; every other resource stays an integer.
from_scale = ELECTRUM_SCALE if src == "electrum" else 1
to_scale = ELECTRUM_SCALE if dst == "electrum" else 1
from_amt = _scaled_int(
raw_from, from_scale, f"optional conversion from_amount ({src})")
to_amt = _scaled_int(
raw_to, to_scale, f"optional conversion to_amount ({dst})")
if from_amt < 0 or to_amt < 0: if from_amt < 0 or to_amt < 0:
raise ValueError("optional conversion amounts must be non-negative") raise ValueError("optional conversion amounts must be non-negative")
max_count = int(c.get("max_count", 1)) max_count = int(c.get("max_count", 1))
@ -1206,7 +1256,9 @@ class _Builder:
self._add_delta(dst, t, to_amt * n) self._add_delta(dst, t, to_amt * n)
meta = { meta = {
"turn": t, "from": src, "to": dst, "turn": t, "from": src, "to": dst,
"from_amount": from_amt, "to_amount": to_amt, "max_count": max_count, "from_amount": from_amt / from_scale if from_scale != 1 else from_amt,
"to_amount": to_amt / to_scale if to_scale != 1 else to_amt,
"max_count": max_count,
} }
self.optional_conversions.append(meta) self.optional_conversions.append(meta)
self._opt_conv_vars.append((meta, n)) self._opt_conv_vars.append((meta, n))
@ -1242,16 +1294,18 @@ class _Builder:
m.Add(first >= gov_any[t] - ever_before) m.Add(first >= gov_any[t] - ever_before)
for r, amt in a.onetime_governor_bonus.items(): for r, amt in a.onetime_governor_bonus.items():
if r in RESOURCES and amt: if r in RESOURCES and amt:
self._add_delta(r, t, amt * first) scaled_amt = amt * ELECTRUM_SCALE if r == "electrum" else amt
self._add_delta(r, t, scaled_amt * first)
def _build_resource_balance(self): def _build_resource_balance(self):
m = self.m m = self.m
T = self.T T = self.T
for r in RESOURCES: for r in RESOURCES:
start = int(round(self.p.start.get(r, 0))) scale = ELECTRUM_SCALE if r == "electrum" else 1
start = _scaled_int(self.p.start.get(r, 0), scale, f"starting {r}")
self.res[r] = [] self.res[r] = []
for t in range(T): for t in range(T):
v = m.NewIntVar(0, self.MAXR, f"res_{r}_t{t}") v = m.NewIntVar(0, self.MAXR * scale, f"res_{r}_t{t}")
self.res[r].append(v) self.res[r].append(v)
prev = self.res[r][t - 1] if t > 0 else start prev = self.res[r][t - 1] if t > 0 else start
deltas = self.delta.get((r, t), []) deltas = self.delta.get((r, t), [])
@ -1262,40 +1316,39 @@ class _Builder:
"""Provisioner: +1.5 Electrum each Turn it governs a City. """Provisioner: +1.5 Electrum each Turn it governs a City.
Electrum is never spent in this model (only accumulated for scoring), Electrum is never spent in this model (only accumulated for scoring),
so the bonus is layered on top of the integer Electrum pool as an so the bonus is layered on top of the Electrum pool as an
``electrum_eff[t]`` amount used by scoring and constraints. The gain is ``electrum_eff[t]`` amount used by scoring and constraints. Both the
tracked in half-units (1.5 = 3 half-units); a partial trailing 0.5 from pool and this overlay are in tenths (see ELECTRUM_SCALE), so the 1.5
an odd number of governing Turns is floored.""" gain is an exact 15-tenths integer with no rounding. ``governor_
electrum_half`` is in half-units (1 half = 0.5 = 5 tenths)."""
m = self.m m = self.m
T = self.T T = self.T
ncities = len(self.p.cities) ncities = len(self.p.cities)
# default: no Provisioner effect, effective Electrum == the pool. # default: no Provisioner effect, effective Electrum == the pool (tenths).
self.electrum_eff = list(self.res["electrum"]) self.electrum_eff = list(self.res["electrum"])
half_terms = {t: [] for t in range(T)} tenth_terms = {t: [] for t in range(T)}
any_half = False any_tenths = False
for ai, a in enumerate(self.p.agents): for ai, a in enumerate(self.p.agents):
if not a.governor_electrum_half: if not a.governor_electrum_half:
continue continue
for ci in range(ncities): for ci in range(ncities):
for t in range(T): for t in range(T):
if (ai, ci, t) in self.gov: if (ai, ci, t) in self.gov:
half_terms[t].append( tenth_terms[t].append(
a.governor_electrum_half * self.gov[(ai, ci, t)]) a.governor_electrum_half * 5 * self.gov[(ai, ci, t)])
any_half = True any_tenths = True
if not any_half: if not any_tenths:
return return
per_turn_max = sum(a.governor_electrum_half for a in self.p.agents) per_turn_max = sum(a.governor_electrum_half for a in self.p.agents) * 5
bound = per_turn_max * T + 2 bound = per_turn_max * T
eff = [] eff = []
cum_prev = 0 cum_prev = 0
for t in range(T): for t in range(T):
cum = m.NewIntVar(0, bound, f"elechalf_cum_t{t}") cum = m.NewIntVar(0, bound, f"electenth_cum_t{t}")
m.Add(cum == cum_prev + sum(half_terms[t])) m.Add(cum == cum_prev + sum(tenth_terms[t]))
cum_prev = cum cum_prev = cum
half = m.NewIntVar(0, bound, f"elechalf_t{t}") e = m.NewIntVar(0, self.MAXR * ELECTRUM_SCALE + bound, f"electrum_eff_t{t}")
m.AddDivisionEquality(half, cum, 2) m.Add(e == self.res["electrum"][t] + cum)
e = m.NewIntVar(0, self.MAXR + bound, f"electrum_eff_t{t}")
m.Add(e == self.res["electrum"][t] + half)
eff.append(e) eff.append(e)
self.electrum_eff = eff self.electrum_eff = eff
@ -1341,7 +1394,10 @@ class _Builder:
if op not in self._OPS: if op not in self._OPS:
raise ValueError(f"resource_constraints op must be one of {self._OPS}") raise ValueError(f"resource_constraints op must be one of {self._OPS}")
var = self._resource_at(c["resource"], c.get("turn")) var = self._resource_at(c["resource"], c.get("turn"))
value = int(c["value"]) # Electrum is compared in tenths; its bound may carry one decimal.
scale = ELECTRUM_SCALE if c["resource"] == "electrum" else 1
value = _scaled_int(
float(c["value"]), scale, f"constraint value ({c['resource']})")
if op == ">=": if op == ">=":
m.Add(var >= value) m.Add(var >= value)
elif op == "<=": elif op == "<=":
@ -1369,11 +1425,24 @@ class _Builder:
if not term.scalar: if not term.scalar:
continue continue
amt_var = self._resource_at(term.resource, term.turn) amt_var = self._resource_at(term.resource, term.turn)
# Electrum's amount var is in tenths; the table/scalar are stated per
# whole Electrum, so account for the scale on the way into the score.
is_elec = term.resource == "electrum"
if term.log_mapping is None: if term.log_mapping is None:
terms.append(scaled(term.scalar) * amt_var) if is_elec:
# scalar * (amt/ELECTRUM_SCALE), kept integral via OBJ_SCALE.
coeff = int(round(term.scalar * OBJ_SCALE / ELECTRUM_SCALE))
terms.append(coeff * amt_var)
else:
terms.append(scaled(term.scalar) * amt_var)
else: else:
table = term.log_mapping table = term.log_mapping
# value = table[min(amt, len-1)]; scale table values to ints. # value = table[min(amt, len-1)]; scale table values to ints.
# The table is indexed in the resource's own internal units, so
# ``amt_var`` indexes it directly: whole units for most resources,
# but tenths for Electrum (see ELECTRUM_SCALE) -- callers must
# therefore supply an Electrum table sampled per tenth (entry k is
# the score at k/ELECTRUM_SCALE Electrum), which the UI does.
vals = [scaled(v) for v in table] vals = [scaled(v) for v in table]
tag = f"{term.resource}_t{term.turn if term.turn is not None else T - 1}" tag = f"{term.resource}_t{term.turn if term.turn is not None else T - 1}"
idx = m.NewIntVar(0, len(table) - 1, f"idx_{tag}") idx = m.NewIntVar(0, len(table) - 1, f"idx_{tag}")
@ -1511,7 +1580,9 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio
for expr in b.city_delta.get((ci, r, t), []): for expr in b.city_delta.get((ci, r, t), []):
total += expr if isinstance(expr, int) else solver.Value(expr) total += expr if isinstance(expr, int) else solver.Value(expr)
if total: if total:
deltas[r] = float(total) # Electrum deltas are accumulated in tenths.
deltas[r] = (float(total) / ELECTRUM_SCALE
if r == "electrum" else float(total))
for r, amt in extra.get((ci, t), {}).items(): for r, amt in extra.get((ci, t), {}).items():
deltas[r] = deltas.get(r, 0.0) + amt deltas[r] = deltas.get(r, 0.0) + amt
if deltas[r] == 0: if deltas[r] == 0:
@ -1522,8 +1593,12 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio
deltas=deltas, deltas=deltas,
)) ))
def _unscale(r, raw):
# The Electrum pool is stored in tenths; everything else is 1:1.
return float(raw) / ELECTRUM_SCALE if r == "electrum" else float(raw)
final_resources = { final_resources = {
r: float(solver.Value(b.final_amt[r])) for r in RESOURCES r: _unscale(r, solver.Value(b.final_amt[r])) for r in RESOURCES
} }
conversions = [] conversions = []
for (r, t), var in sorted(b.trade_conv.items(), key=lambda kv: (kv[0][1], kv[0][0])): for (r, t), var in sorted(b.trade_conv.items(), key=lambda kv: (kv[0][1], kv[0][0])):
@ -1543,14 +1618,17 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio
for t in range(T): for t in range(T):
row = {"turn": t} row = {"turn": t}
for r in RESOURCES: for r in RESOURCES:
row[r] = float(solver.Value(b._resource_at(r, t))) row[r] = _unscale(r, solver.Value(b._resource_at(r, t)))
resources_by_turn.append(row) resources_by_turn.append(row)
return Solution( return Solution(
status=status_name, status=status_name,
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}, start_resources={
r: (round(problem.start.get(r, 0) * ELECTRUM_SCALE) / ELECTRUM_SCALE
if r == "electrum" else 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), forced_conversions=list(b.conversions),