From db9eb00a6f589cead68caf32d66e89c79c65478f Mon Sep 17 00:00:00 2001 From: Pagwin Date: Sat, 20 Jun 2026 21:53:54 -0400 Subject: [PATCH] airship constraint --- index.html | 6 +++++- solve.py | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index e89e7e6..c7c84a5 100644 --- a/index.html +++ b/index.html @@ -422,6 +422,10 @@ // Must match ELECTRUM_SCALE in solve.py. const ELECTRUM_SCALE = 10; const SCORE_KEYS = RESOURCES.concat(["renown"]); + // Constraints may additionally pin "airships": the cumulative count of + // Airships produced via Launch by the end of the chosen Turn. Not a + // scorable resource, so it's only offered for constraints. + const CONSTRAINT_KEYS = SCORE_KEYS.concat(["airships"]); const CITY_TYPES = ["hub", "foundry", "monument", "metropolis"]; const ACTIONS = ["idle", "collect", "renovate", "upgrade", "launch"]; // Upgrades that apply to any city type, plus the type-specific "3rd" upgrade. @@ -851,7 +855,7 @@ // --- resource constraints --- function addConstraint(c = {}) { const card = el("div", {class: "card"}); - const res = selectEl(SCORE_KEYS, c.resource || "capital"); + const res = selectEl(CONSTRAINT_KEYS, c.resource || "capital"); const op = selectEl([">=", "<=", "=="], c.op || ">="); const value = num(c.value ?? 0, {step: resStep(res.value)}); res.onchange = () => {value.step = resStep(res.value);}; diff --git a/solve.py b/solve.py index 1009b0c..61338d7 100644 --- a/solve.py +++ b/solve.py @@ -387,7 +387,10 @@ class Problem: max_vat: int = DEFAULT_MAX_VAT # Hard constraints on a resource's amount at the END of a specific Turn. # Each entry: {"turn": int, "resource": str, "op": one of >=/<=/==, "value": int}. - # Turns are 0-indexed (None or omitted => final Turn). + # ``resource`` is a stockpiled resource, "renown" (final Turn only), or + # "airships" (cumulative count of Airships produced by end of that Turn, + # incl. any ``airships_launched`` before the horizon). 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 @@ -495,6 +498,12 @@ class _Builder: self.city_final_renown: list[cp_model.IntVar] = [] # airship launch booleans (each adds AIRSHIP_RENOWN to asset renown) self.launches: list[cp_model.IntVar] = [] + # launch booleans indexed by the Turn they occur on, so a cumulative + # "airships produced by end of Turn t" count can be built for constraints. + self.launch_by_turn: dict[int, list] = {} + # cumulative airships produced by END of each Turn (incl. any launched + # before the horizon). Populated by _build_airship_counts. + self.airships_cum: list[cp_model.IntVar] = [] # bookkeeping for solution extraction self._collect_detail: dict[tuple[int, int], dict] = {} self._upgrade_choice: dict[tuple[int, int], dict] = {} @@ -524,6 +533,7 @@ class _Builder: self._build_adjacency() self._build_actions_and_governors() self._build_city_dynamics() + self._build_airship_counts() self._build_trade_conversion() self._build_conversions() self._build_optional_conversions() @@ -856,6 +866,7 @@ class _Builder: launch = self.act[(ci, t, Action.LAUNCH)] self._add_delta("steel", t, -AIRSHIP_COST_STEEL * launch) self.launches.append(launch) + self.launch_by_turn.setdefault(t, []).append(launch) steel_spent = steel_spent + AIRSHIP_COST_STEEL * launch # Prodigy Governor: when Upgrading or Launching an Airship, refund @@ -1358,7 +1369,8 @@ class _Builder: """Resource amount var at END of ``turn`` (None => final Turn). ``resource`` may be "renown" only for the final Turn, since per-Turn - Renown is not tracked.""" + Renown is not tracked. It may also be "airships" (the cumulative count + of Airships produced by the end of ``turn``), valid on any Turn.""" T = self.T t = T - 1 if turn is None else turn if not (0 <= t < T): @@ -1367,6 +1379,9 @@ class _Builder: if turn is not None and turn != T - 1: raise ValueError("'renown' is only available on the final Turn") return self.renown_total + if resource == "airships": + # Cumulative Airships produced by END of this Turn (whole units). + return self.airships_cum[t] if resource not in RESOURCES: raise ValueError(f"Unknown resource: {resource!r}") if resource == "electrum": @@ -1374,6 +1389,21 @@ class _Builder: return self.electrum_eff[t] return self.res[resource][t] + def _build_airship_counts(self): + """Cumulative count of Airships produced by the END of each Turn. + + ``airships_cum[t]`` = ``airships_launched`` (produced before the + horizon) + every LAUNCH taken on Turns 0..t across all Cities. This is + the quantity a caller constrains via a ``resource_constraints`` entry + whose resource is ``"airships"`` (see ``_resource_at``).""" + m = self.m + cum_prev = self.p.airships_launched + for t in range(self.T): + cum = m.NewIntVar(0, AIRSHIP_MAX, f"airships_cum_t{t}") + m.Add(cum == cum_prev + sum(self.launch_by_turn.get(t, []))) + cum_prev = cum + self.airships_cum.append(cum) + def _build_renown_total(self): m = self.m # Each Faction can Launch at most 3 Airships total (incl. any already