Google OR tools hints

This commit is contained in:
Pagwin 2026-07-02 00:04:50 -04:00
parent 3f9ee57208
commit 71a428ee48
2 changed files with 60 additions and 0 deletions

View file

@ -650,6 +650,9 @@
placeholder: "Bearhearth, Kingsland" placeholder: "Bearhearth, Kingsland"
}); });
const forced = el("input", {value: pairsToStr(c.forced_action), placeholder: "0:upgrade"}); 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 // Available turns are given as an inclusive arrival..departure range and
// expanded into the explicit list the solver expects. Blank arrival means // expanded into the explicit list the solver expects. Blank arrival means
// "from turn 0", blank departure means "through the last turn"; both blank // "from turn 0", blank departure means "through the last turn"; both blank
@ -713,6 +716,8 @@
if (adj) o.adjacent = adj; if (adj) o.adjacent = adj;
const fa = parsePairs(forced.value); const fa = parsePairs(forced.value);
if (Object.keys(fa).length) o.forced_action = fa; 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; const a = arrival.value, d = departure.value;
if (a !== "" || d !== "") { if (a !== "" || d !== "") {
const lo = a !== "" ? +a : 0; const lo = a !== "" ? +a : 0;
@ -733,6 +738,7 @@
field("Upgrades (already installed)", upWrap), field("Upgrades (already installed)", upWrap),
field("Adjacent cities (csv of names)", adjacent), field("Adjacent cities (csv of names)", adjacent),
field("Forced actions (turn:action, csv)", forced), field("Forced actions (turn:action, csv)", forced),
field("Hint actions (turn:action, csv)", hinted),
field("Arrival turn (blank=start)", arrival), field("Arrival turn (blank=start)", arrival),
field("Departure turn (blank=end)", departure), field("Departure turn (blank=end)", departure),
vatGroup, vatGroup,
@ -754,6 +760,9 @@
const desc = el("span", {class: "help"}); const desc = el("span", {class: "help"});
const bastions = num(a.bonus_trade_goods || 3, {min: 0}); const bastions = num(a.bonus_trade_goods || 3, {min: 0});
const forced = el("input", {value: pairsToStr(a.forced_city), placeholder: "0:Aridias"}); 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 avail = el("input", {value: (a.available_turns || []).join(", ")});
const bastionsField = field("Bastions (Baron only)", bastions); const bastionsField = field("Bastions (Baron only)", bastions);
@ -773,6 +782,8 @@
if (type.value === "Baron") o.bonus_trade_goods = +bastions.value; if (type.value === "Baron") o.bonus_trade_goods = +bastions.value;
const fc = parsePairs(forced.value, true); const fc = parsePairs(forced.value, true);
if (Object.keys(fc).length) o.forced_city = fc; 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); const at = parseInts(avail.value);
if (at) o.available_turns = at; if (at) o.available_turns = at;
return o; return o;
@ -781,6 +792,7 @@
field("Agent", type), field("Agent", type),
field("Effect", desc), field("Effect", desc),
field("Forced city (turn:city, csv)", forced), field("Forced city (turn:city, csv)", forced),
field("Hint city (turn:city, csv)", hintCity),
field("Avail turns (csv, blank=all)", avail), field("Avail turns (csv, blank=all)", avail),
bastionsField, bastionsField,
el("div", {class: "card-actions"}, removeBtn(card))); el("div", {class: "card-actions"}, removeBtn(card)));

View file

@ -183,6 +183,10 @@ class City:
can_renovate: bool = True # Metropolis cannot renovate can_renovate: bool = True # Metropolis cannot renovate
# Hard constraint: force a specific action on a given turn (turn -> Action). # Hard constraint: force a specific action on a given turn (turn -> Action).
forced_action: dict[int, str] = field(default_factory=dict) 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): def __post_init__(self):
if self.renown is None: if self.renown is None:
@ -218,6 +222,10 @@ class Agent:
available_turns: Optional[list[int]] = None available_turns: Optional[list[int]] = None
# Hard constraint: must govern this city on this turn (turn -> city name). # Hard constraint: must govern this city on this turn (turn -> city name).
forced_city: dict[int, str] = field(default_factory=dict) 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: def is_available(self, t: int) -> bool:
return self.available_turns is None or t in self.available_turns return self.available_turns is None or t in self.available_turns
@ -543,6 +551,7 @@ class _Builder:
self._build_renown_total() self._build_renown_total()
self._build_resource_constraints() self._build_resource_constraints()
self._build_objective() self._build_objective()
self._build_hints()
return self.m return self.m
def _build_actions_and_governors(self): def _build_actions_and_governors(self):
@ -629,6 +638,41 @@ class _Builder:
# Only one overworking placement at a time is already implied by # Only one overworking placement at a time is already implied by
# the agent's "<=1 city" constraint. # 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): def _build_adjacency(self):
"""Build the symmetric City adjacency map (``self.adj[ci]`` -> set of """Build the symmetric City adjacency map (``self.adj[ci]`` -> set of
adjacent City indices). Declaring A adjacent to B also makes B adjacent 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"]) c["type"] = CityType(c["type"])
if "forced_action" in c: if "forced_action" in c:
c["forced_action"] = {int(k): v for k, v in c["forced_action"].items()} 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)) cities.append(City(**c))
agents = [] agents = []
for a in d.get("agents", []): for a in d.get("agents", []):
a = dict(a) a = dict(a)
if "forced_city" in a: if "forced_city" in a:
a["forced_city"] = {int(k): v for k, v in a["forced_city"].items()} 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)) agents.append(Agent(**a))
obj_d = dict(d.get("objective", {})) obj_d = dict(d.get("objective", {}))
if "terms" in obj_d: if "terms" in obj_d: