diff --git a/index.html b/index.html index 28adabf..bcf6499 100644 --- a/index.html +++ b/index.html @@ -53,6 +53,13 @@ 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 { font: inherit; padding: .3rem .7rem; @@ -462,6 +469,11 @@ return e; } 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 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) { const s = el("select"); for (const o of opts) { @@ -595,7 +607,7 @@ const startInputs = {}; const tradeInputs = {}; 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; document.getElementById("start").append( el("label", {class: "field"}, [el("span", {}, r), inp])); @@ -830,7 +842,8 @@ const card = el("div", {class: "card"}); const res = selectEl(SCORE_KEYS, c.resource || "capital"); 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); card._get = () => { const o = {resource: res.value, op: op.value, value: +value.value}; @@ -852,9 +865,11 @@ 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 fromAmt = num(c.from_amount ?? c.amount ?? 1, {min: 0, step: resStep(from.value)}); 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); card._get = () => { const o = { @@ -880,10 +895,12 @@ function addOptionalConversion(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 fromAmt = num(c.from_amount ?? c.amount ?? 1, {min: 0, step: resStep(from.value)}); const to = selectEl(RESOURCES, c.to || "capital"); - const toAmt = num(c.to_amount ?? c.amount ?? 1, {min: 0}); - const maxCount = num(c.max_count ?? 1, {min: 1}); + 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 maxCount = num(c.max_count ?? 1, {min: 1, step: "1"}); // Multiple turns may be selected; each chosen turn yields its own copy of // this conversion in the problem JSON (one entry per turn). const turns = multiTurnSelect(c.turn != null ? [c.turn] : []); diff --git a/solve.py b/solve.py index c7d1a32..220fe73 100644 --- a/solve.py +++ b/solve.py @@ -451,6 +451,22 @@ class Solution: # 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: def __init__(self, problem: Problem): self.p = problem @@ -1183,8 +1199,8 @@ class _Builder: # 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)) + 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: raise ValueError("conversion amounts must be non-negative") self._add_delta(src, t, -from_amt) @@ -1226,8 +1242,10 @@ class _Builder: # 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)) + 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: raise ValueError("optional conversion amounts must be non-negative") max_count = int(c.get("max_count", 1)) @@ -1284,7 +1302,7 @@ class _Builder: T = self.T for r in RESOURCES: scale = ELECTRUM_SCALE if r == "electrum" else 1 - start = int(round(self.p.start.get(r, 0) * scale)) + start = _scaled_int(self.p.start.get(r, 0), scale, f"starting {r}") self.res[r] = [] for t in range(T): v = m.NewIntVar(0, self.MAXR * scale, f"res_{r}_t{t}") @@ -1376,9 +1394,10 @@ 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")) - # Electrum is compared in tenths; its bound may carry a decimal. + # Electrum is compared in tenths; its bound may carry one decimal. scale = ELECTRUM_SCALE if c["resource"] == "electrum" else 1 - value = int(round(float(c["value"]) * scale)) + value = _scaled_int( + float(c["value"]), scale, f"constraint value ({c['resource']})") if op == ">=": m.Add(var >= value) elif op == "<=":