From 71a428ee48f6f97043199955400c6921b11693c9 Mon Sep 17 00:00:00 2001 From: Pagwin Date: Thu, 2 Jul 2026 00:04:50 -0400 Subject: [PATCH] Google OR tools hints --- index.html | 12 ++++++++++++ solve.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/index.html b/index.html index 47f9c94..dbf4b90 100644 --- a/index.html +++ b/index.html @@ -650,6 +650,9 @@ placeholder: "Bearhearth, Kingsland" }); const forced = el("input", {value: pairsToStr(c.forced_action), placeholder: "0:upgrade"}); + // Soft hint (not a constraint): a suggested action per turn that + // warm-starts the search but can be overridden by the solver. + const hinted = el("input", {value: pairsToStr(c.hint_action), placeholder: "0:collect"}); // Available turns are given as an inclusive arrival..departure range and // expanded into the explicit list the solver expects. Blank arrival means // "from turn 0", blank departure means "through the last turn"; both blank @@ -713,6 +716,8 @@ if (adj) o.adjacent = adj; const fa = parsePairs(forced.value); if (Object.keys(fa).length) o.forced_action = fa; + const ha = parsePairs(hinted.value); + if (Object.keys(ha).length) o.hint_action = ha; const a = arrival.value, d = departure.value; if (a !== "" || d !== "") { const lo = a !== "" ? +a : 0; @@ -733,6 +738,7 @@ field("Upgrades (already installed)", upWrap), field("Adjacent cities (csv of names)", adjacent), field("Forced actions (turn:action, csv)", forced), + field("Hint actions (turn:action, csv)", hinted), field("Arrival turn (blank=start)", arrival), field("Departure turn (blank=end)", departure), vatGroup, @@ -754,6 +760,9 @@ const desc = el("span", {class: "help"}); const bastions = num(a.bonus_trade_goods || 3, {min: 0}); const forced = el("input", {value: pairsToStr(a.forced_city), placeholder: "0:Aridias"}); + // Soft hint (not a constraint): a suggested city to govern per turn + // that biases the search but can be overridden by the solver. + const hintCity = el("input", {value: pairsToStr(a.hint_city), placeholder: "0:Aridias"}); const avail = el("input", {value: (a.available_turns || []).join(", ")}); const bastionsField = field("Bastions (Baron only)", bastions); @@ -773,6 +782,8 @@ if (type.value === "Baron") o.bonus_trade_goods = +bastions.value; const fc = parsePairs(forced.value, true); if (Object.keys(fc).length) o.forced_city = fc; + const hc = parsePairs(hintCity.value, true); + if (Object.keys(hc).length) o.hint_city = hc; const at = parseInts(avail.value); if (at) o.available_turns = at; return o; @@ -781,6 +792,7 @@ field("Agent", type), field("Effect", desc), field("Forced city (turn:city, csv)", forced), + field("Hint city (turn:city, csv)", hintCity), field("Avail turns (csv, blank=all)", avail), bastionsField, el("div", {class: "card-actions"}, removeBtn(card))); diff --git a/solve.py b/solve.py index 3cd34e0..bff89b4 100644 --- a/solve.py +++ b/solve.py @@ -183,6 +183,10 @@ class City: can_renovate: bool = True # Metropolis cannot renovate # Hard constraint: force a specific action on a given turn (turn -> Action). forced_action: dict[int, str] = field(default_factory=dict) + # Soft hint (NOT a constraint): a suggested action on a given turn + # (turn -> Action). Warm-starts/biases the search toward this plan, but the + # solver may override it if a better plan exists (cf. forced_action). + hint_action: dict[int, str] = field(default_factory=dict) def __post_init__(self): if self.renown is None: @@ -218,6 +222,10 @@ class Agent: available_turns: Optional[list[int]] = None # Hard constraint: must govern this city on this turn (turn -> city name). forced_city: dict[int, str] = field(default_factory=dict) + # Soft hint (NOT a constraint): a suggested city to govern on this turn + # (turn -> city name). Warm-starts/biases the search, but the solver may + # override it (cf. forced_city). + hint_city: dict[int, str] = field(default_factory=dict) def is_available(self, t: int) -> bool: return self.available_turns is None or t in self.available_turns @@ -543,6 +551,7 @@ class _Builder: self._build_renown_total() self._build_resource_constraints() self._build_objective() + self._build_hints() return self.m def _build_actions_and_governors(self): @@ -629,6 +638,41 @@ class _Builder: # Only one overworking placement at a time is already implied by # the agent's "<=1 city" constraint. + def _build_hints(self): + """Apply soft solution hints via ``model.AddHint``. Unlike the forced_* + fields these add no constraints: they only warm-start / bias the search + toward a suggested plan, and the solver may override any of them. + + * City.hint_action : suggest a City takes a given Action on a Turn. + * Agent.hint_city : suggest an Agent governs a given City on a Turn. + + Hints that can't apply are skipped: out-of-range or unavailable Turns, + an Action the City can never take (Renovate on a Metropolis / a City + that can't renovate), or an unknown target City.""" + m = self.m + for ci, city in enumerate(self.p.cities): + for t, action in city.hint_action.items(): + if not (0 <= t < self.T) or not city.is_available(t): + continue + a = Action(action) + # Renovate is a fixed 0-constant for these Cities (see + # _build_actions_and_governors); it isn't a real decision var. + if a == Action.RENOVATE and ( + not city.can_renovate or city.type == CityType.METROPOLIS + ): + continue + var = self.act.get((ci, t, a)) + if var is not None: + m.AddHint(var, 1) + for ai, agent in enumerate(self.p.agents): + for t, city_name in agent.hint_city.items(): + if not (0 <= t < self.T) or not agent.is_available(t): + continue + ci = self._city_index(city_name) + g = self.gov.get((ai, ci, t)) + if g is not None: + m.AddHint(g, 1) + def _build_adjacency(self): """Build the symmetric City adjacency map (``self.adj[ci]`` -> set of adjacent City indices). Declaring A adjacent to B also makes B adjacent @@ -1690,12 +1734,16 @@ def problem_from_dict(d: dict) -> Problem: c["type"] = CityType(c["type"]) if "forced_action" in c: c["forced_action"] = {int(k): v for k, v in c["forced_action"].items()} + if "hint_action" in c: + c["hint_action"] = {int(k): v for k, v in c["hint_action"].items()} cities.append(City(**c)) agents = [] for a in d.get("agents", []): a = dict(a) if "forced_city" in a: a["forced_city"] = {int(k): v for k, v in a["forced_city"].items()} + if "hint_city" in a: + a["hint_city"] = {int(k): v for k, v in a["hint_city"].items()} agents.append(Agent(**a)) obj_d = dict(d.get("objective", {})) if "terms" in obj_d: