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