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: