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>
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 CITY_TYPES = ["hub", "foundry", "monument", "metropolis"];
const ACTIONS = ["idle", "collect", "renovate", "upgrade", "launch"];
@ -777,7 +781,7 @@
card._get = () => {
const o = {resource: res.value, scalar: +scalar.value};
if (turn.value !== "") o.turn = +turn.value;
if (isLog.checked) o.log_mapping = buildLogTable(expr.value);
if (isLog.checked) o.log_mapping = buildLogTable(expr.value, res.value);
return o;
};
const resF = field("Resource", res); resF.classList.add("linear-only");
@ -797,9 +801,12 @@
addTerm({resource, scalar}));
}
// Eval the expression ONCE into a function, then call it over the amounts
// the lookup table needs (0..max_resource).
function buildLogTable(exprStr) {
// Eval the expression ONCE into a function, then sample it over the
// amounts the lookup table needs (0..max_resource). The solver indexes the
// 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;
try {
fn = eval(exprStr);
@ -809,9 +816,10 @@
if (typeof fn !== "function")
throw new Error("Log expression must eval to a function, got " + typeof fn);
const max = +document.getElementById("max_resource").value || 0;
const scale = resource === "electrum" ? ELECTRUM_SCALE : 1;
const table = [];
for (let x = 0; x <= max; x++) {
const v = Number(fn(x));
for (let i = 0; i <= max * scale; i++) {
const v = Number(fn(i / scale));
table.push(Number.isFinite(v) ? v : 0);
}
return table;

127
solve.py
View file

@ -96,6 +96,14 @@ RESOURCES = [
# values so the CP-SAT objective stays integral.
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
# log mode). Override via Problem.max_resource if your game runs hotter.
DEFAULT_MAX_RESOURCE = 300
@ -311,6 +319,12 @@ class ScoreTerm:
``resource`` is a stockpiled resource name or "renown". Because per-Turn
Renown is not tracked, a "renown" term must target the final Turn
(``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
scalar: float = 1.0
@ -1062,6 +1076,9 @@ class _Builder:
picked_level = self._gate_expr(vat[r][t], pick[r])
gain_base = picked_level + self._mul_bool(harv, pick[r], 1)
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)
# transition to next turn's vat levels
@ -1134,7 +1151,9 @@ class _Builder:
for r in targets:
c = m.NewIntVar(0, self.MAXR, f"tgconv_{r}_t{t}")
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
def _build_conversions(self):
@ -1158,15 +1177,22 @@ class _Builder:
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)))
raw_from = c.get("from_amount", amount if amount is not None else 0)
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:
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,
"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):
@ -1194,8 +1220,14 @@ class _Builder:
if dst not in RESOURCES:
raise ValueError(f"optional 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)))
raw_from = c.get("from_amount", amount if amount is not None else 0)
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:
raise ValueError("optional conversion amounts must be non-negative")
max_count = int(c.get("max_count", 1))
@ -1206,7 +1238,9 @@ class _Builder:
self._add_delta(dst, t, to_amt * n)
meta = {
"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._opt_conv_vars.append((meta, n))
@ -1242,16 +1276,18 @@ class _Builder:
m.Add(first >= gov_any[t] - ever_before)
for r, amt in a.onetime_governor_bonus.items():
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):
m = self.m
T = self.T
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] = []
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)
prev = self.res[r][t - 1] if t > 0 else start
deltas = self.delta.get((r, t), [])
@ -1262,40 +1298,39 @@ class _Builder:
"""Provisioner: +1.5 Electrum each Turn it governs a City.
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
``electrum_eff[t]`` amount used by scoring and constraints. The gain is
tracked in half-units (1.5 = 3 half-units); a partial trailing 0.5 from
an odd number of governing Turns is floored."""
so the bonus is layered on top of the Electrum pool as an
``electrum_eff[t]`` amount used by scoring and constraints. Both the
pool and this overlay are in tenths (see ELECTRUM_SCALE), so the 1.5
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
T = self.T
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"])
half_terms = {t: [] for t in range(T)}
any_half = False
tenth_terms = {t: [] for t in range(T)}
any_tenths = False
for ai, a in enumerate(self.p.agents):
if not a.governor_electrum_half:
continue
for ci in range(ncities):
for t in range(T):
if (ai, ci, t) in self.gov:
half_terms[t].append(
a.governor_electrum_half * self.gov[(ai, ci, t)])
any_half = True
if not any_half:
tenth_terms[t].append(
a.governor_electrum_half * 5 * self.gov[(ai, ci, t)])
any_tenths = True
if not any_tenths:
return
per_turn_max = sum(a.governor_electrum_half for a in self.p.agents)
bound = per_turn_max * T + 2
per_turn_max = sum(a.governor_electrum_half for a in self.p.agents) * 5
bound = per_turn_max * T
eff = []
cum_prev = 0
for t in range(T):
cum = m.NewIntVar(0, bound, f"elechalf_cum_t{t}")
m.Add(cum == cum_prev + sum(half_terms[t]))
cum = m.NewIntVar(0, bound, f"electenth_cum_t{t}")
m.Add(cum == cum_prev + sum(tenth_terms[t]))
cum_prev = cum
half = m.NewIntVar(0, bound, f"elechalf_t{t}")
m.AddDivisionEquality(half, cum, 2)
e = m.NewIntVar(0, self.MAXR + bound, f"electrum_eff_t{t}")
m.Add(e == self.res["electrum"][t] + half)
e = m.NewIntVar(0, self.MAXR * ELECTRUM_SCALE + bound, f"electrum_eff_t{t}")
m.Add(e == self.res["electrum"][t] + cum)
eff.append(e)
self.electrum_eff = eff
@ -1341,7 +1376,9 @@ class _Builder:
if op not in self._OPS:
raise ValueError(f"resource_constraints op must be one of {self._OPS}")
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 == ">=":
m.Add(var >= value)
elif op == "<=":
@ -1369,11 +1406,24 @@ class _Builder:
if not term.scalar:
continue
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 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:
table = term.log_mapping
# 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]
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}")
@ -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), []):
total += expr if isinstance(expr, int) else solver.Value(expr)
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():
deltas[r] = deltas.get(r, 0.0) + amt
if deltas[r] == 0:
@ -1522,8 +1574,12 @@ def _extract(problem: Problem, b: _Builder, solver, status_name: str) -> Solutio
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 = {
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 = []
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):
row = {"turn": t}
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)
return Solution(
status=status_name,
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},
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)),
trade_conversions=conversions,
forced_conversions=list(b.conversions),