electrum handled correctly

This commit is contained in:
Pagwin 2026-06-19 14:37:23 -04:00
parent 2207cf2fbc
commit dff7f995ef
2 changed files with 108 additions and 41 deletions

View file

@ -406,6 +406,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"];
@ -777,7 +781,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 +801,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 +816,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;

127
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
@ -1062,6 +1076,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 +1151,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 +1177,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 = int(round(raw_from * from_scale))
to_amt = int(round(raw_to * to_scale))
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 +1220,14 @@ 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 = int(round(raw_from * from_scale))
to_amt = int(round(raw_to * to_scale))
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 +1238,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 +1276,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 = int(round(self.p.start.get(r, 0) * scale))
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 +1298,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 +1376,9 @@ 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 a decimal.
scale = ELECTRUM_SCALE if c["resource"] == "electrum" else 1
value = int(round(float(c["value"]) * scale))
if op == ">=": if op == ">=":
m.Add(var >= value) m.Add(var >= value)
elif op == "<=": elif op == "<=":
@ -1369,11 +1406,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:
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) 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 +1561,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 +1574,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 +1599,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),